diff --git a/docker/nginx-https.conf b/docker/nginx-https.conf index 37610e456..6587e8d14 100644 --- a/docker/nginx-https.conf +++ b/docker/nginx-https.conf @@ -161,7 +161,7 @@ http { root /app/html; index index.html index.htm; expires off; - add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'wasm-unsafe-eval' 'sha256-u6nDWD7Mm1U/e9z2vyubUQB8Wk00VJsojS2q1tGdZwg='; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: http: https:; font-src 'self' data:; connect-src 'self' http: https: ws: wss:; media-src 'self' data: blob: http: https:; worker-src 'self' blob:; frame-src http: https:; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'" always; + add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'wasm-unsafe-eval' 'sha256-4lI7pWY2I7KZ35vkFr1bKo1OZllWHDP1lggz9ajwA84='; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: http: https:; font-src 'self' data:; connect-src 'self' http: https: ws: wss:; media-src 'self' data: blob: http: https:; worker-src 'self' blob:; frame-src http: https:; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'" always; add_header X-Frame-Options "DENY" always; add_header Referrer-Policy "strict-origin-when-cross-origin" always; add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always; diff --git a/docker/nginx.conf b/docker/nginx.conf index 508455d5c..b9f2b75ed 100644 --- a/docker/nginx.conf +++ b/docker/nginx.conf @@ -142,7 +142,7 @@ http { root /app/html; index index.html index.htm; expires off; - add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'wasm-unsafe-eval' 'sha256-u6nDWD7Mm1U/e9z2vyubUQB8Wk00VJsojS2q1tGdZwg='; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: http: https:; font-src 'self' data:; connect-src 'self' http: https: ws: wss:; media-src 'self' data: blob: http: https:; worker-src 'self' blob:; frame-src http: https:; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'" always; + add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'wasm-unsafe-eval' 'sha256-4lI7pWY2I7KZ35vkFr1bKo1OZllWHDP1lggz9ajwA84='; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: http: https:; font-src 'self' data:; connect-src 'self' http: https: ws: wss:; media-src 'self' data: blob: http: https:; worker-src 'self' blob:; frame-src http: https:; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'" always; add_header X-Frame-Options "DENY" always; add_header Referrer-Policy "strict-origin-when-cross-origin" always; add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always; diff --git a/drizzle/mysql/0047_red_silver_sable.sql b/drizzle/mysql/0047_red_silver_sable.sql new file mode 100644 index 000000000..ad49ffb54 --- /dev/null +++ b/drizzle/mysql/0047_red_silver_sable.sql @@ -0,0 +1,8 @@ +-- The ai tables moved to the ai plugin, which renames them into its own +-- namespace at activation, and ssh_data.enable_ai_assistant and the two +-- user_preferences ai columns moved to its settings. Both run after this +-- migration, so a real DROP here would delete the data first. The columns +-- stay in place, unused, until 3.0.0. The timestamp and label type changes +-- drizzle-kit also wrote are generator drift (see FINISH-LIST.md), not a +-- change to the database, and are left out on purpose. +SELECT 1; diff --git a/drizzle/mysql/meta/0047_snapshot.json b/drizzle/mysql/meta/0047_snapshot.json new file mode 100644 index 000000000..73fcc903d --- /dev/null +++ b/drizzle/mysql/meta/0047_snapshot.json @@ -0,0 +1,6072 @@ +{ + "version": "5", + "dialect": "mysql", + "id": "1453029e-3b9c-4306-8dde-35039d3b7d76", + "prevId": "004a3020-a354-42b9-b7bf-ab314d4ceb73", + "tables": { + "api_keys": { + "name": "api_keys", + "columns": { + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token_prefix": { + "name": "token_prefix", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "expires_at": { + "name": "expires_at", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_used_at": { + "name": "last_used_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + } + }, + "indexes": { + "idx_api_keys_user_id": { + "name": "idx_api_keys_user_id", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "api_keys_user_id_users_id_fk": { + "name": "api_keys_user_id_users_id_fk", + "tableFrom": "api_keys", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "api_keys_id": { + "name": "api_keys_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "audit_logs": { + "name": "audit_logs", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "action": { + "name": "action", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "resource_type": { + "name": "resource_type", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "resource_id": { + "name": "resource_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "resource_name": { + "name": "resource_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "details": { + "name": "details", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "success": { + "name": "success", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "timestamp": { + "name": "timestamp", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "idx_audit_logs_timestamp": { + "name": "idx_audit_logs_timestamp", + "columns": [ + "timestamp" + ], + "isUnique": false + }, + "idx_audit_logs_user_ts": { + "name": "idx_audit_logs_user_ts", + "columns": [ + "user_id", + "timestamp" + ], + "isUnique": false + }, + "idx_audit_logs_action_ts": { + "name": "idx_audit_logs_action_ts", + "columns": [ + "action", + "timestamp" + ], + "isUnique": false + }, + "idx_audit_logs_resource_ts": { + "name": "idx_audit_logs_resource_ts", + "columns": [ + "resource_type", + "timestamp" + ], + "isUnique": false + } + }, + "foreignKeys": { + "audit_logs_user_id_users_id_fk": { + "name": "audit_logs_user_id_users_id_fk", + "tableFrom": "audit_logs", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "audit_logs_id": { + "name": "audit_logs_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "credential_access": { + "name": "credential_access", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "credential_id": { + "name": "credential_id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "role_id": { + "name": "role_id", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "granted_by": { + "name": "granted_by", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "permission_level": { + "name": "permission_level", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "('use')" + }, + "expires_at": { + "name": "expires_at", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "idx_credential_access_user_id": { + "name": "idx_credential_access_user_id", + "columns": [ + "user_id" + ], + "isUnique": false + }, + "idx_credential_access_role_id": { + "name": "idx_credential_access_role_id", + "columns": [ + "role_id" + ], + "isUnique": false + }, + "idx_credential_access_credential_id": { + "name": "idx_credential_access_credential_id", + "columns": [ + "credential_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "credential_access_credential_id_ssh_credentials_id_fk": { + "name": "credential_access_credential_id_ssh_credentials_id_fk", + "tableFrom": "credential_access", + "tableTo": "ssh_credentials", + "columnsFrom": [ + "credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_access_user_id_users_id_fk": { + "name": "credential_access_user_id_users_id_fk", + "tableFrom": "credential_access", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_access_role_id_roles_id_fk": { + "name": "credential_access_role_id_roles_id_fk", + "tableFrom": "credential_access", + "tableTo": "roles", + "columnsFrom": [ + "role_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_access_granted_by_users_id_fk": { + "name": "credential_access_granted_by_users_id_fk", + "tableFrom": "credential_access", + "tableTo": "users", + "columnsFrom": [ + "granted_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "credential_access_id": { + "name": "credential_access_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "credential_sidebar_preferences": { + "name": "credential_sidebar_preferences", + "columns": { + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "data": { + "name": "data", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": {}, + "foreignKeys": { + "credential_sidebar_preferences_user_id_users_id_fk": { + "name": "credential_sidebar_preferences_user_id_users_id_fk", + "tableFrom": "credential_sidebar_preferences", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "credential_sidebar_preferences_user_id": { + "name": "credential_sidebar_preferences_user_id", + "columns": [ + "user_id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "dashboard_service_links": { + "name": "dashboard_service_links", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "order": { + "name": "order", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "sync_id": { + "name": "sync_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": {}, + "foreignKeys": { + "dashboard_service_links_user_id_users_id_fk": { + "name": "dashboard_service_links_user_id_users_id_fk", + "tableFrom": "dashboard_service_links", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "dashboard_service_links_id": { + "name": "dashboard_service_links_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": { + "dashboard_service_links_sync_id_unique": { + "name": "dashboard_service_links_sync_id_unique", + "columns": [ + "sync_id" + ] + } + }, + "checkConstraint": {} + }, + "dismissed_alerts": { + "name": "dismissed_alerts", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "alert_id": { + "name": "alert_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "dismissed_at": { + "name": "dismissed_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "idx_dismissed_alerts_user_id": { + "name": "idx_dismissed_alerts_user_id", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "dismissed_alerts_user_id_users_id_fk": { + "name": "dismissed_alerts_user_id_users_id_fk", + "tableFrom": "dismissed_alerts", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "dismissed_alerts_id": { + "name": "dismissed_alerts_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "folder_access": { + "name": "folder_access", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "folder": { + "name": "folder", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "role_id": { + "name": "role_id", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "granted_by": { + "name": "granted_by", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "permission_level": { + "name": "permission_level", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "('connect')" + }, + "expires_at": { + "name": "expires_at", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "idx_folder_access_owner_folder": { + "name": "idx_folder_access_owner_folder", + "columns": [ + "owner_user_id", + "folder" + ], + "isUnique": false + } + }, + "foreignKeys": { + "folder_access_owner_user_id_users_id_fk": { + "name": "folder_access_owner_user_id_users_id_fk", + "tableFrom": "folder_access", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "folder_access_user_id_users_id_fk": { + "name": "folder_access_user_id_users_id_fk", + "tableFrom": "folder_access", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "folder_access_role_id_roles_id_fk": { + "name": "folder_access_role_id_roles_id_fk", + "tableFrom": "folder_access", + "tableTo": "roles", + "columnsFrom": [ + "role_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "folder_access_granted_by_users_id_fk": { + "name": "folder_access_granted_by_users_id_fk", + "tableFrom": "folder_access", + "tableTo": "users", + "columnsFrom": [ + "granted_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "folder_access_id": { + "name": "folder_access_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "homepage_items": { + "name": "homepage_items", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type_id": { + "name": "type_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "config": { + "name": "config", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "('{}')" + }, + "folder_id": { + "name": "folder_id", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sync_id": { + "name": "sync_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "idx_homepage_items_user_id": { + "name": "idx_homepage_items_user_id", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "homepage_items_user_id_users_id_fk": { + "name": "homepage_items_user_id_users_id_fk", + "tableFrom": "homepage_items", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "homepage_items_id": { + "name": "homepage_items_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": { + "homepage_items_sync_id_unique": { + "name": "homepage_items_sync_id_unique", + "columns": [ + "sync_id" + ] + } + }, + "checkConstraint": {} + }, + "homepage_layouts": { + "name": "homepage_layouts", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "layout": { + "name": "layout", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "('{}')" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": {}, + "foreignKeys": { + "homepage_layouts_user_id_users_id_fk": { + "name": "homepage_layouts_user_id_users_id_fk", + "tableFrom": "homepage_layouts", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "homepage_layouts_id": { + "name": "homepage_layouts_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": { + "homepage_layouts_user_id_unique": { + "name": "homepage_layouts_user_id_unique", + "columns": [ + "user_id" + ] + } + }, + "checkConstraint": {} + }, + "host_access": { + "name": "host_access", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "host_id": { + "name": "host_id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "role_id": { + "name": "role_id", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "granted_by": { + "name": "granted_by", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "permission_level": { + "name": "permission_level", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "('connect')" + }, + "expires_at": { + "name": "expires_at", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "last_accessed_at": { + "name": "last_accessed_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "access_count": { + "name": "access_count", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + } + }, + "indexes": { + "idx_host_access_user_id": { + "name": "idx_host_access_user_id", + "columns": [ + "user_id" + ], + "isUnique": false + }, + "idx_host_access_role_id": { + "name": "idx_host_access_role_id", + "columns": [ + "role_id" + ], + "isUnique": false + }, + "idx_host_access_host_id": { + "name": "idx_host_access_host_id", + "columns": [ + "host_id" + ], + "isUnique": false + }, + "idx_host_access_expires_at": { + "name": "idx_host_access_expires_at", + "columns": [ + "expires_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "host_access_host_id_ssh_data_id_fk": { + "name": "host_access_host_id_ssh_data_id_fk", + "tableFrom": "host_access", + "tableTo": "ssh_data", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "host_access_user_id_users_id_fk": { + "name": "host_access_user_id_users_id_fk", + "tableFrom": "host_access", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "host_access_role_id_roles_id_fk": { + "name": "host_access_role_id_roles_id_fk", + "tableFrom": "host_access", + "tableTo": "roles", + "columnsFrom": [ + "role_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "host_access_granted_by_users_id_fk": { + "name": "host_access_granted_by_users_id_fk", + "tableFrom": "host_access", + "tableTo": "users", + "columnsFrom": [ + "granted_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "host_access_id": { + "name": "host_access_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "host_sidebar_preferences": { + "name": "host_sidebar_preferences", + "columns": { + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "data": { + "name": "data", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": {}, + "foreignKeys": { + "host_sidebar_preferences_user_id_users_id_fk": { + "name": "host_sidebar_preferences_user_id_users_id_fk", + "tableFrom": "host_sidebar_preferences", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "host_sidebar_preferences_user_id": { + "name": "host_sidebar_preferences_user_id", + "columns": [ + "user_id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "ssh_data": { + "name": "ssh_data", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "connection_type": { + "name": "connection_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "('ssh')" + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "ip": { + "name": "ip", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "port": { + "name": "port", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "folder": { + "name": "folder", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "parent_host_id": { + "name": "parent_host_id", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "tags": { + "name": "tags", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "pin": { + "name": "pin", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "sort_order": { + "name": "sort_order", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "auth_type": { + "name": "auth_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "use_warpgate": { + "name": "use_warpgate", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "share_ssh_auth": { + "name": "share_ssh_auth", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "force_keyboard_interactive": { + "name": "force_keyboard_interactive", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "key_password": { + "name": "key_password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "key_type": { + "name": "key_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sudo_password": { + "name": "sudo_password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "autostart_password": { + "name": "autostart_password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "autostart_key": { + "name": "autostart_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "autostart_key_password": { + "name": "autostart_key_password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "credential_id": { + "name": "credential_id", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "override_credential_username": { + "name": "override_credential_username", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "vault_profile_id": { + "name": "vault_profile_id", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "enable_terminal": { + "name": "enable_terminal", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "enable_session_logging": { + "name": "enable_session_logging", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "allow_session_sharing": { + "name": "allow_session_sharing", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "enable_command_history": { + "name": "enable_command_history", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "enable_tunnel": { + "name": "enable_tunnel", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "tunnel_connections": { + "name": "tunnel_connections", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "jump_hosts": { + "name": "jump_hosts", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "enable_file_manager": { + "name": "enable_file_manager", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "scp_legacy": { + "name": "scp_legacy", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "enable_web_ui": { + "name": "enable_web_ui", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "enable_tmux_monitor": { + "name": "enable_tmux_monitor", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "enable_terminal_toolbar": { + "name": "enable_terminal_toolbar", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "show_terminal_in_sidebar": { + "name": "show_terminal_in_sidebar", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "show_file_manager_in_sidebar": { + "name": "show_file_manager_in_sidebar", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "show_tunnel_in_sidebar": { + "name": "show_tunnel_in_sidebar", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "show_docker_in_sidebar": { + "name": "show_docker_in_sidebar", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "show_server_stats_in_sidebar": { + "name": "show_server_stats_in_sidebar", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "default_path": { + "name": "default_path", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status_check_enabled": { + "name": "status_check_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "status_check_interval": { + "name": "status_check_interval", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "web_ui_config": { + "name": "web_ui_config", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "terminal_config": { + "name": "terminal_config", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "quick_actions": { + "name": "quick_actions", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "enable_ssh": { + "name": "enable_ssh", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "ssh_port": { + "name": "ssh_port", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 22 + }, + "rdp_credential_id": { + "name": "rdp_credential_id", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "rdp_user": { + "name": "rdp_user", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "rdp_password": { + "name": "rdp_password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "rdp_domain": { + "name": "rdp_domain", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "vnc_credential_id": { + "name": "vnc_credential_id", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "vnc_password": { + "name": "vnc_password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "vnc_user": { + "name": "vnc_user", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "telnet_user": { + "name": "telnet_user", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "telnet_password": { + "name": "telnet_password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "telnet_credential_id": { + "name": "telnet_credential_id", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "rdp_auth_type": { + "name": "rdp_auth_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "vnc_auth_type": { + "name": "vnc_auth_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "telnet_auth_type": { + "name": "telnet_auth_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "domain": { + "name": "domain", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "use_socks5": { + "name": "use_socks5", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "socks5_host": { + "name": "socks5_host", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "socks5_port": { + "name": "socks5_port", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "socks5_username": { + "name": "socks5_username", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "socks5_password": { + "name": "socks5_password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "socks5_proxy_chain": { + "name": "socks5_proxy_chain", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "connection_origin": { + "name": "connection_origin", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "mac_address": { + "name": "mac_address", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "wol_broadcast_address": { + "name": "wol_broadcast_address", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "port_knock_sequence": { + "name": "port_knock_sequence", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "host_key_fingerprint": { + "name": "host_key_fingerprint", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "host_key_type": { + "name": "host_key_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "host_key_algorithm": { + "name": "host_key_algorithm", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "('sha256')" + }, + "host_key_first_seen": { + "name": "host_key_first_seen", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "host_key_last_verified": { + "name": "host_key_last_verified", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "host_key_changed_count": { + "name": "host_key_changed_count", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "sync_id": { + "name": "sync_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "idx_ssh_data_user_id": { + "name": "idx_ssh_data_user_id", + "columns": [ + "user_id" + ], + "isUnique": false + }, + "idx_ssh_data_parent_host": { + "name": "idx_ssh_data_parent_host", + "columns": [ + "parent_host_id" + ], + "isUnique": false + }, + "idx_ssh_data_credential": { + "name": "idx_ssh_data_credential", + "columns": [ + "credential_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "ssh_data_user_id_users_id_fk": { + "name": "ssh_data_user_id_users_id_fk", + "tableFrom": "ssh_data", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "ssh_data_parent_host_id_ssh_data_id_fk": { + "name": "ssh_data_parent_host_id_ssh_data_id_fk", + "tableFrom": "ssh_data", + "tableTo": "ssh_data", + "columnsFrom": [ + "parent_host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "ssh_data_credential_id_ssh_credentials_id_fk": { + "name": "ssh_data_credential_id_ssh_credentials_id_fk", + "tableFrom": "ssh_data", + "tableTo": "ssh_credentials", + "columnsFrom": [ + "credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "ssh_data_vault_profile_id_vault_profiles_id_fk": { + "name": "ssh_data_vault_profile_id_vault_profiles_id_fk", + "tableFrom": "ssh_data", + "tableTo": "vault_profiles", + "columnsFrom": [ + "vault_profile_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "ssh_data_rdp_credential_id_ssh_credentials_id_fk": { + "name": "ssh_data_rdp_credential_id_ssh_credentials_id_fk", + "tableFrom": "ssh_data", + "tableTo": "ssh_credentials", + "columnsFrom": [ + "rdp_credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "ssh_data_vnc_credential_id_ssh_credentials_id_fk": { + "name": "ssh_data_vnc_credential_id_ssh_credentials_id_fk", + "tableFrom": "ssh_data", + "tableTo": "ssh_credentials", + "columnsFrom": [ + "vnc_credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "ssh_data_telnet_credential_id_ssh_credentials_id_fk": { + "name": "ssh_data_telnet_credential_id_ssh_credentials_id_fk", + "tableFrom": "ssh_data", + "tableTo": "ssh_credentials", + "columnsFrom": [ + "telnet_credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "ssh_data_id": { + "name": "ssh_data_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": { + "ssh_data_sync_id_unique": { + "name": "ssh_data_sync_id_unique", + "columns": [ + "sync_id" + ] + } + }, + "checkConstraint": {} + }, + "notification_channels": { + "name": "notification_channels", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "config": { + "name": "config", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": {}, + "foreignKeys": { + "notification_channels_user_id_users_id_fk": { + "name": "notification_channels_user_id_users_id_fk", + "tableFrom": "notification_channels", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "notification_channels_id": { + "name": "notification_channels_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "opkssh_tokens": { + "name": "opkssh_tokens", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "host_id": { + "name": "host_id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ssh_cert": { + "name": "ssh_cert", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "private_key": { + "name": "private_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sub": { + "name": "sub", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "issuer": { + "name": "issuer", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "audience": { + "name": "audience", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "expires_at": { + "name": "expires_at", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_used": { + "name": "last_used", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_opkssh_tokens_user_host": { + "name": "idx_opkssh_tokens_user_host", + "columns": [ + "user_id", + "host_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "opkssh_tokens_user_id_users_id_fk": { + "name": "opkssh_tokens_user_id_users_id_fk", + "tableFrom": "opkssh_tokens", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "opkssh_tokens_host_id_ssh_data_id_fk": { + "name": "opkssh_tokens_host_id_ssh_data_id_fk", + "tableFrom": "opkssh_tokens", + "tableTo": "ssh_data", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "opkssh_tokens_id": { + "name": "opkssh_tokens_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "plugin_install_counts": { + "name": "plugin_install_counts", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "plugin_id": { + "name": "plugin_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "registry_id": { + "name": "registry_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "count": { + "name": "count", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "('aggregate-telemetry')" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "idx_plugin_install_counts_plugin_registry": { + "name": "idx_plugin_install_counts_plugin_registry", + "columns": [ + "plugin_id", + "registry_id" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "plugin_install_counts_id": { + "name": "plugin_install_counts_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "plugin_migrations": { + "name": "plugin_migrations", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "plugin_id": { + "name": "plugin_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "migration_id": { + "name": "migration_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "checksum": { + "name": "checksum", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "applied_at": { + "name": "applied_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "idx_plugin_migrations_plugin_migration": { + "name": "idx_plugin_migrations_plugin_migration", + "columns": [ + "plugin_id", + "migration_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "plugin_migrations_plugin_id_plugins_id_fk": { + "name": "plugin_migrations_plugin_id_plugins_id_fk", + "tableFrom": "plugin_migrations", + "tableTo": "plugins", + "columnsFrom": [ + "plugin_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "plugin_migrations_id": { + "name": "plugin_migrations_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "plugin_permission_grants": { + "name": "plugin_permission_grants", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "plugin_id": { + "name": "plugin_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "capability": { + "name": "capability", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "granted_at": { + "name": "granted_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "granted_by": { + "name": "granted_by", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "('admin')" + } + }, + "indexes": { + "idx_plugin_permission_grants_plugin_capability": { + "name": "idx_plugin_permission_grants_plugin_capability", + "columns": [ + "plugin_id", + "capability" + ], + "isUnique": true + } + }, + "foreignKeys": { + "plugin_permission_grants_plugin_id_plugins_id_fk": { + "name": "plugin_permission_grants_plugin_id_plugins_id_fk", + "tableFrom": "plugin_permission_grants", + "tableTo": "plugins", + "columnsFrom": [ + "plugin_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "plugin_permission_grants_granted_by_users_id_fk": { + "name": "plugin_permission_grants_granted_by_users_id_fk", + "tableFrom": "plugin_permission_grants", + "tableTo": "users", + "columnsFrom": [ + "granted_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "plugin_permission_grants_id": { + "name": "plugin_permission_grants_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "plugin_registries": { + "name": "plugin_registries", + "columns": { + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "('community')" + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "signing_key": { + "name": "signing_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_checked_at": { + "name": "last_checked_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_index_hash": { + "name": "last_index_hash", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "plugin_registries_id": { + "name": "plugin_registries_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "plugin_settings": { + "name": "plugin_settings", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "plugin_id": { + "name": "plugin_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "scope": { + "name": "scope", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "scope_id": { + "name": "scope_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "key": { + "name": "key", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "encrypted": { + "name": "encrypted", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "idx_plugin_settings_scope_key": { + "name": "idx_plugin_settings_scope_key", + "columns": [ + "plugin_id", + "scope", + "scope_id", + "key" + ], + "isUnique": true + }, + "idx_plugin_settings_plugin_scope": { + "name": "idx_plugin_settings_plugin_scope", + "columns": [ + "plugin_id", + "scope" + ], + "isUnique": false + } + }, + "foreignKeys": { + "plugin_settings_plugin_id_plugins_id_fk": { + "name": "plugin_settings_plugin_id_plugins_id_fk", + "tableFrom": "plugin_settings", + "tableTo": "plugins", + "columnsFrom": [ + "plugin_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "plugin_settings_id": { + "name": "plugin_settings_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "plugin_storage": { + "name": "plugin_storage", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "plugin_id": { + "name": "plugin_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "key": { + "name": "key", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "idx_plugin_storage_plugin_key": { + "name": "idx_plugin_storage_plugin_key", + "columns": [ + "plugin_id", + "key" + ], + "isUnique": true + } + }, + "foreignKeys": { + "plugin_storage_plugin_id_plugins_id_fk": { + "name": "plugin_storage_plugin_id_plugins_id_fk", + "tableFrom": "plugin_storage", + "tableTo": "plugins", + "columnsFrom": [ + "plugin_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "plugin_storage_id": { + "name": "plugin_storage_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "plugins": { + "name": "plugins", + "columns": { + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "version": { + "name": "version", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tier": { + "name": "tier", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "('available')" + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "('community')" + }, + "registry_id": { + "name": "registry_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "('disabled')" + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "installed_at": { + "name": "installed_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "auto_update": { + "name": "auto_update", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "manifest_json": { + "name": "manifest_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_plugins_registry_id": { + "name": "idx_plugins_registry_id", + "columns": [ + "registry_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "plugins_id": { + "name": "plugins_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "rbac_applied_defaults": { + "name": "rbac_applied_defaults", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "role_name": { + "name": "role_name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "permission": { + "name": "permission", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "applied_at": { + "name": "applied_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "idx_rbac_applied_defaults_role_permission": { + "name": "idx_rbac_applied_defaults_role_permission", + "columns": [ + "role_name", + "permission" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "rbac_applied_defaults_id": { + "name": "rbac_applied_defaults_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "rbac_known_permissions": { + "name": "rbac_known_permissions", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "permission": { + "name": "permission", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "plugin_id": { + "name": "plugin_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "first_seen_at": { + "name": "first_seen_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "idx_rbac_known_permissions_permission": { + "name": "idx_rbac_known_permissions_permission", + "columns": [ + "permission" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "rbac_known_permissions_id": { + "name": "rbac_known_permissions_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "recent_activity": { + "name": "recent_activity", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "host_id": { + "name": "host_id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "host_name": { + "name": "host_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "timestamp": { + "name": "timestamp", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "idx_recent_activity_user_ts": { + "name": "idx_recent_activity_user_ts", + "columns": [ + "user_id", + "timestamp" + ], + "isUnique": false + } + }, + "foreignKeys": { + "recent_activity_user_id_users_id_fk": { + "name": "recent_activity_user_id_users_id_fk", + "tableFrom": "recent_activity", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "recent_activity_host_id_ssh_data_id_fk": { + "name": "recent_activity_host_id_ssh_data_id_fk", + "tableFrom": "recent_activity", + "tableTo": "ssh_data", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "recent_activity_id": { + "name": "recent_activity_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "roles": { + "name": "roles", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_system": { + "name": "is_system", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "permissions": { + "name": "permissions", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "roles_id": { + "name": "roles_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": { + "roles_name_unique": { + "name": "roles_name_unique", + "columns": [ + "name" + ] + } + }, + "checkConstraint": {} + }, + "secret_sources": { + "name": "secret_sources", + "columns": { + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "('onepassword-connect')" + }, + "base_url": { + "name": "base_url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "shared": { + "name": "shared", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "idx_secret_sources_user": { + "name": "idx_secret_sources_user", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "secret_sources_user_id_users_id_fk": { + "name": "secret_sources_user_id_users_id_fk", + "tableFrom": "secret_sources", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "secret_sources_id": { + "name": "secret_sources_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "sessions": { + "name": "sessions", + "columns": { + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "jwt_token": { + "name": "jwt_token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "device_type": { + "name": "device_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "device_info": { + "name": "device_info", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "oidc_sub": { + "name": "oidc_sub", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "oidc_sid": { + "name": "oidc_sid", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sso_provider_id": { + "name": "sso_provider_id", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "expires_at": { + "name": "expires_at", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_active_at": { + "name": "last_active_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "idx_sessions_user_id": { + "name": "idx_sessions_user_id", + "columns": [ + "user_id" + ], + "isUnique": false + }, + "idx_sessions_expires_at": { + "name": "idx_sessions_expires_at", + "columns": [ + "expires_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "sessions_user_id_users_id_fk": { + "name": "sessions_user_id_users_id_fk", + "tableFrom": "sessions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "sessions_id": { + "name": "sessions_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "settings": { + "name": "settings", + "columns": { + "key": { + "name": "key", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "settings_key": { + "name": "settings_key", + "columns": [ + "key" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "shared_credential_secrets": { + "name": "shared_credential_secrets", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "credential_access_id": { + "name": "credential_access_id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "target_user_id": { + "name": "target_user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "credential_id": { + "name": "credential_id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "encrypted_username": { + "name": "encrypted_username", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "auth_type": { + "name": "auth_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "('password')" + }, + "encrypted_password": { + "name": "encrypted_password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "encrypted_key": { + "name": "encrypted_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "encrypted_key_password": { + "name": "encrypted_key_password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "key_type": { + "name": "key_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "public_key": { + "name": "public_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cert_public_key": { + "name": "cert_public_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "idx_shared_credential_secrets_scope": { + "name": "idx_shared_credential_secrets_scope", + "columns": [ + "credential_access_id", + "target_user_id" + ], + "isUnique": true + }, + "idx_shared_credential_secrets_target": { + "name": "idx_shared_credential_secrets_target", + "columns": [ + "target_user_id", + "credential_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "shared_credential_secrets_target_user_id_users_id_fk": { + "name": "shared_credential_secrets_target_user_id_users_id_fk", + "tableFrom": "shared_credential_secrets", + "tableTo": "users", + "columnsFrom": [ + "target_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "shared_credential_secrets_credential_id_ssh_credentials_id_fk": { + "name": "shared_credential_secrets_credential_id_ssh_credentials_id_fk", + "tableFrom": "shared_credential_secrets", + "tableTo": "ssh_credentials", + "columnsFrom": [ + "credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "shared_cred_secrets_access_id_fk": { + "name": "shared_cred_secrets_access_id_fk", + "tableFrom": "shared_credential_secrets", + "tableTo": "credential_access", + "columnsFrom": [ + "credential_access_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "shared_credential_secrets_id": { + "name": "shared_credential_secrets_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "shared_host_auth_overrides": { + "name": "shared_host_auth_overrides", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "host_id": { + "name": "host_id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "protocol": { + "name": "protocol", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'ssh'" + }, + "credential_id": { + "name": "credential_id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "shared_host_auth_overrides_host_user_protocol_unique": { + "name": "shared_host_auth_overrides_host_user_protocol_unique", + "columns": [ + "host_id", + "user_id", + "protocol" + ], + "isUnique": true + } + }, + "foreignKeys": { + "shared_host_auth_overrides_host_id_ssh_data_id_fk": { + "name": "shared_host_auth_overrides_host_id_ssh_data_id_fk", + "tableFrom": "shared_host_auth_overrides", + "tableTo": "ssh_data", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "shared_host_auth_overrides_user_id_users_id_fk": { + "name": "shared_host_auth_overrides_user_id_users_id_fk", + "tableFrom": "shared_host_auth_overrides", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "shared_host_auth_overrides_credential_id_ssh_credentials_id_fk": { + "name": "shared_host_auth_overrides_credential_id_ssh_credentials_id_fk", + "tableFrom": "shared_host_auth_overrides", + "tableTo": "ssh_credentials", + "columnsFrom": [ + "credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "shared_host_auth_overrides_id": { + "name": "shared_host_auth_overrides_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "shared_host_secrets": { + "name": "shared_host_secrets", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "host_access_id": { + "name": "host_access_id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "target_user_id": { + "name": "target_user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "protocol": { + "name": "protocol", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'ssh'" + }, + "source_type": { + "name": "source_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "('credential')" + }, + "original_credential_id": { + "name": "original_credential_id", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "encrypted_username": { + "name": "encrypted_username", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "encrypted_auth_type": { + "name": "encrypted_auth_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "encrypted_password": { + "name": "encrypted_password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "encrypted_key": { + "name": "encrypted_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "encrypted_key_password": { + "name": "encrypted_key_password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "encrypted_key_type": { + "name": "encrypted_key_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "encrypted_domain": { + "name": "encrypted_domain", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "idx_shared_host_secrets_scope": { + "name": "idx_shared_host_secrets_scope", + "columns": [ + "host_access_id", + "target_user_id", + "protocol" + ], + "isUnique": true + } + }, + "foreignKeys": { + "shared_host_secrets_host_access_id_host_access_id_fk": { + "name": "shared_host_secrets_host_access_id_host_access_id_fk", + "tableFrom": "shared_host_secrets", + "tableTo": "host_access", + "columnsFrom": [ + "host_access_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "shared_host_secrets_target_user_id_users_id_fk": { + "name": "shared_host_secrets_target_user_id_users_id_fk", + "tableFrom": "shared_host_secrets", + "tableTo": "users", + "columnsFrom": [ + "target_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "shared_host_secrets_original_credential_id_ssh_credentials_id_fk": { + "name": "shared_host_secrets_original_credential_id_ssh_credentials_id_fk", + "tableFrom": "shared_host_secrets", + "tableTo": "ssh_credentials", + "columnsFrom": [ + "original_credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "shared_host_secrets_id": { + "name": "shared_host_secrets_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "ssh_credential_usage": { + "name": "ssh_credential_usage", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "credential_id": { + "name": "credential_id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "host_id": { + "name": "host_id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "used_at": { + "name": "used_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "idx_ssh_credential_usage_credential": { + "name": "idx_ssh_credential_usage_credential", + "columns": [ + "credential_id" + ], + "isUnique": false + }, + "idx_ssh_credential_usage_user": { + "name": "idx_ssh_credential_usage_user", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "ssh_credential_usage_credential_id_ssh_credentials_id_fk": { + "name": "ssh_credential_usage_credential_id_ssh_credentials_id_fk", + "tableFrom": "ssh_credential_usage", + "tableTo": "ssh_credentials", + "columnsFrom": [ + "credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "ssh_credential_usage_host_id_ssh_data_id_fk": { + "name": "ssh_credential_usage_host_id_ssh_data_id_fk", + "tableFrom": "ssh_credential_usage", + "tableTo": "ssh_data", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "ssh_credential_usage_user_id_users_id_fk": { + "name": "ssh_credential_usage_user_id_users_id_fk", + "tableFrom": "ssh_credential_usage", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "ssh_credential_usage_id": { + "name": "ssh_credential_usage_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "ssh_credentials": { + "name": "ssh_credentials", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "folder": { + "name": "folder", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "tags": { + "name": "tags", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "pin": { + "name": "pin", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "sort_order": { + "name": "sort_order", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "auth_type": { + "name": "auth_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "private_key": { + "name": "private_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "public_key": { + "name": "public_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "key_password": { + "name": "key_password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "key_type": { + "name": "key_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "detected_key_type": { + "name": "detected_key_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cert_public_key": { + "name": "cert_public_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "usage_count": { + "name": "usage_count", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "last_used": { + "name": "last_used", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sync_id": { + "name": "sync_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "idx_ssh_credentials_user_id": { + "name": "idx_ssh_credentials_user_id", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "ssh_credentials_user_id_users_id_fk": { + "name": "ssh_credentials_user_id_users_id_fk", + "tableFrom": "ssh_credentials", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "ssh_credentials_id": { + "name": "ssh_credentials_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": { + "ssh_credentials_sync_id_unique": { + "name": "ssh_credentials_sync_id_unique", + "columns": [ + "sync_id" + ] + } + }, + "checkConstraint": {} + }, + "ssh_folders": { + "name": "ssh_folders", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "icon": { + "name": "icon", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "credential_id": { + "name": "credential_id", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sort_order": { + "name": "sort_order", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sync_id": { + "name": "sync_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "idx_ssh_folders_user_id": { + "name": "idx_ssh_folders_user_id", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "ssh_folders_user_id_users_id_fk": { + "name": "ssh_folders_user_id_users_id_fk", + "tableFrom": "ssh_folders", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "ssh_folders_credential_id_ssh_credentials_id_fk": { + "name": "ssh_folders_credential_id_ssh_credentials_id_fk", + "tableFrom": "ssh_folders", + "tableTo": "ssh_credentials", + "columnsFrom": [ + "credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "ssh_folders_id": { + "name": "ssh_folders_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": { + "ssh_folders_sync_id_unique": { + "name": "ssh_folders_sync_id_unique", + "columns": [ + "sync_id" + ] + } + }, + "checkConstraint": {} + }, + "sso_providers": { + "name": "sso_providers", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "display_order": { + "name": "display_order", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "config": { + "name": "config", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "sso_providers_id": { + "name": "sso_providers_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "sync_tombstones": { + "name": "sync_tombstones", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "entity_type": { + "name": "entity_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sync_id": { + "name": "sync_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": {}, + "foreignKeys": { + "sync_tombstones_user_id_users_id_fk": { + "name": "sync_tombstones_user_id_users_id_fk", + "tableFrom": "sync_tombstones", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "sync_tombstones_id": { + "name": "sync_tombstones_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "termix_identities": { + "name": "termix_identities", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "handle": { + "name": "handle", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": {}, + "foreignKeys": { + "termix_identities_user_id_users_id_fk": { + "name": "termix_identities_user_id_users_id_fk", + "tableFrom": "termix_identities", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "termix_identities_id": { + "name": "termix_identities_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": { + "termix_identities_user_id_unique": { + "name": "termix_identities_user_id_unique", + "columns": [ + "user_id" + ] + }, + "termix_identities_handle_unique": { + "name": "termix_identities_handle_unique", + "columns": [ + "handle" + ] + } + }, + "checkConstraint": {} + }, + "termix_identity_ca": { + "name": "termix_identity_ca", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "identity_id": { + "name": "identity_id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "public_key": { + "name": "public_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "private_key": { + "name": "private_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "validity_days": { + "name": "validity_days", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 90 + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": {}, + "foreignKeys": { + "termix_identity_ca_identity_id_termix_identities_id_fk": { + "name": "termix_identity_ca_identity_id_termix_identities_id_fk", + "tableFrom": "termix_identity_ca", + "tableTo": "termix_identities", + "columnsFrom": [ + "identity_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "termix_identity_ca_user_id_users_id_fk": { + "name": "termix_identity_ca_user_id_users_id_fk", + "tableFrom": "termix_identity_ca", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "termix_identity_ca_id": { + "name": "termix_identity_ca_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": { + "termix_identity_ca_identity_id_unique": { + "name": "termix_identity_ca_identity_id_unique", + "columns": [ + "identity_id" + ] + } + }, + "checkConstraint": {} + }, + "termix_identity_keys": { + "name": "termix_identity_keys", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "identity_id": { + "name": "identity_id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "public_key": { + "name": "public_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "key_type": { + "name": "key_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "algorithm": { + "name": "algorithm", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "comment": { + "name": "comment", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "('manual')" + }, + "credential_id": { + "name": "credential_id", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": {}, + "foreignKeys": { + "termix_identity_keys_identity_id_termix_identities_id_fk": { + "name": "termix_identity_keys_identity_id_termix_identities_id_fk", + "tableFrom": "termix_identity_keys", + "tableTo": "termix_identities", + "columnsFrom": [ + "identity_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "termix_identity_keys_user_id_users_id_fk": { + "name": "termix_identity_keys_user_id_users_id_fk", + "tableFrom": "termix_identity_keys", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "termix_identity_keys_credential_id_ssh_credentials_id_fk": { + "name": "termix_identity_keys_credential_id_ssh_credentials_id_fk", + "tableFrom": "termix_identity_keys", + "tableTo": "ssh_credentials", + "columnsFrom": [ + "credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "termix_identity_keys_id": { + "name": "termix_identity_keys_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "trusted_devices": { + "name": "trusted_devices", + "columns": { + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "device_fingerprint": { + "name": "device_fingerprint", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "device_type": { + "name": "device_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "device_info": { + "name": "device_info", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "expires_at": { + "name": "expires_at", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_used_at": { + "name": "last_used_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "idx_trusted_devices_user_id": { + "name": "idx_trusted_devices_user_id", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "trusted_devices_user_id_users_id_fk": { + "name": "trusted_devices_user_id_users_id_fk", + "tableFrom": "trusted_devices", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "trusted_devices_id": { + "name": "trusted_devices_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "ui_preferences": { + "name": "ui_preferences", + "columns": { + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "data": { + "name": "data", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": {}, + "foreignKeys": { + "ui_preferences_user_id_users_id_fk": { + "name": "ui_preferences_user_id_users_id_fk", + "tableFrom": "ui_preferences", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "ui_preferences_user_id": { + "name": "ui_preferences_user_id", + "columns": [ + "user_id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "user_external_identities": { + "name": "user_external_identities", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider_id": { + "name": "provider_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "subject": { + "name": "subject", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "idx_user_external_identities_provider_subject": { + "name": "idx_user_external_identities_provider_subject", + "columns": [ + "provider_id", + "subject" + ], + "isUnique": true + }, + "idx_user_external_identities_user": { + "name": "idx_user_external_identities_user", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "user_external_identities_user_id_users_id_fk": { + "name": "user_external_identities_user_id_users_id_fk", + "tableFrom": "user_external_identities", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "user_external_identities_id": { + "name": "user_external_identities_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "user_open_tabs": { + "name": "user_open_tabs", + "columns": { + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tab_type": { + "name": "tab_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "host_id": { + "name": "host_id", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tab_order": { + "name": "tab_order", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "backend_session_id": { + "name": "backend_session_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "idx_user_open_tabs_user_id": { + "name": "idx_user_open_tabs_user_id", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "user_open_tabs_user_id_users_id_fk": { + "name": "user_open_tabs_user_id_users_id_fk", + "tableFrom": "user_open_tabs", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_open_tabs_host_id_ssh_data_id_fk": { + "name": "user_open_tabs_host_id_ssh_data_id_fk", + "tableFrom": "user_open_tabs", + "tableTo": "ssh_data", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "user_open_tabs_id": { + "name": "user_open_tabs_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "user_preferences": { + "name": "user_preferences", + "columns": { + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "reopen_tabs_on_login": { + "name": "reopen_tabs_on_login", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "theme": { + "name": "theme", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "font_size": { + "name": "font_size", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "accent_color": { + "name": "accent_color", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "language": { + "name": "language", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "storage_mode": { + "name": "storage_mode", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "command_autocomplete": { + "name": "command_autocomplete", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "command_palette_enabled": { + "name": "command_palette_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "show_host_tags": { + "name": "show_host_tags", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "host_tray_on_click": { + "name": "host_tray_on_click", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "pin_app_rail": { + "name": "pin_app_rail", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "expand_app_rail_on_hover": { + "name": "expand_app_rail_on_hover", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "show_pin_app_rail_button": { + "name": "show_pin_app_rail_button", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "folders_collapsed": { + "name": "folders_collapsed", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "confirm_snippet_execution": { + "name": "confirm_snippet_execution", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "disable_update_check": { + "name": "disable_update_check", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "confirm_tab_close": { + "name": "confirm_tab_close", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "hidden_rail_tabs": { + "name": "hidden_rail_tabs", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "compact_host_view": { + "name": "compact_host_view", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status_color_scheme": { + "name": "status_color_scheme", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "custom_themes": { + "name": "custom_themes", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "custom_keybindings": { + "name": "custom_keybindings", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "terminal_defaults": { + "name": "terminal_defaults", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "terminal_macros": { + "name": "terminal_macros", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": {}, + "foreignKeys": { + "user_preferences_user_id_users_id_fk": { + "name": "user_preferences_user_id_users_id_fk", + "tableFrom": "user_preferences", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "user_preferences_user_id": { + "name": "user_preferences_user_id", + "columns": [ + "user_id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "user_roles": { + "name": "user_roles", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role_id": { + "name": "role_id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "granted_by": { + "name": "granted_by", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "granted_at": { + "name": "granted_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "idx_user_roles_user_role": { + "name": "idx_user_roles_user_role", + "columns": [ + "user_id", + "role_id" + ], + "isUnique": true + }, + "idx_user_roles_role_id": { + "name": "idx_user_roles_role_id", + "columns": [ + "role_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "user_roles_user_id_users_id_fk": { + "name": "user_roles_user_id_users_id_fk", + "tableFrom": "user_roles", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_roles_role_id_roles_id_fk": { + "name": "user_roles_role_id_roles_id_fk", + "tableFrom": "user_roles", + "tableTo": "roles", + "columnsFrom": [ + "role_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_roles_granted_by_users_id_fk": { + "name": "user_roles_granted_by_users_id_fk", + "tableFrom": "user_roles", + "tableTo": "users", + "columnsFrom": [ + "granted_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "user_roles_id": { + "name": "user_roles_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "user_second_factors": { + "name": "user_second_factors", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "plugin_id": { + "name": "plugin_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "factor_id": { + "name": "factor_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "enrolled_at": { + "name": "enrolled_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "idx_user_second_factors_user_factor": { + "name": "idx_user_second_factors_user_factor", + "columns": [ + "user_id", + "plugin_id", + "factor_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "user_second_factors_user_id_users_id_fk": { + "name": "user_second_factors_user_id_users_id_fk", + "tableFrom": "user_second_factors", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "user_second_factors_id": { + "name": "user_second_factors_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "users": { + "name": "users", + "columns": { + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "password_hash": { + "name": "password_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "is_admin": { + "name": "is_admin", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "is_oidc": { + "name": "is_oidc", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "oidc_identifier": { + "name": "oidc_identifier", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sso_provider_id": { + "name": "sso_provider_id", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "client_secret": { + "name": "client_secret", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "issuer_url": { + "name": "issuer_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "authorization_url": { + "name": "authorization_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "token_url": { + "name": "token_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "identifier_path": { + "name": "identifier_path", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "name_path": { + "name": "name_path", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "scopes": { + "name": "scopes", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "('openid email profile')" + }, + "totp_secret": { + "name": "totp_secret", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "totp_enabled": { + "name": "totp_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "totp_backup_codes": { + "name": "totp_backup_codes", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "registered_at": { + "name": "registered_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "donation_modal_dismissed": { + "name": "donation_modal_dismissed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "users_id": { + "name": "users_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "vault_profiles": { + "name": "vault_profiles", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "folder": { + "name": "folder", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "tags": { + "name": "tags", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "vault_addr": { + "name": "vault_addr", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "vault_namespace": { + "name": "vault_namespace", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "oidc_mount": { + "name": "oidc_mount", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "oidc_role": { + "name": "oidc_role", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "ssh_mount": { + "name": "ssh_mount", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "ssh_role": { + "name": "ssh_role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "valid_principals": { + "name": "valid_principals", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "key_type": { + "name": "key_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "shared": { + "name": "shared", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "sync_id": { + "name": "sync_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": {}, + "foreignKeys": { + "vault_profiles_user_id_users_id_fk": { + "name": "vault_profiles_user_id_users_id_fk", + "tableFrom": "vault_profiles", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "vault_profiles_id": { + "name": "vault_profiles_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": { + "vault_profiles_sync_id_unique": { + "name": "vault_profiles_sync_id_unique", + "columns": [ + "sync_id" + ] + } + }, + "checkConstraint": {} + }, + "vault_tokens": { + "name": "vault_tokens", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "profile_id": { + "name": "profile_id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ssh_cert": { + "name": "ssh_cert", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "private_key": { + "name": "private_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "expires_at": { + "name": "expires_at", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_used": { + "name": "last_used", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_vault_tokens_user_profile": { + "name": "idx_vault_tokens_user_profile", + "columns": [ + "user_id", + "profile_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "vault_tokens_user_id_users_id_fk": { + "name": "vault_tokens_user_id_users_id_fk", + "tableFrom": "vault_tokens", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "vault_tokens_profile_id_vault_profiles_id_fk": { + "name": "vault_tokens_profile_id_vault_profiles_id_fk", + "tableFrom": "vault_tokens", + "tableTo": "vault_profiles", + "columnsFrom": [ + "profile_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "vault_tokens_id": { + "name": "vault_tokens_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "webauthn_credentials": { + "name": "webauthn_credentials", + "columns": { + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "credential_id": { + "name": "credential_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "public_key": { + "name": "public_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "counter": { + "name": "counter", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "device_type": { + "name": "device_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "backed_up": { + "name": "backed_up", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "transports": { + "name": "transports", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_verification": { + "name": "user_verification", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "('preferred')" + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "last_used_at": { + "name": "last_used_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "webauthn_credentials_user_id_users_id_fk": { + "name": "webauthn_credentials_user_id_users_id_fk", + "tableFrom": "webauthn_credentials", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "webauthn_credentials_id": { + "name": "webauthn_credentials_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + } + }, + "views": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "tables": {}, + "indexes": {} + } +} \ No newline at end of file diff --git a/drizzle/mysql/meta/_journal.json b/drizzle/mysql/meta/_journal.json index f6275149d..4a2e25a73 100644 --- a/drizzle/mysql/meta/_journal.json +++ b/drizzle/mysql/meta/_journal.json @@ -330,6 +330,13 @@ "when": 1790272734438, "tag": "0046_rich_logan", "breakpoints": true + }, + { + "idx": 47, + "version": "5", + "when": 1790274704817, + "tag": "0047_red_silver_sable", + "breakpoints": true } ] } \ No newline at end of file diff --git a/drizzle/postgres/0048_rare_catseye.sql b/drizzle/postgres/0048_rare_catseye.sql new file mode 100644 index 000000000..ad49ffb54 --- /dev/null +++ b/drizzle/postgres/0048_rare_catseye.sql @@ -0,0 +1,8 @@ +-- The ai tables moved to the ai plugin, which renames them into its own +-- namespace at activation, and ssh_data.enable_ai_assistant and the two +-- user_preferences ai columns moved to its settings. Both run after this +-- migration, so a real DROP here would delete the data first. The columns +-- stay in place, unused, until 3.0.0. The timestamp and label type changes +-- drizzle-kit also wrote are generator drift (see FINISH-LIST.md), not a +-- change to the database, and are left out on purpose. +SELECT 1; diff --git a/drizzle/postgres/meta/0048_snapshot.json b/drizzle/postgres/meta/0048_snapshot.json new file mode 100644 index 000000000..8fbb2f703 --- /dev/null +++ b/drizzle/postgres/meta/0048_snapshot.json @@ -0,0 +1,5886 @@ +{ + "id": "0aa47ca9-9459-452f-af7e-f98bc08cb1f5", + "prevId": "2409d374-fdcc-4143-8d51-6a55e514b1ef", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.api_keys": { + "name": "api_keys", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token_prefix": { + "name": "token_prefix", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "expires_at": { + "name": "expires_at", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "last_used_at": { + "name": "last_used_at", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + } + }, + "indexes": { + "idx_api_keys_user_id": { + "name": "idx_api_keys_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "api_keys_user_id_users_id_fk": { + "name": "api_keys_user_id_users_id_fk", + "tableFrom": "api_keys", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.audit_logs": { + "name": "audit_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "action": { + "name": "action", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "resource_type": { + "name": "resource_type", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "resource_id": { + "name": "resource_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "resource_name": { + "name": "resource_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "details": { + "name": "details", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "success": { + "name": "success", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "timestamp": { + "name": "timestamp", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_audit_logs_timestamp": { + "name": "idx_audit_logs_timestamp", + "columns": [ + { + "expression": "timestamp", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_audit_logs_user_ts": { + "name": "idx_audit_logs_user_ts", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "timestamp", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_audit_logs_action_ts": { + "name": "idx_audit_logs_action_ts", + "columns": [ + { + "expression": "action", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "timestamp", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_audit_logs_resource_ts": { + "name": "idx_audit_logs_resource_ts", + "columns": [ + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "timestamp", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "audit_logs_user_id_users_id_fk": { + "name": "audit_logs_user_id_users_id_fk", + "tableFrom": "audit_logs", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.credential_access": { + "name": "credential_access", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "credential_id": { + "name": "credential_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "role_id": { + "name": "role_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "granted_by": { + "name": "granted_by", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "permission_level": { + "name": "permission_level", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'use'" + }, + "expires_at": { + "name": "expires_at", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_credential_access_user_id": { + "name": "idx_credential_access_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_credential_access_role_id": { + "name": "idx_credential_access_role_id", + "columns": [ + { + "expression": "role_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_credential_access_credential_id": { + "name": "idx_credential_access_credential_id", + "columns": [ + { + "expression": "credential_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "credential_access_credential_id_ssh_credentials_id_fk": { + "name": "credential_access_credential_id_ssh_credentials_id_fk", + "tableFrom": "credential_access", + "tableTo": "ssh_credentials", + "columnsFrom": [ + "credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_access_user_id_users_id_fk": { + "name": "credential_access_user_id_users_id_fk", + "tableFrom": "credential_access", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_access_role_id_roles_id_fk": { + "name": "credential_access_role_id_roles_id_fk", + "tableFrom": "credential_access", + "tableTo": "roles", + "columnsFrom": [ + "role_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_access_granted_by_users_id_fk": { + "name": "credential_access_granted_by_users_id_fk", + "tableFrom": "credential_access", + "tableTo": "users", + "columnsFrom": [ + "granted_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.credential_sidebar_preferences": { + "name": "credential_sidebar_preferences", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "data": { + "name": "data", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": { + "credential_sidebar_preferences_user_id_users_id_fk": { + "name": "credential_sidebar_preferences_user_id_users_id_fk", + "tableFrom": "credential_sidebar_preferences", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.dashboard_service_links": { + "name": "dashboard_service_links", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "order": { + "name": "order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "sync_id": { + "name": "sync_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": { + "dashboard_service_links_user_id_users_id_fk": { + "name": "dashboard_service_links_user_id_users_id_fk", + "tableFrom": "dashboard_service_links", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "dashboard_service_links_sync_id_unique": { + "name": "dashboard_service_links_sync_id_unique", + "nullsNotDistinct": false, + "columns": [ + "sync_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.dismissed_alerts": { + "name": "dismissed_alerts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "alert_id": { + "name": "alert_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "dismissed_at": { + "name": "dismissed_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_dismissed_alerts_user_id": { + "name": "idx_dismissed_alerts_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "dismissed_alerts_user_id_users_id_fk": { + "name": "dismissed_alerts_user_id_users_id_fk", + "tableFrom": "dismissed_alerts", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.folder_access": { + "name": "folder_access", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "folder": { + "name": "folder", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "role_id": { + "name": "role_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "granted_by": { + "name": "granted_by", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "permission_level": { + "name": "permission_level", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'connect'" + }, + "expires_at": { + "name": "expires_at", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_folder_access_owner_folder": { + "name": "idx_folder_access_owner_folder", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "folder", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "folder_access_owner_user_id_users_id_fk": { + "name": "folder_access_owner_user_id_users_id_fk", + "tableFrom": "folder_access", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "folder_access_user_id_users_id_fk": { + "name": "folder_access_user_id_users_id_fk", + "tableFrom": "folder_access", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "folder_access_role_id_roles_id_fk": { + "name": "folder_access_role_id_roles_id_fk", + "tableFrom": "folder_access", + "tableTo": "roles", + "columnsFrom": [ + "role_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "folder_access_granted_by_users_id_fk": { + "name": "folder_access_granted_by_users_id_fk", + "tableFrom": "folder_access", + "tableTo": "users", + "columnsFrom": [ + "granted_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.homepage_items": { + "name": "homepage_items", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "type_id": { + "name": "type_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "config": { + "name": "config", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "folder_id": { + "name": "folder_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "sync_id": { + "name": "sync_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_homepage_items_user_id": { + "name": "idx_homepage_items_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "homepage_items_user_id_users_id_fk": { + "name": "homepage_items_user_id_users_id_fk", + "tableFrom": "homepage_items", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "homepage_items_sync_id_unique": { + "name": "homepage_items_sync_id_unique", + "nullsNotDistinct": false, + "columns": [ + "sync_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.homepage_layouts": { + "name": "homepage_layouts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "layout": { + "name": "layout", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": { + "homepage_layouts_user_id_users_id_fk": { + "name": "homepage_layouts_user_id_users_id_fk", + "tableFrom": "homepage_layouts", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "homepage_layouts_user_id_unique": { + "name": "homepage_layouts_user_id_unique", + "nullsNotDistinct": false, + "columns": [ + "user_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.host_access": { + "name": "host_access", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "host_id": { + "name": "host_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "role_id": { + "name": "role_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "granted_by": { + "name": "granted_by", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "permission_level": { + "name": "permission_level", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'connect'" + }, + "expires_at": { + "name": "expires_at", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "last_accessed_at": { + "name": "last_accessed_at", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_count": { + "name": "access_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + } + }, + "indexes": { + "idx_host_access_user_id": { + "name": "idx_host_access_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_host_access_role_id": { + "name": "idx_host_access_role_id", + "columns": [ + { + "expression": "role_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_host_access_host_id": { + "name": "idx_host_access_host_id", + "columns": [ + { + "expression": "host_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_host_access_expires_at": { + "name": "idx_host_access_expires_at", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "host_access_host_id_ssh_data_id_fk": { + "name": "host_access_host_id_ssh_data_id_fk", + "tableFrom": "host_access", + "tableTo": "ssh_data", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "host_access_user_id_users_id_fk": { + "name": "host_access_user_id_users_id_fk", + "tableFrom": "host_access", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "host_access_role_id_roles_id_fk": { + "name": "host_access_role_id_roles_id_fk", + "tableFrom": "host_access", + "tableTo": "roles", + "columnsFrom": [ + "role_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "host_access_granted_by_users_id_fk": { + "name": "host_access_granted_by_users_id_fk", + "tableFrom": "host_access", + "tableTo": "users", + "columnsFrom": [ + "granted_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.host_sidebar_preferences": { + "name": "host_sidebar_preferences", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "data": { + "name": "data", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": { + "host_sidebar_preferences_user_id_users_id_fk": { + "name": "host_sidebar_preferences_user_id_users_id_fk", + "tableFrom": "host_sidebar_preferences", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.ssh_data": { + "name": "ssh_data", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "connection_type": { + "name": "connection_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'ssh'" + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "ip": { + "name": "ip", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "port": { + "name": "port", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "folder": { + "name": "folder", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "parent_host_id": { + "name": "parent_host_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "tags": { + "name": "tags", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "pin": { + "name": "pin", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "auth_type": { + "name": "auth_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "use_warpgate": { + "name": "use_warpgate", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "share_ssh_auth": { + "name": "share_ssh_auth", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "force_keyboard_interactive": { + "name": "force_keyboard_interactive", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "key_password": { + "name": "key_password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "key_type": { + "name": "key_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sudo_password": { + "name": "sudo_password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "autostart_password": { + "name": "autostart_password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "autostart_key": { + "name": "autostart_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "autostart_key_password": { + "name": "autostart_key_password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "credential_id": { + "name": "credential_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "override_credential_username": { + "name": "override_credential_username", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "vault_profile_id": { + "name": "vault_profile_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "enable_terminal": { + "name": "enable_terminal", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "enable_session_logging": { + "name": "enable_session_logging", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "allow_session_sharing": { + "name": "allow_session_sharing", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "enable_command_history": { + "name": "enable_command_history", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "enable_tunnel": { + "name": "enable_tunnel", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "tunnel_connections": { + "name": "tunnel_connections", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "jump_hosts": { + "name": "jump_hosts", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "enable_file_manager": { + "name": "enable_file_manager", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "scp_legacy": { + "name": "scp_legacy", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "enable_web_ui": { + "name": "enable_web_ui", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "enable_tmux_monitor": { + "name": "enable_tmux_monitor", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "enable_terminal_toolbar": { + "name": "enable_terminal_toolbar", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "show_terminal_in_sidebar": { + "name": "show_terminal_in_sidebar", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "show_file_manager_in_sidebar": { + "name": "show_file_manager_in_sidebar", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "show_tunnel_in_sidebar": { + "name": "show_tunnel_in_sidebar", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "show_docker_in_sidebar": { + "name": "show_docker_in_sidebar", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "show_server_stats_in_sidebar": { + "name": "show_server_stats_in_sidebar", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "default_path": { + "name": "default_path", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status_check_enabled": { + "name": "status_check_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "status_check_interval": { + "name": "status_check_interval", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "web_ui_config": { + "name": "web_ui_config", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "terminal_config": { + "name": "terminal_config", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "quick_actions": { + "name": "quick_actions", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "enable_ssh": { + "name": "enable_ssh", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "ssh_port": { + "name": "ssh_port", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 22 + }, + "rdp_credential_id": { + "name": "rdp_credential_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "rdp_user": { + "name": "rdp_user", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "rdp_password": { + "name": "rdp_password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "rdp_domain": { + "name": "rdp_domain", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "vnc_credential_id": { + "name": "vnc_credential_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "vnc_password": { + "name": "vnc_password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "vnc_user": { + "name": "vnc_user", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "telnet_user": { + "name": "telnet_user", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "telnet_password": { + "name": "telnet_password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "telnet_credential_id": { + "name": "telnet_credential_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "rdp_auth_type": { + "name": "rdp_auth_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "vnc_auth_type": { + "name": "vnc_auth_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "telnet_auth_type": { + "name": "telnet_auth_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "domain": { + "name": "domain", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "use_socks5": { + "name": "use_socks5", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "socks5_host": { + "name": "socks5_host", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "socks5_port": { + "name": "socks5_port", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "socks5_username": { + "name": "socks5_username", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "socks5_password": { + "name": "socks5_password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "socks5_proxy_chain": { + "name": "socks5_proxy_chain", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "connection_origin": { + "name": "connection_origin", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "mac_address": { + "name": "mac_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "wol_broadcast_address": { + "name": "wol_broadcast_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "port_knock_sequence": { + "name": "port_knock_sequence", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "host_key_fingerprint": { + "name": "host_key_fingerprint", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "host_key_type": { + "name": "host_key_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "host_key_algorithm": { + "name": "host_key_algorithm", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'sha256'" + }, + "host_key_first_seen": { + "name": "host_key_first_seen", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "host_key_last_verified": { + "name": "host_key_last_verified", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "host_key_changed_count": { + "name": "host_key_changed_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "sync_id": { + "name": "sync_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_ssh_data_user_id": { + "name": "idx_ssh_data_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_ssh_data_parent_host": { + "name": "idx_ssh_data_parent_host", + "columns": [ + { + "expression": "parent_host_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_ssh_data_credential": { + "name": "idx_ssh_data_credential", + "columns": [ + { + "expression": "credential_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "ssh_data_user_id_users_id_fk": { + "name": "ssh_data_user_id_users_id_fk", + "tableFrom": "ssh_data", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "ssh_data_parent_host_id_ssh_data_id_fk": { + "name": "ssh_data_parent_host_id_ssh_data_id_fk", + "tableFrom": "ssh_data", + "tableTo": "ssh_data", + "columnsFrom": [ + "parent_host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "ssh_data_credential_id_ssh_credentials_id_fk": { + "name": "ssh_data_credential_id_ssh_credentials_id_fk", + "tableFrom": "ssh_data", + "tableTo": "ssh_credentials", + "columnsFrom": [ + "credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "ssh_data_vault_profile_id_vault_profiles_id_fk": { + "name": "ssh_data_vault_profile_id_vault_profiles_id_fk", + "tableFrom": "ssh_data", + "tableTo": "vault_profiles", + "columnsFrom": [ + "vault_profile_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "ssh_data_rdp_credential_id_ssh_credentials_id_fk": { + "name": "ssh_data_rdp_credential_id_ssh_credentials_id_fk", + "tableFrom": "ssh_data", + "tableTo": "ssh_credentials", + "columnsFrom": [ + "rdp_credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "ssh_data_vnc_credential_id_ssh_credentials_id_fk": { + "name": "ssh_data_vnc_credential_id_ssh_credentials_id_fk", + "tableFrom": "ssh_data", + "tableTo": "ssh_credentials", + "columnsFrom": [ + "vnc_credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "ssh_data_telnet_credential_id_ssh_credentials_id_fk": { + "name": "ssh_data_telnet_credential_id_ssh_credentials_id_fk", + "tableFrom": "ssh_data", + "tableTo": "ssh_credentials", + "columnsFrom": [ + "telnet_credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "ssh_data_sync_id_unique": { + "name": "ssh_data_sync_id_unique", + "nullsNotDistinct": false, + "columns": [ + "sync_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.notification_channels": { + "name": "notification_channels", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "config": { + "name": "config", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": { + "notification_channels_user_id_users_id_fk": { + "name": "notification_channels_user_id_users_id_fk", + "tableFrom": "notification_channels", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.opkssh_tokens": { + "name": "opkssh_tokens", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "host_id": { + "name": "host_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "ssh_cert": { + "name": "ssh_cert", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "private_key": { + "name": "private_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sub": { + "name": "sub", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "issuer": { + "name": "issuer", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "audience": { + "name": "audience", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "expires_at": { + "name": "expires_at", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "last_used": { + "name": "last_used", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_opkssh_tokens_user_host": { + "name": "idx_opkssh_tokens_user_host", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "host_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "opkssh_tokens_user_id_users_id_fk": { + "name": "opkssh_tokens_user_id_users_id_fk", + "tableFrom": "opkssh_tokens", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "opkssh_tokens_host_id_ssh_data_id_fk": { + "name": "opkssh_tokens_host_id_ssh_data_id_fk", + "tableFrom": "opkssh_tokens", + "tableTo": "ssh_data", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.plugin_install_counts": { + "name": "plugin_install_counts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "plugin_id": { + "name": "plugin_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "registry_id": { + "name": "registry_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "count": { + "name": "count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'aggregate-telemetry'" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_plugin_install_counts_plugin_registry": { + "name": "idx_plugin_install_counts_plugin_registry", + "columns": [ + { + "expression": "plugin_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "registry_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.plugin_migrations": { + "name": "plugin_migrations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "plugin_id": { + "name": "plugin_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "migration_id": { + "name": "migration_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "checksum": { + "name": "checksum", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "applied_at": { + "name": "applied_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_plugin_migrations_plugin_migration": { + "name": "idx_plugin_migrations_plugin_migration", + "columns": [ + { + "expression": "plugin_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "migration_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "plugin_migrations_plugin_id_plugins_id_fk": { + "name": "plugin_migrations_plugin_id_plugins_id_fk", + "tableFrom": "plugin_migrations", + "tableTo": "plugins", + "columnsFrom": [ + "plugin_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.plugin_permission_grants": { + "name": "plugin_permission_grants", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "plugin_id": { + "name": "plugin_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "capability": { + "name": "capability", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "granted_at": { + "name": "granted_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "granted_by": { + "name": "granted_by", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'admin'" + } + }, + "indexes": { + "idx_plugin_permission_grants_plugin_capability": { + "name": "idx_plugin_permission_grants_plugin_capability", + "columns": [ + { + "expression": "plugin_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "capability", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "plugin_permission_grants_plugin_id_plugins_id_fk": { + "name": "plugin_permission_grants_plugin_id_plugins_id_fk", + "tableFrom": "plugin_permission_grants", + "tableTo": "plugins", + "columnsFrom": [ + "plugin_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "plugin_permission_grants_granted_by_users_id_fk": { + "name": "plugin_permission_grants_granted_by_users_id_fk", + "tableFrom": "plugin_permission_grants", + "tableTo": "users", + "columnsFrom": [ + "granted_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.plugin_registries": { + "name": "plugin_registries", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'community'" + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "signing_key": { + "name": "signing_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_checked_at": { + "name": "last_checked_at", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_index_hash": { + "name": "last_index_hash", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.plugin_settings": { + "name": "plugin_settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "plugin_id": { + "name": "plugin_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "scope": { + "name": "scope", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "scope_id": { + "name": "scope_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "key": { + "name": "key", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "encrypted": { + "name": "encrypted", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_plugin_settings_scope_key": { + "name": "idx_plugin_settings_scope_key", + "columns": [ + { + "expression": "plugin_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "scope", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "scope_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_plugin_settings_plugin_scope": { + "name": "idx_plugin_settings_plugin_scope", + "columns": [ + { + "expression": "plugin_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "scope", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "plugin_settings_plugin_id_plugins_id_fk": { + "name": "plugin_settings_plugin_id_plugins_id_fk", + "tableFrom": "plugin_settings", + "tableTo": "plugins", + "columnsFrom": [ + "plugin_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.plugin_storage": { + "name": "plugin_storage", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "plugin_id": { + "name": "plugin_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_plugin_storage_plugin_key": { + "name": "idx_plugin_storage_plugin_key", + "columns": [ + { + "expression": "plugin_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "plugin_storage_plugin_id_plugins_id_fk": { + "name": "plugin_storage_plugin_id_plugins_id_fk", + "tableFrom": "plugin_storage", + "tableTo": "plugins", + "columnsFrom": [ + "plugin_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.plugins": { + "name": "plugins", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tier": { + "name": "tier", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'available'" + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'community'" + }, + "registry_id": { + "name": "registry_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'disabled'" + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "installed_at": { + "name": "installed_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "auto_update": { + "name": "auto_update", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "manifest_json": { + "name": "manifest_json", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "idx_plugins_registry_id": { + "name": "idx_plugins_registry_id", + "columns": [ + { + "expression": "registry_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.rbac_applied_defaults": { + "name": "rbac_applied_defaults", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "role_name": { + "name": "role_name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "permission": { + "name": "permission", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "applied_at": { + "name": "applied_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_rbac_applied_defaults_role_permission": { + "name": "idx_rbac_applied_defaults_role_permission", + "columns": [ + { + "expression": "role_name", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "permission", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.rbac_known_permissions": { + "name": "rbac_known_permissions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "permission": { + "name": "permission", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "plugin_id": { + "name": "plugin_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "first_seen_at": { + "name": "first_seen_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_rbac_known_permissions_permission": { + "name": "idx_rbac_known_permissions_permission", + "columns": [ + { + "expression": "permission", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.recent_activity": { + "name": "recent_activity", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "host_id": { + "name": "host_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "host_name": { + "name": "host_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "timestamp": { + "name": "timestamp", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_recent_activity_user_ts": { + "name": "idx_recent_activity_user_ts", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "timestamp", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "recent_activity_user_id_users_id_fk": { + "name": "recent_activity_user_id_users_id_fk", + "tableFrom": "recent_activity", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "recent_activity_host_id_ssh_data_id_fk": { + "name": "recent_activity_host_id_ssh_data_id_fk", + "tableFrom": "recent_activity", + "tableTo": "ssh_data", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.roles": { + "name": "roles", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_system": { + "name": "is_system", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "permissions": { + "name": "permissions", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "roles_name_unique": { + "name": "roles_name_unique", + "nullsNotDistinct": false, + "columns": [ + "name" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.secret_sources": { + "name": "secret_sources", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'onepassword-connect'" + }, + "base_url": { + "name": "base_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "shared": { + "name": "shared", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_secret_sources_user": { + "name": "idx_secret_sources_user", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "secret_sources_user_id_users_id_fk": { + "name": "secret_sources_user_id_users_id_fk", + "tableFrom": "secret_sources", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sessions": { + "name": "sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "jwt_token": { + "name": "jwt_token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "device_type": { + "name": "device_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "device_info": { + "name": "device_info", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "oidc_sub": { + "name": "oidc_sub", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "oidc_sid": { + "name": "oidc_sid", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sso_provider_id": { + "name": "sso_provider_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "expires_at": { + "name": "expires_at", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "last_active_at": { + "name": "last_active_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_sessions_user_id": { + "name": "idx_sessions_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_sessions_expires_at": { + "name": "idx_sessions_expires_at", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sessions_user_id_users_id_fk": { + "name": "sessions_user_id_users_id_fk", + "tableFrom": "sessions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.settings": { + "name": "settings", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.shared_credential_secrets": { + "name": "shared_credential_secrets", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "credential_access_id": { + "name": "credential_access_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "target_user_id": { + "name": "target_user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "credential_id": { + "name": "credential_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "encrypted_username": { + "name": "encrypted_username", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "auth_type": { + "name": "auth_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'password'" + }, + "encrypted_password": { + "name": "encrypted_password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "encrypted_key": { + "name": "encrypted_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "encrypted_key_password": { + "name": "encrypted_key_password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "key_type": { + "name": "key_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "public_key": { + "name": "public_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cert_public_key": { + "name": "cert_public_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_shared_credential_secrets_scope": { + "name": "idx_shared_credential_secrets_scope", + "columns": [ + { + "expression": "credential_access_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_shared_credential_secrets_target": { + "name": "idx_shared_credential_secrets_target", + "columns": [ + { + "expression": "target_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "credential_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "shared_credential_secrets_target_user_id_users_id_fk": { + "name": "shared_credential_secrets_target_user_id_users_id_fk", + "tableFrom": "shared_credential_secrets", + "tableTo": "users", + "columnsFrom": [ + "target_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "shared_credential_secrets_credential_id_ssh_credentials_id_fk": { + "name": "shared_credential_secrets_credential_id_ssh_credentials_id_fk", + "tableFrom": "shared_credential_secrets", + "tableTo": "ssh_credentials", + "columnsFrom": [ + "credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "shared_cred_secrets_access_id_fk": { + "name": "shared_cred_secrets_access_id_fk", + "tableFrom": "shared_credential_secrets", + "tableTo": "credential_access", + "columnsFrom": [ + "credential_access_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.shared_host_auth_overrides": { + "name": "shared_host_auth_overrides", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "host_id": { + "name": "host_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "protocol": { + "name": "protocol", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "default": "'ssh'" + }, + "credential_id": { + "name": "credential_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "shared_host_auth_overrides_host_user_protocol_unique": { + "name": "shared_host_auth_overrides_host_user_protocol_unique", + "columns": [ + { + "expression": "host_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "protocol", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "shared_host_auth_overrides_host_id_ssh_data_id_fk": { + "name": "shared_host_auth_overrides_host_id_ssh_data_id_fk", + "tableFrom": "shared_host_auth_overrides", + "tableTo": "ssh_data", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "shared_host_auth_overrides_user_id_users_id_fk": { + "name": "shared_host_auth_overrides_user_id_users_id_fk", + "tableFrom": "shared_host_auth_overrides", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "shared_host_auth_overrides_credential_id_ssh_credentials_id_fk": { + "name": "shared_host_auth_overrides_credential_id_ssh_credentials_id_fk", + "tableFrom": "shared_host_auth_overrides", + "tableTo": "ssh_credentials", + "columnsFrom": [ + "credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.shared_host_secrets": { + "name": "shared_host_secrets", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "host_access_id": { + "name": "host_access_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "target_user_id": { + "name": "target_user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "protocol": { + "name": "protocol", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "default": "'ssh'" + }, + "source_type": { + "name": "source_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'credential'" + }, + "original_credential_id": { + "name": "original_credential_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "encrypted_username": { + "name": "encrypted_username", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "encrypted_auth_type": { + "name": "encrypted_auth_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "encrypted_password": { + "name": "encrypted_password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "encrypted_key": { + "name": "encrypted_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "encrypted_key_password": { + "name": "encrypted_key_password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "encrypted_key_type": { + "name": "encrypted_key_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "encrypted_domain": { + "name": "encrypted_domain", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_shared_host_secrets_scope": { + "name": "idx_shared_host_secrets_scope", + "columns": [ + { + "expression": "host_access_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "protocol", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "shared_host_secrets_host_access_id_host_access_id_fk": { + "name": "shared_host_secrets_host_access_id_host_access_id_fk", + "tableFrom": "shared_host_secrets", + "tableTo": "host_access", + "columnsFrom": [ + "host_access_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "shared_host_secrets_target_user_id_users_id_fk": { + "name": "shared_host_secrets_target_user_id_users_id_fk", + "tableFrom": "shared_host_secrets", + "tableTo": "users", + "columnsFrom": [ + "target_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "shared_host_secrets_original_credential_id_ssh_credentials_id_fk": { + "name": "shared_host_secrets_original_credential_id_ssh_credentials_id_fk", + "tableFrom": "shared_host_secrets", + "tableTo": "ssh_credentials", + "columnsFrom": [ + "original_credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.ssh_credential_usage": { + "name": "ssh_credential_usage", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "credential_id": { + "name": "credential_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "host_id": { + "name": "host_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "used_at": { + "name": "used_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_ssh_credential_usage_credential": { + "name": "idx_ssh_credential_usage_credential", + "columns": [ + { + "expression": "credential_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_ssh_credential_usage_user": { + "name": "idx_ssh_credential_usage_user", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "ssh_credential_usage_credential_id_ssh_credentials_id_fk": { + "name": "ssh_credential_usage_credential_id_ssh_credentials_id_fk", + "tableFrom": "ssh_credential_usage", + "tableTo": "ssh_credentials", + "columnsFrom": [ + "credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "ssh_credential_usage_host_id_ssh_data_id_fk": { + "name": "ssh_credential_usage_host_id_ssh_data_id_fk", + "tableFrom": "ssh_credential_usage", + "tableTo": "ssh_data", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "ssh_credential_usage_user_id_users_id_fk": { + "name": "ssh_credential_usage_user_id_users_id_fk", + "tableFrom": "ssh_credential_usage", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.ssh_credentials": { + "name": "ssh_credentials", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "folder": { + "name": "folder", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "tags": { + "name": "tags", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "pin": { + "name": "pin", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "auth_type": { + "name": "auth_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "private_key": { + "name": "private_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "public_key": { + "name": "public_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "key_password": { + "name": "key_password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "key_type": { + "name": "key_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "detected_key_type": { + "name": "detected_key_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cert_public_key": { + "name": "cert_public_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "usage_count": { + "name": "usage_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_used": { + "name": "last_used", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sync_id": { + "name": "sync_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_ssh_credentials_user_id": { + "name": "idx_ssh_credentials_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "ssh_credentials_user_id_users_id_fk": { + "name": "ssh_credentials_user_id_users_id_fk", + "tableFrom": "ssh_credentials", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "ssh_credentials_sync_id_unique": { + "name": "ssh_credentials_sync_id_unique", + "nullsNotDistinct": false, + "columns": [ + "sync_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.ssh_folders": { + "name": "ssh_folders", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "icon": { + "name": "icon", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "credential_id": { + "name": "credential_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "sync_id": { + "name": "sync_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_ssh_folders_user_id": { + "name": "idx_ssh_folders_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "ssh_folders_user_id_users_id_fk": { + "name": "ssh_folders_user_id_users_id_fk", + "tableFrom": "ssh_folders", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "ssh_folders_credential_id_ssh_credentials_id_fk": { + "name": "ssh_folders_credential_id_ssh_credentials_id_fk", + "tableFrom": "ssh_folders", + "tableTo": "ssh_credentials", + "columnsFrom": [ + "credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "ssh_folders_sync_id_unique": { + "name": "ssh_folders_sync_id_unique", + "nullsNotDistinct": false, + "columns": [ + "sync_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sso_providers": { + "name": "sso_providers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "display_order": { + "name": "display_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "config": { + "name": "config", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sync_tombstones": { + "name": "sync_tombstones", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "entity_type": { + "name": "entity_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sync_id": { + "name": "sync_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "deleted_at": { + "name": "deleted_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": { + "sync_tombstones_user_id_users_id_fk": { + "name": "sync_tombstones_user_id_users_id_fk", + "tableFrom": "sync_tombstones", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.termix_identities": { + "name": "termix_identities", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "handle": { + "name": "handle", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": { + "termix_identities_user_id_users_id_fk": { + "name": "termix_identities_user_id_users_id_fk", + "tableFrom": "termix_identities", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "termix_identities_user_id_unique": { + "name": "termix_identities_user_id_unique", + "nullsNotDistinct": false, + "columns": [ + "user_id" + ] + }, + "termix_identities_handle_unique": { + "name": "termix_identities_handle_unique", + "nullsNotDistinct": false, + "columns": [ + "handle" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.termix_identity_ca": { + "name": "termix_identity_ca", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "identity_id": { + "name": "identity_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "public_key": { + "name": "public_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "private_key": { + "name": "private_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "validity_days": { + "name": "validity_days", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 90 + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": { + "termix_identity_ca_identity_id_termix_identities_id_fk": { + "name": "termix_identity_ca_identity_id_termix_identities_id_fk", + "tableFrom": "termix_identity_ca", + "tableTo": "termix_identities", + "columnsFrom": [ + "identity_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "termix_identity_ca_user_id_users_id_fk": { + "name": "termix_identity_ca_user_id_users_id_fk", + "tableFrom": "termix_identity_ca", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "termix_identity_ca_identity_id_unique": { + "name": "termix_identity_ca_identity_id_unique", + "nullsNotDistinct": false, + "columns": [ + "identity_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.termix_identity_keys": { + "name": "termix_identity_keys", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "identity_id": { + "name": "identity_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "public_key": { + "name": "public_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key_type": { + "name": "key_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "algorithm": { + "name": "algorithm", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "comment": { + "name": "comment", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'manual'" + }, + "credential_id": { + "name": "credential_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": { + "termix_identity_keys_identity_id_termix_identities_id_fk": { + "name": "termix_identity_keys_identity_id_termix_identities_id_fk", + "tableFrom": "termix_identity_keys", + "tableTo": "termix_identities", + "columnsFrom": [ + "identity_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "termix_identity_keys_user_id_users_id_fk": { + "name": "termix_identity_keys_user_id_users_id_fk", + "tableFrom": "termix_identity_keys", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "termix_identity_keys_credential_id_ssh_credentials_id_fk": { + "name": "termix_identity_keys_credential_id_ssh_credentials_id_fk", + "tableFrom": "termix_identity_keys", + "tableTo": "ssh_credentials", + "columnsFrom": [ + "credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.trusted_devices": { + "name": "trusted_devices", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "device_fingerprint": { + "name": "device_fingerprint", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "device_type": { + "name": "device_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "device_info": { + "name": "device_info", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "expires_at": { + "name": "expires_at", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "last_used_at": { + "name": "last_used_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_trusted_devices_user_id": { + "name": "idx_trusted_devices_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "trusted_devices_user_id_users_id_fk": { + "name": "trusted_devices_user_id_users_id_fk", + "tableFrom": "trusted_devices", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.ui_preferences": { + "name": "ui_preferences", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "data": { + "name": "data", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": { + "ui_preferences_user_id_users_id_fk": { + "name": "ui_preferences_user_id_users_id_fk", + "tableFrom": "ui_preferences", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_external_identities": { + "name": "user_external_identities", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_user_external_identities_provider_subject": { + "name": "idx_user_external_identities_provider_subject", + "columns": [ + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "subject", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_user_external_identities_user": { + "name": "idx_user_external_identities_user", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_external_identities_user_id_users_id_fk": { + "name": "user_external_identities_user_id_users_id_fk", + "tableFrom": "user_external_identities", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_open_tabs": { + "name": "user_open_tabs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "tab_type": { + "name": "tab_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "host_id": { + "name": "host_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tab_order": { + "name": "tab_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "backend_session_id": { + "name": "backend_session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_user_open_tabs_user_id": { + "name": "idx_user_open_tabs_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_open_tabs_user_id_users_id_fk": { + "name": "user_open_tabs_user_id_users_id_fk", + "tableFrom": "user_open_tabs", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_open_tabs_host_id_ssh_data_id_fk": { + "name": "user_open_tabs_host_id_ssh_data_id_fk", + "tableFrom": "user_open_tabs", + "tableTo": "ssh_data", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_preferences": { + "name": "user_preferences", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "reopen_tabs_on_login": { + "name": "reopen_tabs_on_login", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "theme": { + "name": "theme", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "font_size": { + "name": "font_size", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "accent_color": { + "name": "accent_color", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "language": { + "name": "language", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "storage_mode": { + "name": "storage_mode", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "command_autocomplete": { + "name": "command_autocomplete", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "command_palette_enabled": { + "name": "command_palette_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "show_host_tags": { + "name": "show_host_tags", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "host_tray_on_click": { + "name": "host_tray_on_click", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "pin_app_rail": { + "name": "pin_app_rail", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "expand_app_rail_on_hover": { + "name": "expand_app_rail_on_hover", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "show_pin_app_rail_button": { + "name": "show_pin_app_rail_button", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "folders_collapsed": { + "name": "folders_collapsed", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "confirm_snippet_execution": { + "name": "confirm_snippet_execution", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "disable_update_check": { + "name": "disable_update_check", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "confirm_tab_close": { + "name": "confirm_tab_close", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "hidden_rail_tabs": { + "name": "hidden_rail_tabs", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "compact_host_view": { + "name": "compact_host_view", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "status_color_scheme": { + "name": "status_color_scheme", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "custom_themes": { + "name": "custom_themes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "custom_keybindings": { + "name": "custom_keybindings", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "terminal_defaults": { + "name": "terminal_defaults", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "terminal_macros": { + "name": "terminal_macros", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": { + "user_preferences_user_id_users_id_fk": { + "name": "user_preferences_user_id_users_id_fk", + "tableFrom": "user_preferences", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_roles": { + "name": "user_roles", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "role_id": { + "name": "role_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "granted_by": { + "name": "granted_by", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "granted_at": { + "name": "granted_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_user_roles_user_role": { + "name": "idx_user_roles_user_role", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "role_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_user_roles_role_id": { + "name": "idx_user_roles_role_id", + "columns": [ + { + "expression": "role_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_roles_user_id_users_id_fk": { + "name": "user_roles_user_id_users_id_fk", + "tableFrom": "user_roles", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_roles_role_id_roles_id_fk": { + "name": "user_roles_role_id_roles_id_fk", + "tableFrom": "user_roles", + "tableTo": "roles", + "columnsFrom": [ + "role_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_roles_granted_by_users_id_fk": { + "name": "user_roles_granted_by_users_id_fk", + "tableFrom": "user_roles", + "tableTo": "users", + "columnsFrom": [ + "granted_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_second_factors": { + "name": "user_second_factors", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "plugin_id": { + "name": "plugin_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "factor_id": { + "name": "factor_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "enrolled_at": { + "name": "enrolled_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_user_second_factors_user_factor": { + "name": "idx_user_second_factors_user_factor", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "plugin_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "factor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_second_factors_user_id_users_id_fk": { + "name": "user_second_factors_user_id_users_id_fk", + "tableFrom": "user_second_factors", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "password_hash": { + "name": "password_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_admin": { + "name": "is_admin", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "is_oidc": { + "name": "is_oidc", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "oidc_identifier": { + "name": "oidc_identifier", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sso_provider_id": { + "name": "sso_provider_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "client_secret": { + "name": "client_secret", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "issuer_url": { + "name": "issuer_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "authorization_url": { + "name": "authorization_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "token_url": { + "name": "token_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "identifier_path": { + "name": "identifier_path", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name_path": { + "name": "name_path", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scopes": { + "name": "scopes", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'openid email profile'" + }, + "totp_secret": { + "name": "totp_secret", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "totp_enabled": { + "name": "totp_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "totp_backup_codes": { + "name": "totp_backup_codes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "registered_at": { + "name": "registered_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "donation_modal_dismissed": { + "name": "donation_modal_dismissed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.vault_profiles": { + "name": "vault_profiles", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "folder": { + "name": "folder", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "tags": { + "name": "tags", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "vault_addr": { + "name": "vault_addr", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "vault_namespace": { + "name": "vault_namespace", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "oidc_mount": { + "name": "oidc_mount", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "oidc_role": { + "name": "oidc_role", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ssh_mount": { + "name": "ssh_mount", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ssh_role": { + "name": "ssh_role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "valid_principals": { + "name": "valid_principals", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "key_type": { + "name": "key_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "shared": { + "name": "shared", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "sync_id": { + "name": "sync_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": { + "vault_profiles_user_id_users_id_fk": { + "name": "vault_profiles_user_id_users_id_fk", + "tableFrom": "vault_profiles", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "vault_profiles_sync_id_unique": { + "name": "vault_profiles_sync_id_unique", + "nullsNotDistinct": false, + "columns": [ + "sync_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.vault_tokens": { + "name": "vault_tokens", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "profile_id": { + "name": "profile_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "ssh_cert": { + "name": "ssh_cert", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "private_key": { + "name": "private_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "expires_at": { + "name": "expires_at", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "last_used": { + "name": "last_used", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_vault_tokens_user_profile": { + "name": "idx_vault_tokens_user_profile", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "profile_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "vault_tokens_user_id_users_id_fk": { + "name": "vault_tokens_user_id_users_id_fk", + "tableFrom": "vault_tokens", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "vault_tokens_profile_id_vault_profiles_id_fk": { + "name": "vault_tokens_profile_id_vault_profiles_id_fk", + "tableFrom": "vault_tokens", + "tableTo": "vault_profiles", + "columnsFrom": [ + "profile_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.webauthn_credentials": { + "name": "webauthn_credentials", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "credential_id": { + "name": "credential_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "public_key": { + "name": "public_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "counter": { + "name": "counter", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "device_type": { + "name": "device_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "backed_up": { + "name": "backed_up", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "transports": { + "name": "transports", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_verification": { + "name": "user_verification", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'preferred'" + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "last_used_at": { + "name": "last_used_at", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "webauthn_credentials_user_id_users_id_fk": { + "name": "webauthn_credentials_user_id_users_id_fk", + "tableFrom": "webauthn_credentials", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/drizzle/postgres/meta/_journal.json b/drizzle/postgres/meta/_journal.json index 47a3dc8d9..9244a139b 100644 --- a/drizzle/postgres/meta/_journal.json +++ b/drizzle/postgres/meta/_journal.json @@ -337,6 +337,13 @@ "when": 1790272733784, "tag": "0047_mighty_butterfly", "breakpoints": true + }, + { + "idx": 48, + "version": "7", + "when": 1790274704269, + "tag": "0048_rare_catseye", + "breakpoints": true } ] } \ No newline at end of file diff --git a/drizzle/sqlite/0044_gorgeous_yellow_claw.sql b/drizzle/sqlite/0044_gorgeous_yellow_claw.sql new file mode 100644 index 000000000..2a36eaf55 --- /dev/null +++ b/drizzle/sqlite/0044_gorgeous_yellow_claw.sql @@ -0,0 +1,6 @@ +-- The ai tables moved to the ai plugin, which renames them into its own +-- namespace at activation, and ssh_data.enable_ai_assistant and the two +-- user_preferences ai columns moved to its settings. Both run after this +-- migration, so a real DROP here would delete the data first. The columns +-- stay in place, unused, until 3.0.0. +SELECT 1; diff --git a/drizzle/sqlite/meta/0044_snapshot.json b/drizzle/sqlite/meta/0044_snapshot.json new file mode 100644 index 000000000..6b0c6e629 --- /dev/null +++ b/drizzle/sqlite/meta/0044_snapshot.json @@ -0,0 +1,5729 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "52d31840-4880-4133-8a5e-9e2ff8271b15", + "prevId": "f43cf928-7535-4aa8-a406-f030212b01e5", + "tables": { + "api_keys": { + "name": "api_keys", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token_prefix": { + "name": "token_prefix", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "expires_at": { + "name": "expires_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_used_at": { + "name": "last_used_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_active": { + "name": "is_active", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + } + }, + "indexes": { + "idx_api_keys_user_id": { + "name": "idx_api_keys_user_id", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "api_keys_user_id_users_id_fk": { + "name": "api_keys_user_id_users_id_fk", + "tableFrom": "api_keys", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "audit_logs": { + "name": "audit_logs", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "resource_type": { + "name": "resource_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "resource_id": { + "name": "resource_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "resource_name": { + "name": "resource_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "details": { + "name": "details", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "success": { + "name": "success", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "timestamp": { + "name": "timestamp", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_audit_logs_timestamp": { + "name": "idx_audit_logs_timestamp", + "columns": [ + "timestamp" + ], + "isUnique": false + }, + "idx_audit_logs_user_ts": { + "name": "idx_audit_logs_user_ts", + "columns": [ + "user_id", + "timestamp" + ], + "isUnique": false + }, + "idx_audit_logs_action_ts": { + "name": "idx_audit_logs_action_ts", + "columns": [ + "action", + "timestamp" + ], + "isUnique": false + }, + "idx_audit_logs_resource_ts": { + "name": "idx_audit_logs_resource_ts", + "columns": [ + "resource_type", + "timestamp" + ], + "isUnique": false + } + }, + "foreignKeys": { + "audit_logs_user_id_users_id_fk": { + "name": "audit_logs_user_id_users_id_fk", + "tableFrom": "audit_logs", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "credential_access": { + "name": "credential_access", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "credential_id": { + "name": "credential_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "role_id": { + "name": "role_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "granted_by": { + "name": "granted_by", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "permission_level": { + "name": "permission_level", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'use'" + }, + "expires_at": { + "name": "expires_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_credential_access_user_id": { + "name": "idx_credential_access_user_id", + "columns": [ + "user_id" + ], + "isUnique": false + }, + "idx_credential_access_role_id": { + "name": "idx_credential_access_role_id", + "columns": [ + "role_id" + ], + "isUnique": false + }, + "idx_credential_access_credential_id": { + "name": "idx_credential_access_credential_id", + "columns": [ + "credential_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "credential_access_credential_id_ssh_credentials_id_fk": { + "name": "credential_access_credential_id_ssh_credentials_id_fk", + "tableFrom": "credential_access", + "tableTo": "ssh_credentials", + "columnsFrom": [ + "credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_access_user_id_users_id_fk": { + "name": "credential_access_user_id_users_id_fk", + "tableFrom": "credential_access", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_access_role_id_roles_id_fk": { + "name": "credential_access_role_id_roles_id_fk", + "tableFrom": "credential_access", + "tableTo": "roles", + "columnsFrom": [ + "role_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_access_granted_by_users_id_fk": { + "name": "credential_access_granted_by_users_id_fk", + "tableFrom": "credential_access", + "tableTo": "users", + "columnsFrom": [ + "granted_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "credential_sidebar_preferences": { + "name": "credential_sidebar_preferences", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "data": { + "name": "data", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": { + "credential_sidebar_preferences_user_id_users_id_fk": { + "name": "credential_sidebar_preferences_user_id_users_id_fk", + "tableFrom": "credential_sidebar_preferences", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "dashboard_service_links": { + "name": "dashboard_service_links", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "order": { + "name": "order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "sync_id": { + "name": "sync_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "dashboard_service_links_sync_id_unique": { + "name": "dashboard_service_links_sync_id_unique", + "columns": [ + "sync_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "dashboard_service_links_user_id_users_id_fk": { + "name": "dashboard_service_links_user_id_users_id_fk", + "tableFrom": "dashboard_service_links", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "dismissed_alerts": { + "name": "dismissed_alerts", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "alert_id": { + "name": "alert_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "dismissed_at": { + "name": "dismissed_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_dismissed_alerts_user_id": { + "name": "idx_dismissed_alerts_user_id", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "dismissed_alerts_user_id_users_id_fk": { + "name": "dismissed_alerts_user_id_users_id_fk", + "tableFrom": "dismissed_alerts", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "folder_access": { + "name": "folder_access", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "folder": { + "name": "folder", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "role_id": { + "name": "role_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "granted_by": { + "name": "granted_by", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "permission_level": { + "name": "permission_level", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'connect'" + }, + "expires_at": { + "name": "expires_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_folder_access_owner_folder": { + "name": "idx_folder_access_owner_folder", + "columns": [ + "owner_user_id", + "folder" + ], + "isUnique": false + } + }, + "foreignKeys": { + "folder_access_owner_user_id_users_id_fk": { + "name": "folder_access_owner_user_id_users_id_fk", + "tableFrom": "folder_access", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "folder_access_user_id_users_id_fk": { + "name": "folder_access_user_id_users_id_fk", + "tableFrom": "folder_access", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "folder_access_role_id_roles_id_fk": { + "name": "folder_access_role_id_roles_id_fk", + "tableFrom": "folder_access", + "tableTo": "roles", + "columnsFrom": [ + "role_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "folder_access_granted_by_users_id_fk": { + "name": "folder_access_granted_by_users_id_fk", + "tableFrom": "folder_access", + "tableTo": "users", + "columnsFrom": [ + "granted_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "homepage_items": { + "name": "homepage_items", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type_id": { + "name": "type_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "config": { + "name": "config", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'{}'" + }, + "folder_id": { + "name": "folder_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sync_id": { + "name": "sync_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "homepage_items_sync_id_unique": { + "name": "homepage_items_sync_id_unique", + "columns": [ + "sync_id" + ], + "isUnique": true + }, + "idx_homepage_items_user_id": { + "name": "idx_homepage_items_user_id", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "homepage_items_user_id_users_id_fk": { + "name": "homepage_items_user_id_users_id_fk", + "tableFrom": "homepage_items", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "homepage_layouts": { + "name": "homepage_layouts", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "layout": { + "name": "layout", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'{}'" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "homepage_layouts_user_id_unique": { + "name": "homepage_layouts_user_id_unique", + "columns": [ + "user_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "homepage_layouts_user_id_users_id_fk": { + "name": "homepage_layouts_user_id_users_id_fk", + "tableFrom": "homepage_layouts", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "host_access": { + "name": "host_access", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "host_id": { + "name": "host_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "role_id": { + "name": "role_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "granted_by": { + "name": "granted_by", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "permission_level": { + "name": "permission_level", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'connect'" + }, + "expires_at": { + "name": "expires_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "last_accessed_at": { + "name": "last_accessed_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "access_count": { + "name": "access_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + } + }, + "indexes": { + "idx_host_access_user_id": { + "name": "idx_host_access_user_id", + "columns": [ + "user_id" + ], + "isUnique": false + }, + "idx_host_access_role_id": { + "name": "idx_host_access_role_id", + "columns": [ + "role_id" + ], + "isUnique": false + }, + "idx_host_access_host_id": { + "name": "idx_host_access_host_id", + "columns": [ + "host_id" + ], + "isUnique": false + }, + "idx_host_access_expires_at": { + "name": "idx_host_access_expires_at", + "columns": [ + "expires_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "host_access_host_id_ssh_data_id_fk": { + "name": "host_access_host_id_ssh_data_id_fk", + "tableFrom": "host_access", + "tableTo": "ssh_data", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "host_access_user_id_users_id_fk": { + "name": "host_access_user_id_users_id_fk", + "tableFrom": "host_access", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "host_access_role_id_roles_id_fk": { + "name": "host_access_role_id_roles_id_fk", + "tableFrom": "host_access", + "tableTo": "roles", + "columnsFrom": [ + "role_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "host_access_granted_by_users_id_fk": { + "name": "host_access_granted_by_users_id_fk", + "tableFrom": "host_access", + "tableTo": "users", + "columnsFrom": [ + "granted_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "host_sidebar_preferences": { + "name": "host_sidebar_preferences", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "data": { + "name": "data", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": { + "host_sidebar_preferences_user_id_users_id_fk": { + "name": "host_sidebar_preferences_user_id_users_id_fk", + "tableFrom": "host_sidebar_preferences", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "ssh_data": { + "name": "ssh_data", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "connection_type": { + "name": "connection_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'ssh'" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "ip": { + "name": "ip", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "port": { + "name": "port", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "folder": { + "name": "folder", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "parent_host_id": { + "name": "parent_host_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "tags": { + "name": "tags", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "pin": { + "name": "pin", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "auth_type": { + "name": "auth_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "use_warpgate": { + "name": "use_warpgate", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "share_ssh_auth": { + "name": "share_ssh_auth", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "force_keyboard_interactive": { + "name": "force_keyboard_interactive", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "key": { + "name": "key", + "type": "text(8192)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "key_password": { + "name": "key_password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "key_type": { + "name": "key_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sudo_password": { + "name": "sudo_password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "autostart_password": { + "name": "autostart_password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "autostart_key": { + "name": "autostart_key", + "type": "text(8192)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "autostart_key_password": { + "name": "autostart_key_password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "credential_id": { + "name": "credential_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "override_credential_username": { + "name": "override_credential_username", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "vault_profile_id": { + "name": "vault_profile_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "enable_terminal": { + "name": "enable_terminal", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "enable_session_logging": { + "name": "enable_session_logging", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "allow_session_sharing": { + "name": "allow_session_sharing", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "enable_command_history": { + "name": "enable_command_history", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "enable_tunnel": { + "name": "enable_tunnel", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "tunnel_connections": { + "name": "tunnel_connections", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "jump_hosts": { + "name": "jump_hosts", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "enable_file_manager": { + "name": "enable_file_manager", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "scp_legacy": { + "name": "scp_legacy", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "enable_web_ui": { + "name": "enable_web_ui", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "enable_tmux_monitor": { + "name": "enable_tmux_monitor", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "enable_terminal_toolbar": { + "name": "enable_terminal_toolbar", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "show_terminal_in_sidebar": { + "name": "show_terminal_in_sidebar", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "show_file_manager_in_sidebar": { + "name": "show_file_manager_in_sidebar", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "show_tunnel_in_sidebar": { + "name": "show_tunnel_in_sidebar", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "show_docker_in_sidebar": { + "name": "show_docker_in_sidebar", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "show_server_stats_in_sidebar": { + "name": "show_server_stats_in_sidebar", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "default_path": { + "name": "default_path", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status_check_enabled": { + "name": "status_check_enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "status_check_interval": { + "name": "status_check_interval", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "web_ui_config": { + "name": "web_ui_config", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "terminal_config": { + "name": "terminal_config", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "quick_actions": { + "name": "quick_actions", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "enable_ssh": { + "name": "enable_ssh", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "ssh_port": { + "name": "ssh_port", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 22 + }, + "rdp_credential_id": { + "name": "rdp_credential_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "rdp_user": { + "name": "rdp_user", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "rdp_password": { + "name": "rdp_password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "rdp_domain": { + "name": "rdp_domain", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "vnc_credential_id": { + "name": "vnc_credential_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "vnc_password": { + "name": "vnc_password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "vnc_user": { + "name": "vnc_user", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "telnet_user": { + "name": "telnet_user", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "telnet_password": { + "name": "telnet_password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "telnet_credential_id": { + "name": "telnet_credential_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "rdp_auth_type": { + "name": "rdp_auth_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "vnc_auth_type": { + "name": "vnc_auth_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "telnet_auth_type": { + "name": "telnet_auth_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "domain": { + "name": "domain", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "use_socks5": { + "name": "use_socks5", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "socks5_host": { + "name": "socks5_host", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "socks5_port": { + "name": "socks5_port", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "socks5_username": { + "name": "socks5_username", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "socks5_password": { + "name": "socks5_password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "socks5_proxy_chain": { + "name": "socks5_proxy_chain", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "connection_origin": { + "name": "connection_origin", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "mac_address": { + "name": "mac_address", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "wol_broadcast_address": { + "name": "wol_broadcast_address", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "port_knock_sequence": { + "name": "port_knock_sequence", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "host_key_fingerprint": { + "name": "host_key_fingerprint", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "host_key_type": { + "name": "host_key_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "host_key_algorithm": { + "name": "host_key_algorithm", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'sha256'" + }, + "host_key_first_seen": { + "name": "host_key_first_seen", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "host_key_last_verified": { + "name": "host_key_last_verified", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "host_key_changed_count": { + "name": "host_key_changed_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "sync_id": { + "name": "sync_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "ssh_data_sync_id_unique": { + "name": "ssh_data_sync_id_unique", + "columns": [ + "sync_id" + ], + "isUnique": true + }, + "idx_ssh_data_user_id": { + "name": "idx_ssh_data_user_id", + "columns": [ + "user_id" + ], + "isUnique": false + }, + "idx_ssh_data_parent_host": { + "name": "idx_ssh_data_parent_host", + "columns": [ + "parent_host_id" + ], + "isUnique": false + }, + "idx_ssh_data_credential": { + "name": "idx_ssh_data_credential", + "columns": [ + "credential_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "ssh_data_user_id_users_id_fk": { + "name": "ssh_data_user_id_users_id_fk", + "tableFrom": "ssh_data", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "ssh_data_parent_host_id_ssh_data_id_fk": { + "name": "ssh_data_parent_host_id_ssh_data_id_fk", + "tableFrom": "ssh_data", + "tableTo": "ssh_data", + "columnsFrom": [ + "parent_host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "ssh_data_credential_id_ssh_credentials_id_fk": { + "name": "ssh_data_credential_id_ssh_credentials_id_fk", + "tableFrom": "ssh_data", + "tableTo": "ssh_credentials", + "columnsFrom": [ + "credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "ssh_data_vault_profile_id_vault_profiles_id_fk": { + "name": "ssh_data_vault_profile_id_vault_profiles_id_fk", + "tableFrom": "ssh_data", + "tableTo": "vault_profiles", + "columnsFrom": [ + "vault_profile_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "ssh_data_rdp_credential_id_ssh_credentials_id_fk": { + "name": "ssh_data_rdp_credential_id_ssh_credentials_id_fk", + "tableFrom": "ssh_data", + "tableTo": "ssh_credentials", + "columnsFrom": [ + "rdp_credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "ssh_data_vnc_credential_id_ssh_credentials_id_fk": { + "name": "ssh_data_vnc_credential_id_ssh_credentials_id_fk", + "tableFrom": "ssh_data", + "tableTo": "ssh_credentials", + "columnsFrom": [ + "vnc_credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "ssh_data_telnet_credential_id_ssh_credentials_id_fk": { + "name": "ssh_data_telnet_credential_id_ssh_credentials_id_fk", + "tableFrom": "ssh_data", + "tableTo": "ssh_credentials", + "columnsFrom": [ + "telnet_credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "notification_channels": { + "name": "notification_channels", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "config": { + "name": "config", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": { + "notification_channels_user_id_users_id_fk": { + "name": "notification_channels_user_id_users_id_fk", + "tableFrom": "notification_channels", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "opkssh_tokens": { + "name": "opkssh_tokens", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "host_id": { + "name": "host_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ssh_cert": { + "name": "ssh_cert", + "type": "text(8192)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "private_key": { + "name": "private_key", + "type": "text(8192)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sub": { + "name": "sub", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "issuer": { + "name": "issuer", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "audience": { + "name": "audience", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "expires_at": { + "name": "expires_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_used": { + "name": "last_used", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_opkssh_tokens_user_host": { + "name": "idx_opkssh_tokens_user_host", + "columns": [ + "user_id", + "host_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "opkssh_tokens_user_id_users_id_fk": { + "name": "opkssh_tokens_user_id_users_id_fk", + "tableFrom": "opkssh_tokens", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "opkssh_tokens_host_id_ssh_data_id_fk": { + "name": "opkssh_tokens_host_id_ssh_data_id_fk", + "tableFrom": "opkssh_tokens", + "tableTo": "ssh_data", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "plugin_install_counts": { + "name": "plugin_install_counts", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "plugin_id": { + "name": "plugin_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "registry_id": { + "name": "registry_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "count": { + "name": "count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'aggregate-telemetry'" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_plugin_install_counts_plugin_registry": { + "name": "idx_plugin_install_counts_plugin_registry", + "columns": [ + "plugin_id", + "registry_id" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "plugin_migrations": { + "name": "plugin_migrations", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "plugin_id": { + "name": "plugin_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "migration_id": { + "name": "migration_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "checksum": { + "name": "checksum", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "applied_at": { + "name": "applied_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_plugin_migrations_plugin_migration": { + "name": "idx_plugin_migrations_plugin_migration", + "columns": [ + "plugin_id", + "migration_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "plugin_migrations_plugin_id_plugins_id_fk": { + "name": "plugin_migrations_plugin_id_plugins_id_fk", + "tableFrom": "plugin_migrations", + "tableTo": "plugins", + "columnsFrom": [ + "plugin_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "plugin_permission_grants": { + "name": "plugin_permission_grants", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "plugin_id": { + "name": "plugin_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "capability": { + "name": "capability", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "granted_at": { + "name": "granted_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "granted_by": { + "name": "granted_by", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'admin'" + } + }, + "indexes": { + "idx_plugin_permission_grants_plugin_capability": { + "name": "idx_plugin_permission_grants_plugin_capability", + "columns": [ + "plugin_id", + "capability" + ], + "isUnique": true + } + }, + "foreignKeys": { + "plugin_permission_grants_plugin_id_plugins_id_fk": { + "name": "plugin_permission_grants_plugin_id_plugins_id_fk", + "tableFrom": "plugin_permission_grants", + "tableTo": "plugins", + "columnsFrom": [ + "plugin_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "plugin_permission_grants_granted_by_users_id_fk": { + "name": "plugin_permission_grants_granted_by_users_id_fk", + "tableFrom": "plugin_permission_grants", + "tableTo": "users", + "columnsFrom": [ + "granted_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "plugin_registries": { + "name": "plugin_registries", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'community'" + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "signing_key": { + "name": "signing_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_checked_at": { + "name": "last_checked_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_index_hash": { + "name": "last_index_hash", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "plugin_settings": { + "name": "plugin_settings", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "plugin_id": { + "name": "plugin_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "scope_id": { + "name": "scope_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "encrypted": { + "name": "encrypted", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_plugin_settings_scope_key": { + "name": "idx_plugin_settings_scope_key", + "columns": [ + "plugin_id", + "scope", + "scope_id", + "key" + ], + "isUnique": true + }, + "idx_plugin_settings_plugin_scope": { + "name": "idx_plugin_settings_plugin_scope", + "columns": [ + "plugin_id", + "scope" + ], + "isUnique": false + } + }, + "foreignKeys": { + "plugin_settings_plugin_id_plugins_id_fk": { + "name": "plugin_settings_plugin_id_plugins_id_fk", + "tableFrom": "plugin_settings", + "tableTo": "plugins", + "columnsFrom": [ + "plugin_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "plugin_storage": { + "name": "plugin_storage", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "plugin_id": { + "name": "plugin_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_plugin_storage_plugin_key": { + "name": "idx_plugin_storage_plugin_key", + "columns": [ + "plugin_id", + "key" + ], + "isUnique": true + } + }, + "foreignKeys": { + "plugin_storage_plugin_id_plugins_id_fk": { + "name": "plugin_storage_plugin_id_plugins_id_fk", + "tableFrom": "plugin_storage", + "tableTo": "plugins", + "columnsFrom": [ + "plugin_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "plugins": { + "name": "plugins", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "version": { + "name": "version", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tier": { + "name": "tier", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'available'" + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'community'" + }, + "registry_id": { + "name": "registry_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'disabled'" + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "installed_at": { + "name": "installed_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "auto_update": { + "name": "auto_update", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "manifest_json": { + "name": "manifest_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_plugins_registry_id": { + "name": "idx_plugins_registry_id", + "columns": [ + "registry_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "rbac_applied_defaults": { + "name": "rbac_applied_defaults", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "role_name": { + "name": "role_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "permission": { + "name": "permission", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "applied_at": { + "name": "applied_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_rbac_applied_defaults_role_permission": { + "name": "idx_rbac_applied_defaults_role_permission", + "columns": [ + "role_name", + "permission" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "rbac_known_permissions": { + "name": "rbac_known_permissions", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "permission": { + "name": "permission", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "plugin_id": { + "name": "plugin_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "first_seen_at": { + "name": "first_seen_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_rbac_known_permissions_permission": { + "name": "idx_rbac_known_permissions_permission", + "columns": [ + "permission" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "recent_activity": { + "name": "recent_activity", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "host_id": { + "name": "host_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "host_name": { + "name": "host_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "timestamp": { + "name": "timestamp", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_recent_activity_user_ts": { + "name": "idx_recent_activity_user_ts", + "columns": [ + "user_id", + "timestamp" + ], + "isUnique": false + } + }, + "foreignKeys": { + "recent_activity_user_id_users_id_fk": { + "name": "recent_activity_user_id_users_id_fk", + "tableFrom": "recent_activity", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "recent_activity_host_id_ssh_data_id_fk": { + "name": "recent_activity_host_id_ssh_data_id_fk", + "tableFrom": "recent_activity", + "tableTo": "ssh_data", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "roles": { + "name": "roles", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_system": { + "name": "is_system", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "permissions": { + "name": "permissions", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "roles_name_unique": { + "name": "roles_name_unique", + "columns": [ + "name" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "secret_sources": { + "name": "secret_sources", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'onepassword-connect'" + }, + "base_url": { + "name": "base_url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "shared": { + "name": "shared", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_secret_sources_user": { + "name": "idx_secret_sources_user", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "secret_sources_user_id_users_id_fk": { + "name": "secret_sources_user_id_users_id_fk", + "tableFrom": "secret_sources", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "sessions": { + "name": "sessions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "jwt_token": { + "name": "jwt_token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "device_type": { + "name": "device_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "device_info": { + "name": "device_info", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "oidc_sub": { + "name": "oidc_sub", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "oidc_sid": { + "name": "oidc_sid", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sso_provider_id": { + "name": "sso_provider_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "expires_at": { + "name": "expires_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_active_at": { + "name": "last_active_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_sessions_user_id": { + "name": "idx_sessions_user_id", + "columns": [ + "user_id" + ], + "isUnique": false + }, + "idx_sessions_expires_at": { + "name": "idx_sessions_expires_at", + "columns": [ + "expires_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "sessions_user_id_users_id_fk": { + "name": "sessions_user_id_users_id_fk", + "tableFrom": "sessions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "settings": { + "name": "settings", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "shared_credential_secrets": { + "name": "shared_credential_secrets", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "credential_access_id": { + "name": "credential_access_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "target_user_id": { + "name": "target_user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "credential_id": { + "name": "credential_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "encrypted_username": { + "name": "encrypted_username", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "auth_type": { + "name": "auth_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'password'" + }, + "encrypted_password": { + "name": "encrypted_password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "encrypted_key": { + "name": "encrypted_key", + "type": "text(16384)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "encrypted_key_password": { + "name": "encrypted_key_password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "key_type": { + "name": "key_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "public_key": { + "name": "public_key", + "type": "text(4096)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cert_public_key": { + "name": "cert_public_key", + "type": "text(8192)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_shared_credential_secrets_scope": { + "name": "idx_shared_credential_secrets_scope", + "columns": [ + "credential_access_id", + "target_user_id" + ], + "isUnique": true + }, + "idx_shared_credential_secrets_target": { + "name": "idx_shared_credential_secrets_target", + "columns": [ + "target_user_id", + "credential_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "shared_credential_secrets_target_user_id_users_id_fk": { + "name": "shared_credential_secrets_target_user_id_users_id_fk", + "tableFrom": "shared_credential_secrets", + "tableTo": "users", + "columnsFrom": [ + "target_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "shared_credential_secrets_credential_id_ssh_credentials_id_fk": { + "name": "shared_credential_secrets_credential_id_ssh_credentials_id_fk", + "tableFrom": "shared_credential_secrets", + "tableTo": "ssh_credentials", + "columnsFrom": [ + "credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "shared_cred_secrets_access_id_fk": { + "name": "shared_cred_secrets_access_id_fk", + "tableFrom": "shared_credential_secrets", + "tableTo": "credential_access", + "columnsFrom": [ + "credential_access_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "shared_host_auth_overrides": { + "name": "shared_host_auth_overrides", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "host_id": { + "name": "host_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "protocol": { + "name": "protocol", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'ssh'" + }, + "credential_id": { + "name": "credential_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "shared_host_auth_overrides_host_user_protocol_unique": { + "name": "shared_host_auth_overrides_host_user_protocol_unique", + "columns": [ + "host_id", + "user_id", + "protocol" + ], + "isUnique": true + } + }, + "foreignKeys": { + "shared_host_auth_overrides_host_id_ssh_data_id_fk": { + "name": "shared_host_auth_overrides_host_id_ssh_data_id_fk", + "tableFrom": "shared_host_auth_overrides", + "tableTo": "ssh_data", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "shared_host_auth_overrides_user_id_users_id_fk": { + "name": "shared_host_auth_overrides_user_id_users_id_fk", + "tableFrom": "shared_host_auth_overrides", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "shared_host_auth_overrides_credential_id_ssh_credentials_id_fk": { + "name": "shared_host_auth_overrides_credential_id_ssh_credentials_id_fk", + "tableFrom": "shared_host_auth_overrides", + "tableTo": "ssh_credentials", + "columnsFrom": [ + "credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "shared_host_secrets": { + "name": "shared_host_secrets", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "host_access_id": { + "name": "host_access_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "target_user_id": { + "name": "target_user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "protocol": { + "name": "protocol", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'ssh'" + }, + "source_type": { + "name": "source_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'credential'" + }, + "original_credential_id": { + "name": "original_credential_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "encrypted_username": { + "name": "encrypted_username", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "encrypted_auth_type": { + "name": "encrypted_auth_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "encrypted_password": { + "name": "encrypted_password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "encrypted_key": { + "name": "encrypted_key", + "type": "text(16384)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "encrypted_key_password": { + "name": "encrypted_key_password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "encrypted_key_type": { + "name": "encrypted_key_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "encrypted_domain": { + "name": "encrypted_domain", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_shared_host_secrets_scope": { + "name": "idx_shared_host_secrets_scope", + "columns": [ + "host_access_id", + "target_user_id", + "protocol" + ], + "isUnique": true + } + }, + "foreignKeys": { + "shared_host_secrets_host_access_id_host_access_id_fk": { + "name": "shared_host_secrets_host_access_id_host_access_id_fk", + "tableFrom": "shared_host_secrets", + "tableTo": "host_access", + "columnsFrom": [ + "host_access_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "shared_host_secrets_target_user_id_users_id_fk": { + "name": "shared_host_secrets_target_user_id_users_id_fk", + "tableFrom": "shared_host_secrets", + "tableTo": "users", + "columnsFrom": [ + "target_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "shared_host_secrets_original_credential_id_ssh_credentials_id_fk": { + "name": "shared_host_secrets_original_credential_id_ssh_credentials_id_fk", + "tableFrom": "shared_host_secrets", + "tableTo": "ssh_credentials", + "columnsFrom": [ + "original_credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "ssh_credential_usage": { + "name": "ssh_credential_usage", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "credential_id": { + "name": "credential_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "host_id": { + "name": "host_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "used_at": { + "name": "used_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_ssh_credential_usage_credential": { + "name": "idx_ssh_credential_usage_credential", + "columns": [ + "credential_id" + ], + "isUnique": false + }, + "idx_ssh_credential_usage_user": { + "name": "idx_ssh_credential_usage_user", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "ssh_credential_usage_credential_id_ssh_credentials_id_fk": { + "name": "ssh_credential_usage_credential_id_ssh_credentials_id_fk", + "tableFrom": "ssh_credential_usage", + "tableTo": "ssh_credentials", + "columnsFrom": [ + "credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "ssh_credential_usage_host_id_ssh_data_id_fk": { + "name": "ssh_credential_usage_host_id_ssh_data_id_fk", + "tableFrom": "ssh_credential_usage", + "tableTo": "ssh_data", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "ssh_credential_usage_user_id_users_id_fk": { + "name": "ssh_credential_usage_user_id_users_id_fk", + "tableFrom": "ssh_credential_usage", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "ssh_credentials": { + "name": "ssh_credentials", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "folder": { + "name": "folder", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "tags": { + "name": "tags", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "pin": { + "name": "pin", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "auth_type": { + "name": "auth_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "key": { + "name": "key", + "type": "text(16384)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "private_key": { + "name": "private_key", + "type": "text(16384)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "public_key": { + "name": "public_key", + "type": "text(4096)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "key_password": { + "name": "key_password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "key_type": { + "name": "key_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "detected_key_type": { + "name": "detected_key_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cert_public_key": { + "name": "cert_public_key", + "type": "text(8192)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "usage_count": { + "name": "usage_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "last_used": { + "name": "last_used", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sync_id": { + "name": "sync_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "ssh_credentials_sync_id_unique": { + "name": "ssh_credentials_sync_id_unique", + "columns": [ + "sync_id" + ], + "isUnique": true + }, + "idx_ssh_credentials_user_id": { + "name": "idx_ssh_credentials_user_id", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "ssh_credentials_user_id_users_id_fk": { + "name": "ssh_credentials_user_id_users_id_fk", + "tableFrom": "ssh_credentials", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "ssh_folders": { + "name": "ssh_folders", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "icon": { + "name": "icon", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "credential_id": { + "name": "credential_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sync_id": { + "name": "sync_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "ssh_folders_sync_id_unique": { + "name": "ssh_folders_sync_id_unique", + "columns": [ + "sync_id" + ], + "isUnique": true + }, + "idx_ssh_folders_user_id": { + "name": "idx_ssh_folders_user_id", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "ssh_folders_user_id_users_id_fk": { + "name": "ssh_folders_user_id_users_id_fk", + "tableFrom": "ssh_folders", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "ssh_folders_credential_id_ssh_credentials_id_fk": { + "name": "ssh_folders_credential_id_ssh_credentials_id_fk", + "tableFrom": "ssh_folders", + "tableTo": "ssh_credentials", + "columnsFrom": [ + "credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "sso_providers": { + "name": "sso_providers", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "display_order": { + "name": "display_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "config": { + "name": "config", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "sync_tombstones": { + "name": "sync_tombstones", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "entity_type": { + "name": "entity_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sync_id": { + "name": "sync_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": { + "sync_tombstones_user_id_users_id_fk": { + "name": "sync_tombstones_user_id_users_id_fk", + "tableFrom": "sync_tombstones", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "termix_identities": { + "name": "termix_identities", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "handle": { + "name": "handle", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "termix_identities_user_id_unique": { + "name": "termix_identities_user_id_unique", + "columns": [ + "user_id" + ], + "isUnique": true + }, + "termix_identities_handle_unique": { + "name": "termix_identities_handle_unique", + "columns": [ + "handle" + ], + "isUnique": true + } + }, + "foreignKeys": { + "termix_identities_user_id_users_id_fk": { + "name": "termix_identities_user_id_users_id_fk", + "tableFrom": "termix_identities", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "termix_identity_ca": { + "name": "termix_identity_ca", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "identity_id": { + "name": "identity_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "public_key": { + "name": "public_key", + "type": "text(4096)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "private_key": { + "name": "private_key", + "type": "text(8192)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "validity_days": { + "name": "validity_days", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 90 + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "termix_identity_ca_identity_id_unique": { + "name": "termix_identity_ca_identity_id_unique", + "columns": [ + "identity_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "termix_identity_ca_identity_id_termix_identities_id_fk": { + "name": "termix_identity_ca_identity_id_termix_identities_id_fk", + "tableFrom": "termix_identity_ca", + "tableTo": "termix_identities", + "columnsFrom": [ + "identity_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "termix_identity_ca_user_id_users_id_fk": { + "name": "termix_identity_ca_user_id_users_id_fk", + "tableFrom": "termix_identity_ca", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "termix_identity_keys": { + "name": "termix_identity_keys", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "identity_id": { + "name": "identity_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "public_key": { + "name": "public_key", + "type": "text(8192)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "key_type": { + "name": "key_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "algorithm": { + "name": "algorithm", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "comment": { + "name": "comment", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'manual'" + }, + "credential_id": { + "name": "credential_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": { + "termix_identity_keys_identity_id_termix_identities_id_fk": { + "name": "termix_identity_keys_identity_id_termix_identities_id_fk", + "tableFrom": "termix_identity_keys", + "tableTo": "termix_identities", + "columnsFrom": [ + "identity_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "termix_identity_keys_user_id_users_id_fk": { + "name": "termix_identity_keys_user_id_users_id_fk", + "tableFrom": "termix_identity_keys", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "termix_identity_keys_credential_id_ssh_credentials_id_fk": { + "name": "termix_identity_keys_credential_id_ssh_credentials_id_fk", + "tableFrom": "termix_identity_keys", + "tableTo": "ssh_credentials", + "columnsFrom": [ + "credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "trusted_devices": { + "name": "trusted_devices", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "device_fingerprint": { + "name": "device_fingerprint", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "device_type": { + "name": "device_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "device_info": { + "name": "device_info", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "expires_at": { + "name": "expires_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_used_at": { + "name": "last_used_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_trusted_devices_user_id": { + "name": "idx_trusted_devices_user_id", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "trusted_devices_user_id_users_id_fk": { + "name": "trusted_devices_user_id_users_id_fk", + "tableFrom": "trusted_devices", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "ui_preferences": { + "name": "ui_preferences", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "data": { + "name": "data", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": { + "ui_preferences_user_id_users_id_fk": { + "name": "ui_preferences_user_id_users_id_fk", + "tableFrom": "ui_preferences", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "user_external_identities": { + "name": "user_external_identities", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "subject": { + "name": "subject", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_user_external_identities_provider_subject": { + "name": "idx_user_external_identities_provider_subject", + "columns": [ + "provider_id", + "subject" + ], + "isUnique": true + }, + "idx_user_external_identities_user": { + "name": "idx_user_external_identities_user", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "user_external_identities_user_id_users_id_fk": { + "name": "user_external_identities_user_id_users_id_fk", + "tableFrom": "user_external_identities", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "user_open_tabs": { + "name": "user_open_tabs", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tab_type": { + "name": "tab_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "host_id": { + "name": "host_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tab_order": { + "name": "tab_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "backend_session_id": { + "name": "backend_session_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_user_open_tabs_user_id": { + "name": "idx_user_open_tabs_user_id", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "user_open_tabs_user_id_users_id_fk": { + "name": "user_open_tabs_user_id_users_id_fk", + "tableFrom": "user_open_tabs", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_open_tabs_host_id_ssh_data_id_fk": { + "name": "user_open_tabs_host_id_ssh_data_id_fk", + "tableFrom": "user_open_tabs", + "tableTo": "ssh_data", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "user_preferences": { + "name": "user_preferences", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "reopen_tabs_on_login": { + "name": "reopen_tabs_on_login", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "theme": { + "name": "theme", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "font_size": { + "name": "font_size", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "accent_color": { + "name": "accent_color", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "language": { + "name": "language", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "storage_mode": { + "name": "storage_mode", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "command_autocomplete": { + "name": "command_autocomplete", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "command_palette_enabled": { + "name": "command_palette_enabled", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "show_host_tags": { + "name": "show_host_tags", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "host_tray_on_click": { + "name": "host_tray_on_click", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "pin_app_rail": { + "name": "pin_app_rail", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "expand_app_rail_on_hover": { + "name": "expand_app_rail_on_hover", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "show_pin_app_rail_button": { + "name": "show_pin_app_rail_button", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "folders_collapsed": { + "name": "folders_collapsed", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "confirm_snippet_execution": { + "name": "confirm_snippet_execution", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "disable_update_check": { + "name": "disable_update_check", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "confirm_tab_close": { + "name": "confirm_tab_close", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "hidden_rail_tabs": { + "name": "hidden_rail_tabs", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "compact_host_view": { + "name": "compact_host_view", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status_color_scheme": { + "name": "status_color_scheme", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "custom_themes": { + "name": "custom_themes", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "custom_keybindings": { + "name": "custom_keybindings", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "terminal_defaults": { + "name": "terminal_defaults", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "terminal_macros": { + "name": "terminal_macros", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": { + "user_preferences_user_id_users_id_fk": { + "name": "user_preferences_user_id_users_id_fk", + "tableFrom": "user_preferences", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "user_roles": { + "name": "user_roles", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role_id": { + "name": "role_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "granted_by": { + "name": "granted_by", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "granted_at": { + "name": "granted_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_user_roles_user_role": { + "name": "idx_user_roles_user_role", + "columns": [ + "user_id", + "role_id" + ], + "isUnique": true + }, + "idx_user_roles_role_id": { + "name": "idx_user_roles_role_id", + "columns": [ + "role_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "user_roles_user_id_users_id_fk": { + "name": "user_roles_user_id_users_id_fk", + "tableFrom": "user_roles", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_roles_role_id_roles_id_fk": { + "name": "user_roles_role_id_roles_id_fk", + "tableFrom": "user_roles", + "tableTo": "roles", + "columnsFrom": [ + "role_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_roles_granted_by_users_id_fk": { + "name": "user_roles_granted_by_users_id_fk", + "tableFrom": "user_roles", + "tableTo": "users", + "columnsFrom": [ + "granted_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "user_second_factors": { + "name": "user_second_factors", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "plugin_id": { + "name": "plugin_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "factor_id": { + "name": "factor_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "enrolled_at": { + "name": "enrolled_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_user_second_factors_user_factor": { + "name": "idx_user_second_factors_user_factor", + "columns": [ + "user_id", + "plugin_id", + "factor_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "user_second_factors_user_id_users_id_fk": { + "name": "user_second_factors_user_id_users_id_fk", + "tableFrom": "user_second_factors", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "users": { + "name": "users", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "password_hash": { + "name": "password_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "is_admin": { + "name": "is_admin", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "is_oidc": { + "name": "is_oidc", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "oidc_identifier": { + "name": "oidc_identifier", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sso_provider_id": { + "name": "sso_provider_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "client_secret": { + "name": "client_secret", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "issuer_url": { + "name": "issuer_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "authorization_url": { + "name": "authorization_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "token_url": { + "name": "token_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "identifier_path": { + "name": "identifier_path", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "name_path": { + "name": "name_path", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "scopes": { + "name": "scopes", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'openid email profile'" + }, + "totp_secret": { + "name": "totp_secret", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "totp_enabled": { + "name": "totp_enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "totp_backup_codes": { + "name": "totp_backup_codes", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "registered_at": { + "name": "registered_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "donation_modal_dismissed": { + "name": "donation_modal_dismissed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "vault_profiles": { + "name": "vault_profiles", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "folder": { + "name": "folder", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "tags": { + "name": "tags", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "vault_addr": { + "name": "vault_addr", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "vault_namespace": { + "name": "vault_namespace", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "oidc_mount": { + "name": "oidc_mount", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "oidc_role": { + "name": "oidc_role", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "ssh_mount": { + "name": "ssh_mount", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "ssh_role": { + "name": "ssh_role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "valid_principals": { + "name": "valid_principals", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "key_type": { + "name": "key_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "shared": { + "name": "shared", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "sync_id": { + "name": "sync_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "vault_profiles_sync_id_unique": { + "name": "vault_profiles_sync_id_unique", + "columns": [ + "sync_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "vault_profiles_user_id_users_id_fk": { + "name": "vault_profiles_user_id_users_id_fk", + "tableFrom": "vault_profiles", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "vault_tokens": { + "name": "vault_tokens", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "profile_id": { + "name": "profile_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ssh_cert": { + "name": "ssh_cert", + "type": "text(8192)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "private_key": { + "name": "private_key", + "type": "text(8192)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "expires_at": { + "name": "expires_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_used": { + "name": "last_used", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_vault_tokens_user_profile": { + "name": "idx_vault_tokens_user_profile", + "columns": [ + "user_id", + "profile_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "vault_tokens_user_id_users_id_fk": { + "name": "vault_tokens_user_id_users_id_fk", + "tableFrom": "vault_tokens", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "vault_tokens_profile_id_vault_profiles_id_fk": { + "name": "vault_tokens_profile_id_vault_profiles_id_fk", + "tableFrom": "vault_tokens", + "tableTo": "vault_profiles", + "columnsFrom": [ + "profile_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "webauthn_credentials": { + "name": "webauthn_credentials", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "credential_id": { + "name": "credential_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "public_key": { + "name": "public_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "counter": { + "name": "counter", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "device_type": { + "name": "device_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "backed_up": { + "name": "backed_up", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "transports": { + "name": "transports", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_verification": { + "name": "user_verification", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'preferred'" + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "last_used_at": { + "name": "last_used_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "webauthn_credentials_user_id_users_id_fk": { + "name": "webauthn_credentials_user_id_users_id_fk", + "tableFrom": "webauthn_credentials", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/drizzle/sqlite/meta/_journal.json b/drizzle/sqlite/meta/_journal.json index a0091e43f..94547cef1 100644 --- a/drizzle/sqlite/meta/_journal.json +++ b/drizzle/sqlite/meta/_journal.json @@ -309,6 +309,13 @@ "when": 1790272733108, "tag": "0043_curvy_red_shift", "breakpoints": true + }, + { + "idx": 44, + "version": "6", + "when": 1790274703680, + "tag": "0044_gorgeous_yellow_claw", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/plugin-sdk/ARCHITECTURE.md b/packages/plugin-sdk/ARCHITECTURE.md index 0d8dcd185..90d9d2afa 100644 --- a/packages/plugin-sdk/ARCHITECTURE.md +++ b/packages/plugin-sdk/ARCHITECTURE.md @@ -457,6 +457,7 @@ boundary and Suspense. | `registerLoginMethod`, `registerSecondFactorUI` | Login screen UI for a method and a second factor challenge (plus an optional `enrollment` section) | | `api`, `wsUrl(path)` | axios on `/plugin-api//` and the plugin's WebSocket URL | | `apiFor(origin)` | The same client for a resolved connection origin: `"remote"` reaches the desktop app's connected server (**B14**) | +| `fetch(path, init)` | A raw `fetch` on `/plugin-api//` with core's auth, for a streamed response (SSE) axios cannot read (**B18**) | | `t`, `hasPermission` | The plugin's strings and a permission check, for code outside a component (a toast from `activate`) | | `tabs.open`, `getLayout`, `applyLayout`, `onChange` | Tab control, used by workspaces; `tabs.openRailView` (**B12**) opens a rail view | | `guest`, `info`, `onDispose` | Guest mode flag, plugin info, extra cleanup | @@ -767,6 +768,29 @@ live at `/plugin-api/automations/webhook/`; the 2.8 path `/automations/webhook/` is rewritten to it in both nginx configs, because external systems hold that URL. +**AI assistant (B18)** imports nothing from core. It adopts `ai_providers`, +`ai_conversations`, `ai_messages` and `ai_proposals` (the migrations keep the +conversation foreign keys by hand), keeps provider keys in `ctx.secrets`, +reaches providers through `ctx.fetch` with the admin's private endpoint list +as `allowPrivateHosts`, and runs approved commands through +`ctx.ssh.withConnection`. Hosts come from `ctx.hosts` (proposals use +`create`, `update` and `delete`, after checking the approving user's core +`hosts.*` permission), channels from `ctx.notify.channels()`, and everything +else from optional services: `snippets.access`, `fleets.access`, +`automations.access`, `workspaces.saved`, `network-topology.graph`, +`terminal.history` and `homepage.items` v1 (`list()`, which the homepage +plugin provides from B19). A tool names the service it needs, and a tool +whose service has no running provider is not offered to the model at all. B18 +made the write methods of `snippets.access` and `fleets.access` check the +same permission their routes do (`snippets.create`/`edit`/`delete`, +`fleets.manage`), since a proposal runs as the approving user. Its settings +are the admin `globallyEnabled` and `privateEndpoints`, the user `enabled` +and `allowReadOnlyCommands` plus a custom `providers` field, and the host +`enableAiAssistant`, which decides whether its `terminal.toolbar` button and +`terminal.sidePanel` appear. `ai-settings-migration.ts` moves the 2.8 values +and keys. The shell knows nothing about it: availability is the plugin being +enabled, `ai.use`, and those settings. + #### Host status is core The status dot in the host list, the dashboard and the homepage widget is @@ -1380,6 +1404,7 @@ Built per plugin in `src/backend/plugins/ctx.ts` and passed to `activate`. | `ctx.registry.*` | none | **A1** | | `ctx.services.provide` / `.get` / `.providers` | per-service RBAC | **A1**, named **B12** | | `ctx.secrets.offer` / `.withdraw` / `.getShared` | per-secret RBAC | **A1** | +| `ctx.secrets.get` / `.set` / `.delete` | `secrets:own` | **B18** | | `ctx.disposables.add` | none | **A1** | | `ctx.asUser(userId, fn)` | none, always audited | **A1** | | `ctx.currentActor()` | none | **A1** | @@ -1389,7 +1414,7 @@ Built per plugin in `src/backend/plugins/ctx.ts` and passed to `activate`. | `ctx.http.router` / `ctx.ws.route` / `.upgrade` | `network:serve` | **A4** | | `ctx.rbac.has` / `.hasFor` / `.require` | own permissions only | **A5** | | `ctx.capabilities.has` / `.require` | the capability itself | **B11** | -| `ctx.hosts.*` | `hosts:read` / `hosts:write` | **B4**, extended **B5** | +| `ctx.hosts.*` | `hosts:read` / `hosts:write` | **B4**, **B5**, **B18** | | `ctx.hosts.status.*` | `hosts:read` | **B16** | | `ctx.ssh.*` | `ssh:connect`, `credentials:use` | **A8** | | `ctx.settings.*` | `settings:read-core` (readCore only) | **A6** | @@ -1400,7 +1425,7 @@ Built per plugin in `src/backend/plugins/ctx.ts` and passed to `activate`. | `ctx.credentials.resolveHostProtocol` | `credentials:read` | **B14** | | `ctx.audit.record` | none, the actor is the runtime's | **B9** | | `ctx.schedule.every` / `.after` | none | **B16** | -| `ctx.fetch` | `network:outbound` | **B17** | +| `ctx.fetch` | `network:outbound` | **B17**, signal **B18** | **B11** added `ctx.capabilities.has(capability)` / `.require(capability)`, a generic check for a capability no other ctx member wraps. Unlike every other @@ -1609,6 +1634,28 @@ notification)` delivers to the ones among them that are theirs and passes the admin allowlist it reads through `ctx.settings.readCore` (`notification_private_endpoint_allowlist` joined `CORE_SETTINGS_ALLOWLIST`). +**B18** added three members for the AI assistant: + +- `ctx.secrets.get(key)` / `.set(key, value)` / `.delete(key)` store a + plugin's own secrets for the acting user, next to the `offer` side of + `ctx.secrets`. Rows live in `plugin_settings` under the `secret` scope + (`scope_id` the user id), encrypted with `encryptSystemSecret`, so they go + with the plugin on uninstall and with the user when `UserRepository.delete` + removes that user's rows. The settings service is manifest-driven and never + reads them. All three need `secrets:own` and an actor; `set` and `delete` + are audited without the value, `get` only checks the capability, like + `ctx.db.persist`, because a plugin reads its keys on every request that + uses them. The AI assistant keeps each provider's key under + `provider:` instead of in its providers table. +- `ctx.fetch` takes a `signal`. It aborts the request and a streamed body; + `timeoutMs` still only covers the wait for the headers, so a long model + stream is not cut. +- `ctx.hosts.delete(hostId)` deletes a host the actor owns through core's + own delete path (`hosts/delete-host.ts`, shared with the host editor's + route: access grants, activity, plugin settings, the tombstone and the + `host.deleted` event). Needs `hosts:write`, and the actor needs the core + `hosts.delete` permission. False for a host that is missing or not theirs. + `ctx.hosts.create` (**B14**) hands the created row to every plugin's `hostImportNormalizer`, the same as a bulk import row, so a plugin creating a host can set another plugin's host settings (proxmox marks an imported @@ -2139,8 +2186,8 @@ Then, with the app running: ## Legacy core imports: the debt D1 removes The bundled plugins predate the SDK, apart from workspaces (A9), snippets -(B2), remote-desktop (B14), docker (B15), host-metrics (B16) and automations -(B17), which import +(B2), remote-desktop (B14), docker (B15), host-metrics (B16), automations +(B17) and ai (B18), which import nothing from core. The others still reach core by relative path (`../../../../src/backend/...`), which an esbuild plugin, `packages/plugin-sdk/cli/lib/legacy-core-imports.mjs`, keeps out of the bundle @@ -2168,8 +2215,8 @@ What the lint fence enforces today, in `eslint.config.mjs`: | Core importing a plugin backend | **Error** | 0 | - | | A plugin backend importing frontend code or `@/` | **Error** | 0 | - | | The shell importing plugin code | **Error** | 0 | - | -| A plugin frontend importing core through `@/` | Warning | 64 files | D1 | -| A plugin importing core by relative path | Warning | 12 files | D1 | +| A plugin frontend importing core through `@/` | Warning | 55 files | D1 | +| A plugin importing core by relative path | Warning | 4 files | D1 | | A plugin importing another plugin's source | Warning | 0 files | - | A warning does not fail a build, so the counts are held by diff --git a/packages/plugin-sdk/FINISH-LIST.md b/packages/plugin-sdk/FINISH-LIST.md index 8826a80bd..3a70f476e 100644 --- a/packages/plugin-sdk/FINISH-LIST.md +++ b/packages/plugin-sdk/FINISH-LIST.md @@ -374,10 +374,6 @@ build`'s esbuild step has no static-asset-copy pipeline the way core's Vite brings `enable_docker` across, the same gap every earlier column move left there: it writes host rows only and never plugin settings. Owner: D1, with the generic host-settings export and import. -- **B15 (docker), for B18:** the AI assistant's `list_hosts`/`get_host` - tools still report `enableDocker` from the host row, which no longer has - the column, so it always reads null. Read `pluginSettings.docker` or ask the - `docker.containers` service instead. Owner: B18. - **B17 (automations), for B20:** the wake-on-lan step calls the optional `wake-on-lan.send` v1 service, `wake(hostId): Promise`, as the @@ -408,9 +404,34 @@ build`'s esbuild step has no static-asset-copy pipeline the way core's Vite ever is not, the run now fails on its first SSH step instead of being skipped silently. Owner: none unless a background "is this user unlocked" check is added to the SDK. -- **B17 (automations):** AI's `list_notification_channels` read tool still reads - notification channels through core's repository by relative import; it - can use `ctx.notify.channels()` now. Owner: B18. +- **B18 (ai), for B19:** the assistant's `list_homepage_items` tool calls + the optional `homepage.items` v1 service, `list(): Promise>`, as the acting user. Until B19 provides it the tool is + not offered to the model. Owner: B19. +- **B18 (ai):** the "allow read-only diagnostic commands" user setting only + adds a line to the system prompt. No tool runs a command directly; + `tools/command-allowlist.ts` (`isReadOnlyCommand`) is tested but unused, so + every command is still a proposal. Either add the direct tool or drop the + setting. Owner: D0. +- **B18 (ai):** provider requests now go through `ctx.fetch`, which has no + outbound proxy support. Before, an admin-allowlisted private provider went + through `fetchWithProxy` (public ones never did). Decide whether + `ctx.fetch` should honour the proxy settings. Owner: D0. +- **B18:** `scripts/generate-dialect-schema.cjs` decides varchar or text by + column name across every table, so removing the ai indexes on + `created_at`, `updated_at` and `label` turned those columns into `text` + everywhere in `schema.pg.ts` and `schema.mysql.ts`. drizzle-kit then wrote + about a hundred type changes, which B18 left out of + `drizzle/postgres/0048` and `drizzle/mysql/0047` (both a no-op), so real + databases keep `varchar(255)` while the snapshots say `text`. Key the + generator per table, regenerate, and check `verify:dialect`. Owner: D0. +- **B18:** saving a plugin setting from the Settings screen tells nobody. The + ai frontend re-reads its status on `termix:plugins-changed` and on window + focus (throttled to 30 seconds), so an admin turning it on shows up on the + next focus. A settings-changed hook on the app object would be cleaner. + Owner: D1. +- **B18:** tunnels builds its own SSE URL off the axios base instead of the + new `app.fetch`. Owner: D1. ## Manual checks after 2.9.0 diff --git a/packages/plugin-sdk/src/backend.ts b/packages/plugin-sdk/src/backend.ts index db7c06c34..79cccfc26 100644 --- a/packages/plugin-sdk/src/backend.ts +++ b/packages/plugin-sdk/src/backend.ts @@ -219,6 +219,19 @@ export interface PluginServices { } export interface PluginSecrets { + /** + * The acting user's own copy of a secret this plugin stored, decrypted, or + * null when nothing is stored. Needs secrets:own and an actor. Not audited: + * a plugin reads its keys on every request that uses them. + */ + get: (key: string) => Promise; + /** + * Stores a secret for the acting user, encrypted at rest. Null clears it. + * Needs secrets:own and an actor. Audited without the value. + */ + set: (key: string, value: string | null) => Promise; + /** Removes the acting user's secret. Needs secrets:own. Audited. */ + delete: (key: string) => Promise; offer: ( key: string, resolve: (userId: string) => Promise | string | null, @@ -580,6 +593,14 @@ export interface PluginHosts { hostId: number, patch: PluginHostUpdateInput, ) => Promise; + /** + * Deletes a host the acting user owns, the same way the host editor's + * delete does: its access grants, activity and plugin settings go with it + * and remote sync gets a tombstone. Needs hosts:write, and the acting user + * needs the core hosts.delete permission. False when the host does not + * exist or is not theirs. + */ + delete: (hostId: number) => Promise; /** * Every host the acting user owns, decrypted and wide (PluginHostRecord, * not the narrow list() summary). For a plugin that scans its own hosts in @@ -1317,6 +1338,8 @@ export interface PluginFetchInit { * private is refused, and redirects are never followed. */ allowPrivateHosts?: readonly string[]; + /** Aborts the request, and a streamed body, when the caller gives up. */ + signal?: AbortSignal; } /** diff --git a/packages/plugin-sdk/src/frontend.ts b/packages/plugin-sdk/src/frontend.ts index a6aa5a11b..83e9e8254 100644 --- a/packages/plugin-sdk/src/frontend.ts +++ b/packages/plugin-sdk/src/frontend.ts @@ -600,6 +600,11 @@ export interface TermixApp extends TermixAppInfo { * desktop app's connected remote server, anything else is `api`. */ apiFor: (origin?: unknown) => PluginApiClient; + /** + * A raw fetch on /plugin-api// with core's auth, for a response + * read as a stream (server-sent events), which `api` cannot do. + */ + fetch: (path: string, init?: RequestInit) => Promise; /** WebSocket URL and auth subprotocols for /plugin-ws//. */ wsUrl: ( path: string, diff --git a/packages/plugin-sdk/src/testing.ts b/packages/plugin-sdk/src/testing.ts index f6f7be0bc..6835d71bd 100644 --- a/packages/plugin-sdk/src/testing.ts +++ b/packages/plugin-sdk/src/testing.ts @@ -185,6 +185,8 @@ export interface FakePluginContext { channelIds: number[]; notification: PluginNotification; }>; + /** Backing store behind ctx.secrets.get/set, keyed ":". */ + secretStore: Map; /** Every ctx.fetch call, in order. */ fetches: Array<{ url: string; init?: PluginFetchInit }>; /** Every ctx.audit.record entry, in order. */ @@ -290,6 +292,7 @@ export function createFakeContext( const credentialReads: FakePluginContext["credentialReads"] = []; const notifications: FakePluginContext["notifications"] = []; const fetches: FakePluginContext["fetches"] = []; + const secretStore = new Map(); const audits: FakePluginContext["audits"] = []; const activities: FakePluginContext["activities"] = []; const trackedSessions: number[] = []; @@ -491,6 +494,14 @@ export function createFakeContext( }, secrets: { + get: async (key) => secretStore.get(`${actor ?? ""}:${key}`) ?? null, + set: async (key, value) => { + if (value === null) secretStore.delete(`${actor ?? ""}:${key}`); + else secretStore.set(`${actor ?? ""}:${key}`, value); + }, + delete: async (key) => { + secretStore.delete(`${actor ?? ""}:${key}`); + }, offer: () => {}, withdraw: () => false, getShared: async () => null, @@ -629,6 +640,11 @@ export function createFakeContext( hostRecordsById.set(hostId, updated); return updated; }, + delete: async (hostId: number): Promise => { + const existed = + hostRecordsById.delete(hostId) || hostsById.delete(hostId); + return existed; + }, listOwned: async (): Promise => [ ...hostRecordsById.values(), ], @@ -836,6 +852,7 @@ export function createFakeContext( credentialReads, notifications, fetches, + secretStore, audits, activities, trackedSessions, @@ -1076,6 +1093,10 @@ export function createMockCtx( require("hosts:write"); return ctx.hosts.update(hostId, patch); }, + delete: async (hostId) => { + require("hosts:write"); + return ctx.hosts.delete(hostId); + }, listOwned: async () => { require("hosts:write"); return ctx.hosts.listOwned(); @@ -1220,6 +1241,22 @@ export function createMockCtx( return ctx.fetch(url, init); }, + secrets: { + ...ctx.secrets, + get: async (key) => { + require("secrets:own"); + return ctx.secrets.get(key); + }, + set: async (key, value) => { + require("secrets:own"); + return ctx.secrets.set(key, value); + }, + delete: async (key) => { + require("secrets:own"); + return ctx.secrets.delete(key); + }, + }, + capabilities: { has: async (capability) => granted.has(capability), require: async (capability) => { diff --git a/plugins/ai/locales/en.json b/plugins/ai/locales/en.json index 1b4bc3d1a..e146f2349 100644 --- a/plugins/ai/locales/en.json +++ b/plugins/ai/locales/en.json @@ -22,7 +22,24 @@ } }, "settings": { - "assistantUnavailable": "The AI assistant is turned off on this server." + "assistantUnavailable": "The AI assistant is turned off on this server.", + "providersNeedOptIn": "Turn on the AI assistant above to add providers.", + "admin": { + "globallyEnabled": { + "label": "AI assistant", + "description": "Let users turn on the AI assistant. While this is off, the assistant is hidden and blocked for everyone." + }, + "privateEndpoints": { + "label": "Allowed private AI hosts", + "description": "Hosts on your private network that users may point a provider at, such as a self-hosted Ollama. Put one host on each line." + } + }, + "host": { + "enableAiAssistant": { + "label": "Enable AI Assistant", + "description": "Allow the AI assistant to help in this host's terminal. Off by default, and still needs AI enabled for your account." + } + } }, "ai": { "title": "Assistant", @@ -95,13 +112,5 @@ "aiEnableTitle": "Turn it on", "aiEnableDesc": "Adds an assistant that reads your setup and suggests changes you approve. Needs a provider you supply, such as a local Ollama.", "aiNote": "Nothing is sent anywhere until you add a provider and send a message." - }, - "admin": { - "aiGloballyEnabled": "AI assistant", - "aiGloballyEnabledDesc": "Let users turn on the AI assistant. While this is off, the assistant is hidden and blocked for everyone.", - "aiPrivateEndpoints": "Allowed private AI hosts", - "aiPrivateEndpointsDesc": "Hosts on your private network that users may point a provider at, such as a self-hosted Ollama. Separate them with commas.", - "updateAiEnabledFailed": "Could not update the AI setting", - "updateAiEndpointsFailed": "Could not update the allowed hosts" } } diff --git a/plugins/ai/locales/translated/af_ZA.json b/plugins/ai/locales/translated/af_ZA.json index 763374548..29f4c6b94 100644 --- a/plugins/ai/locales/translated/af_ZA.json +++ b/plugins/ai/locales/translated/af_ZA.json @@ -67,12 +67,21 @@ "aiEnableDesc": "Voeg 'n assistent by wat jou opstelling lees en veranderinge voorstel wat jy goedkeur. Benodig 'n verskaffer wat jy verskaf, soos 'n plaaslike Ollama.", "aiNote": "Niks word êrens heen gestuur totdat jy 'n verskaffer byvoeg en 'n boodskap stuur nie." }, - "admin": { - "aiGloballyEnabled": "KI-assistent", - "aiGloballyEnabledDesc": "Laat gebruikers die KI-assistent aanskakel. Terwyl dit af is, is die assistent vir almal versteek en geblokkeer.", - "aiPrivateEndpoints": "Toegelate private KI-gashere", - "aiPrivateEndpointsDesc": "Gashere op jou privaat netwerk waarna gebruikers 'n verskaffer kan verwys, soos 'n self-gehoste Ollama. Skei hulle met kommas.", - "updateAiEnabledFailed": "Kon nie die KI-instelling opdateer nie", - "updateAiEndpointsFailed": "Kon nie die toegelate gashere opdateer nie" + "settings": { + "admin": { + "globallyEnabled": { + "label": "KI-assistent", + "description": "Laat gebruikers die KI-assistent aanskakel. Terwyl dit af is, is die assistent vir almal versteek en geblokkeer." + }, + "privateEndpoints": { + "label": "Toegelate private KI-gashere" + } + }, + "host": { + "enableAiAssistant": { + "label": "Aktiveer KI-assistent", + "description": "Laat die KI-assistent toe om in hierdie gasheer se terminaal te help. Standaard afgeskakel, en KI moet steeds vir jou rekening geaktiveer word." + } + } } } diff --git a/plugins/ai/locales/translated/ar_SA.json b/plugins/ai/locales/translated/ar_SA.json index 9c1fd3f99..ba724303a 100644 --- a/plugins/ai/locales/translated/ar_SA.json +++ b/plugins/ai/locales/translated/ar_SA.json @@ -67,12 +67,21 @@ "aiEnableDesc": "يُضيف هذا البرنامج مساعدًا يقرأ إعداداتك ويقترح تغييرات توافق عليها. يتطلب ذلك مزود خدمة تُحدده بنفسك، مثل مزود خدمة Ollama محلي.", "aiNote": "لن يتم إرسال أي شيء إلى أي مكان حتى تقوم بإضافة مزود خدمة وإرسال رسالة." }, - "admin": { - "aiGloballyEnabled": "مساعد الذكاء الاصطناعي", - "aiGloballyEnabledDesc": "اسمح للمستخدمين بتفعيل مساعد الذكاء الاصطناعي. عند إيقاف تشغيله، يكون المساعد مخفيًا ومحظورًا عن الجميع.", - "aiPrivateEndpoints": "المضيفون الخاصون المسموح بهم للذكاء الاصطناعي", - "aiPrivateEndpointsDesc": "المضيفون على شبكتك الخاصة الذين يمكن للمستخدمين توجيه مزود الخدمة إليهم، مثل Ollama المستضاف ذاتيًا. افصل بينهم بفواصل.", - "updateAiEnabledFailed": "تعذر تحديث إعدادات الذكاء الاصطناعي", - "updateAiEndpointsFailed": "تعذر تحديث المضيفين المسموح بهم" + "settings": { + "admin": { + "globallyEnabled": { + "label": "مساعد الذكاء الاصطناعي", + "description": "اسمح للمستخدمين بتفعيل مساعد الذكاء الاصطناعي. عند إيقاف تشغيله، يكون المساعد مخفيًا ومحظورًا عن الجميع." + }, + "privateEndpoints": { + "label": "المضيفون الخاصون المسموح بهم للذكاء الاصطناعي" + } + }, + "host": { + "enableAiAssistant": { + "label": "تفعيل مساعد الذكاء الاصطناعي", + "description": "اسمح للمساعد الذكي بالمساعدة في طرفية هذا المضيف. هذه الميزة معطلة افتراضيًا، ولا يزال يتعين تفعيلها لحسابك." + } + } } } diff --git a/plugins/ai/locales/translated/bg_BG.json b/plugins/ai/locales/translated/bg_BG.json index 63fbc29fb..fdb3dfbc4 100644 --- a/plugins/ai/locales/translated/bg_BG.json +++ b/plugins/ai/locales/translated/bg_BG.json @@ -67,12 +67,21 @@ "aiEnableDesc": "Добавя асистент, който чете настройките ви и предлага промени, които одобрявате. Необходим е доставчик, когото вие предоставяте, например местен Ollama.", "aiNote": "Нищо не се изпраща никъде, докато не добавите доставчик и не изпратите съобщение." }, - "admin": { - "aiGloballyEnabled": "Асистент с изкуствен интелект", - "aiGloballyEnabledDesc": "Позволете на потребителите да включат асистента с изкуствен интелект. Докато това е изключено, асистентът е скрит и блокиран за всички.", - "aiPrivateEndpoints": "Разрешени частни хостове с изкуствен интелект", - "aiPrivateEndpointsDesc": "Хостове във вашата частна мрежа, към които потребителите могат да насочат доставчик, като например самостоятелно хостван Ollama. Разделете ги със запетаи.", - "updateAiEnabledFailed": "Не можа да се актуализира настройката за изкуствен интелект", - "updateAiEndpointsFailed": "Не можаха да се актуализират разрешените хостове" + "settings": { + "admin": { + "globallyEnabled": { + "label": "Асистент с изкуствен интелект", + "description": "Позволете на потребителите да включат асистента с изкуствен интелект. Докато това е изключено, асистентът е скрит и блокиран за всички." + }, + "privateEndpoints": { + "label": "Разрешени частни хостове с изкуствен интелект" + } + }, + "host": { + "enableAiAssistant": { + "label": "Активиране на AI асистент", + "description": "Разрешете на AI асистента да помага в терминала на този хост. Изключено по подразбиране и все още изисква AI да е активиран за вашия акаунт." + } + } } } diff --git a/plugins/ai/locales/translated/bn_BD.json b/plugins/ai/locales/translated/bn_BD.json index 5022e0c27..ee68fa6f3 100644 --- a/plugins/ai/locales/translated/bn_BD.json +++ b/plugins/ai/locales/translated/bn_BD.json @@ -67,12 +67,21 @@ "aiEnableDesc": "একজন সহকারী যোগ করে যে আপনার সেটআপ পড়ে এবং আপনার অনুমোদিত পরিবর্তনের পরামর্শ দেয়। এর জন্য আপনার সরবরাহ করা একজন প্রদানকারীর প্রয়োজন, যেমন একজন স্থানীয় ওলামা।", "aiNote": "আপনি একজন প্রোভাইডার যোগ করে মেসেজ না পাঠানো পর্যন্ত কোথাও কিছু পাঠানো হয় না।" }, - "admin": { - "aiGloballyEnabled": "এআই সহকারী", - "aiGloballyEnabledDesc": "ব্যবহারকারীদের এআই অ্যাসিস্ট্যান্ট চালু করার সুযোগ দিন। এটি বন্ধ থাকা অবস্থায়, অ্যাসিস্ট্যান্টটি সকলের জন্য লুকানো এবং ব্লক করা থাকবে।", - "aiPrivateEndpoints": "ব্যক্তিগত এআই হোস্ট অনুমোদিত", - "aiPrivateEndpointsDesc": "আপনার প্রাইভেট নেটওয়ার্কের হোস্টগুলো, যেগুলোকে ব্যবহারকারীরা কোনো প্রোভাইডারের দিকে নির্দেশ করতে পারে, যেমন একটি সেলফ-হোস্টেড ওলামা। এগুলোকে কমা দিয়ে আলাদা করুন।", - "updateAiEnabledFailed": "এআই সেটিং আপডেট করা যায়নি", - "updateAiEndpointsFailed": "অনুমোদিত হোস্টগুলি আপডেট করা যায়নি" + "settings": { + "admin": { + "globallyEnabled": { + "label": "এআই সহকারী", + "description": "ব্যবহারকারীদের এআই অ্যাসিস্ট্যান্ট চালু করার সুযোগ দিন। এটি বন্ধ থাকা অবস্থায়, অ্যাসিস্ট্যান্টটি সকলের জন্য লুকানো এবং ব্লক করা থাকবে।" + }, + "privateEndpoints": { + "label": "ব্যক্তিগত এআই হোস্ট অনুমোদিত" + } + }, + "host": { + "enableAiAssistant": { + "label": "এআই অ্যাসিস্ট্যান্ট সক্রিয় করুন", + "description": "এই হোস্টের টার্মিনালে সাহায্য করার জন্য এআই অ্যাসিস্ট্যান্টকে অনুমতি দিন। এটি ডিফল্টরূপে বন্ধ থাকে এবং আপনার অ্যাকাউন্টের জন্য এআই চালু করা প্রয়োজন।" + } + } } } diff --git a/plugins/ai/locales/translated/ca_ES.json b/plugins/ai/locales/translated/ca_ES.json index 38c87313f..84926cdd4 100644 --- a/plugins/ai/locales/translated/ca_ES.json +++ b/plugins/ai/locales/translated/ca_ES.json @@ -67,12 +67,21 @@ "aiEnableDesc": "Afegeix un assistent que llegeix la configuració i suggereix els canvis que aproveu. Necessita un proveïdor que subministreu, com ara un Ollama local.", "aiNote": "No s'envia res enlloc fins que no afegiu un proveïdor i envieu un missatge." }, - "admin": { - "aiGloballyEnabled": "Assistent d'IA", - "aiGloballyEnabledDesc": "Permet que els usuaris activin l'assistent d'IA. Mentre estigui desactivat, l'assistent està ocult i bloquejat per a tothom.", - "aiPrivateEndpoints": "Amfitrions d'IA privats permesos", - "aiPrivateEndpointsDesc": "Amfitrions de la vostra xarxa privada als quals els usuaris poden dirigir un proveïdor, com ara un Ollama autoallotjat. Separeu-los amb comes.", - "updateAiEnabledFailed": "No s'ha pogut actualitzar la configuració de la IA", - "updateAiEndpointsFailed": "No s'han pogut actualitzar els amfitrions permesos" + "settings": { + "admin": { + "globallyEnabled": { + "label": "Assistent d'IA", + "description": "Permet que els usuaris activin l'assistent d'IA. Mentre estigui desactivat, l'assistent està ocult i bloquejat per a tothom." + }, + "privateEndpoints": { + "label": "Amfitrions d'IA privats permesos" + } + }, + "host": { + "enableAiAssistant": { + "label": "Activa l'Assistent d'IA", + "description": "Permet que l'assistent d'IA ajudi al terminal d'aquest amfitrió. Desactivat per defecte, però encara cal que la IA estigui activada per al teu compte." + } + } } } diff --git a/plugins/ai/locales/translated/cs_CZ.json b/plugins/ai/locales/translated/cs_CZ.json index 43e5ba12a..75f959974 100644 --- a/plugins/ai/locales/translated/cs_CZ.json +++ b/plugins/ai/locales/translated/cs_CZ.json @@ -67,12 +67,21 @@ "aiEnableDesc": "Přidá asistenta, který si přečte vaše nastavení a navrhne změny, které schválíte. Vyžaduje poskytovatele, kterého dodáte, například místní Ollamu.", "aiNote": "Nic se nikam neodesílá, dokud nepřidáte poskytovatele a neodešlete zprávu." }, - "admin": { - "aiGloballyEnabled": "Asistent s umělou inteligencí", - "aiGloballyEnabledDesc": "Umožněte uživatelům zapnout asistenta s umělou inteligencí. Pokud je tato funkce vypnutá, asistent je skrytý a blokovaný pro všechny.", - "aiPrivateEndpoints": "Povolení soukromí hostitelé s umělou inteligencí", - "aiPrivateEndpointsDesc": "Hostitelé ve vaší privátní síti, na které mohou uživatelé odkazovat poskytovatele, například samostatně hostovaný Ollama. Oddělte je čárkami.", - "updateAiEnabledFailed": "Nepodařilo se aktualizovat nastavení umělé inteligence", - "updateAiEndpointsFailed": "Nepodařilo se aktualizovat povolené hostitele" + "settings": { + "admin": { + "globallyEnabled": { + "label": "Asistent s umělou inteligencí", + "description": "Umožněte uživatelům zapnout asistenta s umělou inteligencí. Pokud je tato funkce vypnutá, asistent je skrytý a blokovaný pro všechny." + }, + "privateEndpoints": { + "label": "Povolení soukromí hostitelé s umělou inteligencí" + } + }, + "host": { + "enableAiAssistant": { + "label": "Povolit asistenta s umělou inteligencí", + "description": "Povolit asistentovi s umělou inteligencí pomáhat v terminálu tohoto hostitele. Ve výchozím nastavení vypnuto, ale stále vyžaduje, aby byla pro váš účet povolena umělá inteligence." + } + } } } diff --git a/plugins/ai/locales/translated/da_DK.json b/plugins/ai/locales/translated/da_DK.json index 8a0697998..921eb9491 100644 --- a/plugins/ai/locales/translated/da_DK.json +++ b/plugins/ai/locales/translated/da_DK.json @@ -67,12 +67,21 @@ "aiEnableDesc": "Tilføjer en assistent, der læser din opsætning og foreslår ændringer, du godkender. Kræver en udbyder, du angiver, f.eks. en lokal Ollama.", "aiNote": "Intet sendes nogen steder, før du tilføjer en udbyder og sender en besked." }, - "admin": { - "aiGloballyEnabled": "AI-assistent", - "aiGloballyEnabledDesc": "Lad brugerne aktivere AI-assistenten. Når dette er slået fra, er assistenten skjult og blokeret for alle.", - "aiPrivateEndpoints": "Tilladte private AI-værter", - "aiPrivateEndpointsDesc": "Værter på dit private netværk, som brugerne kan henvise til en udbyder, f.eks. en selvhostet Ollama. Adskil dem med kommaer.", - "updateAiEnabledFailed": "Kunne ikke opdatere AI-indstillingen", - "updateAiEndpointsFailed": "Kunne ikke opdatere de tilladte værter" + "settings": { + "admin": { + "globallyEnabled": { + "label": "AI-assistent", + "description": "Lad brugerne aktivere AI-assistenten. Når dette er slået fra, er assistenten skjult og blokeret for alle." + }, + "privateEndpoints": { + "label": "Tilladte private AI-værter" + } + }, + "host": { + "enableAiAssistant": { + "label": "Aktivér AI-assistent", + "description": "Tillad AI-assistenten at hjælpe i denne værts terminal. Deaktiveret som standard, og kræver stadig, at AI er aktiveret for din konto." + } + } } } diff --git a/plugins/ai/locales/translated/de_DE.json b/plugins/ai/locales/translated/de_DE.json index 62468dfe5..de5760035 100644 --- a/plugins/ai/locales/translated/de_DE.json +++ b/plugins/ai/locales/translated/de_DE.json @@ -67,12 +67,21 @@ "aiEnableDesc": "Fügt einen Assistenten hinzu, der Ihre Einstellungen analysiert und Ihnen von Ihnen genehmigte Änderungen vorschlägt. Benötigt einen von Ihnen angegebenen Anbieter, z. B. einen lokalen Ollama-Händler.", "aiNote": "Es wird nichts gesendet, bis Sie einen Anbieter hinzufügen und eine Nachricht senden." }, - "admin": { - "aiGloballyEnabled": "KI-Assistent", - "aiGloballyEnabledDesc": "Ermöglichen Sie den Nutzern, den KI-Assistenten zu aktivieren. Solange diese Option deaktiviert ist, ist der Assistent für alle ausgeblendet und gesperrt.", - "aiPrivateEndpoints": "Zulässige private KI-Hosts", - "aiPrivateEndpointsDesc": "Hosts in Ihrem privaten Netzwerk, auf die Benutzer einen Provider verweisen können, wie z. B. ein selbst gehostetes Ollama. Trennen Sie diese durch Kommas.", - "updateAiEnabledFailed": "Die KI-Einstellungen konnten nicht aktualisiert werden.", - "updateAiEndpointsFailed": "Die Liste der zulässigen Hosts konnte nicht aktualisiert werden." + "settings": { + "admin": { + "globallyEnabled": { + "label": "KI-Assistent", + "description": "Ermöglichen Sie den Nutzern, den KI-Assistenten zu aktivieren. Solange diese Option deaktiviert ist, ist der Assistent für alle ausgeblendet und gesperrt." + }, + "privateEndpoints": { + "label": "Zulässige private KI-Hosts" + } + }, + "host": { + "enableAiAssistant": { + "label": "KI-Assistent aktivieren", + "description": "Erlauben Sie dem KI-Assistenten, im Terminal dieses Hosts Hilfe zu leisten. Standardmäßig deaktiviert; die KI muss weiterhin für Ihr Konto aktiviert sein." + } + } } } diff --git a/plugins/ai/locales/translated/el_GR.json b/plugins/ai/locales/translated/el_GR.json index beba7d035..4d01bc3d1 100644 --- a/plugins/ai/locales/translated/el_GR.json +++ b/plugins/ai/locales/translated/el_GR.json @@ -67,12 +67,21 @@ "aiEnableDesc": "Προσθέτει έναν βοηθό που διαβάζει τη ρύθμισή σας και προτείνει αλλαγές που εγκρίνετε. Χρειάζεται έναν πάροχο που παρέχετε, όπως ένα τοπικό Ollama.", "aiNote": "Τίποτα δεν αποστέλλεται πουθενά μέχρι να προσθέσετε έναν πάροχο και να στείλετε ένα μήνυμα." }, - "admin": { - "aiGloballyEnabled": "Βοηθός Τεχνητής Νοημοσύνης", - "aiGloballyEnabledDesc": "Επιτρέψτε στους χρήστες να ενεργοποιήσουν τον βοηθό τεχνητής νοημοσύνης. Όσο είναι απενεργοποιημένος, ο βοηθός είναι κρυφός και αποκλεισμένος για όλους.", - "aiPrivateEndpoints": "Επιτρεπόμενοι ιδιωτικοί κεντρικοί υπολογιστές τεχνητής νοημοσύνης", - "aiPrivateEndpointsDesc": "Κεντρικοί υπολογιστές στο ιδιωτικό σας δίκτυο στους οποίους οι χρήστες μπορούν να υποδείξουν έναν πάροχο, όπως ένα αυτοφιλοξενούμενο Ollama. Διαχωρίστε τους με κόμματα.", - "updateAiEnabledFailed": "Δεν ήταν δυνατή η ενημέρωση της ρύθμισης AI", - "updateAiEndpointsFailed": "Δεν ήταν δυνατή η ενημέρωση των επιτρεπόμενων κεντρικών υπολογιστών" + "settings": { + "admin": { + "globallyEnabled": { + "label": "Βοηθός Τεχνητής Νοημοσύνης", + "description": "Επιτρέψτε στους χρήστες να ενεργοποιήσουν τον βοηθό τεχνητής νοημοσύνης. Όσο είναι απενεργοποιημένος, ο βοηθός είναι κρυφός και αποκλεισμένος για όλους." + }, + "privateEndpoints": { + "label": "Επιτρεπόμενοι ιδιωτικοί κεντρικοί υπολογιστές τεχνητής νοημοσύνης" + } + }, + "host": { + "enableAiAssistant": { + "label": "Ενεργοποίηση Βοηθού Τεχνητής Νοημοσύνης", + "description": "Επιτρέψτε στον βοηθό τεχνητής νοημοσύνης να βοηθά στο τερματικό αυτού του κεντρικού υπολογιστή. Απενεργοποιημένο από προεπιλογή και εξακολουθεί να χρειάζεται ενεργοποιημένη την τεχνητή νοημοσύνη για τον λογαριασμό σας." + } + } } } diff --git a/plugins/ai/locales/translated/es_ES.json b/plugins/ai/locales/translated/es_ES.json index 3e64afa90..ce7fb65b2 100644 --- a/plugins/ai/locales/translated/es_ES.json +++ b/plugins/ai/locales/translated/es_ES.json @@ -67,12 +67,21 @@ "aiEnableDesc": "Agrega un asistente que lee tu configuración y sugiere cambios que apruebas. Requiere un proveedor que tú proporciones, como un Ollama local.", "aiNote": "No se envía nada a ningún sitio hasta que añadas un proveedor y envíes un mensaje." }, - "admin": { - "aiGloballyEnabled": "Asistente de IA", - "aiGloballyEnabledDesc": "Permita que los usuarios activen el asistente de IA. Mientras esté desactivado, el asistente permanecerá oculto y bloqueado para todos.", - "aiPrivateEndpoints": "Hosts de IA privados permitidos", - "aiPrivateEndpointsDesc": "Hosts en tu red privada a los que los usuarios pueden dirigir un proveedor, como un servidor Ollama autogestionado. Sepáralos con comas.", - "updateAiEnabledFailed": "No se pudo actualizar la configuración de la IA.", - "updateAiEndpointsFailed": "No se pudieron actualizar los hosts permitidos." + "settings": { + "admin": { + "globallyEnabled": { + "label": "Asistente de IA", + "description": "Permita que los usuarios activen el asistente de IA. Mientras esté desactivado, el asistente permanecerá oculto y bloqueado para todos." + }, + "privateEndpoints": { + "label": "Hosts de IA privados permitidos" + } + }, + "host": { + "enableAiAssistant": { + "label": "Habilitar el asistente de IA", + "description": "Permite que el asistente de IA te ayude en la terminal de este host. Está desactivado por defecto y aún así necesitas tener la IA habilitada para tu cuenta." + } + } } } diff --git a/plugins/ai/locales/translated/fi_FI.json b/plugins/ai/locales/translated/fi_FI.json index c385b8e5b..44d0957df 100644 --- a/plugins/ai/locales/translated/fi_FI.json +++ b/plugins/ai/locales/translated/fi_FI.json @@ -67,12 +67,21 @@ "aiEnableDesc": "Lisää avustajan, joka lukee asetuksesi ja ehdottaa hyväksymiäsi muutoksia. Edellyttää toimittamaasi palveluntarjoajaa, kuten paikallisen Ollaman.", "aiNote": "Mitään ei lähetetä mihinkään ennen kuin lisäät palveluntarjoajan ja lähetät viestin." }, - "admin": { - "aiGloballyEnabled": "Tekoälyavustaja", - "aiGloballyEnabledDesc": "Salli käyttäjien ottaa tekoälyavustaja käyttöön. Kun tämä on pois päältä, avustaja on piilotettu ja estetty kaikilta.", - "aiPrivateEndpoints": "Sallitut yksityiset tekoälyisännät", - "aiPrivateEndpointsDesc": "Yksityisen verkon isännät, joihin käyttäjät voivat ohjata palveluntarjoajan, kuten itse isännöity Ollama. Erota ne pilkuilla.", - "updateAiEnabledFailed": "Tekoälyasetuksen päivittäminen epäonnistui", - "updateAiEndpointsFailed": "Sallittujen isäntien päivittäminen epäonnistui" + "settings": { + "admin": { + "globallyEnabled": { + "label": "Tekoälyavustaja", + "description": "Salli käyttäjien ottaa tekoälyavustaja käyttöön. Kun tämä on pois päältä, avustaja on piilotettu ja estetty kaikilta." + }, + "privateEndpoints": { + "label": "Sallitut yksityiset tekoälyisännät" + } + }, + "host": { + "enableAiAssistant": { + "label": "Ota tekoälyavustaja käyttöön", + "description": "Salli tekoälyavustaja auttaa tämän isännän terminaalissa. Oletusarvoisesti pois päältä, mutta tekoälyn on silti oltava käytössä tililläsi." + } + } } } diff --git a/plugins/ai/locales/translated/fr_FR.json b/plugins/ai/locales/translated/fr_FR.json index ae54cb28b..646ef3758 100644 --- a/plugins/ai/locales/translated/fr_FR.json +++ b/plugins/ai/locales/translated/fr_FR.json @@ -67,12 +67,21 @@ "aiEnableDesc": "Ajoute un assistant qui analyse votre configuration et suggère des modifications que vous approuvez. Nécessite un fournisseur que vous désignez, comme un Ollama local.", "aiNote": "Aucun message n'est envoyé tant que vous n'avez pas ajouté de fournisseur et envoyé un message." }, - "admin": { - "aiGloballyEnabled": "Assistant IA", - "aiGloballyEnabledDesc": "Autoriser les utilisateurs à activer l'assistant IA. Lorsqu'il est désactivé, l'assistant est masqué et inaccessible à tous.", - "aiPrivateEndpoints": "Hôtes d'IA privés autorisés", - "aiPrivateEndpointsDesc": "Les hôtes de votre réseau privé vers lesquels les utilisateurs peuvent orienter un fournisseur, comme un serveur Ollama auto-hébergé. Séparez-les par des virgules.", - "updateAiEnabledFailed": "Impossible de mettre à jour les paramètres d'IA", - "updateAiEndpointsFailed": "Impossible de mettre à jour les hôtes autorisés." + "settings": { + "admin": { + "globallyEnabled": { + "label": "Assistant IA", + "description": "Autoriser les utilisateurs à activer l'assistant IA. Lorsqu'il est désactivé, l'assistant est masqué et inaccessible à tous." + }, + "privateEndpoints": { + "label": "Hôtes d'IA privés autorisés" + } + }, + "host": { + "enableAiAssistant": { + "label": "Activer l'assistant IA", + "description": "Autoriser l'assistant IA à vous aider dans le terminal de cet hôte. Désactivé par défaut ; l'IA doit être activée pour votre compte." + } + } } } diff --git a/plugins/ai/locales/translated/he_IL.json b/plugins/ai/locales/translated/he_IL.json index f50cebbbf..722022f18 100644 --- a/plugins/ai/locales/translated/he_IL.json +++ b/plugins/ai/locales/translated/he_IL.json @@ -67,12 +67,21 @@ "aiEnableDesc": "מוסיף עוזר שקורא את ההגדרות שלך ומציע שינויים שאתה מאשר. נדרש ספק שאתה מספק, כגון אולמה מקומי.", "aiNote": "שום דבר לא נשלח לשום מקום עד שתוסיף ספק ותשלח הודעה." }, - "admin": { - "aiGloballyEnabled": "עוזר בינה מלאכותית", - "aiGloballyEnabledDesc": "אפשר למשתמשים להפעיל את עוזר הבינה המלאכותית. כאשר זה כבוי, העוזר מוסתר וחסום עבור כולם.", - "aiPrivateEndpoints": "מארחים פרטיים של בינה מלאכותית מורשים", - "aiPrivateEndpointsDesc": "מארחים ברשת הפרטית שלך שמשתמשים עשויים להפנות אליהם ספק, כגון Ollama המאוחסן בעצמו. הפרד ביניהם באמצעות פסיקים.", - "updateAiEnabledFailed": "לא ניתן היה לעדכן את הגדרת הבינה המלאכותית", - "updateAiEndpointsFailed": "לא ניתן היה לעדכן את המארחים המותרים" + "settings": { + "admin": { + "globallyEnabled": { + "label": "עוזר בינה מלאכותית", + "description": "אפשר למשתמשים להפעיל את עוזר הבינה המלאכותית. כאשר זה כבוי, העוזר מוסתר וחסום עבור כולם." + }, + "privateEndpoints": { + "label": "מארחים פרטיים של בינה מלאכותית מורשים" + } + }, + "host": { + "enableAiAssistant": { + "label": "הפעל את עוזר הבינה המלאכותית", + "description": "אפשר לעוזר הבינה המלאכותית לסייע במסוף של מארח זה. כבוי כברירת מחדל, ועדיין נדרשת הפעלת בינה מלאכותית עבור חשבונך." + } + } } } diff --git a/plugins/ai/locales/translated/hi_IN.json b/plugins/ai/locales/translated/hi_IN.json index 8b0e7b585..e7d052027 100644 --- a/plugins/ai/locales/translated/hi_IN.json +++ b/plugins/ai/locales/translated/hi_IN.json @@ -67,12 +67,21 @@ "aiEnableDesc": "यह एक सहायक सुविधा जोड़ता है जो आपके सेटअप को पढ़ता है और आपके द्वारा स्वीकृत किए जाने वाले परिवर्तनों का सुझाव देता है। इसके लिए आपको एक प्रदाता उपलब्ध कराना होगा, जैसे कि स्थानीय ओलामा।", "aiNote": "जब तक आप कोई प्रदाता नहीं जोड़ते और संदेश नहीं भेजते, तब तक कुछ भी कहीं नहीं भेजा जाता।" }, - "admin": { - "aiGloballyEnabled": "एआई सहायक", - "aiGloballyEnabledDesc": "उपयोगकर्ताओं को एआई सहायक चालू करने की अनुमति दें। जब यह बंद होगा, तो सहायक सभी के लिए छिपा हुआ और अवरुद्ध रहेगा।", - "aiPrivateEndpoints": "निजी एआई होस्ट की अनुमति दी गई", - "aiPrivateEndpointsDesc": "आपके निजी नेटवर्क पर मौजूद होस्ट, जिन्हें उपयोगकर्ता किसी प्रदाता से जोड़ सकते हैं, जैसे कि स्व-होस्टेड ओलामा। इन्हें अल्पविराम से अलग करें।", - "updateAiEnabledFailed": "एआई सेटिंग अपडेट नहीं हो सकी", - "updateAiEndpointsFailed": "अनुमत होस्टों को अपडेट नहीं किया जा सका" + "settings": { + "admin": { + "globallyEnabled": { + "label": "एआई सहायक", + "description": "उपयोगकर्ताओं को एआई सहायक चालू करने की अनुमति दें। जब यह बंद होगा, तो सहायक सभी के लिए छिपा हुआ और अवरुद्ध रहेगा।" + }, + "privateEndpoints": { + "label": "निजी एआई होस्ट की अनुमति दी गई" + } + }, + "host": { + "enableAiAssistant": { + "label": "एआई असिस्टेंट को सक्षम करें", + "description": "इस होस्ट के टर्मिनल में एआई सहायक को मदद करने की अनुमति दें। यह डिफ़ॉल्ट रूप से बंद है, लेकिन आपके खाते के लिए एआई को सक्षम करना आवश्यक है।" + } + } } } diff --git a/plugins/ai/locales/translated/hu_HU.json b/plugins/ai/locales/translated/hu_HU.json index d2ba82771..62b8dcce3 100644 --- a/plugins/ai/locales/translated/hu_HU.json +++ b/plugins/ai/locales/translated/hu_HU.json @@ -67,12 +67,21 @@ "aiEnableDesc": "Hozzáad egy asszisztenst, amely beolvassa a beállításait, és javaslatokat tesz az Ön által jóváhagyott módosításokra. Szükség van egy Ön által biztosított szolgáltatóra, például egy helyi Ollama szakemberre.", "aiNote": "Semmi sem kerül sehova, amíg nem adsz hozzá egy szolgáltatót és nem küldesz üzenetet." }, - "admin": { - "aiGloballyEnabled": "MI asszisztens", - "aiGloballyEnabledDesc": "Engedélyezheti a felhasználóknak a mesterséges intelligencia asszisztens bekapcsolását. Amíg ez ki van kapcsolva, az asszisztens rejtett és blokkolt mindenki számára.", - "aiPrivateEndpoints": "Engedélyezett privát AI-gazdagépek", - "aiPrivateEndpointsDesc": "A privát hálózatodon található olyan hosztok, amelyekre a felhasználók szolgáltatót irányíthatnak, például egy saját hosztolt Ollama. Vesszővel válaszd el őket.", - "updateAiEnabledFailed": "Nem sikerült frissíteni a mesterséges intelligencia beállítását", - "updateAiEndpointsFailed": "Nem sikerült frissíteni az engedélyezett gazdagépeket" + "settings": { + "admin": { + "globallyEnabled": { + "label": "MI asszisztens", + "description": "Engedélyezheti a felhasználóknak a mesterséges intelligencia asszisztens bekapcsolását. Amíg ez ki van kapcsolva, az asszisztens rejtett és blokkolt mindenki számára." + }, + "privateEndpoints": { + "label": "Engedélyezett privát AI-gazdagépek" + } + }, + "host": { + "enableAiAssistant": { + "label": "AI asszisztens engedélyezése", + "description": "Engedélyezi a mesterséges intelligencia asszisztensének a gazdagép terminálján való segítséget. Alapértelmezés szerint ki van kapcsolva, de továbbra is engedélyeznie kell a mesterséges intelligenciát a fiókjában." + } + } } } diff --git a/plugins/ai/locales/translated/id_ID.json b/plugins/ai/locales/translated/id_ID.json index 491c48826..085d298fe 100644 --- a/plugins/ai/locales/translated/id_ID.json +++ b/plugins/ai/locales/translated/id_ID.json @@ -67,12 +67,21 @@ "aiEnableDesc": "Menambahkan asisten yang membaca pengaturan Anda dan menyarankan perubahan yang Anda setujui. Membutuhkan penyedia yang Anda berikan, seperti Ollama lokal.", "aiNote": "Tidak ada data yang dikirim ke mana pun sampai Anda menambahkan penyedia dan mengirim pesan." }, - "admin": { - "aiGloballyEnabled": "Asisten AI", - "aiGloballyEnabledDesc": "Izinkan pengguna untuk mengaktifkan asisten AI. Saat dinonaktifkan, asisten akan disembunyikan dan diblokir untuk semua orang.", - "aiPrivateEndpoints": "Host AI pribadi yang diizinkan", - "aiPrivateEndpointsDesc": "Host di jaringan pribadi Anda yang mungkin ditunjuk pengguna untuk penyedia layanan, seperti Ollama yang dihosting sendiri. Pisahkan dengan koma.", - "updateAiEnabledFailed": "Pengaturan AI tidak dapat diperbarui.", - "updateAiEndpointsFailed": "Tidak dapat memperbarui host yang diizinkan." + "settings": { + "admin": { + "globallyEnabled": { + "label": "Asisten AI", + "description": "Izinkan pengguna untuk mengaktifkan asisten AI. Saat dinonaktifkan, asisten akan disembunyikan dan diblokir untuk semua orang." + }, + "privateEndpoints": { + "label": "Host AI pribadi yang diizinkan" + } + }, + "host": { + "enableAiAssistant": { + "label": "Aktifkan Asisten AI", + "description": "Izinkan asisten AI untuk membantu di terminal host ini. Secara default dinonaktifkan, dan AI tetap perlu diaktifkan untuk akun Anda." + } + } } } diff --git a/plugins/ai/locales/translated/it_IT.json b/plugins/ai/locales/translated/it_IT.json index 48d505a66..6f4ae61c9 100644 --- a/plugins/ai/locales/translated/it_IT.json +++ b/plugins/ai/locales/translated/it_IT.json @@ -67,12 +67,21 @@ "aiEnableDesc": "Aggiunge un assistente che legge la tua configurazione e suggerisce modifiche che approvi. Richiede un provider che tu fornisca, come ad esempio un Ollama locale.", "aiNote": "Non viene inviato nulla finché non si aggiunge un provider e si invia un messaggio." }, - "admin": { - "aiGloballyEnabled": "Assistente basato sull'intelligenza artificiale", - "aiGloballyEnabledDesc": "Consenti agli utenti di attivare l'assistente AI. Quando è disattivato, l'assistente è nascosto e bloccato per tutti.", - "aiPrivateEndpoints": "Host di IA privati consentiti", - "aiPrivateEndpointsDesc": "Host sulla tua rete privata a cui gli utenti possono indirizzare un provider, come ad esempio un'istanza di Ollama autogestita. Separali con delle virgole.", - "updateAiEnabledFailed": "Impossibile aggiornare le impostazioni dell'IA", - "updateAiEndpointsFailed": "Impossibile aggiornare gli host consentiti" + "settings": { + "admin": { + "globallyEnabled": { + "label": "Assistente basato sull'intelligenza artificiale", + "description": "Consenti agli utenti di attivare l'assistente AI. Quando è disattivato, l'assistente è nascosto e bloccato per tutti." + }, + "privateEndpoints": { + "label": "Host di IA privati consentiti" + } + }, + "host": { + "enableAiAssistant": { + "label": "Assistente AI per l'innovazione", + "description": "Consenti all'assistente IA di fornire assistenza nel terminale di questo host. Disattivato per impostazione predefinita, ma è necessario abilitare l'IA per il tuo account." + } + } } } diff --git a/plugins/ai/locales/translated/ja_JP.json b/plugins/ai/locales/translated/ja_JP.json index 4ea3f16d5..d3d1baf94 100644 --- a/plugins/ai/locales/translated/ja_JP.json +++ b/plugins/ai/locales/translated/ja_JP.json @@ -67,12 +67,21 @@ "aiEnableDesc": "設定内容を読み取り、承認した上で変更案を提案するアシスタント機能を追加します。ローカルのOllamaなど、お客様ご自身で手配するプロバイダーが必要です。", "aiNote": "プロバイダーを追加してメッセージを送信するまで、データはどこにも送信されません。" }, - "admin": { - "aiGloballyEnabled": "AIアシスタント", - "aiGloballyEnabledDesc": "ユーザーがAIアシスタントをオンにできるようにします。オフになっている間は、アシスタントは非表示になり、誰からも利用できなくなります。", - "aiPrivateEndpoints": "プライベートAIホストを許可", - "aiPrivateEndpointsDesc": "ユーザーがプロバイダに指定できる、プライベートネットワーク上のホスト(自己ホスト型のOllamaなど)を指定します。カンマで区切ってください。", - "updateAiEnabledFailed": "AI設定を更新できませんでした", - "updateAiEndpointsFailed": "許可されたホストを更新できませんでした" + "settings": { + "admin": { + "globallyEnabled": { + "label": "AIアシスタント", + "description": "ユーザーがAIアシスタントをオンにできるようにします。オフになっている間は、アシスタントは非表示になり、誰からも利用できなくなります。" + }, + "privateEndpoints": { + "label": "プライベートAIホストを許可" + } + }, + "host": { + "enableAiAssistant": { + "label": "AIアシスタントを有効にする", + "description": "このホストのターミナルでAIアシスタントによる支援を許可します。デフォルトでは無効になっており、アカウントでAIを有効にする必要があります。" + } + } } } diff --git a/plugins/ai/locales/translated/ko_KR.json b/plugins/ai/locales/translated/ko_KR.json index dd1e8c263..5adb3155f 100644 --- a/plugins/ai/locales/translated/ko_KR.json +++ b/plugins/ai/locales/translated/ko_KR.json @@ -67,12 +67,21 @@ "aiEnableDesc": "설정을 읽고 사용자가 승인하는 변경 사항을 제안하는 도우미를 추가합니다. 사용자가 제공하는 공급업체(예: 지역 Ollama)가 필요합니다.", "aiNote": "공급자를 추가하고 메시지를 보내기 전까지는 아무것도 전송되지 않습니다." }, - "admin": { - "aiGloballyEnabled": "AI 비서", - "aiGloballyEnabledDesc": "사용자가 AI 비서를 켜고 끌 수 있도록 하세요. AI 비서가 꺼져 있으면 모든 사용자에게 비서 기능이 숨겨지고 차단됩니다.", - "aiPrivateEndpoints": "개인 AI 호스트 허용", - "aiPrivateEndpointsDesc": "사용자가 공급자에게 연결할 수 있는 개인 네트워크의 호스트(예: 자체 호스팅 Ollama)를 나열하세요. 호스트는 쉼표로 구분하세요.", - "updateAiEnabledFailed": "AI 설정을 업데이트할 수 없습니다.", - "updateAiEndpointsFailed": "허용된 호스트를 업데이트할 수 없습니다." + "settings": { + "admin": { + "globallyEnabled": { + "label": "AI 비서", + "description": "사용자가 AI 비서를 켜고 끌 수 있도록 하세요. AI 비서가 꺼져 있으면 모든 사용자에게 비서 기능이 숨겨지고 차단됩니다." + }, + "privateEndpoints": { + "label": "개인 AI 호스트 허용" + } + }, + "host": { + "enableAiAssistant": { + "label": "AI 어시스턴트 활성화", + "description": "이 호스트의 터미널에서 AI 도우미의 도움을 받을 수 있도록 허용합니다. 기본적으로 비활성화되어 있으며, 계정에 AI 기능이 활성화되어 있어야 합니다." + } + } } } diff --git a/plugins/ai/locales/translated/nl_NL.json b/plugins/ai/locales/translated/nl_NL.json index 0276e0502..db37a0f6e 100644 --- a/plugins/ai/locales/translated/nl_NL.json +++ b/plugins/ai/locales/translated/nl_NL.json @@ -67,12 +67,21 @@ "aiEnableDesc": "Voegt een assistent toe die je instellingen leest en wijzigingen voorstelt die je kunt goedkeuren. Vereist een leverancier die je zelf aanlevert, zoals een lokale Ollama.", "aiNote": "Er wordt niets verzonden totdat je een provider toevoegt en een bericht verstuurt." }, - "admin": { - "aiGloballyEnabled": "AI-assistent", - "aiGloballyEnabledDesc": "Gebruikers kunnen de AI-assistent inschakelen. Wanneer deze is uitgeschakeld, is de assistent verborgen en voor iedereen geblokkeerd.", - "aiPrivateEndpoints": "Toegestane privé-AI-hosts", - "aiPrivateEndpointsDesc": "Hosts op uw privénetwerk waarnaar gebruikers een provider kunnen verwijzen, zoals een zelfgehoste Ollama. Scheid ze met komma's.", - "updateAiEnabledFailed": "De AI-instelling kon niet worden bijgewerkt.", - "updateAiEndpointsFailed": "Het lukte niet om de lijst met toegestane hosts bij te werken." + "settings": { + "admin": { + "globallyEnabled": { + "label": "AI-assistent", + "description": "Gebruikers kunnen de AI-assistent inschakelen. Wanneer deze is uitgeschakeld, is de assistent verborgen en voor iedereen geblokkeerd." + }, + "privateEndpoints": { + "label": "Toegestane privé-AI-hosts" + } + }, + "host": { + "enableAiAssistant": { + "label": "AI-assistent inschakelen", + "description": "Sta de AI-assistent toe om te helpen in de terminal van deze host. Standaard uitgeschakeld, maar AI moet nog steeds voor uw account zijn ingeschakeld." + } + } } } diff --git a/plugins/ai/locales/translated/no_NO.json b/plugins/ai/locales/translated/no_NO.json index 6ec4f43a4..70ca3dcef 100644 --- a/plugins/ai/locales/translated/no_NO.json +++ b/plugins/ai/locales/translated/no_NO.json @@ -67,12 +67,21 @@ "aiEnableDesc": "Legger til en assistent som leser oppsettet ditt og foreslår endringer du godkjenner. Krever en leverandør du oppgir, for eksempel en lokal Ollama.", "aiNote": "Ingenting sendes noe sted før du legger til en leverandør og sender en melding." }, - "admin": { - "aiGloballyEnabled": "AI-assistent", - "aiGloballyEnabledDesc": "La brukere slå på AI-assistenten. Mens dette er av, er assistenten skjult og blokkert for alle.", - "aiPrivateEndpoints": "Tillatte private AI-verter", - "aiPrivateEndpointsDesc": "Verter på ditt private nettverk som brukere kan peke en leverandør til, for eksempel en selvhostet Ollama. Skill dem med komma.", - "updateAiEnabledFailed": "Kunne ikke oppdatere AI-innstillingen", - "updateAiEndpointsFailed": "Kunne ikke oppdatere de tillatte vertene" + "settings": { + "admin": { + "globallyEnabled": { + "label": "AI-assistent", + "description": "La brukere slå på AI-assistenten. Mens dette er av, er assistenten skjult og blokkert for alle." + }, + "privateEndpoints": { + "label": "Tillatte private AI-verter" + } + }, + "host": { + "enableAiAssistant": { + "label": "Aktiver AI-assistent", + "description": "Tillat at AI-assistenten hjelper til i denne vertens terminal. Av som standard, og krever fortsatt at AI er aktivert for kontoen din." + } + } } } diff --git a/plugins/ai/locales/translated/pl_PL.json b/plugins/ai/locales/translated/pl_PL.json index 19094a517..4e3bc8f2b 100644 --- a/plugins/ai/locales/translated/pl_PL.json +++ b/plugins/ai/locales/translated/pl_PL.json @@ -67,12 +67,21 @@ "aiEnableDesc": "Dodaje asystenta, który odczytuje Twoją konfigurację i sugeruje zmiany, które zatwierdzasz. Wymaga wskazanego przez Ciebie dostawcy, np. lokalnej Ollamy.", "aiNote": "Dopóki nie dodasz dostawcy i nie wyślesz wiadomości, nic nie zostanie wysłane." }, - "admin": { - "aiGloballyEnabled": "Asystent AI", - "aiGloballyEnabledDesc": "Pozwól użytkownikom włączyć asystenta AI. Gdy ta opcja jest wyłączona, asystent jest ukryty i zablokowany dla wszystkich.", - "aiPrivateEndpoints": "Dozwolone prywatne hosty AI", - "aiPrivateEndpointsDesc": "Hosty w Twojej sieci prywatnej, na które użytkownicy mogą wskazać dostawcę, na przykład własna instancja Ollamy. Rozdziel je przecinkami.", - "updateAiEnabledFailed": "Nie można zaktualizować ustawień AI", - "updateAiEndpointsFailed": "Nie można zaktualizować dozwolonych hostów" + "settings": { + "admin": { + "globallyEnabled": { + "label": "Asystent AI", + "description": "Pozwól użytkownikom włączyć asystenta AI. Gdy ta opcja jest wyłączona, asystent jest ukryty i zablokowany dla wszystkich." + }, + "privateEndpoints": { + "label": "Dozwolone prywatne hosty AI" + } + }, + "host": { + "enableAiAssistant": { + "label": "Włącz asystenta AI", + "description": "Zezwól asystentowi AI na pomoc w terminalu tego hosta. Domyślnie wyłączone, ale nadal wymaga włączenia AI na Twoim koncie." + } + } } } diff --git a/plugins/ai/locales/translated/pt_BR.json b/plugins/ai/locales/translated/pt_BR.json index b27ec6fe1..4b1737b15 100644 --- a/plugins/ai/locales/translated/pt_BR.json +++ b/plugins/ai/locales/translated/pt_BR.json @@ -67,12 +67,21 @@ "aiEnableDesc": "Adiciona um assistente que lê sua configuração e sugere alterações que você aprova. Requer um provedor fornecido por você, como um Ollama local.", "aiNote": "Nada será enviado até que você adicione um provedor e envie uma mensagem." }, - "admin": { - "aiGloballyEnabled": "assistente de IA", - "aiGloballyEnabledDesc": "Permita que os usuários ativem o assistente de IA. Quando estiver desativado, o assistente ficará oculto e bloqueado para todos.", - "aiPrivateEndpoints": "Hosts de IA privados permitidos", - "aiPrivateEndpointsDesc": "Hosts em sua rede privada que os usuários podem usar como provedores, como um Ollama auto-hospedado. Separe-os com vírgulas.", - "updateAiEnabledFailed": "Não foi possível atualizar as configurações de IA.", - "updateAiEndpointsFailed": "Não foi possível atualizar os hosts permitidos." + "settings": { + "admin": { + "globallyEnabled": { + "label": "assistente de IA", + "description": "Permita que os usuários ativem o assistente de IA. Quando estiver desativado, o assistente ficará oculto e bloqueado para todos." + }, + "privateEndpoints": { + "label": "Hosts de IA privados permitidos" + } + }, + "host": { + "enableAiAssistant": { + "label": "Ativar o assistente de IA", + "description": "Permita que o assistente de IA ajude no terminal deste host. Desativado por padrão, mas ainda precisa que a IA esteja ativada para sua conta." + } + } } } diff --git a/plugins/ai/locales/translated/pt_PT.json b/plugins/ai/locales/translated/pt_PT.json index 498afaf77..09a98f01d 100644 --- a/plugins/ai/locales/translated/pt_PT.json +++ b/plugins/ai/locales/translated/pt_PT.json @@ -67,12 +67,21 @@ "aiEnableDesc": "Adiciona um assistente que lê a sua configuração e sugere alterações que aprova. Requer um fornecedor fornecido por si, como um Ollama local.", "aiNote": "Nada será enviado até que adicione um fornecedor e envie uma mensagem." }, - "admin": { - "aiGloballyEnabled": "assistente de IA", - "aiGloballyEnabledDesc": "Permita que os utilizadores ativem o assistente de IA. Quando estiver desativado, o assistente ficará oculto e bloqueado para todos.", - "aiPrivateEndpoints": "Hosts de IA privados permitidos", - "aiPrivateEndpointsDesc": "Hosts na sua rede privada que os utilizadores podem utilizar como fornecedores, como um Ollama auto-hospedado. Separe-os com vírgulas.", - "updateAiEnabledFailed": "Não foi possível atualizar as definições de IA.", - "updateAiEndpointsFailed": "Não foi possível atualizar os hosts permitidos." + "settings": { + "admin": { + "globallyEnabled": { + "label": "assistente de IA", + "description": "Permita que os utilizadores ativem o assistente de IA. Quando estiver desativado, o assistente ficará oculto e bloqueado para todos." + }, + "privateEndpoints": { + "label": "Hosts de IA privados permitidos" + } + }, + "host": { + "enableAiAssistant": { + "label": "Ativar o assistente de IA", + "description": "Permita que o assistente de IA ajude no terminal deste host. Desativado por defeito, mas ainda precisa que a IA esteja ativada para a sua conta." + } + } } } diff --git a/plugins/ai/locales/translated/ro_RO.json b/plugins/ai/locales/translated/ro_RO.json index 8a6e43e32..b567ea3e9 100644 --- a/plugins/ai/locales/translated/ro_RO.json +++ b/plugins/ai/locales/translated/ro_RO.json @@ -67,12 +67,21 @@ "aiEnableDesc": "Adaugă un asistent care citește configurația și sugerează modificări pe care le aprobi. Necesită un furnizor furnizat de tine, cum ar fi un Ollama local.", "aiNote": "Nimic nu este trimis nicăieri până nu adăugați un furnizor și nu trimiteți un mesaj." }, - "admin": { - "aiGloballyEnabled": "Asistent AI", - "aiGloballyEnabledDesc": "Permiteți utilizatorilor să activeze asistentul AI. Cât timp este dezactivat, asistentul este ascuns și blocat pentru toată lumea.", - "aiPrivateEndpoints": "Gazde private AI permise", - "aiPrivateEndpointsDesc": "Gazde din rețeaua privată către care utilizatorii pot direcționa un furnizor, cum ar fi un Ollama auto-găzduit. Separați-le prin virgule.", - "updateAiEnabledFailed": "Nu s-a putut actualiza setarea AI", - "updateAiEndpointsFailed": "Nu s-au putut actualiza gazdele permise" + "settings": { + "admin": { + "globallyEnabled": { + "label": "Asistent AI", + "description": "Permiteți utilizatorilor să activeze asistentul AI. Cât timp este dezactivat, asistentul este ascuns și blocat pentru toată lumea." + }, + "privateEndpoints": { + "label": "Gazde private AI permise" + } + }, + "host": { + "enableAiAssistant": { + "label": "Activează Asistentul AI", + "description": "Permiteți asistentului AI să vă ajute în terminalul acestei gazde. Dezactivat în mod implicit, dar este nevoie ca AI să fie activat pentru contul dvs." + } + } } } diff --git a/plugins/ai/locales/translated/ru_RU.json b/plugins/ai/locales/translated/ru_RU.json index e2388859f..c883756ce 100644 --- a/plugins/ai/locales/translated/ru_RU.json +++ b/plugins/ai/locales/translated/ru_RU.json @@ -67,12 +67,21 @@ "aiEnableDesc": "Добавляет помощника, который считывает ваши настройки и предлагает изменения, которые вы одобряете. Требуется поставщик, которого вы предоставляете, например, локальный Ollama.", "aiNote": "Ничего никуда не отправляется, пока вы не добавите поставщика услуг и не отправите сообщение." }, - "admin": { - "aiGloballyEnabled": "ИИ-помощник", - "aiGloballyEnabledDesc": "Разрешите пользователям включить голосового помощника. Если он выключен, помощник скрыт и заблокирован для всех.", - "aiPrivateEndpoints": "Разрешены частные хосты ИИ", - "aiPrivateEndpointsDesc": "Укажите хосты в вашей частной сети, на которые пользователи могут направлять запросы провайдера, например, самостоятельно размещенный Ollama. Разделяйте их запятыми.", - "updateAiEnabledFailed": "Не удалось обновить настройки ИИ.", - "updateAiEndpointsFailed": "Не удалось обновить список разрешенных хостов." + "settings": { + "admin": { + "globallyEnabled": { + "label": "ИИ-помощник", + "description": "Разрешите пользователям включить голосового помощника. Если он выключен, помощник скрыт и заблокирован для всех." + }, + "privateEndpoints": { + "label": "Разрешены частные хосты ИИ" + } + }, + "host": { + "enableAiAssistant": { + "label": "Включить ИИ-помощника", + "description": "Разрешите помощнику с искусственным интеллектом помогать на терминале этого хоста. По умолчанию отключено, но для вашей учетной записи ИИ все равно должен быть включен." + } + } } } diff --git a/plugins/ai/locales/translated/sr_SP.json b/plugins/ai/locales/translated/sr_SP.json index 2fa67be85..73828853e 100644 --- a/plugins/ai/locales/translated/sr_SP.json +++ b/plugins/ai/locales/translated/sr_SP.json @@ -67,12 +67,21 @@ "aiEnableDesc": "Додаје асистента који чита ваша подешавања и предлаже измене које одобравате. Потребан је добављач кога ви обезбедите, као што је локални Олама.", "aiNote": "Ништа се не шаље нигде док не додате провајдера и не пошаљете поруку." }, - "admin": { - "aiGloballyEnabled": "Асистент за вештачку интелигенцију", - "aiGloballyEnabledDesc": "Дозволите корисницима да укључе вештачку интелигенцију асистента. Док је ово искључено, асистент је скривен и блокиран за све.", - "aiPrivateEndpoints": "Дозвољени приватни AI хостови", - "aiPrivateEndpointsDesc": "Домаћини на вашој приватној мрежи на које корисници могу усмерити провајдера, као што је самостално хостована Ollama. Раздвојите их зарезима.", - "updateAiEnabledFailed": "Није могуће ажурирати подешавање вештачке интелигенције", - "updateAiEndpointsFailed": "Није могуће ажурирати дозвољене хостове" + "settings": { + "admin": { + "globallyEnabled": { + "label": "Асистент за вештачку интелигенцију", + "description": "Дозволите корисницима да укључе вештачку интелигенцију асистента. Док је ово искључено, асистент је скривен и блокиран за све." + }, + "privateEndpoints": { + "label": "Дозвољени приватни AI хостови" + } + }, + "host": { + "enableAiAssistant": { + "label": "Омогући AI асистент", + "description": "Дозволите вештачкој интелигенцији да помогне у терминалу овог хоста. Искључено је подразумевано, а и даље је потребно да вештачка интелигенција буде омогућена за ваш налог." + } + } } } diff --git a/plugins/ai/locales/translated/sv_SE.json b/plugins/ai/locales/translated/sv_SE.json index 31718fbe7..edfaf6a9c 100644 --- a/plugins/ai/locales/translated/sv_SE.json +++ b/plugins/ai/locales/translated/sv_SE.json @@ -67,12 +67,21 @@ "aiEnableDesc": "Lägger till en assistent som läser din installation och föreslår ändringar som du godkänner. Kräver en leverantör som du tillhandahåller, till exempel en lokal Ollama.", "aiNote": "Ingenting skickas någonstans förrän du lägger till en leverantör och skickar ett meddelande." }, - "admin": { - "aiGloballyEnabled": "AI-assistent", - "aiGloballyEnabledDesc": "Låt användare aktivera AI-assistenten. Medan detta är avstängt är assistenten dold och blockerad för alla.", - "aiPrivateEndpoints": "Tillåtna privata AI-värdar", - "aiPrivateEndpointsDesc": "Värdar på ditt privata nätverk som användare kan hänvisa en leverantör till, till exempel en egenhostad Ollama. Separera dem med kommatecken.", - "updateAiEnabledFailed": "Kunde inte uppdatera AI-inställningen", - "updateAiEndpointsFailed": "Kunde inte uppdatera de tillåtna värdarna" + "settings": { + "admin": { + "globallyEnabled": { + "label": "AI-assistent", + "description": "Låt användare aktivera AI-assistenten. Medan detta är avstängt är assistenten dold och blockerad för alla." + }, + "privateEndpoints": { + "label": "Tillåtna privata AI-värdar" + } + }, + "host": { + "enableAiAssistant": { + "label": "Aktivera AI-assistenten", + "description": "Tillåt AI-assistenten att hjälpa till i den här värdens terminal. Av som standard och kräver fortfarande att AI är aktiverat för ditt konto." + } + } } } diff --git a/plugins/ai/locales/translated/th_TH.json b/plugins/ai/locales/translated/th_TH.json index 9d15e11c9..bf9605514 100644 --- a/plugins/ai/locales/translated/th_TH.json +++ b/plugins/ai/locales/translated/th_TH.json @@ -67,12 +67,21 @@ "aiEnableDesc": "เพิ่มผู้ช่วยที่จะอ่านการตั้งค่าของคุณและแนะนำการเปลี่ยนแปลงที่คุณต้องอนุมัติ จำเป็นต้องมีผู้ให้บริการที่คุณจัดหาให้ เช่น Ollama ในพื้นที่ของคุณ", "aiNote": "จะไม่มีการส่งข้อมูลใดๆ จนกว่าคุณจะเพิ่มผู้ให้บริการและส่งข้อความ" }, - "admin": { - "aiGloballyEnabled": "ผู้ช่วย AI", - "aiGloballyEnabledDesc": "อนุญาตให้ผู้ใช้เปิดใช้งานผู้ช่วย AI ได้ เมื่อปิดใช้งานอยู่ ผู้ช่วยจะถูกซ่อนและบล็อกไม่ให้ใครเห็น", - "aiPrivateEndpoints": "อนุญาตให้โฮสต์ AI ส่วนตัว", - "aiPrivateEndpointsDesc": "โฮสต์บนเครือข่ายส่วนตัวของคุณที่ผู้ใช้สามารถชี้ไปยังผู้ให้บริการได้ เช่น Ollama ที่ติดตั้งเอง คั่นด้วยเครื่องหมายจุลภาค", - "updateAiEnabledFailed": "ไม่สามารถอัปเดตการตั้งค่า AI ได้", - "updateAiEndpointsFailed": "ไม่สามารถอัปเดตโฮสต์ที่อนุญาตได้" + "settings": { + "admin": { + "globallyEnabled": { + "label": "ผู้ช่วย AI", + "description": "อนุญาตให้ผู้ใช้เปิดใช้งานผู้ช่วย AI ได้ เมื่อปิดใช้งานอยู่ ผู้ช่วยจะถูกซ่อนและบล็อกไม่ให้ใครเห็น" + }, + "privateEndpoints": { + "label": "อนุญาตให้โฮสต์ AI ส่วนตัว" + } + }, + "host": { + "enableAiAssistant": { + "label": "เปิดใช้งานผู้ช่วย AI", + "description": "อนุญาตให้ผู้ช่วย AI ช่วยเหลือในเทอร์มินัลของโฮสต์นี้ โดยค่าเริ่มต้นจะปิดอยู่ และยังคงต้องเปิดใช้งาน AI สำหรับบัญชีของคุณก่อน" + } + } } } diff --git a/plugins/ai/locales/translated/tr_TR.json b/plugins/ai/locales/translated/tr_TR.json index 726b80533..04ee35384 100644 --- a/plugins/ai/locales/translated/tr_TR.json +++ b/plugins/ai/locales/translated/tr_TR.json @@ -67,12 +67,21 @@ "aiEnableDesc": "Ayarlarınızı okuyan ve onaylamanız halinde değişiklikler öneren bir asistan ekler. Yerel bir Ollama gibi, sizin sağladığınız bir sağlayıcıya ihtiyaç duyar.", "aiNote": "Bir sağlayıcı ekleyip mesaj gönderene kadar hiçbir şey hiçbir yere gönderilmez." }, - "admin": { - "aiGloballyEnabled": "Yapay zeka asistanı", - "aiGloballyEnabledDesc": "Kullanıcıların yapay zeka asistanını açmasına izin verin. Bu özellik kapalıyken, asistan gizlenir ve herkes için engellenir.", - "aiPrivateEndpoints": "Özel yapay zeka sunucularına izin verildi.", - "aiPrivateEndpointsDesc": "Kullanıcıların bir sağlayıcıya yönlendirebileceği, özel ağınızdaki sunucular (örneğin, kendi sunucunuzda barındırdığınız Ollama). Bunları virgülle ayırın.", - "updateAiEnabledFailed": "Yapay zeka ayarları güncellenemedi.", - "updateAiEndpointsFailed": "İzin verilen sunucular güncellenemedi." + "settings": { + "admin": { + "globallyEnabled": { + "label": "Yapay zeka asistanı", + "description": "Kullanıcıların yapay zeka asistanını açmasına izin verin. Bu özellik kapalıyken, asistan gizlenir ve herkes için engellenir." + }, + "privateEndpoints": { + "label": "Özel yapay zeka sunucularına izin verildi." + } + }, + "host": { + "enableAiAssistant": { + "label": "Yapay Zeka Asistanını Etkinleştir", + "description": "Bu sunucunun terminalinde yapay zeka asistanının yardımcı olmasına izin verin. Varsayılan olarak kapalıdır ve hesabınız için yapay zekanın etkinleştirilmiş olması gerekir." + } + } } } diff --git a/plugins/ai/locales/translated/uk_UA.json b/plugins/ai/locales/translated/uk_UA.json index da6b58f62..43385159c 100644 --- a/plugins/ai/locales/translated/uk_UA.json +++ b/plugins/ai/locales/translated/uk_UA.json @@ -67,12 +67,21 @@ "aiEnableDesc": "Додає помічника, який читає ваші налаштування та пропонує зміни, які ви схвалюєте. Потрібен постачальник, якого ви надаєте, наприклад, місцевий Ollama.", "aiNote": "Нічого нікуди не надсилається, доки ви не додасте постачальника та не надішлете повідомлення." }, - "admin": { - "aiGloballyEnabled": "Асистент зі штучним інтелектом", - "aiGloballyEnabledDesc": "Дозвольте користувачам увімкнути помічника зі штучним інтелектом. Коли це вимкнено, помічник прихований і заблокований для всіх.", - "aiPrivateEndpoints": "Дозволені приватні хости ШІ", - "aiPrivateEndpointsDesc": "Хости у вашій приватній мережі, на які користувачі можуть вказувати провайдера, наприклад, Ollama з власним хостингом. Розділяйте їх комами.", - "updateAiEnabledFailed": "Не вдалося оновити налаштування штучного інтелекту", - "updateAiEndpointsFailed": "Не вдалося оновити дозволені хости" + "settings": { + "admin": { + "globallyEnabled": { + "label": "Асистент зі штучним інтелектом", + "description": "Дозвольте користувачам увімкнути помічника зі штучним інтелектом. Коли це вимкнено, помічник прихований і заблокований для всіх." + }, + "privateEndpoints": { + "label": "Дозволені приватні хости ШІ" + } + }, + "host": { + "enableAiAssistant": { + "label": "Увімкнути помічника зі штучним інтелектом", + "description": "Дозволити помічнику зі штучним інтелектом допомагати в терміналі цього хоста. Вимкнено за замовчуванням, але все одно потребує ввімкнення штучного інтелекту для вашого облікового запису." + } + } } } diff --git a/plugins/ai/locales/translated/vi_VN.json b/plugins/ai/locales/translated/vi_VN.json index c43fec486..737683517 100644 --- a/plugins/ai/locales/translated/vi_VN.json +++ b/plugins/ai/locales/translated/vi_VN.json @@ -67,12 +67,21 @@ "aiEnableDesc": "Thêm một trợ lý đọc cấu hình của bạn và đề xuất các thay đổi mà bạn chấp thuận. Cần có nhà cung cấp do bạn chỉ định, chẳng hạn như Ollama địa phương.", "aiNote": "Không có dữ liệu nào được gửi đi cho đến khi bạn thêm nhà cung cấp và gửi tin nhắn." }, - "admin": { - "aiGloballyEnabled": "Trợ lý AI", - "aiGloballyEnabledDesc": "Cho phép người dùng bật trợ lý AI. Khi trợ lý này tắt, nó sẽ bị ẩn và chặn đối với tất cả mọi người.", - "aiPrivateEndpoints": "Cho phép máy chủ AI riêng", - "aiPrivateEndpointsDesc": "Các máy chủ trên mạng riêng của bạn mà người dùng có thể trỏ đến nhà cung cấp, chẳng hạn như Ollama tự lưu trữ. Phân tách chúng bằng dấu phẩy.", - "updateAiEnabledFailed": "Không thể cập nhật cài đặt AI.", - "updateAiEndpointsFailed": "Không thể cập nhật các máy chủ được cho phép" + "settings": { + "admin": { + "globallyEnabled": { + "label": "Trợ lý AI", + "description": "Cho phép người dùng bật trợ lý AI. Khi trợ lý này tắt, nó sẽ bị ẩn và chặn đối với tất cả mọi người." + }, + "privateEndpoints": { + "label": "Cho phép máy chủ AI riêng" + } + }, + "host": { + "enableAiAssistant": { + "label": "Kích hoạt Trợ lý AI", + "description": "Cho phép trợ lý AI hỗ trợ trong thiết bị đầu cuối của máy chủ này. Mặc định là tắt, nhưng bạn vẫn cần bật AI cho tài khoản của mình." + } + } } } diff --git a/plugins/ai/locales/translated/zh_CN.json b/plugins/ai/locales/translated/zh_CN.json index 761d5f815..06c5cd6cd 100644 --- a/plugins/ai/locales/translated/zh_CN.json +++ b/plugins/ai/locales/translated/zh_CN.json @@ -67,12 +67,21 @@ "aiEnableDesc": "启用 AI 助手,它将分析您的配置并提供优化建议。需自行配置服务商,例如本地运行的 Ollama。", "aiNote": "在您配置服务商并主动发送消息之前,绝不会向外发送任何数据。" }, - "admin": { - "aiGloballyEnabled": "AI 助手", - "aiGloballyEnabledDesc": "允许用户开启 AI 助手。关闭此功能后,助手将被隐藏,所有用户均无法使用。", - "aiPrivateEndpoints": "允许的私有 AI 主机", - "aiPrivateEndpointsDesc": "用户可能将服务提供商指向的私有网络上的主机,例如自托管的 Ollama。用逗号分隔。", - "updateAiEnabledFailed": "无法更新 AI 设置", - "updateAiEndpointsFailed": "无法更新允许的主机列表。" + "settings": { + "admin": { + "globallyEnabled": { + "label": "AI 助手", + "description": "允许用户开启 AI 助手。关闭此功能后,助手将被隐藏,所有用户均无法使用。" + }, + "privateEndpoints": { + "label": "允许的私有 AI 主机" + } + }, + "host": { + "enableAiAssistant": { + "label": "启用人工智能助手", + "description": "允许 AI 助手在此主机的终端上提供帮助。默认情况下处于关闭状态,但仍需在您的帐户中启用 AI 功能。" + } + } } } diff --git a/plugins/ai/locales/translated/zh_TW.json b/plugins/ai/locales/translated/zh_TW.json index b5abcfbe2..71cf15e0a 100644 --- a/plugins/ai/locales/translated/zh_TW.json +++ b/plugins/ai/locales/translated/zh_TW.json @@ -67,12 +67,21 @@ "aiEnableDesc": "添加一個助手,它可以讀取您的設定並提出您批准的更改建議。需要您提供服務提供者,例如當地的 Ollama 服務提供者。", "aiNote": "在您新增服務提供者並發送訊息之前,任何內容都不會發送到任何地方。" }, - "admin": { - "aiGloballyEnabled": "人工智慧助手", - "aiGloballyEnabledDesc": "允許用戶開啟AI助理。關閉此功能後,助手將被隱藏,所有使用者均無法使用。", - "aiPrivateEndpoints": "允許的私有人工智慧主機", - "aiPrivateEndpointsDesc": "使用者可能將服務提供者指向的私有網路上的主機,例如自架的 Ollama。用逗號分隔。", - "updateAiEnabledFailed": "無法更新AI設定", - "updateAiEndpointsFailed": "無法更新允許的主機清單。" + "settings": { + "admin": { + "globallyEnabled": { + "label": "人工智慧助手", + "description": "允許用戶開啟AI助理。關閉此功能後,助手將被隱藏,所有使用者均無法使用。" + }, + "privateEndpoints": { + "label": "允許的私有人工智慧主機" + } + }, + "host": { + "enableAiAssistant": { + "label": "啟用人工智慧助手", + "description": "允許 AI 助理在此主機的終端機上提供協助。預設為關閉狀態,但仍需在您的帳戶中啟用 AI 功能。" + } + } } } diff --git a/plugins/ai/manifest.json b/plugins/ai/manifest.json index 1150bdaec..8e136aae2 100644 --- a/plugins/ai/manifest.json +++ b/plugins/ai/manifest.json @@ -22,7 +22,7 @@ "db:own", "secrets:own", "network:outbound", - "audit:read", + "notify:send", "network:serve", "ui:surface" ], @@ -32,7 +32,8 @@ "snippets": "^1.0.0", "fleets": "^1.0.0", "ssh-terminal": "^1.0.0", - "automations": "^1.0.0" + "automations": "^1.0.0", + "homepage": "^1.0.0" }, "requires": [ { @@ -64,6 +65,11 @@ "service": "automations.access", "versionRange": "^1.0.0", "optional": true + }, + { + "service": "homepage.items", + "versionRange": "^1.0.0", + "optional": true } ], "providesSecret": [ @@ -128,18 +134,55 @@ "settings": { "admin": [ { - "key": "access", - "type": "custom", - "component": "access" + "key": "globallyEnabled", + "type": "boolean", + "labelKey": "settings.admin.globallyEnabled.label", + "descriptionKey": "settings.admin.globallyEnabled.description", + "default": false + }, + { + "key": "privateEndpoints", + "type": "textarea", + "labelKey": "settings.admin.privateEndpoints.label", + "descriptionKey": "settings.admin.privateEndpoints.description", + "requires": "globallyEnabled", + "default": "localhost\n127.0.0.1\n::1\nhost.docker.internal" } ], "user": [ { - "key": "assistant", + "key": "enabled", + "type": "boolean", + "labelKey": "ai.enableTitle", + "descriptionKey": "ai.enableDescription", + "default": false + }, + { + "key": "allowReadOnlyCommands", + "type": "boolean", + "labelKey": "ai.readOnlyCommands", + "descriptionKey": "ai.readOnlyCommandsDescription", + "requires": "enabled", + "default": false + }, + { + "key": "providers", "type": "custom", - "component": "assistant" + "component": "providers", + "permission": "manage_providers" } - ] + ], + "host": { + "fields": [ + { + "key": "enableAiAssistant", + "type": "boolean", + "labelKey": "settings.host.enableAiAssistant.label", + "descriptionKey": "settings.host.enableAiAssistant.description", + "default": false + } + ] + } } }, "locales": "locales", diff --git a/plugins/ai/migrations/mysql/0001_adopt_ai_tables.sql b/plugins/ai/migrations/mysql/0001_adopt_ai_tables.sql new file mode 100644 index 000000000..6abe55137 --- /dev/null +++ b/plugins/ai/migrations/mysql/0001_adopt_ai_tables.sql @@ -0,0 +1,55 @@ +-- ai 0001: adopt_ai_tables +-- Generated by termix-plugin migrations. Review before committing. +-- Hand-edited: keeps the legacy foreign key from messages and proposals to +-- their conversation, which the SDK cannot declare. + +CREATE TABLE IF NOT EXISTS `ai_providers` ( + `id` int AUTO_INCREMENT PRIMARY KEY, + `user_id` varchar(255) NOT NULL, + `provider_type` text NOT NULL, + `label` varchar(255) NOT NULL, + `base_url` text, + `api_key_prefix` text, + `default_model` text, + `enabled` boolean NOT NULL DEFAULT true, + `created_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP), + `updated_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP), + FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE +); +RENAME TABLE `ai_providers` TO `p_ai_providers`; +CREATE TABLE IF NOT EXISTS `ai_conversations` ( + `id` int AUTO_INCREMENT PRIMARY KEY, + `user_id` varchar(255) NOT NULL, + `title` text, + `provider_id` int, + `model` text, + `created_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP), + `updated_at` varchar(255) NOT NULL DEFAULT (CURRENT_TIMESTAMP), + FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE +); +RENAME TABLE `ai_conversations` TO `p_ai_conversations`; +CREATE TABLE IF NOT EXISTS `ai_messages` ( + `id` int AUTO_INCREMENT PRIMARY KEY, + `conversation_id` int NOT NULL, + `role` text NOT NULL, + `content` text NOT NULL DEFAULT (''), + `tool_calls` text, + `created_at` varchar(255) NOT NULL DEFAULT (CURRENT_TIMESTAMP), + FOREIGN KEY (`conversation_id`) REFERENCES `p_ai_conversations` (`id`) ON DELETE CASCADE +); +RENAME TABLE `ai_messages` TO `p_ai_messages`; +CREATE TABLE IF NOT EXISTS `ai_proposals` ( + `id` int AUTO_INCREMENT PRIMARY KEY, + `conversation_id` int NOT NULL, + `user_id` varchar(255) NOT NULL, + `kind` varchar(255) NOT NULL, + `summary` text, + `payload` text NOT NULL DEFAULT ('{}'), + `status` varchar(32) NOT NULL DEFAULT 'pending', + `applied_at` text, + `result_summary` text, + `created_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP), + FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE, + FOREIGN KEY (`conversation_id`) REFERENCES `p_ai_conversations` (`id`) ON DELETE CASCADE +); +RENAME TABLE `ai_proposals` TO `p_ai_proposals`; diff --git a/plugins/ai/migrations/postgres/0001_adopt_ai_tables.sql b/plugins/ai/migrations/postgres/0001_adopt_ai_tables.sql new file mode 100644 index 000000000..b872c9a41 --- /dev/null +++ b/plugins/ai/migrations/postgres/0001_adopt_ai_tables.sql @@ -0,0 +1,60 @@ +-- ai 0001: adopt_ai_tables +-- Generated by termix-plugin migrations. Review before committing. +-- Hand-edited: keeps the legacy foreign key from messages and proposals to +-- their conversation, which the SDK cannot declare. + +CREATE TABLE IF NOT EXISTS "ai_providers" ( + "id" serial PRIMARY KEY, + "user_id" varchar(255) NOT NULL, + "provider_type" text NOT NULL, + "label" varchar(255) NOT NULL, + "base_url" text, + "api_key_prefix" text, + "default_model" text, + "enabled" boolean NOT NULL DEFAULT true, + "created_at" text NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" text NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY ("user_id") REFERENCES "users" ("id") ON DELETE CASCADE +); +CREATE UNIQUE INDEX IF NOT EXISTS "idx_ai_providers_user_label" ON "ai_providers" ("user_id", "label"); +ALTER TABLE "ai_providers" RENAME TO "p_ai_providers"; +CREATE TABLE IF NOT EXISTS "ai_conversations" ( + "id" serial PRIMARY KEY, + "user_id" varchar(255) NOT NULL, + "title" text, + "provider_id" integer, + "model" text, + "created_at" text NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" varchar(255) NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY ("user_id") REFERENCES "users" ("id") ON DELETE CASCADE +); +CREATE INDEX IF NOT EXISTS "idx_ai_conversations_user" ON "ai_conversations" ("user_id", "updated_at"); +ALTER TABLE "ai_conversations" RENAME TO "p_ai_conversations"; +CREATE TABLE IF NOT EXISTS "ai_messages" ( + "id" serial PRIMARY KEY, + "conversation_id" integer NOT NULL, + "role" text NOT NULL, + "content" text NOT NULL DEFAULT '', + "tool_calls" text, + "created_at" varchar(255) NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY ("conversation_id") REFERENCES "p_ai_conversations" ("id") ON DELETE CASCADE +); +CREATE INDEX IF NOT EXISTS "idx_ai_messages_conversation" ON "ai_messages" ("conversation_id", "created_at"); +ALTER TABLE "ai_messages" RENAME TO "p_ai_messages"; +CREATE TABLE IF NOT EXISTS "ai_proposals" ( + "id" serial PRIMARY KEY, + "conversation_id" integer NOT NULL, + "user_id" varchar(255) NOT NULL, + "kind" varchar(255) NOT NULL, + "summary" text, + "payload" text NOT NULL DEFAULT '{}', + "status" varchar(32) NOT NULL DEFAULT 'pending', + "applied_at" text, + "result_summary" text, + "created_at" text NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY ("user_id") REFERENCES "users" ("id") ON DELETE CASCADE, + FOREIGN KEY ("conversation_id") REFERENCES "p_ai_conversations" ("id") ON DELETE CASCADE +); +CREATE INDEX IF NOT EXISTS "idx_ai_proposals_user" ON "ai_proposals" ("user_id", "status"); +CREATE INDEX IF NOT EXISTS "idx_ai_proposals_conversation" ON "ai_proposals" ("conversation_id"); +ALTER TABLE "ai_proposals" RENAME TO "p_ai_proposals"; diff --git a/plugins/ai/migrations/snapshot.json b/plugins/ai/migrations/snapshot.json new file mode 100644 index 000000000..5d98ed661 --- /dev/null +++ b/plugins/ai/migrations/snapshot.json @@ -0,0 +1,196 @@ +{ + "version": 1, + "tables": { + "providers": { + "columns": { + "id": { + "type": "id", + "primaryKey": true, + "notNull": true + }, + "userId": { + "type": "refUser", + "length": 255, + "notNull": true + }, + "providerType": { + "type": "text", + "notNull": true + }, + "label": { + "type": "varchar", + "length": 255, + "notNull": true + }, + "baseUrl": { + "type": "text" + }, + "apiKeyPrefix": { + "type": "text" + }, + "defaultModel": { + "type": "text" + }, + "enabled": { + "type": "boolean", + "notNull": true, + "defaultValue": true + }, + "createdAt": { + "type": "timestamp", + "notNull": true, + "defaultNow": true + }, + "updatedAt": { + "type": "timestamp", + "notNull": true, + "defaultNow": true + } + }, + "indexes": [ + { + "name": "idx_ai_providers_user_label", + "columns": ["userId", "label"], + "unique": true + } + ], + "adopts": "ai_providers" + }, + "conversations": { + "columns": { + "id": { + "type": "id", + "primaryKey": true, + "notNull": true + }, + "userId": { + "type": "refUser", + "length": 255, + "notNull": true + }, + "title": { + "type": "text" + }, + "providerId": { + "type": "integer" + }, + "model": { + "type": "text" + }, + "createdAt": { + "type": "timestamp", + "notNull": true, + "defaultNow": true + }, + "updatedAt": { + "type": "timestamp", + "notNull": true, + "defaultNow": true + } + }, + "indexes": [ + { + "name": "idx_ai_conversations_user", + "columns": ["userId", "updatedAt"] + } + ], + "adopts": "ai_conversations" + }, + "messages": { + "columns": { + "id": { + "type": "id", + "primaryKey": true, + "notNull": true + }, + "conversationId": { + "type": "integer", + "notNull": true + }, + "role": { + "type": "text", + "notNull": true + }, + "content": { + "type": "text", + "notNull": true, + "defaultValue": "" + }, + "toolCalls": { + "type": "text" + }, + "createdAt": { + "type": "timestamp", + "notNull": true, + "defaultNow": true + } + }, + "indexes": [ + { + "name": "idx_ai_messages_conversation", + "columns": ["conversationId", "createdAt"] + } + ], + "adopts": "ai_messages" + }, + "proposals": { + "columns": { + "id": { + "type": "id", + "primaryKey": true, + "notNull": true + }, + "conversationId": { + "type": "integer", + "notNull": true + }, + "userId": { + "type": "refUser", + "length": 255, + "notNull": true + }, + "kind": { + "type": "varchar", + "length": 255, + "notNull": true + }, + "summary": { + "type": "text" + }, + "payload": { + "type": "text", + "notNull": true, + "defaultValue": "{}" + }, + "status": { + "type": "varchar", + "length": 32, + "notNull": true, + "defaultValue": "pending" + }, + "appliedAt": { + "type": "text" + }, + "resultSummary": { + "type": "text" + }, + "createdAt": { + "type": "timestamp", + "notNull": true, + "defaultNow": true + } + }, + "indexes": [ + { + "name": "idx_ai_proposals_user", + "columns": ["userId", "status"] + }, + { + "name": "idx_ai_proposals_conversation", + "columns": ["conversationId"] + } + ], + "adopts": "ai_proposals" + } + } +} diff --git a/plugins/ai/migrations/sqlite/0001_adopt_ai_tables.sql b/plugins/ai/migrations/sqlite/0001_adopt_ai_tables.sql new file mode 100644 index 000000000..cbcffe2b7 --- /dev/null +++ b/plugins/ai/migrations/sqlite/0001_adopt_ai_tables.sql @@ -0,0 +1,60 @@ +-- ai 0001: adopt_ai_tables +-- Generated by termix-plugin migrations. Review before committing. +-- Hand-edited: keeps the legacy foreign key from messages and proposals to +-- their conversation, which the SDK cannot declare. + +CREATE TABLE IF NOT EXISTS "ai_providers" ( + "id" integer PRIMARY KEY AUTOINCREMENT, + "user_id" text NOT NULL, + "provider_type" text NOT NULL, + "label" text NOT NULL, + "base_url" 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, + FOREIGN KEY ("user_id") REFERENCES "users" ("id") ON DELETE CASCADE +); +CREATE UNIQUE INDEX IF NOT EXISTS "idx_ai_providers_user_label" ON "ai_providers" ("user_id", "label"); +ALTER TABLE "ai_providers" RENAME TO "p_ai_providers"; +CREATE TABLE IF NOT EXISTS "ai_conversations" ( + "id" integer PRIMARY KEY AUTOINCREMENT, + "user_id" text NOT NULL, + "title" text, + "provider_id" integer, + "model" text, + "created_at" text NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" text NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY ("user_id") REFERENCES "users" ("id") ON DELETE CASCADE +); +CREATE INDEX IF NOT EXISTS "idx_ai_conversations_user" ON "ai_conversations" ("user_id", "updated_at"); +ALTER TABLE "ai_conversations" RENAME TO "p_ai_conversations"; +CREATE TABLE IF NOT EXISTS "ai_messages" ( + "id" integer PRIMARY KEY AUTOINCREMENT, + "conversation_id" integer NOT NULL, + "role" text NOT NULL, + "content" text NOT NULL DEFAULT '', + "tool_calls" text, + "created_at" text NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY ("conversation_id") REFERENCES "p_ai_conversations" ("id") ON DELETE CASCADE +); +CREATE INDEX IF NOT EXISTS "idx_ai_messages_conversation" ON "ai_messages" ("conversation_id", "created_at"); +ALTER TABLE "ai_messages" RENAME TO "p_ai_messages"; +CREATE TABLE IF NOT EXISTS "ai_proposals" ( + "id" integer PRIMARY KEY AUTOINCREMENT, + "conversation_id" integer NOT NULL, + "user_id" text NOT NULL, + "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, + FOREIGN KEY ("user_id") REFERENCES "users" ("id") ON DELETE CASCADE, + FOREIGN KEY ("conversation_id") REFERENCES "p_ai_conversations" ("id") ON DELETE CASCADE +); +CREATE INDEX IF NOT EXISTS "idx_ai_proposals_user" ON "ai_proposals" ("user_id", "status"); +CREATE INDEX IF NOT EXISTS "idx_ai_proposals_conversation" ON "ai_proposals" ("conversation_id"); +ALTER TABLE "ai_proposals" RENAME TO "p_ai_proposals"; diff --git a/plugins/ai/src/backend/egress.ts b/plugins/ai/src/backend/egress.ts index b410ece36..c8f7026ad 100644 --- a/plugins/ai/src/backend/egress.ts +++ b/plugins/ai/src/backend/egress.ts @@ -1,20 +1,20 @@ -import { isIP } from "net"; -import { isBlockedAddress } from "../../../../src/backend/utils/safe-outbound-fetch.js"; -import { createCurrentSettingsRepository } from "../../../../src/backend/database/repositories/factory.js"; +import { BlockList, isIP } from "net"; /** * Where the assistant is allowed to send requests. * - * Cloud providers are reached through the SSRF-guarded path, which refuses to - * resolve to a private address. That guard is exactly what a self-hosted Ollama - * on localhost trips over, so private destinations are permitted only when an - * admin has named the host. Without that split, any logged-in user could point - * a "provider" at an internal service and use the backend as an authenticated - * probe of the server's own network. + * Every provider request goes through ctx.fetch, which refuses private and + * loopback addresses (after DNS, too) unless the exact host is listed. That + * guard is exactly what a self-hosted Ollama on localhost trips over, so the + * admin setting "privateEndpoints" names the hosts that may be private. + * Without that split, any logged-in user could point a "provider" at an + * internal service and use the backend as an authenticated probe of the + * server's own network. + * + * The checks here only exist to give a useful message before the request is + * made. ctx.fetch is the control. */ -export const AI_PRIVATE_ALLOWLIST_KEY = "ai_private_endpoint_allowlist"; - /** Hosts a self-hoster almost certainly wants, and which reach only this machine. */ export const DEFAULT_PRIVATE_ALLOWLIST = [ "localhost", @@ -23,35 +23,53 @@ export const DEFAULT_PRIVATE_ALLOWLIST = [ "host.docker.internal", ]; -export function parseAllowlist(raw: string | null): string[] { - if (!raw) return [...DEFAULT_PRIVATE_ALLOWLIST]; - try { - const parsed = JSON.parse(raw); - if (!Array.isArray(parsed)) return [...DEFAULT_PRIVATE_ALLOWLIST]; - return parsed - .filter((entry): entry is string => typeof entry === "string") - .map((entry) => entry.trim().toLowerCase()) - .filter(Boolean); - } catch { - return [...DEFAULT_PRIVATE_ALLOWLIST]; +export const PRIVATE_DESTINATION_MESSAGE = + "This address is on a private network. An administrator must add its host to the AI endpoint allowlist first."; + +/** A bare host, not a URL: no scheme, path, port or whitespace. */ +const HOST_PATTERN = /^[a-z0-9._:-]+$/; + +/** + * The admin setting is one host per line. Anything that is not a bare host + * is ignored rather than failing the whole list. + */ +export function parseAllowlist(raw: unknown): string[] { + if (typeof raw !== "string") return [...DEFAULT_PRIVATE_ALLOWLIST]; + const hosts: string[] = []; + for (const line of raw.split(/[\r\n,]+/)) { + const host = line.trim().toLowerCase(); + if (host && HOST_PATTERN.test(host) && !hosts.includes(host)) { + hosts.push(host); + } } + return hosts; } -export async function readPrivateAllowlist(): Promise { - const raw = await createCurrentSettingsRepository().get( - AI_PRIVATE_ALLOWLIST_KEY, - ); - return parseAllowlist(raw); -} +const PRIVATE_RANGES = (() => { + const list = new BlockList(); + list.addSubnet("0.0.0.0", 8, "ipv4"); + list.addSubnet("10.0.0.0", 8, "ipv4"); + list.addSubnet("100.64.0.0", 10, "ipv4"); + list.addSubnet("127.0.0.0", 8, "ipv4"); + list.addSubnet("169.254.0.0", 16, "ipv4"); + list.addSubnet("172.16.0.0", 12, "ipv4"); + list.addSubnet("192.168.0.0", 16, "ipv4"); + list.addAddress("::", "ipv6"); + list.addAddress("::1", "ipv6"); + list.addSubnet("fc00::", 7, "ipv6"); + list.addSubnet("fe80::", 10, "ipv6"); + return list; +})(); function normalizeHost(hostname: string): string { return hostname.replace(/^\[|\]$/g, "").toLowerCase(); } /** - * True when the URL names a destination the SSRF guard would refuse. A bare - * hostname that is not an IP literal (e.g. "ollama.internal") is treated as - * private only if it is "localhost" -- anything else needs DNS resolution. + * True when the URL names a destination the outbound guard would refuse. A + * bare hostname that is not an IP literal (e.g. "ollama.internal") counts as + * private only if it is "localhost"; anything else needs DNS resolution, + * which ctx.fetch does. */ export function isPrivateDestination(rawUrl: string): boolean { let url: URL; @@ -62,13 +80,15 @@ export function isPrivateDestination(rawUrl: string): boolean { } const host = normalizeHost(url.hostname); if (host === "localhost" || host.endsWith(".localhost")) return true; - if (isIP(host)) return isBlockedAddress(host); + const family = isIP(host); + if (family === 4) return PRIVATE_RANGES.check(host, "ipv4"); + if (family === 6) return PRIVATE_RANGES.check(host, "ipv6"); return false; } export interface EgressDecision { allowed: boolean; - /** True when the destination needs the allowlisted-private path. */ + /** True when the destination is an allowlisted private host. */ isPrivate: boolean; reason?: string; } @@ -96,23 +116,19 @@ export function evaluateEgress( } const host = normalizeHost(url.hostname); - const isPrivate = isPrivateDestination(rawUrl); const normalized = allowlist.map((entry) => entry.trim().toLowerCase()); - // An explicitly allowlisted hostname may resolve to a private address. It - // must use the private fetch path; sending it through safeOutboundFetch - // would reject it after DNS resolution and make hostname allowlist entries - // ineffective. Only administrators can write this list. + // An allowlisted hostname may resolve to a private address; ctx.fetch + // lets it through because the same list is passed as allowPrivateHosts. if (normalized.includes(host)) { return { allowed: true, isPrivate: true }; } - if (!isPrivate) return { allowed: true, isPrivate: false }; + if (!isPrivateDestination(rawUrl)) return { allowed: true, isPrivate: false }; return { allowed: false, isPrivate: true, - reason: - "This address is on a private network. An administrator must add its host to the AI endpoint allowlist first.", + reason: PRIVATE_DESTINATION_MESSAGE, }; } diff --git a/plugins/ai/src/backend/engine.ts b/plugins/ai/src/backend/engine.ts index fd0419ea1..c8a735ea3 100644 --- a/plugins/ai/src/backend/engine.ts +++ b/plugins/ai/src/backend/engine.ts @@ -1,4 +1,4 @@ -import { getErrorMessage } from "../../../../src/backend/utils/error-message.js"; +import { getErrorMessage } from "./errors.js"; import { getAdapter } from "./providers/registry.js"; import type { ChatMessage, @@ -6,9 +6,10 @@ import type { ToolCall, } from "./providers/types.js"; import { redact, redactToJson } from "./redaction.js"; -import { getTool, toolDefinitions } from "./tools/catalog.js"; +import { toolDefinitions } from "./tools/catalog.js"; import { isProposalDraft, + type AiTool, type ProposalDraft, type ToolContext, } from "./tools/types.js"; @@ -36,6 +37,8 @@ export interface EngineOptions { system: string; history: ChatMessage[]; context: ToolContext; + /** What the model may call this turn; availableTools() decides. */ + tools: AiTool[]; signal?: AbortSignal; } @@ -43,7 +46,8 @@ export async function* runAgent( options: EngineOptions, ): AsyncGenerator { const adapter = getAdapter(options.config.providerType); - const tools = toolDefinitions(); + const tools = toolDefinitions(options.tools); + const byName = new Map(options.tools.map((tool) => [tool.name, tool])); const messages: ChatMessage[] = [...options.history]; for (let turn = 0; turn < MAX_TURNS; turn += 1) { @@ -89,7 +93,7 @@ export async function* runAgent( for (const call of calls) { yield { type: "tool_call", name: call.name, arguments: call.arguments }; - const result = await runTool(call, options.context); + const result = await runTool(byName, call, options.context); if (isProposalDraft(result)) { // Closes the tool call before the proposal card is emitted. Without @@ -132,10 +136,14 @@ export async function* runAgent( }; } -async function runTool(call: ToolCall, context: ToolContext): Promise { - const tool = getTool(call.name); +async function runTool( + tools: Map, + call: ToolCall, + context: ToolContext, +): Promise { + const tool = tools.get(call.name); - // A model can emit any name it likes; only the catalog decides what runs. + // A model can emit any name it likes; only what was offered runs. if (!tool) { return { error: `Unknown tool: ${call.name}` }; } diff --git a/plugins/ai/src/backend/errors.ts b/plugins/ai/src/backend/errors.ts new file mode 100644 index 000000000..a0766fbd5 --- /dev/null +++ b/plugins/ai/src/backend/errors.ts @@ -0,0 +1,6 @@ +/** The message of an error, or the fallback when there is none worth showing. */ +export function getErrorMessage(error: unknown, fallback: string): string { + if (error instanceof Error && error.message) return error.message; + if (typeof error === "string" && error) return error; + return fallback; +} diff --git a/plugins/ai/src/backend/gating.ts b/plugins/ai/src/backend/gating.ts index b2ceb54fd..dac62d2f1 100644 --- a/plugins/ai/src/backend/gating.ts +++ b/plugins/ai/src/backend/gating.ts @@ -1,28 +1,30 @@ -import type { NextFunction, Response } from "express"; -import type { AuthenticatedRequest } from "../../../../src/types/index.js"; -import { - createCurrentSettingsRepository, - createCurrentUserPreferenceRepository, -} from "../../../../src/backend/database/repositories/factory.js"; +import type { PluginSettings } from "@termix/plugin-sdk/backend"; +import { DEFAULT_PRIVATE_ALLOWLIST, parseAllowlist } from "./egress.js"; /** * Three gates, all checked on the server. * - * The admin global is a hard kill switch: when it is off the feature does not + * The admin switch is a hard kill switch: when it is off the feature does not * exist for anyone, regardless of what any user has enabled. It defaults to - * false so upgrading an existing install turns nothing on by surprise. - * - * Mirrors session sharing's own check, where the global also wins over the - * per-host setting. + * false so upgrading an existing install turns nothing on by surprise. Then + * each user opts in for themselves, and RBAC decides what they may do. */ -export const AI_GLOBAL_ENABLED_KEY = "ai_globally_enabled"; +export type SettingsReader = Pick; -export async function isAiGloballyEnabled(): Promise { - return createCurrentSettingsRepository().getBoolean( - AI_GLOBAL_ENABLED_KEY, - false, - ); +export async function isAiGloballyEnabled( + settings: SettingsReader, +): Promise { + return (await settings.get("globallyEnabled")) === true; +} + +/** The admin's private endpoint hosts, one per line. */ +export async function readPrivateAllowlist( + settings: SettingsReader, +): Promise { + const raw = await settings.get("privateEndpoints"); + if (raw === undefined || raw === null) return [...DEFAULT_PRIVATE_ALLOWLIST]; + return parseAllowlist(raw); } export interface AiAccess { @@ -30,41 +32,59 @@ export interface AiAccess { allowReadOnlyCommands: boolean; } -export async function resolveAiAccess(userId: string): Promise { - const globalEnabled = await isAiGloballyEnabled(); - if (!globalEnabled) { +export async function resolveAiAccess( + settings: SettingsReader, + userId: string, +): Promise { + if (!(await isAiGloballyEnabled(settings))) { return { enabled: false, allowReadOnlyCommands: false }; } - const preferences = - await createCurrentUserPreferenceRepository().findByUserId(userId); - + // Unset means the user was never asked, which is not consent. + const enabled = (await settings.getUser(userId, "enabled")) === true; return { - // Null means the user was never asked, which is not consent. - enabled: preferences?.aiAssistantEnabled === true, - allowReadOnlyCommands: preferences?.aiReadOnlyCommands === true, + enabled, + allowReadOnlyCommands: + enabled && + (await settings.getUser(userId, "allowReadOnlyCommands")) === + true, }; } +interface GateRequest { + aiAccess?: AiAccess; +} + +interface GateResponse { + status: (code: number) => { json: (body: unknown) => unknown }; +} + /** Rejects any AI request unless both gates are open. */ -export function createAiGate() { +export function createAiGate( + settings: SettingsReader, + currentActor: () => string | undefined, +) { return async ( - req: AuthenticatedRequest, - res: Response, - next: NextFunction, + req: GateRequest, + res: GateResponse, + next: (error?: unknown) => void, ): Promise => { - if (!req.userId) { + const userId = currentActor(); + if (!userId) { res.status(401).json({ error: "Authentication required" }); return; } - const access = await resolveAiAccess(req.userId); - if (!access.enabled) { - res.status(403).json({ error: "The AI assistant is not enabled" }); - return; + try { + const access = await resolveAiAccess(settings, userId); + if (!access.enabled) { + res.status(403).json({ error: "The AI assistant is not enabled" }); + return; + } + req.aiAccess = access; + next(); + } catch (error) { + next(error); } - - (req as AuthenticatedRequest & { aiAccess?: AiAccess }).aiAccess = access; - next(); }; } diff --git a/plugins/ai/src/backend/host-import.ts b/plugins/ai/src/backend/host-import.ts new file mode 100644 index 000000000..a1b95d26b --- /dev/null +++ b/plugins/ai/src/backend/host-import.ts @@ -0,0 +1,16 @@ +/** + * Registered as "ai.hostImportNormalizer" so core's host import can carry the + * assistant switch without knowing it exists. Reads a Termix export's + * pluginSettings.ai first, then the flat field hosts had before 2.9.0. + */ +export function hostImportNormalizer( + raw: Record, +): Record | null { + const pluginSettings = raw.pluginSettings as + Record | undefined> | undefined; + const value = pluginSettings?.ai?.enableAiAssistant ?? raw.enableAiAssistant; + if (value === undefined || value === null) return null; + return { + enableAiAssistant: value === true || value === 1 || value === "true", + }; +} diff --git a/plugins/ai/src/backend/index.ts b/plugins/ai/src/backend/index.ts index a2c644fe7..29eb343f9 100644 --- a/plugins/ai/src/backend/index.ts +++ b/plugins/ai/src/backend/index.ts @@ -1,38 +1,38 @@ +import type { Router } from "express"; import type { PluginContext } from "@termix/plugin-sdk/backend"; -import { setPluginSsh } from "./ssh.js"; -import { setPluginServices } from "./services.js"; -import { startAiService, stopAiService } from "./routes.js"; -import { createCurrentAiRepository } from "../../../../src/backend/database/repositories/factory.js"; +import { hostImportNormalizer } from "./host-import.js"; +import { createAiRepository } from "./repository.js"; +import { registerAiRoutes } from "./routes.js"; export async function activate(ctx: PluginContext) { - setPluginSsh(ctx.ssh); - ctx.disposables.add(() => setPluginSsh(null)); - setPluginServices(ctx.services); - ctx.disposables.add(() => setPluginServices(null)); - startAiService(ctx.http.router()); + const repository = await createAiRepository(ctx); + + registerAiRoutes(ctx.http.router(), repository, ctx); + + ctx.registry.provide("ai.hostImportNormalizer", hostImportNormalizer); + ctx.disposables.add( + () => + void ctx.registry.revoke("ai.hostImportNormalizer", hostImportNormalizer), + ); // Offered by reference, not copied: this hands over a resolver, so the key // stays ours. Rotating or deleting the provider takes effect on the next - // read by any borrower, and deactivate() below withdraws the offer entirely. - // The resolve is per-user because the keys are stored per-user and encrypted - // under that user's data key, so a borrowed read returns the acting user's - // own default key, never someone else's. - ctx.secrets.offer("api-key", async (userId: string) => { - const repository = createCurrentAiRepository(); - const providers = await repository.listProviders(userId); - const preferred = providers.find((provider) => provider.apiKeyPrefix); - if (!preferred) return null; - - const withSecret = await repository.findProviderWithSecret( - preferred.id, - userId, - ); - return withSecret?.apiKey ?? null; - }); + // read by any borrower, and deactivate withdraws the offer. Keys are per + // user, so a borrowed read returns the acting user's own key. + ctx.secrets.offer("api-key", (userId: string) => + ctx.asUser(userId, async () => { + const providers = await repository.listProviders(userId); + const preferred = providers.find((provider) => provider.apiKeyPrefix); + if (!preferred) return null; + const withSecret = await repository.findProviderWithSecret( + preferred.id, + userId, + ); + return withSecret?.apiKey ?? null; + }), + ); ctx.log.info("AI assistant routes mounted at /plugin-api/ai"); } -export async function deactivate() { - stopAiService(); -} +export async function deactivate() {} diff --git a/plugins/ai/src/backend/providers/anthropic.ts b/plugins/ai/src/backend/providers/anthropic.ts index d07f2ad69..34d7d0d85 100644 --- a/plugins/ai/src/backend/providers/anthropic.ts +++ b/plugins/ai/src/backend/providers/anthropic.ts @@ -1,5 +1,4 @@ import Anthropic from "@anthropic-ai/sdk"; -import { providerFetch } from "./http.js"; import type { ChatChunk, ChatRequest, @@ -26,7 +25,7 @@ function createClient(config: ProviderConfig): Anthropic { apiKey: config.apiKey, ...(config.baseUrl?.trim() ? { baseURL: config.baseUrl.trim() } : {}), // Routes the SDK's HTTP through the shared egress guard. - fetch: providerFetch as unknown as typeof fetch, + fetch: config.fetch as unknown as typeof fetch, }); } diff --git a/plugins/ai/src/backend/providers/gemini.ts b/plugins/ai/src/backend/providers/gemini.ts index ff974f7a1..6cd6faaed 100644 --- a/plugins/ai/src/backend/providers/gemini.ts +++ b/plugins/ai/src/backend/providers/gemini.ts @@ -1,4 +1,4 @@ -import { assertOk, providerFetch, readSseLines } from "./http.js"; +import { assertOk, readSseLines } from "./http.js"; import type { ChatChunk, ChatRequest, @@ -104,7 +104,7 @@ export const geminiAdapter: ProviderAdapter = { const url = `${baseFor(config).replace(/\/+$/, "")}/models/${encodeURIComponent(request.model)}:streamGenerateContent?alt=sse`; - const response = await providerFetch(url, { + const response = await config.fetch(url, { method: "POST", headers: { "Content-Type": "application/json", @@ -176,7 +176,7 @@ export const geminiAdapter: ProviderAdapter = { async listModels(config: ProviderConfig): Promise { if (!config.apiKey) return []; - const response = await providerFetch( + const response = await config.fetch( `${baseFor(config).replace(/\/+$/, "")}/models`, { method: "GET", headers: { "x-goog-api-key": config.apiKey } }, ); diff --git a/plugins/ai/src/backend/providers/http.ts b/plugins/ai/src/backend/providers/http.ts index e29454d50..b37c71ca0 100644 --- a/plugins/ai/src/backend/providers/http.ts +++ b/plugins/ai/src/backend/providers/http.ts @@ -1,33 +1,63 @@ -import { fetchWithProxy } from "../../../../../src/backend/utils/proxy-agent.js"; -import { safeOutboundFetch } from "../../../../../src/backend/utils/safe-outbound-fetch.js"; -import { evaluateEgress, readPrivateAllowlist } from "../egress.js"; -import { AiProviderError } from "./types.js"; +import type { PluginFetch } from "@termix/plugin-sdk/backend"; +import { evaluateEgress, PRIVATE_DESTINATION_MESSAGE } from "../egress.js"; +import { AiProviderError, type ProviderFetch } from "./types.js"; + +/** A model can take a while to load before the first token. */ +const PROVIDER_TIMEOUT_MS = 10 * 60 * 1000; + +function toHeaderRecord( + headers: RequestInit["headers"] | undefined, +): Record { + const record: Record = {}; + new Headers(headers ?? {}).forEach((value, key) => { + record[key] = value; + }); + return record; +} /** * Every outbound provider request goes through here so the egress rules cannot - * be bypassed by an adapter calling fetch directly. - * - * Public hosts use safeOutboundFetch, which re-checks the resolved address at - * connect time. Allowlisted private hosts cannot use it (its whole job is to - * refuse them), so they use the installed Undici fetch implementation with - * its matching proxy dispatcher -- still respecting proxy configuration. + * be bypassed by an adapter calling fetch directly. The actual request is + * ctx.fetch, which pins DNS and refuses private addresses unless the host is + * on the admin allowlist. */ -export async function providerFetch( - url: string, - init: RequestInit, -): Promise { - const allowlist = await readPrivateAllowlist(); - const decision = evaluateEgress(url, allowlist); +export function createProviderFetch( + fetch: PluginFetch, + allowlist: string[], +): ProviderFetch { + return async (url, init) => { + const decision = evaluateEgress(url, allowlist); + if (!decision.allowed) { + throw new AiProviderError(decision.reason ?? "Destination not allowed"); + } - if (!decision.allowed) { - throw new AiProviderError(decision.reason ?? "Destination not allowed"); - } + if ( + init.body !== undefined && + init.body !== null && + typeof init.body !== "string" + ) { + throw new AiProviderError("Provider requests must send a text body"); + } - if (decision.isPrivate) { - return fetchWithProxy(url, init); - } - - return safeOutboundFetch(url, init) as unknown as Promise; + try { + return await fetch(url, { + method: init.method ?? "GET", + headers: toHeaderRecord(init.headers), + body: (init.body as string | null | undefined) ?? undefined, + signal: init.signal ?? undefined, + timeoutMs: PROVIDER_TIMEOUT_MS, + allowPrivateHosts: allowlist, + }); + } catch (error) { + // A hostname that resolved to a private address is refused after DNS, + // which the check above cannot see coming. + const message = error instanceof Error ? error.message : ""; + if (/private destinations/i.test(message)) { + throw new AiProviderError(PRIVATE_DESTINATION_MESSAGE); + } + throw error; + } + }; } export function joinUrl(base: string, path: string): string { diff --git a/plugins/ai/src/backend/providers/ollama.ts b/plugins/ai/src/backend/providers/ollama.ts index 622b69ca5..6deb4a5f0 100644 --- a/plugins/ai/src/backend/providers/ollama.ts +++ b/plugins/ai/src/backend/providers/ollama.ts @@ -1,4 +1,4 @@ -import { assertOk, joinUrl, providerFetch, readJsonLines } from "./http.js"; +import { assertOk, joinUrl, readJsonLines } from "./http.js"; import type { ChatChunk, ChatRequest, @@ -47,7 +47,7 @@ export const ollamaAdapter: ProviderAdapter = { config: ProviderConfig, request: ChatRequest, ): AsyncIterable { - const response = await providerFetch(joinUrl(baseFor(config), "api/chat"), { + const response = await config.fetch(joinUrl(baseFor(config), "api/chat"), { method: "POST", headers: { "Content-Type": "application/json" }, signal: request.signal, @@ -117,7 +117,7 @@ export const ollamaAdapter: ProviderAdapter = { }, async listModels(config: ProviderConfig): Promise { - const response = await providerFetch(joinUrl(baseFor(config), "api/tags"), { + const response = await config.fetch(joinUrl(baseFor(config), "api/tags"), { method: "GET", }); await assertOk(response, "Ollama"); diff --git a/plugins/ai/src/backend/providers/openai.ts b/plugins/ai/src/backend/providers/openai.ts index 8ba3e5380..3c5f7fb56 100644 --- a/plugins/ai/src/backend/providers/openai.ts +++ b/plugins/ai/src/backend/providers/openai.ts @@ -1,4 +1,4 @@ -import { assertOk, joinUrl, providerFetch, readSseLines } from "./http.js"; +import { assertOk, joinUrl, readSseLines } from "./http.js"; import type { ChatChunk, ChatRequest, @@ -73,7 +73,7 @@ export const openAiAdapter: ProviderAdapter = { }; if (config.apiKey) headers.Authorization = `Bearer ${config.apiKey}`; - const response = await providerFetch(url, { + const response = await config.fetch(url, { method: "POST", headers, signal: request.signal, @@ -145,7 +145,7 @@ export const openAiAdapter: ProviderAdapter = { const headers: Record = {}; if (config.apiKey) headers.Authorization = `Bearer ${config.apiKey}`; - const response = await providerFetch(joinUrl(baseFor(config), "models"), { + const response = await config.fetch(joinUrl(baseFor(config), "models"), { method: "GET", headers, }); diff --git a/plugins/ai/src/backend/providers/types.ts b/plugins/ai/src/backend/providers/types.ts index e170dfb20..2b75bfb28 100644 --- a/plugins/ai/src/backend/providers/types.ts +++ b/plugins/ai/src/backend/providers/types.ts @@ -63,10 +63,20 @@ export type ChatChunk = | { type: "done"; stopReason?: string } | { type: "error"; message: string }; +/** + * How an adapter reaches its provider. Always the egress-checked fetch from + * http.ts, never the global one. + */ +export type ProviderFetch = ( + url: string, + init: RequestInit, +) => Promise; + export interface ProviderConfig { providerType: AiProviderType; baseUrl?: string | null; apiKey?: string | null; + fetch: ProviderFetch; } export interface ProviderAdapter { diff --git a/plugins/ai/src/backend/repository.ts b/plugins/ai/src/backend/repository.ts new file mode 100644 index 000000000..da82d1de7 --- /dev/null +++ b/plugins/ai/src/backend/repository.ts @@ -0,0 +1,421 @@ +import { and, desc, eq } from "drizzle-orm"; +import type { PluginContext } from "@termix/plugin-sdk/backend"; +import { + conversations as conversationsDef, + messages as messagesDef, + proposals as proposalsDef, + providers as providersDef, +} from "./tables.js"; + +/* eslint-disable @typescript-eslint/no-explicit-any */ +// Tables come from ctx.db.define, which the SDK hands back untyped. +type Table = any; +type Drizzle = any; +/* eslint-enable @typescript-eslint/no-explicit-any */ + +export interface AiProviderRecord { + id: number; + userId: string; + providerType: string; + label: string; + baseUrl: string | null; + apiKeyPrefix: string | null; + defaultModel: string | null; + enabled: boolean; + createdAt: string; + updatedAt: string; +} + +/** A provider with its key, for the one path that makes outbound calls. */ +export interface AiProviderWithSecret extends AiProviderRecord { + apiKey: string | null; +} + +export interface AiConversationRecord { + id: number; + userId: string; + title: string | null; + providerId: number | null; + model: string | null; + createdAt: string; + updatedAt: string; +} + +export interface AiMessageRecord { + id: number; + conversationId: number; + role: string; + content: string; + toolCalls: string | null; + createdAt: string; +} + +export interface AiProposalRecord { + id: number; + conversationId: number; + userId: string; + kind: string; + summary: string | null; + payload: string; + status: string; + appliedAt: string | null; + resultSummary: string | null; + createdAt: string; +} + +export interface AiProviderInput { + providerType: string; + label: string; + baseUrl?: string | null; + apiKey?: string | null; + defaultModel?: string | null; + enabled?: boolean; +} + +/** Where a provider's key lives in ctx.secrets. */ +export function providerSecretKey(providerId: number): string { + return `provider:${providerId}`; +} + +/** + * 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); +} + +const now = () => new Date().toISOString(); + +export type AiRepository = Awaited>; + +/** + * The assistant's rows. Every method takes the owning user and scopes to + * them. Keys go through ctx.secrets, which is per acting user, so key reads + * and writes run inside the request (or ctx.asUser) of that same user. + */ +export async function createAiRepository( + ctx: Pick, +) { + const { db, secrets } = ctx; + const providers: Table = await db.define(providersDef); + const conversations: Table = await db.define(conversationsDef); + const messages: Table = await db.define(messagesDef); + const proposals: Table = await db.define(proposalsDef); + + const client = () => db.client(); + + /** Inserts one row and returns its id. MySQL has no RETURNING. */ + async function insertId( + table: Table, + values: Record, + ): Promise { + const drizzle = await client(); + if (db.dialect === "mysql") { + const result = await drizzle.insert(table).values(values); + const header = Array.isArray(result) ? result[0] : result; + return Number(header?.insertId); + } + const rows = await drizzle + .insert(table) + .values(values) + .returning({ id: table.id }); + return rows[0].id as number; + } + + async function first(table: Table, where: unknown): Promise { + const rows = await ( + await client() + ) + .select() + .from(table) + .where(where) + .limit(1); + return (rows[0] as T) ?? null; + } + + const repo = { + // --- providers --- + + /** Never includes key material; callers get the masked prefix only. */ + async listProviders(userId: string): Promise { + return (await client()) + .select() + .from(providers) + .where(eq(providers.userId, userId)) + .orderBy(providers.id); + }, + + async findProvider( + id: number, + userId: string, + ): Promise { + return first( + providers, + and(eq(providers.id, id), eq(providers.userId, userId)), + ); + }, + + /** + * The one path that returns usable key material, right before an + * outbound request. Must run as `userId`. + */ + async findProviderWithSecret( + id: number, + userId: string, + ): Promise { + const row = await repo.findProvider(id, userId); + if (!row) return null; + return { ...row, apiKey: await secrets.get(providerSecretKey(id)) }; + }, + + async createProvider( + userId: string, + input: AiProviderInput, + ): Promise { + const stamp = now(); + const id = await insertId(providers, { + userId, + providerType: input.providerType, + label: input.label, + baseUrl: input.baseUrl ?? null, + apiKeyPrefix: apiKeyPrefix(input.apiKey), + defaultModel: input.defaultModel ?? null, + enabled: input.enabled ?? true, + createdAt: stamp, + updatedAt: stamp, + }); + if (input.apiKey) { + await secrets.set(providerSecretKey(id), input.apiKey); + } + await db.persist(); + const created = await repo.findProvider(id, userId); + if (!created) throw new Error("Provider could not be read back"); + return created; + }, + + async updateProvider( + id: number, + userId: string, + input: Partial, + ): Promise { + const existing = await repo.findProvider(id, userId); + if (!existing) return null; + + const updates: Record = { 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) { + updates.apiKeyPrefix = apiKeyPrefix(input.apiKey); + await secrets.set(providerSecretKey(id), input.apiKey || null); + } + + await ( + await client() + ) + .update(providers) + .set(updates) + .where(and(eq(providers.id, id), eq(providers.userId, userId))); + await db.persist(); + return repo.findProvider(id, userId); + }, + + async deleteProvider(id: number, userId: string): Promise { + const existing = await repo.findProvider(id, userId); + if (!existing) return false; + await ( + await client() + ) + .delete(providers) + .where(and(eq(providers.id, id), eq(providers.userId, userId))); + await secrets.delete(providerSecretKey(id)); + await db.persist(); + return true; + }, + + // --- conversations --- + + async listConversations( + userId: string, + limit = 50, + ): Promise { + return (await client()) + .select() + .from(conversations) + .where(eq(conversations.userId, userId)) + .orderBy(desc(conversations.updatedAt)) + .limit(limit); + }, + + async findConversation( + id: number, + userId: string, + ): Promise { + return first( + conversations, + and(eq(conversations.id, id), eq(conversations.userId, userId)), + ); + }, + + async createConversation(input: { + userId: string; + title?: string | null; + providerId?: number | null; + model?: string | null; + }): Promise { + const stamp = now(); + const id = await insertId(conversations, { + userId: input.userId, + title: input.title ?? null, + providerId: input.providerId ?? null, + model: input.model ?? null, + createdAt: stamp, + updatedAt: stamp, + }); + await db.persist(); + const created = await repo.findConversation(id, input.userId); + if (!created) throw new Error("Conversation could not be read back"); + return created; + }, + + async touchConversation(id: number): Promise { + await ( + await client() + ) + .update(conversations) + .set({ updatedAt: now() }) + .where(eq(conversations.id, id)); + await db.persist(); + }, + + async deleteConversation(id: number, userId: string): Promise { + const existing = await repo.findConversation(id, userId); + if (!existing) return false; + // Deleted explicitly rather than trusting the cascade, which a + // connection without foreign keys on would skip. + const drizzle = await client(); + await drizzle.delete(messages).where(eq(messages.conversationId, id)); + await drizzle.delete(proposals).where(eq(proposals.conversationId, id)); + await drizzle + .delete(conversations) + .where(and(eq(conversations.id, id), eq(conversations.userId, userId))); + await db.persist(); + return true; + }, + + // --- messages --- + + async listMessages(conversationId: number): Promise { + return (await client()) + .select() + .from(messages) + .where(eq(messages.conversationId, conversationId)) + .orderBy(messages.id); + }, + + async appendMessage(input: { + conversationId: number; + role: string; + content: string; + toolCalls?: string | null; + }): Promise { + await insertId(messages, { + conversationId: input.conversationId, + role: input.role, + content: input.content, + toolCalls: input.toolCalls ?? null, + createdAt: now(), + }); + await db.persist(); + }, + + // --- proposals --- + + async listProposals( + userId: string, + conversationId: number, + ): Promise { + return (await client()) + .select() + .from(proposals) + .where( + and( + eq(proposals.userId, userId), + eq(proposals.conversationId, conversationId), + ), + ) + .orderBy(desc(proposals.id)); + }, + + async findProposal( + id: number, + userId: string, + ): Promise { + return first( + proposals, + and(eq(proposals.id, id), eq(proposals.userId, userId)), + ); + }, + + async createProposal(input: { + conversationId: number; + userId: string; + kind: string; + summary?: string | null; + payload: string; + }): Promise { + const id = await insertId(proposals, { + conversationId: input.conversationId, + userId: input.userId, + kind: input.kind, + summary: input.summary ?? null, + payload: input.payload, + status: "pending", + createdAt: now(), + }); + await db.persist(); + const created = await repo.findProposal(id, input.userId); + if (!created) throw new Error("Proposal could not be read back"); + return created; + }, + + /** Only moves a pending proposal. False when it was not pending. */ + async setProposalStatus( + id: number, + userId: string, + status: "applied" | "rejected" | "expired", + resultSummary?: string | null, + ): Promise { + const existing = await repo.findProposal(id, userId); + if (!existing || existing.status !== "pending") return false; + await ( + await client() + ) + .update(proposals) + .set({ + status, + appliedAt: status === "applied" ? now() : null, + resultSummary: resultSummary ?? null, + }) + .where( + and( + eq(proposals.id, id), + eq(proposals.userId, userId), + eq(proposals.status, "pending"), + ), + ); + await db.persist(); + return true; + }, + }; + + return repo; +} diff --git a/plugins/ai/src/backend/routes.ts b/plugins/ai/src/backend/routes.ts index 95194fa3c..6ebfe0ce3 100644 --- a/plugins/ai/src/backend/routes.ts +++ b/plugins/ai/src/backend/routes.ts @@ -1,30 +1,15 @@ -import { getErrorMessage } from "../../../../src/backend/utils/error-message.js"; -import express, { type Router } from "express"; -import type { AuthenticatedRequest } from "../../../../src/types/index.js"; -import { PermissionManager } from "../../../../src/backend/utils/permission-manager.js"; -import { AuthManager } from "../../../../src/backend/utils/auth-manager.js"; -import { databaseLogger } from "../../../../src/backend/utils/logger.js"; -import { - getAuditUsername, - getRequestMeta, - logAudit, -} from "../../../../src/backend/utils/audit-logger.js"; -import { - createCurrentAiRepository, - createCurrentHostRepository, - createCurrentSettingsRepository, - createCurrentUserRepository, -} from "../../../../src/backend/database/repositories/factory.js"; -import type { UserRecord } from "../../../../src/backend/database/repositories/user-repository.js"; +import type { Request, Response, Router } from "express"; +import type { PluginContext } from "@termix/plugin-sdk/backend"; import { buildSystemPrompt } from "./context.js"; -import { AI_PRIVATE_ALLOWLIST_KEY, parseAllowlist } from "./egress.js"; import { runAgent } from "./engine.js"; +import { getErrorMessage } from "./errors.js"; import { - AI_GLOBAL_ENABLED_KEY, createAiGate, isAiGloballyEnabled, + readPrivateAllowlist, resolveAiAccess, } from "./gating.js"; +import { createProviderFetch } from "./providers/http.js"; import { FALLBACK_MODELS, getAdapter, @@ -37,1310 +22,1088 @@ import type { ProviderConfig, } from "./providers/types.js"; import { isAiProviderType } from "./providers/types.js"; +import type { AiRepository } from "./repository.js"; +import { serviceAvailable } from "./services.js"; +import { availableTools } from "./tools/catalog.js"; import { applyProposal } from "./tools/executor.js"; - -const router = express.Router(); - -const authManager = AuthManager.getInstance(); -const permissionManager = PermissionManager.getInstance(); -const authenticateJWT = authManager.createAuthMiddleware(); -const requireDataAccess = authManager.createDataAccessMiddleware(); -const aiGate = createAiGate(); +import type { ToolDeps } from "./tools/types.js"; function parseId(raw: unknown): number | null { const id = typeof raw === "string" ? parseInt(raw, 10) : Number(raw); return Number.isInteger(id) && id > 0 ? id : null; } -async function getAdminActor( - userId: string | undefined, -): Promise { - if (!userId) return null; - const user = await createCurrentUserRepository().findById(userId); - return user?.isAdmin ? user : null; +function actor(ctx: PluginContext): string { + // Core's plugin router authenticates every request and runs it as that user. + return ctx.currentActor() as string; } /** - * @openapi - * /ai/enabled: - * get: - * summary: Get whether the AI assistant is enabled instance-wide - * tags: - * - AI - * responses: - * 200: - * description: AI enabled status. - * content: - * application/json: - * schema: - * type: object - * properties: - * enabled: - * type: boolean + * Mounts every assistant route on the plugin's router, served at + * /plugin-api/ai. Everything but /status sits behind the AI gate: the admin + * switch and the user's own opt-in. */ -router.get("/enabled", authenticateJWT, async (_req, res) => { - try { - res.json({ - // Defaults to false so upgrading an install never turns the assistant - // on without an admin deciding to. - enabled: await createCurrentSettingsRepository().getBoolean( - AI_GLOBAL_ENABLED_KEY, - false, - ), - }); - } catch (err) { - databaseLogger.error("Failed to get AI enabled setting", err, { - operation: "ai_enabled_get_failed", - }); - res.status(500).json({ error: "Failed to get AI enabled setting" }); - } -}); +export function registerAiRoutes( + router: Router, + repository: AiRepository, + ctx: PluginContext, +): void { + const aiGate = createAiGate(ctx.settings, () => ctx.currentActor()); + const gate = aiGate as never; + const toolDeps: ToolDeps = { + hosts: ctx.hosts, + services: ctx.services, + notify: ctx.notify, + rbac: ctx.rbac, + ssh: ctx.ssh, + }; -/** - * @openapi - * /ai/enabled: - * patch: - * summary: Update the instance-wide AI assistant setting (admin only) - * description: Turning this off hides and blocks the assistant for every user, whatever their own preference says. - * tags: - * - AI - * requestBody: - * required: true - * content: - * application/json: - * schema: - * type: object - * properties: - * enabled: - * type: boolean - * responses: - * 200: - * description: Setting updated. - * 403: - * description: Not authorized. - */ -router.patch("/enabled", authenticateJWT, async (req, res) => { - const userId = (req as AuthenticatedRequest).userId; - try { - const actor = await getAdminActor(userId); - if (!actor) { - return res.status(403).json({ error: "Not authorized" }); - } - const { enabled } = req.body ?? {}; - if (typeof enabled !== "boolean") { - return res.status(400).json({ error: "enabled must be a boolean" }); - } - await createCurrentSettingsRepository().set( - AI_GLOBAL_ENABLED_KEY, - enabled ? "true" : "false", + const logError = (message: string, error: unknown) => + ctx.log.error( + message, + error instanceof Error ? error : new Error(String(error)), ); - const { ipAddress, userAgent } = getRequestMeta(req); - await logAudit({ - userId: userId as string, - username: actor.username ?? (userId as string), - action: "update_ai_enabled", - resourceType: "setting", - details: JSON.stringify({ enabled }), - ipAddress, - userAgent, - success: true, - }); + const audit = (entry: Parameters[0]) => + ctx.audit.record(entry).catch(() => undefined); - res.json({ enabled }); - } catch (err) { - databaseLogger.error("Failed to update AI enabled setting", err, { - operation: "ai_enabled_update_failed", - }); - res.status(500).json({ error: "Failed to update AI enabled setting" }); + /** The provider config for an outbound call, with the egress-checked fetch. */ + async function providerConfig(input: { + providerType: string; + baseUrl: string | null; + apiKey: string | null; + }): Promise { + const allowlist = await readPrivateAllowlist(ctx.settings); + return { + providerType: input.providerType as ProviderConfig["providerType"], + baseUrl: input.baseUrl, + apiKey: input.apiKey, + fetch: createProviderFetch(ctx.fetch, allowlist), + }; } -}); -/** - * @openapi - * /ai/private-endpoints: - * get: - * summary: Get the allowlist of private AI endpoint hosts - * tags: - * - AI - * responses: - * 200: - * description: Allowed hosts. - */ -router.get("/private-endpoints", authenticateJWT, async (_req, res) => { - try { - const raw = await createCurrentSettingsRepository().get( - AI_PRIVATE_ALLOWLIST_KEY, - ); - res.json({ hosts: parseAllowlist(raw) }); - } catch (err) { - databaseLogger.error("Failed to get AI private endpoint allowlist", err, { - operation: "ai_private_endpoints_get_failed", - }); - res.status(500).json({ error: "Failed to get the allowlist" }); - } -}); - -/** - * @openapi - * /ai/private-endpoints: - * patch: - * summary: Replace the allowlist of private AI endpoint hosts (admin only) - * description: > - * Providers on private or loopback addresses, such as a self-hosted - * Ollama, are refused unless their host appears here. Without this an - * ordinary user could point a provider at an internal service and use - * the server as a probe of its own network. - * tags: - * - AI - * requestBody: - * required: true - * content: - * application/json: - * schema: - * type: object - * properties: - * hosts: - * type: array - * items: - * type: string - * responses: - * 200: - * description: Allowlist updated. - * 403: - * description: Not authorized. - */ -router.patch("/private-endpoints", authenticateJWT, async (req, res) => { - const userId = (req as AuthenticatedRequest).userId; - try { - const actor = await getAdminActor(userId); - if (!actor) { - return res.status(403).json({ error: "Not authorized" }); + /** + * @openapi + * /plugin-api/ai/status: + * get: + * summary: Whether the AI assistant is available to this user + * description: > + * Deliberately not behind the AI gate: the frontend calls this to decide + * whether to render any AI surface at all, and needs a plain answer rather + * than a 403 when the feature is off. + * tags: + * - AI + * responses: + * 200: + * description: The effective enablement state. + */ + router.get("/status", async (_req: Request, res: Response) => { + const userId = actor(ctx); + try { + const [globallyEnabled, access] = await Promise.all([ + isAiGloballyEnabled(ctx.settings), + resolveAiAccess(ctx.settings, userId), + ]); + res.json({ + globallyEnabled, + enabled: access.enabled, + allowReadOnlyCommands: access.allowReadOnlyCommands, + }); + } catch (err) { + logError("Failed to read AI status", err); + res.status(500).json({ error: "Failed to read AI status" }); } + }); - const { hosts } = req.body ?? {}; - if (!Array.isArray(hosts)) { - return res.status(400).json({ error: "hosts must be an array" }); - } - if (hosts.length > 50) { - return res.status(400).json({ error: "At most 50 hosts are allowed" }); - } - - const cleaned: string[] = []; - for (const entry of hosts) { - if (typeof entry !== "string") { - return res.status(400).json({ error: "Each host must be a string" }); + /** + * @openapi + * /plugin-api/ai/opt-in: + * put: + * summary: Turn the assistant on or off for the current user + * description: > + * The same value as the user setting "enabled", for the onboarding + * step and the panel, which set it outside the settings form. Refused + * while the assistant is off for the whole server. + * tags: + * - AI + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * enabled: + * type: boolean + * responses: + * 200: + * description: The user's new choice. + * 400: + * description: enabled is not a boolean. + * 403: + * description: The assistant is off for the whole server. + */ + router.put( + "/opt-in", + ctx.rbac.require("use") as never, + async (req: Request, res: Response) => { + const { enabled } = req.body ?? {}; + if (typeof enabled !== "boolean") { + return res.status(400).json({ error: "enabled must be a boolean" }); } - const host = entry.trim().toLowerCase(); - if (!host) continue; - // A bare host, not a URL: no scheme, path, port or whitespace. - if (!/^[a-z0-9._:-]+$/.test(host)) { + try { + // Turning it off is always allowed; turning it on needs the switch. + if (enabled && !(await isAiGloballyEnabled(ctx.settings))) { + return res + .status(403) + .json({ error: "The AI assistant is not enabled" }); + } + await ctx.settings.setUser(actor(ctx), "enabled", enabled); + res.json({ enabled }); + } catch (err) { + logError("Failed to save the AI opt-in", err); + res.status(500).json({ error: "Failed to save the setting" }); + } + }, + ); + + /** + * @openapi + * /plugin-api/ai/providers: + * get: + * summary: List the user's configured AI providers + * tags: + * - AI + * responses: + * 200: + * description: Providers, with API keys masked. + * 403: + * description: The AI assistant is not enabled. + */ + router.get( + "/providers", + ctx.rbac.require("use") as never, + gate, + async (_req: Request, res: Response) => { + try { + const providers = await repository.listProviders(actor(ctx)); + res.json({ providers }); + } catch (err) { + logError("Failed to list AI providers", err); + res.status(500).json({ error: "Failed to list providers" }); + } + }, + ); + + /** + * @openapi + * /plugin-api/ai/providers: + * post: + * summary: Add an AI provider + * tags: + * - AI + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * providerType: + * type: string + * label: + * type: string + * baseUrl: + * type: string + * apiKey: + * type: string + * defaultModel: + * type: string + * responses: + * 201: + * description: Provider created. + * 400: + * description: Invalid request body. + */ + router.post( + "/providers", + ctx.rbac.require("manage_providers") as never, + gate, + async (req: Request, res: Response) => { + const userId = actor(ctx); + const { providerType, label, baseUrl, apiKey, defaultModel } = + req.body ?? {}; + + if (!isAiProviderType(providerType)) { + return res.status(400).json({ error: "Unknown provider type" }); + } + if (typeof label !== "string" || !label.trim()) { + return res.status(400).json({ error: "label is required" }); + } + if (REQUIRES_BASE_URL.includes(providerType) && !baseUrl?.trim()) { return res .status(400) - .json({ error: `${entry} is not a valid hostname` }); + .json({ error: "This provider needs a base URL" }); } - if (!cleaned.includes(host)) cleaned.push(host); - } - - await createCurrentSettingsRepository().set( - AI_PRIVATE_ALLOWLIST_KEY, - JSON.stringify(cleaned), - ); - - const { ipAddress, userAgent } = getRequestMeta(req); - await logAudit({ - userId: userId as string, - username: actor.username ?? (userId as string), - action: "update_ai_private_endpoints", - resourceType: "setting", - details: JSON.stringify({ hosts: cleaned }), - ipAddress, - userAgent, - success: true, - }); - - res.json({ hosts: cleaned }); - } catch (err) { - databaseLogger.error( - "Failed to update AI private endpoint allowlist", - err, - { - operation: "ai_private_endpoints_update_failed", - }, - ); - res.status(500).json({ error: "Failed to update the allowlist" }); - } -}); - -/** - * @openapi - * /ai/status: - * get: - * summary: Whether the AI assistant is available to this user - * description: > - * Deliberately not behind the AI gate: the frontend calls this to decide - * whether to render any AI surface at all, and needs a plain answer rather - * than a 403 when the feature is off. - * tags: - * - AI - * responses: - * 200: - * description: The effective enablement state. - */ -router.get("/status", authenticateJWT, async (req, res) => { - const userId = (req as AuthenticatedRequest).userId as string; - try { - const [globalEnabled, access] = await Promise.all([ - isAiGloballyEnabled(), - resolveAiAccess(userId), - ]); - res.json({ - globallyEnabled: globalEnabled, - enabled: access.enabled, - allowReadOnlyCommands: access.allowReadOnlyCommands, - }); - } catch (err) { - databaseLogger.error("Failed to read AI status", err, { - operation: "ai_status_failed", - userId, - }); - res.status(500).json({ error: "Failed to read AI status" }); - } -}); - -/** - * @openapi - * /ai/providers: - * get: - * summary: List the user's configured AI providers - * tags: - * - AI - * responses: - * 200: - * description: Providers, with API keys masked. - * 403: - * description: The AI assistant is not enabled. - */ -router.get( - "/providers", - authenticateJWT, - permissionManager.requirePermission("ai.use"), - requireDataAccess, - aiGate, - async (req, res) => { - const userId = (req as AuthenticatedRequest).userId as string; - try { - const providers = await createCurrentAiRepository().listProviders(userId); - res.json({ providers }); - } catch (err) { - databaseLogger.error("Failed to list AI providers", err, { - operation: "ai_providers_list_failed", - userId, - }); - res.status(500).json({ error: "Failed to list providers" }); - } - }, -); - -/** - * @openapi - * /ai/providers: - * post: - * summary: Add an AI provider - * tags: - * - AI - * requestBody: - * required: true - * content: - * application/json: - * schema: - * type: object - * properties: - * providerType: - * type: string - * label: - * type: string - * baseUrl: - * type: string - * apiKey: - * type: string - * defaultModel: - * type: string - * responses: - * 201: - * description: Provider created. - * 400: - * description: Invalid request body. - */ -router.post( - "/providers", - authenticateJWT, - permissionManager.requirePermission("ai.manage_providers"), - requireDataAccess, - aiGate, - async (req, res) => { - const userId = (req as AuthenticatedRequest).userId as string; - const { providerType, label, baseUrl, apiKey, defaultModel } = - req.body ?? {}; - - if (!isAiProviderType(providerType)) { - return res.status(400).json({ error: "Unknown provider type" }); - } - if (typeof label !== "string" || !label.trim()) { - return res.status(400).json({ error: "label is required" }); - } - if (REQUIRES_BASE_URL.includes(providerType) && !baseUrl?.trim()) { - return res.status(400).json({ error: "This provider needs a base URL" }); - } - if (REQUIRES_API_KEY.includes(providerType) && !apiKey?.trim()) { - return res.status(400).json({ error: "This provider needs an API key" }); - } - - try { - const created = await createCurrentAiRepository().createProvider({ - userId, - providerType, - label: label.trim(), - baseUrl: typeof baseUrl === "string" ? baseUrl.trim() : null, - apiKey: typeof apiKey === "string" ? apiKey.trim() : null, - defaultModel: - typeof defaultModel === "string" ? defaultModel.trim() : null, - }); - - const { ipAddress, userAgent } = getRequestMeta(req); - await logAudit({ - userId, - username: await getAuditUsername(userId), - action: "create_ai_provider", - resourceType: "ai_provider", - resourceId: String(created.id), - resourceName: created.label, - ipAddress, - userAgent, - success: true, - }); - - res.status(201).json({ provider: created }); - } catch (err) { - databaseLogger.error("Failed to create AI provider", err, { - operation: "ai_provider_create_failed", - userId, - }); - res.status(500).json({ error: "Failed to create provider" }); - } - }, -); - -/** - * @openapi - * /ai/providers/{id}: - * patch: - * summary: Update an AI provider - * tags: - * - AI - * parameters: - * - in: path - * name: id - * required: true - * schema: - * type: integer - * responses: - * 200: - * description: Provider updated. - * 404: - * description: Provider not found. - */ -router.patch( - "/providers/:id", - authenticateJWT, - permissionManager.requirePermission("ai.manage_providers"), - requireDataAccess, - aiGate, - async (req, res) => { - const userId = (req as AuthenticatedRequest).userId as string; - const id = parseId(req.params.id); - if (!id) return res.status(400).json({ error: "Invalid provider id" }); - - try { - const updated = await createCurrentAiRepository().updateProvider( - id, - userId, - req.body ?? {}, - ); - if (!updated) - return res.status(404).json({ error: "Provider not found" }); - - const { ipAddress, userAgent } = getRequestMeta(req); - await logAudit({ - userId, - username: await getAuditUsername(userId), - action: "update_ai_provider", - resourceType: "ai_provider", - resourceId: String(id), - resourceName: updated.label, - ipAddress, - userAgent, - success: true, - }); - - res.json({ provider: updated }); - } catch (err) { - databaseLogger.error("Failed to update AI provider", err, { - operation: "ai_provider_update_failed", - userId, - }); - res.status(500).json({ error: "Failed to update provider" }); - } - }, -); - -/** - * @openapi - * /ai/providers/{id}: - * delete: - * summary: Delete an AI provider - * tags: - * - AI - * parameters: - * - in: path - * name: id - * required: true - * schema: - * type: integer - * responses: - * 200: - * description: Provider deleted. - * 404: - * description: Provider not found. - */ -router.delete( - "/providers/:id", - authenticateJWT, - permissionManager.requirePermission("ai.manage_providers"), - requireDataAccess, - aiGate, - async (req, res) => { - const userId = (req as AuthenticatedRequest).userId as string; - const id = parseId(req.params.id); - if (!id) return res.status(400).json({ error: "Invalid provider id" }); - - try { - const deleted = await createCurrentAiRepository().deleteProvider( - id, - userId, - ); - if (!deleted) - return res.status(404).json({ error: "Provider not found" }); - - const { ipAddress, userAgent } = getRequestMeta(req); - await logAudit({ - userId, - username: await getAuditUsername(userId), - action: "delete_ai_provider", - resourceType: "ai_provider", - resourceId: String(id), - ipAddress, - userAgent, - success: true, - }); - - res.json({ success: true }); - } catch (err) { - databaseLogger.error("Failed to delete AI provider", err, { - operation: "ai_provider_delete_failed", - userId, - }); - res.status(500).json({ error: "Failed to delete provider" }); - } - }, -); - -/** - * @openapi - * /ai/probe-models: - * post: - * summary: List models for a provider that has not been saved yet - * description: > - * Lets the add-provider form fill its model picker before the provider - * exists, so nobody has to go and look up model names by hand. - * tags: - * - AI - * requestBody: - * required: true - * content: - * application/json: - * schema: - * type: object - * properties: - * providerType: - * type: string - * baseUrl: - * type: string - * apiKey: - * type: string - * providerId: - * type: integer - * responses: - * 200: - * description: Model ids, possibly a curated fallback list. - * 400: - * description: Unknown provider type. - */ -router.post( - "/probe-models", - authenticateJWT, - permissionManager.requirePermission("ai.manage_providers"), - requireDataAccess, - aiGate, - async (req, res) => { - const userId = (req as AuthenticatedRequest).userId as string; - const { providerType, baseUrl, apiKey, providerId } = req.body ?? {}; - - if (!isAiProviderType(providerType)) { - return res.status(400).json({ error: "Unknown provider type" }); - } - - try { - // Editing an existing provider sends no key, so fall back to the stored - // one rather than making the user retype it just to refresh the list. - let resolvedKey = - typeof apiKey === "string" && apiKey.trim() ? apiKey.trim() : null; - if (!resolvedKey && parseId(providerId)) { - const stored = await createCurrentAiRepository().findProviderWithSecret( - parseId(providerId) as number, - userId, - ); - resolvedKey = stored?.apiKey ?? null; + if (REQUIRES_API_KEY.includes(providerType) && !apiKey?.trim()) { + return res + .status(400) + .json({ error: "This provider needs an API key" }); } - const models = await getAdapter(providerType).listModels({ - providerType, - baseUrl: typeof baseUrl === "string" ? baseUrl.trim() : null, - apiKey: resolvedKey, - }); - - res.json({ models, source: "live" }); - } catch (err) { - // A provider that cannot be reached yet still gets a usable list, so the - // form is never a blank text box the user has to guess into. - const fallback = FALLBACK_MODELS[providerType as AiProviderType] ?? []; - res.json({ - models: fallback, - source: fallback.length ? "fallback" : "none", - warning: err instanceof Error ? err.message : undefined, - }); - } - }, -); - -/** - * @openapi - * /ai/providers/{id}/models: - * get: - * summary: List models available from a provider - * tags: - * - AI - * parameters: - * - in: path - * name: id - * required: true - * schema: - * type: integer - * responses: - * 200: - * description: Model ids. - * 502: - * description: The provider could not be reached. - */ -router.get( - "/providers/:id/models", - authenticateJWT, - permissionManager.requirePermission("ai.use"), - requireDataAccess, - aiGate, - async (req, res) => { - const userId = (req as AuthenticatedRequest).userId as string; - const id = parseId(req.params.id); - if (!id) return res.status(400).json({ error: "Invalid provider id" }); - - try { - const provider = await createCurrentAiRepository().findProviderWithSecret( - id, - userId, - ); - if (!provider) - return res.status(404).json({ error: "Provider not found" }); - - const models = await getAdapter(provider.providerType).listModels({ - providerType: provider.providerType as ProviderConfig["providerType"], - baseUrl: provider.baseUrl, - apiKey: provider.apiKey, - }); - res.json({ models }); - } catch (err) { - // The message can carry the allowlist hint, which the user needs to act on. - const message = getErrorMessage(err, "Could not reach the provider"); - databaseLogger.warn("Failed to list provider models", { - operation: "ai_provider_models_failed", - userId, - }); - res.status(502).json({ error: message }); - } - }, -); - -/** - * @openapi - * /ai/conversations: - * get: - * summary: List the user's AI conversations - * tags: - * - AI - * responses: - * 200: - * description: Conversations, newest first. - */ -router.get( - "/conversations", - authenticateJWT, - permissionManager.requirePermission("ai.use"), - requireDataAccess, - aiGate, - async (req, res) => { - const userId = (req as AuthenticatedRequest).userId as string; - try { - const conversations = - await createCurrentAiRepository().listConversations(userId); - res.json({ conversations }); - } catch (err) { - databaseLogger.error("Failed to list AI conversations", err, { - operation: "ai_conversations_list_failed", - userId, - }); - res.status(500).json({ error: "Failed to list conversations" }); - } - }, -); - -/** - * @openapi - * /ai/conversations/{id}: - * get: - * summary: Get one conversation with its messages and proposals - * tags: - * - AI - * parameters: - * - in: path - * name: id - * required: true - * schema: - * type: integer - * responses: - * 200: - * description: The conversation. - * 404: - * description: Conversation not found. - */ -router.get( - "/conversations/:id", - authenticateJWT, - permissionManager.requirePermission("ai.use"), - requireDataAccess, - aiGate, - async (req, res) => { - const userId = (req as AuthenticatedRequest).userId as string; - const id = parseId(req.params.id); - if (!id) return res.status(400).json({ error: "Invalid conversation id" }); - - try { - const repository = createCurrentAiRepository(); - const conversation = await repository.findConversation(id, userId); - if (!conversation) { - return res.status(404).json({ error: "Conversation not found" }); - } - - const [messages, proposals] = await Promise.all([ - repository.listMessages(id), - repository.listProposals(userId, id), - ]); - - res.json({ conversation, messages, proposals }); - } catch (err) { - databaseLogger.error("Failed to load AI conversation", err, { - operation: "ai_conversation_load_failed", - userId, - }); - res.status(500).json({ error: "Failed to load conversation" }); - } - }, -); - -/** - * @openapi - * /ai/conversations/{id}: - * delete: - * summary: Delete a conversation - * tags: - * - AI - * parameters: - * - in: path - * name: id - * required: true - * schema: - * type: integer - * responses: - * 200: - * description: Conversation deleted. - */ -router.delete( - "/conversations/:id", - authenticateJWT, - permissionManager.requirePermission("ai.use"), - requireDataAccess, - aiGate, - async (req, res) => { - const userId = (req as AuthenticatedRequest).userId as string; - const id = parseId(req.params.id); - if (!id) return res.status(400).json({ error: "Invalid conversation id" }); - - try { - const deleted = await createCurrentAiRepository().deleteConversation( - id, - userId, - ); - if (!deleted) { - return res.status(404).json({ error: "Conversation not found" }); - } - res.json({ success: true }); - } catch (err) { - databaseLogger.error("Failed to delete AI conversation", err, { - operation: "ai_conversation_delete_failed", - userId, - }); - res.status(500).json({ error: "Failed to delete conversation" }); - } - }, -); - -/** - * @openapi - * /ai/chat/stream: - * post: - * summary: Send a message and stream the assistant's reply - * description: > - * Server-sent events. Emits token, tool_call, tool_result, proposal, - * done and error frames. - * tags: - * - AI - * requestBody: - * required: true - * content: - * application/json: - * schema: - * type: object - * properties: - * conversationId: - * type: integer - * providerId: - * type: integer - * model: - * type: string - * message: - * type: string - * activeTab: - * type: string - * responses: - * 200: - * description: An event stream. - * 400: - * description: Invalid request body. - */ -router.post( - "/chat/stream", - authenticateJWT, - permissionManager.requirePermission("ai.use"), - requireDataAccess, - aiGate, - async (req, res) => { - const userId = (req as AuthenticatedRequest).userId as string; - const { conversationId, providerId, model, message, activeTab } = - req.body ?? {}; - - if (typeof message !== "string" || !message.trim()) { - return res.status(400).json({ error: "message is required" }); - } - - const resolvedProviderId = parseId(providerId); - if (!resolvedProviderId) { - return res.status(400).json({ error: "providerId is required" }); - } - - const repository = createCurrentAiRepository(); - - try { - const provider = await repository.findProviderWithSecret( - resolvedProviderId, - userId, - ); - if (!provider) { - return res.status(404).json({ error: "Provider not found" }); - } - - const chosenModel = - (typeof model === "string" && model.trim()) || - provider.defaultModel || - ""; - if (!chosenModel) { - return res.status(400).json({ error: "No model selected" }); - } - - // Resolve or create the conversation before the stream opens, so a - // failure here is still a normal JSON error the client can render. - let conversation = conversationId - ? await repository.findConversation(Number(conversationId), userId) - : null; - if (!conversation) { - conversation = await repository.createConversation({ - userId, - title: message.trim().slice(0, 60), - providerId: resolvedProviderId, - model: chosenModel, - }); - } - - const history = await repository.listMessages(conversation.id); - await repository.appendMessage({ - conversationId: conversation.id, - role: "user", - content: message.trim(), - }); - - const access = await resolveAiAccess(userId); - const hosts = await createCurrentHostRepository().listByUserId(userId); - - res.writeHead(200, { - "Content-Type": "text/event-stream", - "Cache-Control": "no-store, no-transform", - Connection: "keep-alive", - "X-Accel-Buffering": "no", - }); - res.flushHeaders?.(); - - const heartbeat = setInterval(() => { - try { - res.write(": keepalive\n\n"); - } catch { - clearInterval(heartbeat); - } - }, 30000); - - const abort = new AbortController(); - req.on("close", () => { - clearInterval(heartbeat); - abort.abort(); - }); - - const send = (event: unknown) => { - res.write(`data: ${JSON.stringify(event)}\n\n`); - }; - - send({ type: "conversation", conversationId: conversation.id }); - - const chatHistory: ChatMessage[] = history.map((entry) => ({ - role: entry.role as ChatMessage["role"], - content: entry.content, - ...(entry.toolCalls ? { toolCalls: JSON.parse(entry.toolCalls) } : {}), - })); - chatHistory.push({ role: "user", content: message.trim() }); - - let assistantText = ""; - let assistantToolCalls: unknown[] = []; - try { - for await (const event of runAgent({ - config: { - providerType: - provider.providerType as ProviderConfig["providerType"], - baseUrl: provider.baseUrl, - apiKey: provider.apiKey, - }, - model: chosenModel, - system: buildSystemPrompt({ - hostCount: hosts.length, - activeTab: typeof activeTab === "string" ? activeTab : null, - allowReadOnlyCommands: access.allowReadOnlyCommands, - }), - history: chatHistory, - context: { - userId, - conversationId: conversation.id, - allowReadOnlyCommands: access.allowReadOnlyCommands, - }, - signal: abort.signal, - })) { - if (event.type === "assistant_message") { - assistantText = event.content; - // Kept so the next message replays them verbatim. Gemini rejects a - // turn whose functionCall parts lost their thoughtSignature, so - // dropping these breaks the second message in every conversation. - assistantToolCalls = event.toolCalls; - continue; - } + const created = await repository.createProvider(userId, { + providerType, + label: label.trim(), + baseUrl: typeof baseUrl === "string" ? baseUrl.trim() : null, + apiKey: typeof apiKey === "string" ? apiKey.trim() : null, + defaultModel: + typeof defaultModel === "string" ? defaultModel.trim() : null, + }); - if (event.type === "proposal") { - const stored = await repository.createProposal({ - conversationId: conversation.id, - userId, - kind: event.draft.kind, - summary: event.draft.summary, - payload: JSON.stringify(event.draft.payload), - }); + await audit({ + action: "create_ai_provider", + resourceType: "ai_provider", + resourceId: String(created.id), + resourceName: created.label, + success: true, + }); - const { ipAddress, userAgent } = getRequestMeta(req); - await logAudit({ - userId, - username: await getAuditUsername(userId), - action: "ai_proposal_created", - resourceType: "ai_proposal", - resourceId: String(stored.id), - resourceName: event.draft.kind, - ipAddress, - userAgent, - success: true, - }); + res.status(201).json({ provider: created }); + } catch (err) { + logError("Failed to create AI provider", err); + res.status(500).json({ error: "Failed to create provider" }); + } + }, + ); - send({ type: "proposal", proposal: stored }); - continue; - } + /** + * @openapi + * /plugin-api/ai/providers/{id}: + * patch: + * summary: Update an AI provider + * tags: + * - AI + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: integer + * responses: + * 200: + * description: Provider updated. + * 404: + * description: Provider not found. + */ + router.patch( + "/providers/:id", + ctx.rbac.require("manage_providers") as never, + gate, + async (req: Request, res: Response) => { + const userId = actor(ctx); + const id = parseId(req.params.id); + if (!id) return res.status(400).json({ error: "Invalid provider id" }); - send(event); - } - } finally { - clearInterval(heartbeat); + const body = (req.body ?? {}) as Record; + if ( + body.providerType !== undefined && + !isAiProviderType(body.providerType) + ) { + return res.status(400).json({ error: "Unknown provider type" }); } - if (assistantText || assistantToolCalls.length) { + const text = (value: unknown) => + typeof value === "string" ? value.trim() : undefined; + + try { + const updated = await repository.updateProvider(id, userId, { + providerType: body.providerType as string | undefined, + label: text(body.label) || undefined, + baseUrl: + body.baseUrl === null ? null : (text(body.baseUrl) ?? undefined), + apiKey: body.apiKey === null ? "" : text(body.apiKey), + defaultModel: + body.defaultModel === null + ? null + : (text(body.defaultModel) ?? undefined), + enabled: typeof body.enabled === "boolean" ? body.enabled : undefined, + }); + if (!updated) + return res.status(404).json({ error: "Provider not found" }); + + await audit({ + action: "update_ai_provider", + resourceType: "ai_provider", + resourceId: String(id), + resourceName: updated.label, + success: true, + }); + + res.json({ provider: updated }); + } catch (err) { + logError("Failed to update AI provider", err); + res.status(500).json({ error: "Failed to update provider" }); + } + }, + ); + + /** + * @openapi + * /plugin-api/ai/providers/{id}: + * delete: + * summary: Delete an AI provider + * tags: + * - AI + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: integer + * responses: + * 200: + * description: Provider deleted. + * 404: + * description: Provider not found. + */ + router.delete( + "/providers/:id", + ctx.rbac.require("manage_providers") as never, + gate, + async (req: Request, res: Response) => { + const userId = actor(ctx); + const id = parseId(req.params.id); + if (!id) return res.status(400).json({ error: "Invalid provider id" }); + + try { + const deleted = await repository.deleteProvider(id, userId); + if (!deleted) + return res.status(404).json({ error: "Provider not found" }); + + await audit({ + action: "delete_ai_provider", + resourceType: "ai_provider", + resourceId: String(id), + success: true, + }); + + res.json({ success: true }); + } catch (err) { + logError("Failed to delete AI provider", err); + res.status(500).json({ error: "Failed to delete provider" }); + } + }, + ); + + /** + * @openapi + * /plugin-api/ai/probe-models: + * post: + * summary: List models for a provider that has not been saved yet + * description: > + * Lets the add-provider form fill its model picker before the provider + * exists, so nobody has to go and look up model names by hand. + * tags: + * - AI + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * providerType: + * type: string + * baseUrl: + * type: string + * apiKey: + * type: string + * providerId: + * type: integer + * responses: + * 200: + * description: Model ids, possibly a curated fallback list. + * 400: + * description: Unknown provider type. + */ + router.post( + "/probe-models", + ctx.rbac.require("manage_providers") as never, + gate, + async (req: Request, res: Response) => { + const userId = actor(ctx); + const { providerType, baseUrl, apiKey, providerId } = req.body ?? {}; + + if (!isAiProviderType(providerType)) { + return res.status(400).json({ error: "Unknown provider type" }); + } + + try { + // Editing an existing provider sends no key, so fall back to the + // stored one rather than making the user retype it. + let resolvedKey = + typeof apiKey === "string" && apiKey.trim() ? apiKey.trim() : null; + const storedId = parseId(providerId); + if (!resolvedKey && storedId) { + const stored = await repository.findProviderWithSecret( + storedId, + userId, + ); + resolvedKey = stored?.apiKey ?? null; + } + + const models = await getAdapter(providerType).listModels( + await providerConfig({ + providerType, + baseUrl: typeof baseUrl === "string" ? baseUrl.trim() : null, + apiKey: resolvedKey, + }), + ); + + res.json({ models, source: "live" }); + } catch (err) { + // A provider that cannot be reached yet still gets a usable list, so + // the form is never a blank text box the user has to guess into. + const fallback = FALLBACK_MODELS[providerType as AiProviderType] ?? []; + res.json({ + models: fallback, + source: fallback.length ? "fallback" : "none", + warning: err instanceof Error ? err.message : undefined, + }); + } + }, + ); + + /** + * @openapi + * /plugin-api/ai/providers/{id}/models: + * get: + * summary: List models available from a provider + * tags: + * - AI + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: integer + * responses: + * 200: + * description: Model ids. + * 502: + * description: The provider could not be reached. + */ + router.get( + "/providers/:id/models", + ctx.rbac.require("use") as never, + gate, + async (req: Request, res: Response) => { + const userId = actor(ctx); + const id = parseId(req.params.id); + if (!id) return res.status(400).json({ error: "Invalid provider id" }); + + try { + const provider = await repository.findProviderWithSecret(id, userId); + if (!provider) + return res.status(404).json({ error: "Provider not found" }); + + const models = await getAdapter(provider.providerType).listModels( + await providerConfig(provider), + ); + res.json({ models }); + } catch (err) { + // The message can carry the allowlist hint, which the user needs. + const message = getErrorMessage(err, "Could not reach the provider"); + ctx.log.warn(`Failed to list provider models: ${message}`); + res.status(502).json({ error: message }); + } + }, + ); + + /** + * @openapi + * /plugin-api/ai/conversations: + * get: + * summary: List the user's AI conversations + * tags: + * - AI + * responses: + * 200: + * description: Conversations, newest first. + */ + router.get( + "/conversations", + ctx.rbac.require("use") as never, + gate, + async (_req: Request, res: Response) => { + try { + const conversations = await repository.listConversations(actor(ctx)); + res.json({ conversations }); + } catch (err) { + logError("Failed to list AI conversations", err); + res.status(500).json({ error: "Failed to list conversations" }); + } + }, + ); + + /** + * @openapi + * /plugin-api/ai/conversations/{id}: + * get: + * summary: Get one conversation with its messages and proposals + * tags: + * - AI + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: integer + * responses: + * 200: + * description: The conversation. + * 404: + * description: Conversation not found. + */ + router.get( + "/conversations/:id", + ctx.rbac.require("use") as never, + gate, + async (req: Request, res: Response) => { + const userId = actor(ctx); + const id = parseId(req.params.id); + if (!id) + return res.status(400).json({ error: "Invalid conversation id" }); + + try { + const conversation = await repository.findConversation(id, userId); + if (!conversation) { + return res.status(404).json({ error: "Conversation not found" }); + } + + const [messages, proposals] = await Promise.all([ + repository.listMessages(id), + repository.listProposals(userId, id), + ]); + + res.json({ conversation, messages, proposals }); + } catch (err) { + logError("Failed to load AI conversation", err); + res.status(500).json({ error: "Failed to load conversation" }); + } + }, + ); + + /** + * @openapi + * /plugin-api/ai/conversations/{id}: + * delete: + * summary: Delete a conversation + * tags: + * - AI + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: integer + * responses: + * 200: + * description: Conversation deleted. + */ + router.delete( + "/conversations/:id", + ctx.rbac.require("use") as never, + gate, + async (req: Request, res: Response) => { + const id = parseId(req.params.id); + if (!id) + return res.status(400).json({ error: "Invalid conversation id" }); + + try { + const deleted = await repository.deleteConversation(id, actor(ctx)); + if (!deleted) { + return res.status(404).json({ error: "Conversation not found" }); + } + res.json({ success: true }); + } catch (err) { + logError("Failed to delete AI conversation", err); + res.status(500).json({ error: "Failed to delete conversation" }); + } + }, + ); + + /** + * @openapi + * /plugin-api/ai/chat/stream: + * post: + * summary: Send a message and stream the assistant's reply + * description: > + * Server-sent events. Emits conversation, token, tool_call, + * tool_result, proposal, done and error frames. + * tags: + * - AI + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * conversationId: + * type: integer + * providerId: + * type: integer + * model: + * type: string + * message: + * type: string + * activeTab: + * type: string + * responses: + * 200: + * description: An event stream. + * 400: + * description: Invalid request body. + */ + router.post( + "/chat/stream", + ctx.rbac.require("use") as never, + gate, + async (req: Request, res: Response) => { + const userId = actor(ctx); + const { conversationId, providerId, model, message, activeTab } = + req.body ?? {}; + + if (typeof message !== "string" || !message.trim()) { + return res.status(400).json({ error: "message is required" }); + } + + const resolvedProviderId = parseId(providerId); + if (!resolvedProviderId) { + return res.status(400).json({ error: "providerId is required" }); + } + + try { + const provider = await repository.findProviderWithSecret( + resolvedProviderId, + userId, + ); + if (!provider) { + return res.status(404).json({ error: "Provider not found" }); + } + + const chosenModel = + (typeof model === "string" && model.trim()) || + provider.defaultModel || + ""; + if (!chosenModel) { + return res.status(400).json({ error: "No model selected" }); + } + + // Resolve or create the conversation before the stream opens, so a + // failure here is still a normal JSON error the client can render. + let conversation = conversationId + ? await repository.findConversation(Number(conversationId), userId) + : null; + if (!conversation) { + conversation = await repository.createConversation({ + userId, + title: message.trim().slice(0, 60), + providerId: resolvedProviderId, + model: chosenModel, + }); + } + + const history = await repository.listMessages(conversation.id); await repository.appendMessage({ conversationId: conversation.id, - role: "assistant", - content: assistantText, - toolCalls: assistantToolCalls.length - ? JSON.stringify(assistantToolCalls) - : null, + role: "user", + content: message.trim(), + }); + + const access = ( + req as Request & { aiAccess?: { allowReadOnlyCommands: boolean } } + ).aiAccess ?? { allowReadOnlyCommands: false }; + const hosts = await ctx.hosts.list(); + const config = await providerConfig(provider); + const tools = availableTools((service) => + serviceAvailable(ctx.services, service), + ); + + // no-transform keeps core's compression from buffering the stream, + // and X-Accel-Buffering does the same for nginx. + res.writeHead(200, { + "Content-Type": "text/event-stream", + "Cache-Control": "no-store, no-transform", + Connection: "keep-alive", + "X-Accel-Buffering": "no", + }); + res.flushHeaders?.(); + + const heartbeat = setInterval(() => { + try { + res.write(": keepalive\n\n"); + } catch { + clearInterval(heartbeat); + } + }, 30000); + + const abort = new AbortController(); + req.on("close", () => { + clearInterval(heartbeat); + abort.abort(); + }); + + const send = (event: unknown) => { + res.write(`data: ${JSON.stringify(event)}\n\n`); + }; + + send({ type: "conversation", conversationId: conversation.id }); + + const chatHistory: ChatMessage[] = history.map((entry) => ({ + role: entry.role as ChatMessage["role"], + content: entry.content, + ...(entry.toolCalls + ? { toolCalls: JSON.parse(entry.toolCalls) } + : {}), + })); + chatHistory.push({ role: "user", content: message.trim() }); + + let assistantText = ""; + let assistantToolCalls: unknown[] = []; + + try { + for await (const event of runAgent({ + config, + model: chosenModel, + system: buildSystemPrompt({ + hostCount: hosts.length, + activeTab: typeof activeTab === "string" ? activeTab : null, + allowReadOnlyCommands: access.allowReadOnlyCommands, + }), + history: chatHistory, + context: { + userId, + conversationId: conversation.id, + allowReadOnlyCommands: access.allowReadOnlyCommands, + deps: toolDeps, + }, + tools, + signal: abort.signal, + })) { + if (event.type === "assistant_message") { + assistantText = event.content; + // Kept so the next message replays them verbatim. Gemini rejects + // a turn whose functionCall parts lost their thoughtSignature. + assistantToolCalls = event.toolCalls; + continue; + } + + if (event.type === "proposal") { + const stored = await repository.createProposal({ + conversationId: conversation.id, + userId, + kind: event.draft.kind, + summary: event.draft.summary, + payload: JSON.stringify(event.draft.payload), + }); + + await audit({ + action: "ai_proposal_created", + resourceType: "ai_proposal", + resourceId: String(stored.id), + resourceName: event.draft.kind, + success: true, + }); + + send({ type: "proposal", proposal: stored }); + continue; + } + + send(event); + } + } finally { + clearInterval(heartbeat); + } + + if (assistantText || assistantToolCalls.length) { + await repository.appendMessage({ + conversationId: conversation.id, + role: "assistant", + content: assistantText, + toolCalls: assistantToolCalls.length + ? JSON.stringify(assistantToolCalls) + : null, + }); + } + await repository.touchConversation(conversation.id); + + send({ type: "done" }); + res.end(); + } catch (err) { + logError("AI chat stream failed", err); + if (res.headersSent) { + res.write( + `data: ${JSON.stringify({ type: "error", message: "The assistant stopped unexpectedly" })}\n\n`, + ); + res.end(); + } else { + res.status(500).json({ error: "Failed to start the assistant" }); + } + } + }, + ); + + /** + * @openapi + * /plugin-api/ai/proposals/{id}/apply: + * post: + * summary: Apply a pending proposal + * description: > + * Re-validates the stored payload and applies it as the approving user, + * through the same core APIs and plugin services a manual action uses. + * tags: + * - AI + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: integer + * responses: + * 200: + * description: Proposal applied. + * 400: + * description: The proposal could not be applied. + * 404: + * description: Proposal not found. + */ + router.post( + "/proposals/:id/apply", + ctx.rbac.require("apply_proposals") as never, + gate, + async (req: Request, res: Response) => { + const userId = actor(ctx); + const id = parseId(req.params.id); + if (!id) return res.status(400).json({ error: "Invalid proposal id" }); + + try { + const stored = await repository.findProposal(id, userId); + if (!stored) + return res.status(404).json({ error: "Proposal not found" }); + if (stored.status !== "pending") { + return res + .status(400) + .json({ error: `This proposal was already ${stored.status}` }); + } + + let payload: Record; + try { + payload = JSON.parse(stored.payload) as Record; + } catch { + return res + .status(400) + .json({ error: "The proposal payload is invalid" }); + } + + const result = await applyProposal(stored.kind, payload, toolDeps); + await repository.setProposalStatus( + id, + userId, + "applied", + result.summary, + ); + + await audit({ + action: "ai_proposal_applied", + resourceType: "ai_proposal", + resourceId: String(id), + resourceName: stored.kind, + success: true, + }); + + res.json({ success: result.ok, summary: result.summary }); + } catch (err) { + const message = getErrorMessage(err, "Failed to apply the proposal"); + logError("Failed to apply AI proposal", err); + + await audit({ + action: "ai_proposal_applied", + resourceType: "ai_proposal", + resourceId: String(id), + success: false, + errorMessage: message, + }); + + res.status(400).json({ error: message }); + } + }, + ); + + /** + * @openapi + * /plugin-api/ai/proposals/{id}/mark-run-in-terminal: + * post: + * summary: Record that a run_command proposal was executed in an open terminal + * description: > + * For the terminal-docked assistant only. The command is typed into the + * user's already-open SSH session client-side, not re-run over a pooled + * connection here; this just marks the proposal applied with the output + * the client captured, so the card and the /apply path stay in sync + * without running the command twice. + * tags: + * - AI + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: integer + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * hostId: + * type: integer + * summary: + * type: string + * responses: + * 200: + * description: Proposal marked applied. + * 400: + * description: The proposal is not a run_command proposal, or the hostId does not match. + * 404: + * description: Proposal not found. + */ + router.post( + "/proposals/:id/mark-run-in-terminal", + ctx.rbac.require("apply_proposals") as never, + gate, + async (req: Request, res: Response) => { + const userId = actor(ctx); + const id = parseId(req.params.id); + if (!id) return res.status(400).json({ error: "Invalid proposal id" }); + + const { hostId, summary } = req.body ?? {}; + const resolvedHostId = parseId(hostId); + if (!resolvedHostId) { + return res.status(400).json({ error: "hostId is required" }); + } + + try { + const stored = await repository.findProposal(id, userId); + if (!stored) + return res.status(404).json({ error: "Proposal not found" }); + if (stored.kind !== "propose_run_command") { + return res.status(400).json({ + error: "Only run_command proposals can be marked this way", + }); + } + if (stored.status !== "pending") { + return res + .status(400) + .json({ error: `This proposal was already ${stored.status}` }); + } + + let payload: Record; + try { + payload = JSON.parse(stored.payload) as Record; + } catch { + return res + .status(400) + .json({ error: "The proposal payload is invalid" }); + } + if (parseId(payload.hostId) !== resolvedHostId) { + return res + .status(400) + .json({ error: "This proposal is for a different host" }); + } + + const resultSummary = + typeof summary === "string" && summary.trim() + ? summary.trim().slice(0, 2000) + : "Run in the open terminal session"; + await repository.setProposalStatus( + id, + userId, + "applied", + resultSummary, + ); + + await audit({ + action: "ai_proposal_applied", + resourceType: "ai_proposal", + resourceId: String(id), + resourceName: stored.kind, + success: true, + }); + + res.json({ success: true, summary: resultSummary }); + } catch (err) { + logError("Failed to mark AI proposal applied in terminal", err); + res.status(400).json({ + error: getErrorMessage(err, "Failed to mark the proposal applied"), }); } - await repository.touchConversation(conversation.id); + }, + ); - send({ type: "done" }); - res.end(); - } catch (err) { - databaseLogger.error("AI chat stream failed", err, { - operation: "ai_chat_stream_failed", - userId, - }); - if (res.headersSent) { - res.write( - `data: ${JSON.stringify({ type: "error", message: "The assistant stopped unexpectedly" })}\n\n`, + /** + * @openapi + * /plugin-api/ai/proposals/{id}/reject: + * post: + * summary: Reject a pending proposal + * tags: + * - AI + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: integer + * responses: + * 200: + * description: Proposal rejected. + * 404: + * description: Proposal not found. + */ + router.post( + "/proposals/:id/reject", + ctx.rbac.require("use") as never, + gate, + async (req: Request, res: Response) => { + const id = parseId(req.params.id); + if (!id) return res.status(400).json({ error: "Invalid proposal id" }); + + try { + const updated = await repository.setProposalStatus( + id, + actor(ctx), + "rejected", ); - res.end(); - } else { - res.status(500).json({ error: "Failed to start the assistant" }); + if (!updated) { + return res + .status(404) + .json({ error: "Proposal not found or already resolved" }); + } + + await audit({ + action: "ai_proposal_rejected", + resourceType: "ai_proposal", + resourceId: String(id), + success: true, + }); + + res.json({ success: true }); + } catch (err) { + logError("Failed to reject AI proposal", err); + res.status(500).json({ error: "Failed to reject the proposal" }); } - } - }, -); - -/** - * @openapi - * /ai/proposals/{id}/apply: - * post: - * summary: Apply a pending proposal - * description: > - * Re-validates the stored payload and dispatches it through the same - * repository logic a manual action uses. - * tags: - * - AI - * parameters: - * - in: path - * name: id - * required: true - * schema: - * type: integer - * responses: - * 200: - * description: Proposal applied. - * 400: - * description: The proposal could not be applied. - * 404: - * description: Proposal not found. - */ -router.post( - "/proposals/:id/apply", - authenticateJWT, - permissionManager.requirePermission("ai.apply_proposals"), - requireDataAccess, - aiGate, - async (req, res) => { - const userId = (req as AuthenticatedRequest).userId as string; - const id = parseId(req.params.id); - if (!id) return res.status(400).json({ error: "Invalid proposal id" }); - - const repository = createCurrentAiRepository(); - - try { - const stored = await repository.findProposal(id, userId); - if (!stored) return res.status(404).json({ error: "Proposal not found" }); - if (stored.status !== "pending") { - return res - .status(400) - .json({ error: `This proposal was already ${stored.status}` }); - } - - let payload: Record; - try { - payload = JSON.parse(stored.payload) as Record; - } catch { - return res - .status(400) - .json({ error: "The proposal payload is invalid" }); - } - - const result = await applyProposal(stored.kind, payload, userId); - await repository.setProposalStatus(id, userId, "applied", result.summary); - - const { ipAddress, userAgent } = getRequestMeta(req); - await logAudit({ - userId, - username: await getAuditUsername(userId), - action: "ai_proposal_applied", - resourceType: "ai_proposal", - resourceId: String(id), - resourceName: stored.kind, - ipAddress, - userAgent, - success: true, - }); - - res.json({ success: result.ok, summary: result.summary }); - } catch (err) { - const message = getErrorMessage(err, "Failed to apply the proposal"); - databaseLogger.error("Failed to apply AI proposal", err, { - operation: "ai_proposal_apply_failed", - userId, - }); - - const { ipAddress, userAgent } = getRequestMeta(req); - await logAudit({ - userId, - username: await getAuditUsername(userId), - action: "ai_proposal_applied", - resourceType: "ai_proposal", - resourceId: String(id), - ipAddress, - userAgent, - success: false, - errorMessage: message, - }); - - res.status(400).json({ error: message }); - } - }, -); - -/** - * @openapi - * /ai/proposals/{id}/mark-run-in-terminal: - * post: - * summary: Record that a run_command proposal was executed in an open terminal - * description: > - * For the terminal-docked assistant only. The command is typed into the - * user's already-open SSH session client-side, not re-run over a pooled - * connection here; this just marks the proposal applied with the output - * the client captured, so the card and the pooled-connection /apply path - * stay in sync without running the command twice. - * tags: - * - AI - * parameters: - * - in: path - * name: id - * required: true - * schema: - * type: integer - * requestBody: - * required: true - * content: - * application/json: - * schema: - * type: object - * properties: - * hostId: - * type: integer - * summary: - * type: string - * responses: - * 200: - * description: Proposal marked applied. - * 400: - * description: The proposal is not a run_command proposal, or the hostId does not match. - * 404: - * description: Proposal not found. - */ -router.post( - "/proposals/:id/mark-run-in-terminal", - authenticateJWT, - permissionManager.requirePermission("ai.apply_proposals"), - requireDataAccess, - aiGate, - async (req, res) => { - const userId = (req as AuthenticatedRequest).userId as string; - const id = parseId(req.params.id); - if (!id) return res.status(400).json({ error: "Invalid proposal id" }); - - const { hostId, summary } = req.body ?? {}; - const resolvedHostId = parseId(hostId); - if (!resolvedHostId) { - return res.status(400).json({ error: "hostId is required" }); - } - - const repository = createCurrentAiRepository(); - - try { - const stored = await repository.findProposal(id, userId); - if (!stored) return res.status(404).json({ error: "Proposal not found" }); - if (stored.kind !== "propose_run_command") { - return res - .status(400) - .json({ error: "Only run_command proposals can be marked this way" }); - } - if (stored.status !== "pending") { - return res - .status(400) - .json({ error: `This proposal was already ${stored.status}` }); - } - - let payload: Record; - try { - payload = JSON.parse(stored.payload) as Record; - } catch { - return res - .status(400) - .json({ error: "The proposal payload is invalid" }); - } - if (parseId(payload.hostId) !== resolvedHostId) { - return res - .status(400) - .json({ error: "This proposal is for a different host" }); - } - - const resultSummary = - typeof summary === "string" && summary.trim() - ? summary.trim().slice(0, 2000) - : "Run in the open terminal session"; - await repository.setProposalStatus(id, userId, "applied", resultSummary); - - const { ipAddress, userAgent } = getRequestMeta(req); - await logAudit({ - userId, - username: await getAuditUsername(userId), - action: "ai_proposal_applied", - resourceType: "ai_proposal", - resourceId: String(id), - resourceName: stored.kind, - ipAddress, - userAgent, - success: true, - }); - - res.json({ success: true, summary: resultSummary }); - } catch (err) { - const message = getErrorMessage( - err, - "Failed to mark the proposal applied", - ); - databaseLogger.error( - "Failed to mark AI proposal applied in terminal", - err, - { - operation: "ai_proposal_mark_run_in_terminal_failed", - userId, - }, - ); - res.status(400).json({ error: message }); - } - }, -); - -/** - * @openapi - * /ai/proposals/{id}/reject: - * post: - * summary: Reject a pending proposal - * tags: - * - AI - * parameters: - * - in: path - * name: id - * required: true - * schema: - * type: integer - * responses: - * 200: - * description: Proposal rejected. - * 404: - * description: Proposal not found. - */ -router.post( - "/proposals/:id/reject", - authenticateJWT, - permissionManager.requirePermission("ai.use"), - requireDataAccess, - aiGate, - async (req, res) => { - const userId = (req as AuthenticatedRequest).userId as string; - const id = parseId(req.params.id); - if (!id) return res.status(400).json({ error: "Invalid proposal id" }); - - try { - const updated = await createCurrentAiRepository().setProposalStatus( - id, - userId, - "rejected", - ); - if (!updated) { - return res - .status(404) - .json({ error: "Proposal not found or already resolved" }); - } - - const { ipAddress, userAgent } = getRequestMeta(req); - await logAudit({ - userId, - username: await getAuditUsername(userId), - action: "ai_proposal_rejected", - resourceType: "ai_proposal", - resourceId: String(id), - ipAddress, - userAgent, - success: true, - }); - - res.json({ success: true }); - } catch (err) { - databaseLogger.error("Failed to reject AI proposal", err, { - operation: "ai_proposal_reject_failed", - userId, - }); - res.status(500).json({ error: "Failed to reject the proposal" }); - } - }, -); - -/** Called from activate(). Mounts this router at /plugin-api/ai. */ -export function startAiService(mountOn: Router): void { - mountOn.use(router); -} - -/** Called from deactivate(). /ai/* falls back to 404 until reactivated. */ -export function stopAiService(): void { - // Unmounted by the runtime when the plugin deactivates. + }, + ); } diff --git a/plugins/ai/src/backend/services.ts b/plugins/ai/src/backend/services.ts index 1b1d5495c..bbf4abb11 100644 --- a/plugins/ai/src/backend/services.ts +++ b/plugins/ai/src/backend/services.ts @@ -1,111 +1,35 @@ import type { PluginServices } from "@termix/plugin-sdk/backend"; -let current: PluginServices | null = null; - -/** Set in activate, cleared on deactivate. */ -export function setPluginServices(services: PluginServices | null): void { - current = services; -} - -/** Another plugin's workspaces, as ctx.services.get("workspaces.saved") returns them. */ -interface SavedWorkspaces { - list: () => Promise>; -} - /** - * The user's saved workspaces, or null when the workspaces plugin is off or - * the user may not use it. Optional: the assistant works without it. + * Other plugins the assistant reads and changes through, each optional. A + * service call runs as the acting user and the provider checks that user's + * permissions, so nothing here takes a user id. */ -export async function listSavedWorkspaces( - userId: string, -): Promise | null> { - if (!current) return null; + +export const SERVICE = { + snippets: "snippets.access", + fleets: "fleets.access", + automations: "automations.access", + workspaces: "workspaces.saved", + topology: "network-topology.graph", + history: "terminal.history", + homepage: "homepage.items", +} as const; + +export type ServiceName = (typeof SERVICE)[keyof typeof SERVICE]; + +/** Whether a compatible provider is running right now. */ +export function serviceAvailable( + services: Pick, + service: string, +): boolean { try { - return await current - .get("workspaces.saved", { userId }) - .list(); + return services.providers(service).length > 0; } catch { - return null; + return false; } } -/** Another plugin's graph, as ctx.services.get("network-topology.graph") returns it. */ -interface NetworkTopologyGraph { - get: () => Promise; -} - -/** - * The user's saved network topology, or null when the network-topology - * plugin is off or the user may not use it. Optional: the tool disappears - * without it. - */ -export async function getNetworkTopology( - userId: string, -): Promise { - if (!current) return null; - try { - return await current - .get("network-topology.graph", { userId }) - .get(); - } catch { - return null; - } -} - -/** Another plugin's fleets, as ctx.services.get("fleets.access") returns them. */ -interface FleetsAccess { - list: () => Promise< - Array<{ id: number; name: string; color: string | null }> - >; - create: (input: { - name: string; - description?: string | null; - }) => Promise<{ id: number; name: string }>; - addMember: (fleetId: number, hostId: number) => Promise; -} - -/** - * The user's fleets, or null when the fleets plugin is off or the user may - * not use it. Optional: the list_fleets tool disappears without it. - */ -export async function listFleets( - userId: string, -): Promise | null> { - if (!current) return null; - try { - return await current.get("fleets.access", { userId }).list(); - } catch { - return null; - } -} - -/** - * Requires the fleets plugin, unlike listFleets above: a proposal apply must - * surface a failure as an error the user sees, not silently no-op. - */ -function requireFleetsAccess(userId: string): FleetsAccess { - if (!current) { - throw new Error("The fleets plugin is disabled"); - } - return current.get("fleets.access", { userId }); -} - -export async function createFleet( - userId: string, - input: { name: string; description?: string | null }, -): Promise<{ id: number; name: string } | null> { - if (!current) return null; - return requireFleetsAccess(userId).create(input); -} - -export async function addFleetMember( - userId: string, - fleetId: number, - hostId: number, -): Promise { - await requireFleetsAccess(userId).addMember(fleetId, hostId); -} - export interface SnippetSummary { id: number; name: string; @@ -115,8 +39,7 @@ export interface SnippetSummary { folder: string | null; } -/** Another plugin's snippets, as ctx.services.get("snippets.access") returns them. */ -interface SnippetsAccess { +export interface SnippetsAccess { list: () => Promise; get: (id: number) => Promise<{ id: number; @@ -142,115 +65,18 @@ interface SnippetsAccess { remove: (id: number) => Promise; } -/** - * The user's own snippets, or null when the snippets plugin is off or the - * user may not use it. Optional: the list_snippets tool disappears without - * it, and the propose_*_snippet tools fail with a clear message. - */ -export async function listSnippets( - userId: string, -): Promise { - if (!current) return null; - try { - return await current - .get("snippets.access", { userId }) - .list(); - } catch { - return null; - } -} - -export async function getSnippet( - userId: string, - id: number, -): Promise<{ - id: number; - name: string; - content: string; - isNote: boolean; -} | null> { - if (!current) return null; - try { - return await current - .get("snippets.access", { userId }) - .get(id); - } catch { - return null; - } -} - -/** - * Requires the snippets plugin, unlike the read helpers above: a failed - * proposal apply must surface as an error the user sees, not silently no-op. - */ -function requireSnippetsAccess(userId: string): SnippetsAccess { - if (!current) { - throw new Error("The snippets plugin is disabled"); - } - return current.get("snippets.access", { userId }); -} - -export async function createSnippet( - userId: string, - input: { +export interface FleetsAccess { + list: () => Promise< + Array<{ id: number; name: string; color: string | null }> + >; + create: (input: { name: string; - content: string; description?: string | null; - folder?: string | null; - }, -): Promise<{ id: number; name: string }> { - return requireSnippetsAccess(userId).create(input); + }) => Promise<{ id: number; name: string }>; + addMember: (fleetId: number, hostId: number) => Promise; } -export async function updateSnippet( - userId: string, - id: number, - changes: { - name?: string; - content?: string; - description?: string | null; - folder?: string | null; - }, -): Promise { - return requireSnippetsAccess(userId).update(id, changes); -} - -export async function deleteSnippet( - userId: string, - id: number, -): Promise { - return requireSnippetsAccess(userId).remove(id); -} - -/** The terminal's command history, as ctx.services.get("terminal.history") returns it. */ -interface TerminalHistory { - list: ( - hostId: number, - limit?: number, - ) => Promise>; -} - -/** - * The user's recent commands on one host, or null when the ssh-terminal - * plugin is off or the user may not read its history. - */ -export async function listCommandHistory( - userId: string, - hostId: number, - limit: number, -): Promise | null> { - if (!current) return null; - try { - return await current - .get("terminal.history", { userId }) - .list(hostId, limit); - } catch { - return null; - } -} - -/** The automations plugin, as ctx.services.get("automations.access") returns it. */ -interface AutomationsAccess { +export interface AutomationsAccess { list: () => Promise< Array<{ id: number; @@ -277,55 +103,61 @@ interface AutomationsAccess { }) => Promise<{ id: number; name: string }>; } -function automations(userId: string): Partial { - if (!current) return {}; - try { - return current.get("automations.access", { userId }); - } catch { - return {}; - } +export interface SavedWorkspaces { + list: () => Promise>; +} + +export interface NetworkTopologyGraph { + get: () => Promise; +} + +export interface TerminalHistory { + list: ( + hostId: number, + limit?: number, + ) => Promise>; +} + +/** homepage.items v1, which the homepage plugin provides from B19. */ +export interface HomepageItems { + list: () => Promise< + Array<{ id: number; typeId: string; title: string | null }> + >; +} + +interface ServiceTypes { + "snippets.access": SnippetsAccess; + "fleets.access": FleetsAccess; + "automations.access": AutomationsAccess; + "workspaces.saved": SavedWorkspaces; + "network-topology.graph": NetworkTopologyGraph; + "terminal.history": TerminalHistory; + "homepage.items": HomepageItems; } /** - * The user's automations, or null when the automations plugin is off or the - * user may not use it. + * The service handle. Throws when the provider is missing or the user may + * not use it; a proposal apply wants that error, a read tool catches it. */ -export async function listAutomations( - userId: string, -): Promise> | null> { - const list = automations(userId).list; - if (typeof list !== "function") return null; +export function requireService( + services: Pick, + service: S, +): ServiceTypes[S] { + if (!serviceAvailable(services, service)) { + throw new Error(`The ${service.split(".")[0]} plugin is not available`); + } + return services.get(service); +} + +/** A read through a service, or null when it is missing or refused. */ +export async function readService( + services: Pick, + service: S, + read: (handle: ServiceTypes[S]) => Promise, +): Promise { try { - return await list(); + return await read(requireService(services, service)); } catch { return null; } } - -export async function getAutomation( - userId: string, - id: number, -): Promise> | undefined> { - const get = automations(userId).get; - if (typeof get !== "function") return undefined; - try { - return await get(id); - } catch { - return undefined; - } -} - -/** - * Creates an automation through the plugin, which validates it exactly as - * its own route does. Throws when the plugin is off. - */ -export async function createAutomation( - userId: string, - input: Parameters[0], -): Promise<{ id: number; name: string }> { - const create = automations(userId).create; - if (typeof create !== "function") { - throw new Error("The automations plugin is not available"); - } - return create(input); -} diff --git a/plugins/ai/src/backend/ssh.ts b/plugins/ai/src/backend/ssh.ts deleted file mode 100644 index b7a221543..000000000 --- a/plugins/ai/src/backend/ssh.ts +++ /dev/null @@ -1,36 +0,0 @@ -import type { Client } from "ssh2"; -import type { - PluginSsh, - PluginSshConnection, - PluginSshConnectOptions, - PluginSshHost, -} from "@termix/plugin-sdk/backend"; - -let current: PluginSsh | null = null; - -/** Set in activate, cleared on deactivate. */ -export function setPluginSsh(ssh: PluginSsh | null): void { - current = ssh; -} - -export function pluginSsh(): PluginSsh { - if (!current) throw new Error("The plugin is not active"); - return current; -} - -/** withConnection with ssh2's Client type filled in. */ -export function withSshConnection( - host: number | PluginSshHost, - options: PluginSshConnectOptions & { pool: string }, - fn: (client: Client) => Promise, -): Promise { - return pluginSsh().withConnection(host, options, fn); -} - -/** connect with ssh2's Client type filled in. */ -export function connectSsh( - host: number | PluginSshHost, - options?: PluginSshConnectOptions, -): Promise> { - return pluginSsh().connect(host, options); -} diff --git a/plugins/ai/src/backend/tables.ts b/plugins/ai/src/backend/tables.ts new file mode 100644 index 000000000..4a51e6fae --- /dev/null +++ b/plugins/ai/src/backend/tables.ts @@ -0,0 +1,137 @@ +import { + adoptLegacyTable, + boolean, + defineTable, + id, + integer, + refUser, + text, + timestamp, + varchar, +} from "@termix/plugin-sdk/db"; + +/* + * Adopted from core's ai_* tables, so column and index names are the legacy + * ones and the rename carries every row across. + * + * Messages and proposals point at their conversation with a plain integer + * here, since the SDK only references users and ssh_data. The adoption + * migrations keep that foreign key by hand, so deleting a conversation still + * cascades. + * + * Provider API keys are not in these tables: they live in ctx.secrets under + * "provider:". A 2.8 database still has an api_key column, which the + * boot migration empties after moving the key. + */ + +export const providers = adoptLegacyTable( + "ai_providers", + defineTable( + "providers", + { + id: id(), + userId: refUser(), + // ollama | anthropic | openai | gemini | openai_compatible + providerType: text().notNull(), + label: varchar().notNull(), + // Required for ollama and openai_compatible, optional elsewhere. + baseUrl: text(), + // First few characters, kept in the clear so the UI can tell keys apart. + apiKeyPrefix: text(), + defaultModel: text(), + enabled: boolean().notNull().default(true), + createdAt: timestamp().notNull().defaultNow(), + updatedAt: timestamp().notNull().defaultNow(), + }, + { + uniques: [ + { name: "idx_ai_providers_user_label", columns: ["userId", "label"] }, + ], + }, + ), +); + +export const conversations = adoptLegacyTable( + "ai_conversations", + defineTable( + "conversations", + { + id: id(), + userId: refUser(), + title: text(), + providerId: integer(), + model: text(), + createdAt: timestamp().notNull().defaultNow(), + updatedAt: timestamp().notNull().defaultNow(), + }, + { + indexes: [ + { + name: "idx_ai_conversations_user", + columns: ["userId", "updatedAt"], + }, + ], + }, + ), +); + +export const messages = adoptLegacyTable( + "ai_messages", + defineTable( + "messages", + { + id: id(), + conversationId: integer().notNull(), + // user | assistant | tool + role: text().notNull(), + content: text().notNull().default(""), + // Serialized tool calls for this turn. + toolCalls: text(), + createdAt: timestamp().notNull().defaultNow(), + }, + { + indexes: [ + { + name: "idx_ai_messages_conversation", + columns: ["conversationId", "createdAt"], + }, + ], + }, + ), +); + +/** + * A change the assistant wants to make. Nothing here has been applied: the + * payload is re-validated at apply time and only then dispatched. + */ +export const proposals = adoptLegacyTable( + "ai_proposals", + defineTable( + "proposals", + { + id: id(), + conversationId: integer().notNull(), + userId: refUser(), + // The propose_* tool name that produced this. + kind: varchar().notNull(), + summary: text(), + payload: text().notNull().default("{}"), + // pending | applied | rejected | expired + status: varchar(32).notNull().default("pending"), + appliedAt: text(), + resultSummary: text(), + createdAt: timestamp().notNull().defaultNow(), + }, + { + indexes: [ + { name: "idx_ai_proposals_user", columns: ["userId", "status"] }, + { + name: "idx_ai_proposals_conversation", + columns: ["conversationId"], + }, + ], + }, + ), +); + +export const tables = [providers, conversations, messages, proposals]; diff --git a/plugins/ai/src/backend/tools/catalog.ts b/plugins/ai/src/backend/tools/catalog.ts index 5a13061d3..80a5a86fc 100644 --- a/plugins/ai/src/backend/tools/catalog.ts +++ b/plugins/ai/src/backend/tools/catalog.ts @@ -58,9 +58,21 @@ export const FORBIDDEN_DOMAINS = [ "settings", ]; +/** + * The tools offered right now. A tool whose service has no running provider + * is left out, so the model never sees something it cannot use. + */ +export function availableTools( + isAvailable: (service: string) => boolean, +): AiTool[] { + return AI_TOOLS.filter((tool) => !tool.service || isAvailable(tool.service)); +} + /** Tool definitions in the shape the provider adapters expect. */ -export function toolDefinitions(): ToolDefinitionShape[] { - return AI_TOOLS.map((tool) => ({ +export function toolDefinitions( + tools: AiTool[] = AI_TOOLS, +): ToolDefinitionShape[] { + return tools.map((tool) => ({ name: tool.name, description: tool.description, parameters: tool.parameters, diff --git a/plugins/ai/src/backend/tools/executor.ts b/plugins/ai/src/backend/tools/executor.ts index 3c34b2c9f..5916ea22d 100644 --- a/plugins/ai/src/backend/tools/executor.ts +++ b/plugins/ai/src/backend/tools/executor.ts @@ -1,17 +1,8 @@ -import { createCurrentHostRepository } from "../../../../../src/backend/database/repositories/factory.js"; -import { resolveHostById } from "../../../../../src/backend/hosts/host-resolver.js"; +import type { Client } from "ssh2"; import { execCommand } from "@termix/plugin-sdk/host-commands"; -import { withSshConnection } from "../ssh.js"; import { getTool } from "./catalog.js"; -import { - createSnippet, - updateSnippet, - deleteSnippet, - getSnippet, - createFleet, - addFleetMember, - createAutomation, -} from "../services.js"; +import type { ToolDeps } from "./types.js"; +import { requireService, SERVICE } from "../services.js"; /** Approved commands get a bounded window rather than hanging the request. */ const COMMAND_TIMEOUT_MS = 60_000; @@ -22,8 +13,8 @@ const COMMAND_TIMEOUT_MS = 60_000; * The stored payload is treated as untrusted input even though the server wrote * it: the proposal could have sat in the table across a release, and defending * the apply path rather than the create path means one place to get right. - * Everything goes through the same repositories a human action uses, scoped to - * the approving user. + * Everything goes through the same core APIs and plugin services a human + * action uses, and runs as the approving user: the apply request's actor. */ export interface ApplyResult { @@ -52,10 +43,29 @@ function optionalString(value: unknown): string | null { return trimmed ? trimmed : null; } +/** Tags as ssh_data stores them, comma-separated. */ +function joinTags(value: unknown): string { + if (!Array.isArray(value)) return ""; + return value + .filter((tag): tag is string => typeof tag === "string") + .map((tag) => tag.trim().replace(/,/g, "")) + .filter(Boolean) + .join(","); +} + +async function requirePermission( + deps: ToolDeps, + permission: string, +): Promise { + if (!(await deps.rbac.has(permission))) { + throw new Error("You do not have permission to do this"); + } +} + export async function applyProposal( kind: string, payload: Record, - userId: string, + deps: ToolDeps, ): Promise { // A payload whose tool no longer exists is refused rather than guessed at. if (!getTool(kind)) { @@ -64,31 +74,25 @@ export async function applyProposal( switch (kind) { case "propose_create_host": { - const created = await createCurrentHostRepository().create({ - userId, + await requirePermission(deps, "hosts.create"); + const created = await deps.hosts.create({ name: requireString(payload.name, "name"), ip: requireString(payload.ip, "ip"), port: Number(payload.port) || 22, username: optionalString(payload.username) ?? "", - folder: optionalString(payload.folder) ?? "", - tags: JSON.stringify(Array.isArray(payload.tags) ? payload.tags : []), - } as any); - return { - ok: true, - summary: `Created host ${(created as any).name ?? ""}`.trim(), - }; + // Credentials are attached by the user after approving. + authType: "password", + folder: optionalString(payload.folder), + tags: joinTags(payload.tags), + }); + return { ok: true, summary: `Created host ${created.name ?? ""}`.trim() }; } case "propose_update_host": { + await requirePermission(deps, "hosts.edit"); const hostId = requireNumber(payload.hostId, "hostId"); const changes = (payload.changes ?? {}) as Record; - const existing = await createCurrentHostRepository().findByIdForUser( - userId, - hostId, - ); - if (!existing) throw new Error("Host not found"); - const updates: Record = {}; if (changes.name !== undefined) updates.name = requireString(changes.name, "name"); @@ -98,37 +102,32 @@ export async function applyProposal( if (changes.username !== undefined) updates.username = optionalString(changes.username) ?? ""; if (changes.folder !== undefined) - updates.folder = optionalString(changes.folder) ?? ""; - if (changes.tags !== undefined) { - updates.tags = JSON.stringify( - Array.isArray(changes.tags) ? changes.tags : [], - ); - } + updates.folder = optionalString(changes.folder); + if (changes.tags !== undefined) updates.tags = joinTags(changes.tags); if (!Object.keys(updates).length) { return { ok: false, summary: "Nothing to change" }; } - await createCurrentHostRepository().updateForUser( - userId, - hostId, - updates as any, - ); + // update() refuses a host the approving user does not own. + const updated = await deps.hosts.update(hostId, updates); + if (!updated) throw new Error("Host not found"); return { ok: true, summary: `Updated host ${hostId}` }; } case "propose_delete_host": { const hostId = requireNumber(payload.hostId, "hostId"); - const deleted = await createCurrentHostRepository().deleteForUser( - userId, - hostId, - ); + // delete() checks hosts.delete and ownership itself. + const deleted = await deps.hosts.delete(hostId); if (!deleted) throw new Error("Host not found"); return { ok: true, summary: `Deleted host ${hostId}` }; } case "propose_create_snippet": { - const created = await createSnippet(userId, { + const created = await requireService( + deps.services, + SERVICE.snippets, + ).create({ name: requireString(payload.name, "name"), content: requireString(payload.content, "content"), description: optionalString(payload.description), @@ -144,7 +143,8 @@ export async function applyProposal( const snippetId = requireNumber(payload.snippetId, "snippetId"); const changes = (payload.changes ?? {}) as Record; - const existing = await getSnippet(userId, snippetId); + const snippets = requireService(deps.services, SERVICE.snippets); + const existing = await snippets.get(snippetId); if (!existing) throw new Error("Snippet not found"); const updates: Record = {}; @@ -161,23 +161,26 @@ export async function applyProposal( return { ok: false, summary: "Nothing to change" }; } - await updateSnippet(userId, snippetId, updates); + await snippets.update(snippetId, updates); return { ok: true, summary: `Updated snippet ${snippetId}` }; } case "propose_delete_snippet": { const snippetId = requireNumber(payload.snippetId, "snippetId"); - const deleted = await deleteSnippet(userId, snippetId); + const deleted = await requireService( + deps.services, + SERVICE.snippets, + ).remove(snippetId); if (!deleted) throw new Error("Snippet not found"); return { ok: true, summary: `Deleted snippet ${snippetId}` }; } case "propose_create_fleet": { - const fleet = await createFleet(userId, { + const fleets = requireService(deps.services, SERVICE.fleets); + const fleet = await fleets.create({ name: requireString(payload.name, "name"), description: optionalString(payload.description), }); - if (!fleet) throw new Error("The fleets plugin is disabled"); const hostIds = Array.isArray(payload.hostIds) ? payload.hostIds : []; let added = 0; @@ -185,12 +188,9 @@ export async function applyProposal( const hostId = Number(raw); if (!Number.isInteger(hostId) || hostId <= 0) continue; // Only hosts the approving user owns can join their fleet. - const host = await createCurrentHostRepository().findByIdForUser( - userId, - hostId, - ); - if (!host) continue; - await addFleetMember(userId, fleet.id, hostId); + const access = await deps.hosts.checkAccess(hostId, "view"); + if (!access.isOwner) continue; + await fleets.addMember(fleet.id, hostId); added += 1; } @@ -203,7 +203,10 @@ export async function applyProposal( case "propose_create_automation": { // The automations plugin runs the same validator its own route does, // so an LLM-authored definition is held to exactly the human standard. - const created = await createAutomation(userId, { + const created = await requireService( + deps.services, + SERVICE.automations, + ).create({ name: requireString(payload.name, "name"), description: optionalString(payload.description), definition: payload.definition, @@ -222,14 +225,10 @@ export async function applyProposal( const hostId = requireNumber(payload.hostId, "hostId"); const command = requireString(payload.command, "command"); - // resolveHostById, not the raw repository row: it runs the connect-level - // permission check, decrypts auth under the owner's key, and resolves the - // jump host chain. A plain row has none of that, so the SSH factory saw - // an unresolved jumpHosts field and failed the connection. - const host = await resolveHostById(hostId, userId); - if (!host) throw new Error("Host not found"); - - const result = await runCommandOnHost(host, command); + // ctx.ssh resolves the host for the approving user, with the same + // connect-level access check, owner-key decryption and jump chain a + // terminal gets. + const result = await runCommandOnHost(deps, hostId, command); if (result.error) { throw new Error(result.error); } @@ -245,18 +244,20 @@ export async function applyProposal( } /** - * Runs one approved command over the shared SSH pool, mirroring how an + * Runs one approved command over this plugin's SSH pool, mirroring how an * automation run_command step executes. */ async function runCommandOnHost( - host: Record, + deps: ToolDeps, + hostId: number, command: string, ): Promise<{ output?: string; error?: string }> { try { - const result = await withSshConnection( - host as any as never, - { pool: "fleet", purpose: "fleet" }, - async (client) => execCommand(client, command, COMMAND_TIMEOUT_MS), + const result = await deps.ssh.withConnection< + Awaited>, + Client + >(hostId, { pool: "ai", purpose: "fleet" }, (client) => + execCommand(client, command, COMMAND_TIMEOUT_MS), ); const output = [result.stdout, result.stderr].filter(Boolean).join("\n"); diff --git a/plugins/ai/src/backend/tools/propose-tools.ts b/plugins/ai/src/backend/tools/propose-tools.ts index 0d737a52a..94952261f 100644 --- a/plugins/ai/src/backend/tools/propose-tools.ts +++ b/plugins/ai/src/backend/tools/propose-tools.ts @@ -1,4 +1,5 @@ import { num, objectSchema, proposal, str, type AiTool } from "./types.js"; +import { SERVICE } from "../services.js"; /** * Propose tools never mutate anything. They return a draft that is stored as a @@ -100,6 +101,7 @@ export const proposeTools: AiTool[] = [ description: "Propose saving a new command snippet the user can run against their hosts.", category: "propose", + service: SERVICE.snippets, parameters: objectSchema( { name: str("Snippet name"), @@ -125,6 +127,7 @@ export const proposeTools: AiTool[] = [ name: "propose_update_snippet", description: "Propose editing an existing snippet.", category: "propose", + service: SERVICE.snippets, parameters: objectSchema( { snippetId: num("The snippet id to update"), @@ -151,6 +154,7 @@ export const proposeTools: AiTool[] = [ name: "propose_delete_snippet", description: "Propose deleting a snippet.", category: "propose", + service: SERVICE.snippets, parameters: objectSchema( { snippetId: num("The snippet id to delete"), @@ -170,6 +174,7 @@ export const proposeTools: AiTool[] = [ description: "Propose a new automation. The definition must be a valid AutomationDefinition object with a trigger and an ordered list of steps. It is validated server-side and previewed with a dry run before anything happens.", category: "propose", + service: SERVICE.automations, parameters: objectSchema( { name: str("Automation name"), @@ -197,6 +202,7 @@ export const proposeTools: AiTool[] = [ name: "propose_create_fleet", description: "Propose grouping hosts into a new fleet.", category: "propose", + service: SERVICE.fleets, parameters: objectSchema( { name: str("Fleet name"), diff --git a/plugins/ai/src/backend/tools/read-tools.ts b/plugins/ai/src/backend/tools/read-tools.ts index db3cc2cb9..5f77fac1a 100644 --- a/plugins/ai/src/backend/tools/read-tools.ts +++ b/plugins/ai/src/backend/tools/read-tools.ts @@ -1,18 +1,6 @@ -import { - createCurrentNotificationChannelRepository, - createCurrentHomepageItemRepository, - createCurrentHostRepository, -} from "../../../../../src/backend/database/repositories/factory.js"; +import type { PluginHostSummary } from "@termix/plugin-sdk/backend"; import { num, objectSchema, type AiTool } from "./types.js"; -import { - listSavedWorkspaces, - getNetworkTopology, - listSnippets, - listFleets, - listCommandHistory, - listAutomations, - getAutomation, -} from "../services.js"; +import { readService, SERVICE } from "../services.js"; /** * Read tools project explicit fields rather than spreading rows. Redaction runs @@ -20,35 +8,24 @@ import { * primary control: a field that is never selected cannot leak. */ -interface HostSummary { - id: number; - name: string | null; - ip: string | null; - port: number | null; - username: string | null; - folder: string | null; - tags: unknown; - protocol: string | null; - enableTerminal: boolean | null; - enableFileManager: boolean | null; - enableTunnel: boolean | null; - enableDocker: boolean | null; +function splitTags(tags: string | null): string[] { + if (!tags) return []; + return tags + .split(",") + .map((tag) => tag.trim()) + .filter(Boolean); } -function toHostSummary(host: Record): HostSummary { +function toHostSummary(host: PluginHostSummary) { return { id: host.id, name: host.name ?? null, - ip: host.ip ?? null, - port: host.port ?? null, - username: host.username ?? null, + ip: host.ip, + port: host.port, + username: host.username, folder: host.folder ?? null, - tags: host.tags ?? null, - protocol: host.protocol ?? null, - enableTerminal: host.enableTerminal ?? null, - enableFileManager: host.enableFileManager ?? null, - enableTunnel: host.enableTunnel ?? null, - enableDocker: host.enableDocker ?? null, + tags: splitTags(host.tags), + authType: host.authType, }; } @@ -60,10 +37,8 @@ export const readTools: AiTool[] = [ category: "read", parameters: objectSchema({}), handler: async (_args, context) => { - const hosts = await createCurrentHostRepository().listByUserId( - context.userId, - ); - return { hosts: hosts.map((host) => toHostSummary(host as any)) }; + const hosts = await context.deps.hosts.list(); + return { hosts: hosts.map(toHostSummary) }; }, }, { @@ -76,40 +51,43 @@ export const readTools: AiTool[] = [ ["hostId"], ), handler: async (args, context) => { - const hostId = Number(args.hostId); - const host = await createCurrentHostRepository().findByIdForUser( - context.userId, - hostId, - ); + const host = await context.deps.hosts.get(Number(args.hostId)); if (!host) return { error: "Host not found" }; - return { host: toHostSummary(host as any) }; + return { host: toHostSummary(host) }; }, }, { name: "list_fleets", description: - "List the user's fleets. Fleets group hosts for bulk operations and inventory. Unavailable if the fleets plugin is disabled.", + "List the user's fleets. Fleets group hosts for bulk operations and inventory.", category: "read", + service: SERVICE.fleets, parameters: objectSchema({}), handler: async (_args, context) => { - const fleets = await listFleets(context.userId); + const fleets = await readService( + context.deps.services, + SERVICE.fleets, + (fleetsAccess) => fleetsAccess.list(), + ); if (fleets === null) return { unavailable: true, fleets: [] }; return { - fleets: fleets.map((fleet) => ({ - id: fleet.id, - name: fleet.name, - })), + fleets: fleets.map((fleet) => ({ id: fleet.id, name: fleet.name })), }; }, }, { name: "list_snippets", description: - "List the user's saved command snippets, including their folder and the command text. Unavailable if the snippets plugin is disabled.", + "List the user's saved command snippets, including their folder and the command text.", category: "read", + service: SERVICE.snippets, parameters: objectSchema({}), handler: async (_args, context) => { - const snippets = await listSnippets(context.userId); + const snippets = await readService( + context.deps.services, + SERVICE.snippets, + (snippetsAccess) => snippetsAccess.list(), + ); if (!snippets) return { unavailable: true, snippets: [] }; return { snippets: snippets.map((snippet) => ({ @@ -127,9 +105,14 @@ export const readTools: AiTool[] = [ description: "List the user's automations with their trigger kind and enabled state. Read this before proposing a change to an existing automation.", category: "read", + service: SERVICE.automations, parameters: objectSchema({}), handler: async (_args, context) => { - const automations = await listAutomations(context.userId); + const automations = await readService( + context.deps.services, + SERVICE.automations, + (automationsAccess) => automationsAccess.list(), + ); if (!automations) return { automations: [], unavailable: true }; return { automations: automations.map((automation) => ({ @@ -150,6 +133,7 @@ export const readTools: AiTool[] = [ description: "Get one automation's full definition (trigger and steps) by id.", category: "read", + service: SERVICE.automations, parameters: objectSchema( { automationId: num("The automation id, as returned by list_automations"), @@ -157,11 +141,15 @@ export const readTools: AiTool[] = [ ["automationId"], ), handler: async (args, context) => { - const automation = await getAutomation( - context.userId, - Number(args.automationId), + const result = await readService( + context.deps.services, + SERVICE.automations, + async (automationsAccess) => ({ + automation: await automationsAccess.get(Number(args.automationId)), + }), ); - if (automation === undefined) return { unavailable: true }; + if (result === null) return { unavailable: true }; + const { automation } = result; if (!automation) return { error: "Automation not found" }; return { automation: { @@ -178,9 +166,14 @@ export const readTools: AiTool[] = [ description: "List the user's saved workspace layouts (named sets of open tabs and splits).", category: "read", + service: SERVICE.workspaces, parameters: objectSchema({}), handler: async (_args, context) => { - const workspaces = await listSavedWorkspaces(context.userId); + const workspaces = await readService( + context.deps.services, + SERVICE.workspaces, + (saved) => saved.list(), + ); if (workspaces === null) return { workspaces: [], unavailable: true }; return { workspaces }; }, @@ -192,16 +185,13 @@ export const readTools: AiTool[] = [ category: "read", parameters: objectSchema({}), handler: async (_args, context) => { - const channels = - await createCurrentNotificationChannelRepository().listNotificationChannels( - context.userId, - ); + const channels = await context.deps.notify.channels(); return { channels: channels.map((channel) => ({ id: channel.id, name: channel.name, type: channel.type, - enabled: channel.enabled === 1, + enabled: channel.enabled, })), }; }, @@ -210,13 +200,17 @@ export const readTools: AiTool[] = [ name: "list_homepage_items", description: "List the user's homepage service-link tiles.", category: "read", + service: SERVICE.homepage, parameters: objectSchema({}), handler: async (_args, context) => { - const items = await createCurrentHomepageItemRepository().listByUserId( - context.userId, + const items = await readService( + context.deps.services, + SERVICE.homepage, + (homepage) => homepage.list(), ); + if (items === null) return { items: [], unavailable: true }; return { - items: (items as any[]).map((item) => ({ + items: items.map((item) => ({ id: item.id, typeId: item.typeId, title: item.title ?? null, @@ -229,6 +223,7 @@ export const readTools: AiTool[] = [ description: "Recent commands the user has run on one host, newest first. Useful for understanding what they have been working on.", category: "read", + service: SERVICE.history, parameters: objectSchema( { hostId: num("The host id, as returned by list_hosts"), @@ -240,14 +235,15 @@ export const readTools: AiTool[] = [ const limit = Math.min(Math.max(Number(args.limit) || 25, 1), 100); const hostId = Number(args.hostId); - // Ownership is enforced here rather than trusted from the model. - const host = await createCurrentHostRepository().findByIdForUser( - context.userId, - hostId, - ); + // Access is checked here rather than trusted from the model. + const host = await context.deps.hosts.get(hostId); if (!host) return { error: "Host not found" }; - const history = await listCommandHistory(context.userId, hostId, limit); + const history = await readService( + context.deps.services, + SERVICE.history, + (terminalHistory) => terminalHistory.list(hostId, limit), + ); if (history === null) return { commands: [], unavailable: true }; return { commands: history.map((entry) => entry.command) }; }, @@ -256,11 +252,16 @@ export const readTools: AiTool[] = [ name: "get_network_topology", description: "The user's saved network topology graph, if they have one.", category: "read", + service: SERVICE.topology, parameters: objectSchema({}), handler: async (_args, context) => { - const topology = await getNetworkTopology(context.userId); - if (topology === null) return { topology: null, unavailable: true }; - return { topology }; + const result = await readService( + context.deps.services, + SERVICE.topology, + async (graph) => ({ topology: await graph.get() }), + ); + if (result === null) return { topology: null, unavailable: true }; + return { topology: result.topology }; }, }, ]; diff --git a/plugins/ai/src/backend/tools/types.ts b/plugins/ai/src/backend/tools/types.ts index 34117dcff..91512de7f 100644 --- a/plugins/ai/src/backend/tools/types.ts +++ b/plugins/ai/src/backend/tools/types.ts @@ -1,17 +1,31 @@ +import type { PluginContext } from "@termix/plugin-sdk/backend"; + export type ToolCategory = "read" | "propose"; +/** What tools and the proposal executor reach core and other plugins through. */ +export type ToolDeps = Pick< + PluginContext, + "hosts" | "services" | "notify" | "rbac" | "ssh" +>; + export interface ToolContext { - /** Always taken from the verified JWT, never from model input. */ + /** Always the request's authenticated user, never from model input. */ userId: string; conversationId: number; /** Per-user opt-in for running allowlisted read-only commands. */ allowReadOnlyCommands: boolean; + deps: ToolDeps; } export interface AiTool { name: string; description: string; category: ToolCategory; + /** + * The service this tool needs. While no plugin provides it, the tool is + * not offered to the model at all. + */ + service?: string; /** JSON Schema for the arguments, sent to the provider verbatim. */ parameters: Record; /** diff --git a/plugins/ai/src/frontend/AiAssistantStep.tsx b/plugins/ai/src/frontend/AiAssistantStep.tsx index 6bc93e932..7004e2638 100644 --- a/plugins/ai/src/frontend/AiAssistantStep.tsx +++ b/plugins/ai/src/frontend/AiAssistantStep.tsx @@ -1,7 +1,7 @@ import { useEffect, useState } from "react"; import { Check } from "lucide-react"; import { useTranslation } from "@termix/plugin-sdk/frontend"; -import { setAssistantEnabledForUser } from "./settings/AiUserSettings"; +import { setAiOptIn } from "./ai-api"; /** * Asks once, plainly. @@ -17,7 +17,8 @@ export function AiAssistantStep() { function apply(next: boolean) { setEnabled(next); - setAssistantEnabledForUser(next); + // The local choice still shows if the write fails. + setAiOptIn(next).catch(() => undefined); } // Skipping the step entirely still has to mean off, not unset. diff --git a/plugins/ai/src/frontend/AiPanel.tsx b/plugins/ai/src/frontend/AiPanel.tsx index 937abdf6d..b7eb78749 100644 --- a/plugins/ai/src/frontend/AiPanel.tsx +++ b/plugins/ai/src/frontend/AiPanel.tsx @@ -1,20 +1,20 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { useTranslation } from "@termix/plugin-sdk/frontend"; import { Loader2, Send, Settings2, Sparkles, Square } from "lucide-react"; -import { Button } from "@/components/button"; -import { Textarea } from "@/components/textarea"; -import { saveUserPreferences } from "@/main-axios"; import { + Button, Select, SelectContent, SelectItem, SelectTrigger, SelectValue, -} from "@/components/select"; + Textarea, +} from "@termix/plugin-sdk/ui"; import { getAiConversation, getAiProviders, getAiStatus, + setAiOptIn, type AiProposal, type AiProvider, } from "./ai-api"; @@ -133,7 +133,7 @@ export function AiPanel({ activeTab }: { activeTab?: string | null }) { // 403s through the gate, which surfaces as a bare permission error // rather than anything the user can act on. if (status.globallyEnabled && !status.enabled) { - await saveUserPreferences({ aiAssistantEnabled: true }); + await setAiOptIn(true); } if (!status.globallyEnabled) return; diff --git a/plugins/ai/src/frontend/AiProviderSettings.tsx b/plugins/ai/src/frontend/AiProviderSettings.tsx index 92012640e..f2db036f6 100644 --- a/plugins/ai/src/frontend/AiProviderSettings.tsx +++ b/plugins/ai/src/frontend/AiProviderSettings.tsx @@ -1,18 +1,18 @@ -import { getErrorMessage } from "@/lib/error-message"; +import { getErrorMessage } from "./errors"; import { useCallback, useEffect, useState } from "react"; import { useTranslation } from "@termix/plugin-sdk/frontend"; import { toast } from "sonner"; import { Loader2, Pencil, Plus, RefreshCw, Trash2 } from "lucide-react"; -import { Button } from "@/components/button"; -import { Input } from "@/components/input"; -import { Label } from "@/components/label"; import { + Button, + Input, + Label, Select, SelectContent, SelectItem, SelectTrigger, SelectValue, -} from "@/components/select"; +} from "@termix/plugin-sdk/ui"; import { createAiProvider, deleteAiProvider, diff --git a/plugins/ai/src/frontend/ProposalCard.tsx b/plugins/ai/src/frontend/ProposalCard.tsx index f0e92515d..6f59e8949 100644 --- a/plugins/ai/src/frontend/ProposalCard.tsx +++ b/plugins/ai/src/frontend/ProposalCard.tsx @@ -1,9 +1,9 @@ -import { getErrorMessage } from "@/lib/error-message"; +import { getErrorMessage } from "./errors"; import { useState } from "react"; import { useTranslation } from "@termix/plugin-sdk/frontend"; import { toast } from "sonner"; import { Check, Loader2, TriangleAlert, X } from "lucide-react"; -import { Button } from "@/components/button"; +import { Button } from "@termix/plugin-sdk/ui"; import { applyAiProposal, rejectAiProposal, type AiProposal } from "./ai-api"; import { fieldLabel, toolLabel } from "./labels"; diff --git a/plugins/ai/src/frontend/ai-api.ts b/plugins/ai/src/frontend/ai-api.ts index 6841f218c..127bb14a3 100644 --- a/plugins/ai/src/frontend/ai-api.ts +++ b/plugins/ai/src/frontend/ai-api.ts @@ -1,4 +1,35 @@ -import { authApi, handleApiError } from "@/main-axios"; +import { aiApp } from "./app-ref"; + +/** Fired when the AI status may have changed, so every surface re-reads it. */ +export const AI_STATUS_CHANGED_EVENT = "termix-ai:status-changed"; + +export function notifyAiStatusChanged(): void { + window.dispatchEvent(new Event(AI_STATUS_CHANGED_EVENT)); +} + +/** The server's own message when it sent one, so the user sees why. */ +function apiError(error: unknown, action: string): Error { + const data = (error as { response?: { data?: { error?: unknown } } }) + ?.response?.data; + if (typeof data?.error === "string" && data.error) { + return new Error(data.error); + } + if (error instanceof Error && error.message) return error; + return new Error(`Failed to ${action}`); +} + +/* eslint-disable @typescript-eslint/no-explicit-any */ +// Response bodies are typed per function below, at the edge. +interface LooseClient { + get(url: string): Promise<{ data: any }>; + delete(url: string): Promise<{ data: any }>; + post(url: string, body?: unknown): Promise<{ data: any }>; + put(url: string, body?: unknown): Promise<{ data: any }>; + patch(url: string, body?: unknown): Promise<{ data: any }>; +} +/* eslint-enable @typescript-eslint/no-explicit-any */ + +const api = () => aiApp().api as unknown as LooseClient; export type AiProviderType = "ollama" | "anthropic" | "openai" | "gemini" | "openai_compatible"; @@ -52,17 +83,17 @@ export interface AiStatus { export async function getAiStatus(): Promise { try { - return (await authApi.get("/plugin-api/ai/status")).data; + return (await api().get("/status")).data; } catch (error) { - throw handleApiError(error, "get AI status"); + throw apiError(error, "get AI status"); } } export async function getAiProviders(): Promise { try { - return (await authApi.get("/plugin-api/ai/providers")).data.providers; + return (await api().get("/providers")).data.providers; } catch (error) { - throw handleApiError(error, "list AI providers"); + throw apiError(error, "list AI providers"); } } @@ -74,10 +105,9 @@ export async function createAiProvider(input: { defaultModel?: string | null; }): Promise { try { - return (await authApi.post("/plugin-api/ai/providers", input)).data - .provider; + return (await api().post("/providers", input)).data.provider; } catch (error) { - throw handleApiError(error, "create AI provider"); + throw apiError(error, "create AI provider"); } } @@ -92,18 +122,17 @@ export async function updateAiProvider( }>, ): Promise { try { - return (await authApi.patch(`/plugin-api/ai/providers/${id}`, input)).data - .provider; + return (await api().patch(`/providers/${id}`, input)).data.provider; } catch (error) { - throw handleApiError(error, "update AI provider"); + throw apiError(error, "update AI provider"); } } export async function deleteAiProvider(id: number): Promise { try { - await authApi.delete(`/plugin-api/ai/providers/${id}`); + await api().delete(`/providers/${id}`); } catch (error) { - throw handleApiError(error, "delete AI provider"); + throw apiError(error, "delete AI provider"); } } @@ -122,27 +151,25 @@ export async function probeAiModels(input: { warning?: string; }> { try { - return (await authApi.post("/plugin-api/ai/probe-models", input)).data; + return (await api().post("/probe-models", input)).data; } catch (error) { - throw handleApiError(error, "detect models"); + throw apiError(error, "detect models"); } } export async function getAiProviderModels(id: number): Promise { try { - return (await authApi.get(`/plugin-api/ai/providers/${id}/models`)).data - .models; + return (await api().get(`/providers/${id}/models`)).data.models; } catch (error) { - throw handleApiError(error, "list provider models"); + throw apiError(error, "list provider models"); } } export async function getAiConversations(): Promise { try { - return (await authApi.get("/plugin-api/ai/conversations")).data - .conversations; + return (await api().get("/conversations")).data.conversations; } catch (error) { - throw handleApiError(error, "list AI conversations"); + throw apiError(error, "list AI conversations"); } } @@ -152,17 +179,17 @@ export async function getAiConversation(id: number): Promise<{ proposals: AiProposal[]; }> { try { - return (await authApi.get(`/plugin-api/ai/conversations/${id}`)).data; + return (await api().get(`/conversations/${id}`)).data; } catch (error) { - throw handleApiError(error, "load AI conversation"); + throw apiError(error, "load AI conversation"); } } export async function deleteAiConversation(id: number): Promise { try { - await authApi.delete(`/plugin-api/ai/conversations/${id}`); + await api().delete(`/conversations/${id}`); } catch (error) { - throw handleApiError(error, "delete AI conversation"); + throw apiError(error, "delete AI conversation"); } } @@ -170,17 +197,17 @@ export async function applyAiProposal( id: number, ): Promise<{ success: boolean; summary: string }> { try { - return (await authApi.post(`/plugin-api/ai/proposals/${id}/apply`)).data; + return (await api().post(`/proposals/${id}/apply`)).data; } catch (error) { - throw handleApiError(error, "apply AI proposal"); + throw apiError(error, "apply AI proposal"); } } export async function rejectAiProposal(id: number): Promise { try { - await authApi.post(`/plugin-api/ai/proposals/${id}/reject`); + await api().post(`/proposals/${id}/reject`); } catch (error) { - throw handleApiError(error, "reject AI proposal"); + throw apiError(error, "reject AI proposal"); } } @@ -195,53 +222,23 @@ export async function markAiProposalRunInTerminal( ): Promise<{ success: boolean; summary: string }> { try { return ( - await authApi.post( - `/plugin-api/ai/proposals/${id}/mark-run-in-terminal`, - { - hostId, - summary, - }, - ) + await api().post(`/proposals/${id}/mark-run-in-terminal`, { + hostId, + summary, + }) ).data; } catch (error) { - throw handleApiError(error, "mark AI proposal run in terminal"); + throw apiError(error, "mark AI proposal run in terminal"); } } -// --- admin --- - -export async function getAiGloballyEnabled(): Promise { +/** Turns the assistant on or off for the current user. */ +export async function setAiOptIn(enabled: boolean): Promise { try { - return (await authApi.get("/plugin-api/ai/enabled")).data.enabled; + await api().put("/opt-in", { enabled }); } catch (error) { - throw handleApiError(error, "get AI enabled setting"); - } -} - -export async function setAiGloballyEnabled(enabled: boolean): Promise { - try { - return (await authApi.patch("/plugin-api/ai/enabled", { enabled })).data - .enabled; - } catch (error) { - throw handleApiError(error, "update AI enabled setting"); - } -} - -export async function getAiPrivateEndpoints(): Promise { - try { - return (await authApi.get("/plugin-api/ai/private-endpoints")).data.hosts; - } catch (error) { - throw handleApiError(error, "get AI endpoint allowlist"); - } -} - -export async function setAiPrivateEndpoints( - hosts: string[], -): Promise { - try { - return (await authApi.patch("/plugin-api/ai/private-endpoints", { hosts })) - .data.hosts; - } catch (error) { - throw handleApiError(error, "update AI endpoint allowlist"); + throw apiError(error, "update the AI setting"); + } finally { + notifyAiStatusChanged(); } } diff --git a/plugins/ai/src/frontend/app-ref.ts b/plugins/ai/src/frontend/app-ref.ts new file mode 100644 index 000000000..d7466492c --- /dev/null +++ b/plugins/ai/src/frontend/app-ref.ts @@ -0,0 +1,13 @@ +import type { TermixApp } from "@termix/plugin-sdk/frontend"; + +let current: TermixApp | null = null; + +/** Set in activate, cleared on deactivate. */ +export function setAiApp(app: TermixApp | null): void { + current = app; +} + +export function aiApp(): TermixApp { + if (!current) throw new Error("The AI plugin is not active"); + return current; +} diff --git a/plugins/ai/src/frontend/errors.ts b/plugins/ai/src/frontend/errors.ts new file mode 100644 index 000000000..3926398dd --- /dev/null +++ b/plugins/ai/src/frontend/errors.ts @@ -0,0 +1,4 @@ +/** A readable message from anything thrown, or the fallback. */ +export function getErrorMessage(error: unknown, fallback: string): string { + return error instanceof Error && error.message ? error.message : fallback; +} diff --git a/plugins/ai/src/frontend/index.tsx b/plugins/ai/src/frontend/index.tsx index f720f44b8..7308dd3b9 100644 --- a/plugins/ai/src/frontend/index.tsx +++ b/plugins/ai/src/frontend/index.tsx @@ -12,11 +12,12 @@ import type { import { AiPanel } from "./AiPanel"; import { TerminalAiPanel } from "./terminal/TerminalAiPanel"; import { AiAssistantStep } from "./AiAssistantStep"; -import { AiAccessSettings } from "./settings/AiAccessSettings"; -import { AI_RAIL_ID, AiUserSettings } from "./settings/AiUserSettings"; -import { getAiStatus } from "./ai-api"; -import { AI_STATUS_CHANGED_EVENT } from "./use-ai-availability"; +import { ProvidersSetting } from "./settings/ProvidersSetting"; +import { AI_STATUS_CHANGED_EVENT, getAiStatus } from "./ai-api"; +import { setAiApp } from "./app-ref"; +/** The rail id the assistant registers under. */ +const AI_RAIL_ID = "ai"; const ASSISTANT_ACTION = "ai.openWithContext"; const SIDE_PANEL_ID = "ai.assistant"; @@ -57,6 +58,9 @@ function TerminalSidePanel({ } export function activate(app: TermixApp): void { + setAiApp(app); + app.onDispose(() => setAiApp(null)); + let globallyEnabled = false; let userEnabled = false; const surface: (() => void)[] = []; @@ -99,10 +103,14 @@ export function activate(app: TermixApp): void { }), ); + // The per-host switch is this plugin's host setting. const offeredOnHost = (context: Record) => userEnabled && - (context.host as { enableAiAssistant?: boolean } | undefined) - ?.enableAiAssistant === true; + ( + context.host as + | { pluginSettings?: { ai?: { enableAiAssistant?: boolean } } } + | undefined + )?.pluginSettings?.ai?.enableAiAssistant === true; surface.push( app.registerSlotContribution("terminal.toolbar", { @@ -171,15 +179,23 @@ export function activate(app: TermixApp): void { { permission: "services.use" }, ); - app.registerSettingsComponent("access", AiAccessSettings); - app.registerSettingsComponent("assistant", AiUserSettings); + app.registerSettingsComponent("providers", ProvidersSetting); applyStatus(); refresh(); - window.addEventListener(AI_STATUS_CHANGED_EVENT, refresh); - window.addEventListener("hiddenRailTabsChanged", refresh); + // The admin switch and the user's own choice are settings, saved from + // the settings screen, so re-read when plugin state or focus changes too. + let lastFocusRefresh = Date.now(); + const onFocus = () => { + if (Date.now() - lastFocusRefresh < 30_000) return; + lastFocusRefresh = Date.now(); + refresh(); + }; + const events = [AI_STATUS_CHANGED_EVENT, "termix:plugins-changed"]; + for (const event of events) window.addEventListener(event, refresh); + window.addEventListener("focus", onFocus); app.onDispose(() => { - window.removeEventListener(AI_STATUS_CHANGED_EVENT, refresh); - window.removeEventListener("hiddenRailTabsChanged", refresh); + for (const event of events) window.removeEventListener(event, refresh); + window.removeEventListener("focus", onFocus); }); } diff --git a/plugins/ai/src/frontend/settings/AiAccessSettings.tsx b/plugins/ai/src/frontend/settings/AiAccessSettings.tsx deleted file mode 100644 index 866462e65..000000000 --- a/plugins/ai/src/frontend/settings/AiAccessSettings.tsx +++ /dev/null @@ -1,95 +0,0 @@ -import { useEffect, useState } from "react"; -import { useToast, useTranslation } from "@termix/plugin-sdk/frontend"; -import { FakeSwitch, Input, SettingRow } from "@termix/plugin-sdk/ui"; -import { - getAiGloballyEnabled, - getAiPrivateEndpoints, - setAiGloballyEnabled, - setAiPrivateEndpoints, -} from "../ai-api"; -import { notifyAiStatusChanged } from "../use-ai-availability"; - -/** - * The instance-wide switch and the private address allowlist. Stored in the - * assistant's own backend rather than plugin settings, so this saves on - * change instead of through the page's Save. - */ -export function AiAccessSettings() { - const { t } = useTranslation(); - const toast = useToast(); - const [enabled, setEnabled] = useState(false); - const [endpoints, setEndpoints] = useState(null); - - useEffect(() => { - let cancelled = false; - void Promise.allSettled([ - getAiGloballyEnabled(), - getAiPrivateEndpoints(), - ]).then(([enabledResult, endpointsResult]) => { - if (cancelled) return; - if (enabledResult.status === "fulfilled") setEnabled(enabledResult.value); - setEndpoints( - endpointsResult.status === "fulfilled" ? endpointsResult.value : [], - ); - }); - return () => { - cancelled = true; - }; - }, []); - - const toggle = async (next: boolean) => { - setEnabled(next); - try { - await setAiGloballyEnabled(next); - notifyAiStatusChanged(); - } catch { - setEnabled(!next); - toast.error(t("admin.updateAiEnabledFailed")); - } - }; - - const saveEndpoints = async (hosts: string[]) => { - const previous = endpoints ?? []; - setEndpoints(hosts); - try { - setEndpoints(await setAiPrivateEndpoints(hosts)); - } catch { - setEndpoints(previous); - toast.error(t("admin.updateAiEndpointsFailed")); - } - }; - - return ( -
- - void toggle(next)} /> - - {enabled && endpoints !== null && ( -
- - {t("admin.aiPrivateEndpoints")} - - - {t("admin.aiPrivateEndpointsDesc")} - - - void saveEndpoints( - event.target.value - .split(",") - .map((entry) => entry.trim()) - .filter(Boolean), - ) - } - /> -
- )} -
- ); -} diff --git a/plugins/ai/src/frontend/settings/AiUserSettings.tsx b/plugins/ai/src/frontend/settings/AiUserSettings.tsx deleted file mode 100644 index 1ed59557a..000000000 --- a/plugins/ai/src/frontend/settings/AiUserSettings.tsx +++ /dev/null @@ -1,112 +0,0 @@ -import { useEffect, useState } from "react"; -import { useTranslation } from "@termix/plugin-sdk/frontend"; -import { FakeSwitch, SettingRow } from "@termix/plugin-sdk/ui"; -import { saveUserPreferences } from "@/main-axios"; -import { readHiddenRailTabs } from "@/sidebar/hidden-rail-tabs"; -import { getAiStatus } from "../ai-api"; -import { notifyAiStatusChanged } from "../use-ai-availability"; - -/** The rail id the assistant registers under. */ -export const AI_RAIL_ID = "ai"; - -/** - * Turns the assistant on or off for one person. Off also hides its rail - * entry, so declining removes the feature from view rather than leaving an - * inert button behind. Stored in core user preferences, where it has always - * lived, so it roams with the rest of a user's settings. - */ -export function setAssistantEnabledForUser(enabled: boolean): void { - const hidden = readHiddenRailTabs(); - if (enabled) hidden.delete(AI_RAIL_ID); - else hidden.add(AI_RAIL_ID); - - const serialized = JSON.stringify([...hidden]); - localStorage.setItem("hiddenRailTabs", serialized); - window.dispatchEvent(new Event("hiddenRailTabsChanged")); - - saveUserPreferences({ - aiAssistantEnabled: enabled, - hiddenRailTabs: serialized, - }) - .catch(() => { - // The local choice still applies if the write fails. - }) - .finally(() => notifyAiStatusChanged()); -} - -export function AiUserSettings() { - const { t } = useTranslation(); - const [status, setStatus] = useState<{ - globallyEnabled: boolean; - enabled: boolean; - readOnly: boolean; - } | null>(null); - - useEffect(() => { - let cancelled = false; - getAiStatus() - .then((next) => { - if (!cancelled) { - setStatus({ - globallyEnabled: next.globallyEnabled, - enabled: next.enabled, - readOnly: next.allowReadOnlyCommands, - }); - } - }) - .catch(() => { - if (!cancelled) { - setStatus({ - globallyEnabled: false, - enabled: false, - readOnly: false, - }); - } - }); - return () => { - cancelled = true; - }; - }, []); - - // A user who cannot have the feature is not told about it. - if (!status?.globallyEnabled) { - return ( -

- {t("settings.assistantUnavailable")} -

- ); - } - - return ( -
- - { - setStatus({ ...status, enabled }); - setAssistantEnabledForUser(enabled); - }} - /> - - {status.enabled && ( - - { - setStatus({ ...status, readOnly }); - saveUserPreferences({ aiReadOnlyCommands: readOnly }).catch( - () => {}, - ); - }} - /> - - )} -
- ); -} diff --git a/plugins/ai/src/frontend/settings/ProvidersSetting.tsx b/plugins/ai/src/frontend/settings/ProvidersSetting.tsx new file mode 100644 index 000000000..28daa5e22 --- /dev/null +++ b/plugins/ai/src/frontend/settings/ProvidersSetting.tsx @@ -0,0 +1,59 @@ +import { useCallback, useEffect, useState } from "react"; +import { useSettings, useTranslation } from "@termix/plugin-sdk/frontend"; +import { AiProviderSettings } from "../AiProviderSettings"; +import { + AI_STATUS_CHANGED_EVENT, + getAiProviders, + getAiStatus, + type AiProvider, +} from "../ai-api"; + +/** + * The "providers" custom field in the plugin's user settings: a list with + * per-provider forms, which a schema field cannot express. + */ +export function ProvidersSetting() { + const { t } = useTranslation(); + const [status, setStatus] = useState<{ + globallyEnabled: boolean; + enabled: boolean; + } | null>(null); + const [providers, setProviders] = useState([]); + // Re-read when the "enabled" switch above this field is saved. + const optedIn = useSettings("user").values.enabled; + + const load = useCallback(async () => { + try { + const next = await getAiStatus(); + setStatus(next); + if (next.enabled) setProviders(await getAiProviders()); + } catch { + setStatus({ globallyEnabled: false, enabled: false }); + } + }, []); + + useEffect(() => { + void load(); + }, [load, optedIn]); + + useEffect(() => { + window.addEventListener(AI_STATUS_CHANGED_EVENT, load); + return () => window.removeEventListener(AI_STATUS_CHANGED_EVENT, load); + }, [load]); + + if (status === null) return null; + + if (!status.enabled) { + return ( +

+ {status.globallyEnabled + ? t("settings.providersNeedOptIn") + : t("settings.assistantUnavailable")} +

+ ); + } + + return ( + void load()} /> + ); +} diff --git a/plugins/ai/src/frontend/terminal/TerminalAiPanel.tsx b/plugins/ai/src/frontend/terminal/TerminalAiPanel.tsx index cca691cd5..da83cff05 100644 --- a/plugins/ai/src/frontend/terminal/TerminalAiPanel.tsx +++ b/plugins/ai/src/frontend/terminal/TerminalAiPanel.tsx @@ -7,7 +7,7 @@ import { } from "react"; import { useTranslation } from "@termix/plugin-sdk/frontend"; import { Bot, GripVertical, Send, Square, X } from "lucide-react"; -import { Button } from "@/components/button"; +import { Button } from "@termix/plugin-sdk/ui"; import { getAiProviders, type AiProposal, type AiProvider } from "../ai-api"; import { AiMessage } from "../AiMessage"; import { AiToolCall } from "../AiToolCall"; diff --git a/plugins/ai/src/frontend/terminal/TerminalProposalCard.tsx b/plugins/ai/src/frontend/terminal/TerminalProposalCard.tsx index d68efb45b..b4471863f 100644 --- a/plugins/ai/src/frontend/terminal/TerminalProposalCard.tsx +++ b/plugins/ai/src/frontend/terminal/TerminalProposalCard.tsx @@ -1,9 +1,9 @@ -import { getErrorMessage } from "@/lib/error-message"; +import { getErrorMessage } from "../errors"; import { useState } from "react"; import { useTranslation } from "@termix/plugin-sdk/frontend"; import { toast } from "sonner"; import { Check, Loader2, TriangleAlert, X } from "lucide-react"; -import { Button } from "@/components/button"; +import { Button } from "@termix/plugin-sdk/ui"; import { markAiProposalRunInTerminal, rejectAiProposal, diff --git a/plugins/ai/src/frontend/terminal/terminal-slot-types.ts b/plugins/ai/src/frontend/terminal/terminal-slot-types.ts index e1f9160ea..75f98ff1e 100644 --- a/plugins/ai/src/frontend/terminal/terminal-slot-types.ts +++ b/plugins/ai/src/frontend/terminal/terminal-slot-types.ts @@ -7,7 +7,7 @@ interface TerminalHost { id: number | string; name?: string; ip?: string; - enableAiAssistant?: boolean; + pluginSettings?: Record | undefined>; [key: string]: unknown; } diff --git a/plugins/ai/src/frontend/use-ai-availability.ts b/plugins/ai/src/frontend/use-ai-availability.ts deleted file mode 100644 index e34868716..000000000 --- a/plugins/ai/src/frontend/use-ai-availability.ts +++ /dev/null @@ -1,68 +0,0 @@ -import { useEffect, useState } from "react"; - -export interface AiAvailability { - /** Admin kill switch. When false the feature does not exist for anyone. */ - globallyEnabled: boolean; - /** Global switch and the user's own opt-in, which is what the rail needs. */ - userEnabled: boolean; - /** False until the status call answers, so nothing flashes in or out. */ - loaded: boolean; -} - -/** Fired when the AI status may have changed, so every surface re-reads it. */ -export const AI_STATUS_CHANGED_EVENT = "aiStatusChanged"; - -export function notifyAiStatusChanged(): void { - window.dispatchEvent(new Event(AI_STATUS_CHANGED_EVENT)); -} - -/** - * One source of truth for whether any AI surface should render. - * - * The admin global is a hard kill switch: when it is off the assistant is - * hidden everywhere for everyone, including the tab bar, command palette, - * mobile bar, and the navigation visibility toggles, so nobody can reach or - * even see an entry the server would refuse anyway. - */ -export function useAiAvailability(): AiAvailability { - const [state, setState] = useState({ - globallyEnabled: false, - userEnabled: false, - loaded: false, - }); - - useEffect(() => { - let cancelled = false; - const refresh = () => { - import("./ai-api") - .then(({ getAiStatus }) => getAiStatus()) - .then((status) => { - if (cancelled) return; - setState({ - globallyEnabled: status.globallyEnabled, - userEnabled: status.globallyEnabled && status.enabled, - loaded: true, - }); - }) - .catch(() => { - if (!cancelled) - setState({ - globallyEnabled: false, - userEnabled: false, - loaded: true, - }); - }); - }; - refresh(); - window.addEventListener(AI_STATUS_CHANGED_EVENT, refresh); - // The profile toggle and onboarding still fire this older event. - window.addEventListener("hiddenRailTabsChanged", refresh); - return () => { - cancelled = true; - window.removeEventListener(AI_STATUS_CHANGED_EVENT, refresh); - window.removeEventListener("hiddenRailTabsChanged", refresh); - }; - }, []); - - return state; -} diff --git a/plugins/ai/src/frontend/use-ai-stream.ts b/plugins/ai/src/frontend/use-ai-stream.ts index d251dbef4..cfeed6c4c 100644 --- a/plugins/ai/src/frontend/use-ai-stream.ts +++ b/plugins/ai/src/frontend/use-ai-stream.ts @@ -1,18 +1,8 @@ -import { getErrorMessage } from "@/lib/error-message"; +import { getErrorMessage } from "./errors"; import { useCallback, useRef, useState } from "react"; -import { authApi } from "@/main-axios"; +import { aiApp } from "./app-ref"; import type { AiProposal } from "./ai-api"; -/** - * Reuses whatever base authApi resolved to, so the stream follows the same - * dev proxy, Electron localhost and reverse-proxy base path rules as every - * other call instead of hardcoding an origin. - */ -function streamUrl(): string { - const base = (authApi.defaults.baseURL ?? "").replace(/\/+$/, ""); - return `${base}/ai/chat/stream`; -} - /** * Drives the chat stream. * @@ -86,10 +76,9 @@ export function useAiStream() { let toolSequence = 0; try { - const response = await fetch(streamUrl(), { + const response = await aiApp().fetch("chat/stream", { method: "POST", headers: { "Content-Type": "application/json" }, - credentials: "include", signal: controller.signal, body: JSON.stringify({ message: input.message, diff --git a/plugins/ai/src/frontend/useMentions.ts b/plugins/ai/src/frontend/useMentions.ts index 5b9d94a42..5d3e9475e 100644 --- a/plugins/ai/src/frontend/useMentions.ts +++ b/plugins/ai/src/frontend/useMentions.ts @@ -1,7 +1,5 @@ import { useCallback, useEffect, useMemo, useState } from "react"; -import { getSSHHosts } from "@/api/ssh-host-management-api"; -import { getSnippets } from "@/api/snippets-api"; -import { invokeAction } from "@termix/plugin-sdk/frontend"; +import { invokeAction, useHosts } from "@termix/plugin-sdk/frontend"; /** * Backs the @-mention picker in the composer. @@ -39,11 +37,14 @@ export function activeMentionQuery( return { query, start: at }; } -/** The automations plugin's list, or nothing when it is not running. */ -async function listAutomationsForMentions(): Promise< - { id: number; name: string }[] -> { - const result = await invokeAction("automations.list"); +/** + * Another plugin's list through its action, or nothing when it is not + * running, so the assistant works without it and never imports its code. + */ +async function listFromAction( + actionId: string, +): Promise<{ id: number; name: string }[]> { + const result = await invokeAction(actionId); return Array.isArray(result) ? (result as { id: number; name: string }[]) : []; @@ -51,35 +52,28 @@ async function listAutomationsForMentions(): Promise< export function useMentions(enabled: boolean) { const [items, setItems] = useState([]); + const { hosts } = useHosts(); useEffect(() => { if (!enabled) return; let cancelled = false; (async () => { - const [hosts, snippets, automations] = await Promise.allSettled([ - getSSHHosts(), - getSnippets(), - // Through the automations plugin's action, so the assistant works - // without it and never imports its code. - listAutomationsForMentions(), + const [snippets, automations] = await Promise.allSettled([ + listFromAction("snippets.list"), + listFromAction("automations.list"), ]); if (cancelled) return; - const collected: MentionItem[] = []; + const collected: MentionItem[] = hosts.map((host) => ({ + kind: "host" as const, + id: Number(host.id), + label: host.name || host.ip, + detail: host.ip, + })); - if (hosts.status === "fulfilled") { - for (const host of (hosts.value ?? []) as any[]) { - collected.push({ - kind: "host", - id: host.id, - label: host.name || host.ip, - detail: host.ip, - }); - } - } if (snippets.status === "fulfilled") { - for (const snippet of (snippets.value ?? []) as any[]) { + for (const snippet of snippets.value) { collected.push({ kind: "snippet", id: snippet.id, @@ -88,7 +82,7 @@ export function useMentions(enabled: boolean) { } } if (automations.status === "fulfilled") { - for (const automation of (automations.value ?? []) as any[]) { + for (const automation of automations.value) { collected.push({ kind: "automation", id: automation.id, @@ -103,7 +97,7 @@ export function useMentions(enabled: boolean) { return () => { cancelled = true; }; - }, [enabled]); + }, [enabled, hosts]); const search = useCallback( (query: string): MentionItem[] => { diff --git a/plugins/ai/tests/backend/activate.test.ts b/plugins/ai/tests/backend/activate.test.ts new file mode 100644 index 000000000..fbe040cec --- /dev/null +++ b/plugins/ai/tests/backend/activate.test.ts @@ -0,0 +1,73 @@ +import express from "express"; +import { afterEach, describe, expect, it } from "vitest"; +import { PluginCapabilityError } from "@termix/plugin-sdk/backend"; +import { + createMockCtx, + createTestDb, + type TestDb, +} from "@termix/plugin-sdk/testing"; +import { activate } from "../../src/backend/index.js"; +import { manifest, pluginDir, startServer, type TestServer } from "./helpers"; + +let db: TestDb | null = null; +let server: TestServer | null = null; + +afterEach(async () => { + db?.close(); + db = null; + await server?.close(); + server = null; +}); + +describe("ai activate", () => { + it.each(["db:own", "network:serve"])( + "fails closed without %s", + async (capability) => { + db = await createTestDb(pluginDir); + const mock = createMockCtx({ + pluginId: manifest.id, + manifest, + capabilities: manifest.capabilities.filter((c) => c !== capability), + db: db.database, + router: () => express.Router(), + }); + await expect(activate(mock.ctx)).rejects.toBeInstanceOf( + PluginCapabilityError, + ); + }, + ); + + it("cannot store a provider key without secrets:own", async () => { + db = await createTestDb(pluginDir); + const mock = createMockCtx({ + pluginId: manifest.id, + manifest, + capabilities: manifest.capabilities.filter((c) => c !== "secrets:own"), + db: db.database, + router: () => express.Router(), + }); + await activate(mock.ctx); + await expect( + mock.ctx.secrets.set("provider:1", "k"), + ).rejects.toBeInstanceOf(PluginCapabilityError); + }); + + it("offers the api key and the host import normalizer", async () => { + server = await startServer(); + await server.enableFor("user-1"); + await server.request("POST", "/providers", { + body: { providerType: "openai", label: "Work", apiKey: "sk-shared" }, + }); + + const normalizer = server.mock.ctx.registry.consume< + (raw: Record) => Record | null + >("ai.hostImportNormalizer"); + expect(normalizer?.({ enableAiAssistant: true })).toEqual({ + enableAiAssistant: true, + }); + expect( + normalizer?.({ pluginSettings: { ai: { enableAiAssistant: false } } }), + ).toEqual({ enableAiAssistant: false }); + expect(normalizer?.({ name: "x" })).toBeNull(); + }); +}); diff --git a/plugins/ai/tests/backend/command-history-service.test.ts b/plugins/ai/tests/backend/command-history-service.test.ts index 426eedfce..0c3e57a26 100644 --- a/plugins/ai/tests/backend/command-history-service.test.ts +++ b/plugins/ai/tests/backend/command-history-service.test.ts @@ -1,39 +1,60 @@ -import { afterEach, describe, expect, it, vi } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { - listCommandHistory, - setPluginServices, + readService, + requireService, + serviceAvailable, + SERVICE, } from "../../src/backend/services.js"; -afterEach(() => setPluginServices(null)); +function services(provided: Record) { + return { + providers: (service: string) => (service in provided ? [""] : []), + get: vi.fn((service: string) => provided[service]), + } as never; +} -describe("listCommandHistory", () => { +describe("optional services", () => { it("reads the user's history through terminal.history", async () => { const list = vi.fn(async () => [ { command: "ls", executedAt: "2026-01-01T00:00:00.000Z" }, ]); - const get = vi.fn(() => ({ list })); - setPluginServices({ get, provide: vi.fn() } as never); + const history = await readService( + services({ [SERVICE.history]: { list } }), + SERVICE.history, + (terminal) => terminal.list(4, 25), + ); - const history = await listCommandHistory("user-1", 4, 25); - - expect(get).toHaveBeenCalledWith("terminal.history", { userId: "user-1" }); expect(list).toHaveBeenCalledWith(4, 25); expect(history).toEqual([ { command: "ls", executedAt: "2026-01-01T00:00:00.000Z" }, ]); }); - it("answers null while the terminal is off or refuses the user", async () => { - expect(await listCommandHistory("user-1", 4, 25)).toBeNull(); + it("answers null while the provider is off or refuses the user", async () => { + expect( + await readService(services({}), SERVICE.history, (terminal) => + terminal.list(4, 25), + ), + ).toBeNull(); - setPluginServices({ - get: () => ({ - list: async () => { - throw new Error("unavailable"); - }, - }), - provide: vi.fn(), - } as never); - expect(await listCommandHistory("user-1", 4, 25)).toBeNull(); + const refusing = { + list: async () => { + throw new Error("Missing permission"); + }, + }; + expect( + await readService( + services({ [SERVICE.history]: refusing }), + SERVICE.history, + (terminal) => terminal.list(4, 25), + ), + ).toBeNull(); + }); + + it("says which plugin is missing when a write needs it", () => { + expect(serviceAvailable(services({}), SERVICE.fleets)).toBe(false); + expect(() => requireService(services({}), SERVICE.fleets)).toThrow( + "The fleets plugin is not available", + ); }); }); diff --git a/plugins/ai/tests/backend/egress.test.ts b/plugins/ai/tests/backend/egress.test.ts index 9d4ab5cd6..551aaa611 100644 --- a/plugins/ai/tests/backend/egress.test.ts +++ b/plugins/ai/tests/backend/egress.test.ts @@ -101,22 +101,22 @@ describe("evaluateEgress", () => { }); describe("parseAllowlist", () => { - it("falls back to the defaults when unset or malformed", () => { + it("falls back to the defaults when the setting is not text", () => { expect(parseAllowlist(null)).toEqual(DEFAULT_PRIVATE_ALLOWLIST); - expect(parseAllowlist("not json")).toEqual(DEFAULT_PRIVATE_ALLOWLIST); - expect(parseAllowlist('{"a":1}')).toEqual(DEFAULT_PRIVATE_ALLOWLIST); + expect(parseAllowlist(undefined)).toEqual(DEFAULT_PRIVATE_ALLOWLIST); }); - it("normalises stored entries", () => { - expect(parseAllowlist('[" LocalHost ", "", "10.0.0.5"]')).toEqual([ - "localhost", - "10.0.0.5", - ]); + it("reads one host per line, normalised, skipping anything that is not a host", () => { + expect( + parseAllowlist( + [" LocalHost ", "", "10.0.0.5", "http://x/y", "10.0.0.5"].join("\n"), + ), + ).toEqual(["localhost", "10.0.0.5"]); }); it("honours a deliberately empty allowlist", () => { // An admin clearing the list must actually block everything private, // not silently get the defaults back. - expect(parseAllowlist("[]")).toEqual([]); + expect(parseAllowlist("")).toEqual([]); }); }); diff --git a/plugins/ai/tests/backend/engine.test.ts b/plugins/ai/tests/backend/engine.test.ts index 13a32ad7b..77beaefad 100644 --- a/plugins/ai/tests/backend/engine.test.ts +++ b/plugins/ai/tests/backend/engine.test.ts @@ -8,22 +8,6 @@ vi.mock("../../src/backend/providers/registry.js", () => ({ getAdapter: () => ({ streamChat, listModels: async () => [] }), })); -vi.mock("../../src/backend/tools/catalog.js", () => ({ - getTool: (name: string) => - name === "list_hosts" - ? { - name: "list_hosts", - description: "List hosts", - category: "read", - parameters: { type: "object", properties: {} }, - handler, - } - : undefined, - toolDefinitions: () => [ - { name: "list_hosts", description: "List hosts", parameters: {} }, - ], -})); - const { runAgent } = await import("../../src/backend/engine.js"); function chunks(...values: ChatChunk[]) { @@ -33,14 +17,25 @@ function chunks(...values: ChatChunk[]) { } const BASE = { - config: { providerType: "ollama" as const }, + config: { providerType: "ollama" as const, fetch: vi.fn() }, model: "test", system: "system", context: { userId: "user-1", conversationId: 1, allowReadOnlyCommands: false, + deps: {} as never, }, + // Only what was offered can run, so this is the whole catalog here. + tools: [ + { + name: "list_hosts", + description: "List hosts", + category: "read" as const, + parameters: { type: "object", properties: {} }, + handler, + }, + ], }; async function collect(history: any[] = []) { diff --git a/plugins/ai/tests/backend/gating.test.ts b/plugins/ai/tests/backend/gating.test.ts index e1939af96..a7c5d4df5 100644 --- a/plugins/ai/tests/backend/gating.test.ts +++ b/plugins/ai/tests/backend/gating.test.ts @@ -1,94 +1,127 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { describe, expect, it, vi } from "vitest"; +import { + createAiGate, + isAiGloballyEnabled, + readPrivateAllowlist, + resolveAiAccess, +} from "../../src/backend/gating.js"; +import { DEFAULT_PRIVATE_ALLOWLIST } from "../../src/backend/egress.js"; -const settingsRepository = { getBoolean: vi.fn() }; -const userPreferenceRepository = { findByUserId: vi.fn() }; - -vi.mock("../../../../src/backend/database/repositories/factory.js", () => ({ - createCurrentSettingsRepository: () => settingsRepository, - createCurrentUserPreferenceRepository: () => userPreferenceRepository, -})); - -const { isAiGloballyEnabled, resolveAiAccess } = - await import("../../src/backend/gating.js"); +/** The plugin's settings as ctx.settings reads them: admin, then per user. */ +function settings( + admin: Record, + user: Record = {}, +) { + return { + get: vi.fn(async (key: string) => admin[key]) as never, + getUser: vi.fn(async (_userId: string, key: string) => user[key]) as never, + }; +} describe("AI gating", () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - it("defaults to off so upgrading an install enables nothing", async () => { - settingsRepository.getBoolean.mockResolvedValue(false); - await isAiGloballyEnabled(); - expect(settingsRepository.getBoolean).toHaveBeenCalledWith( - "ai_globally_enabled", - false, + it("is off until an admin turns it on, so an upgrade enables nothing", async () => { + expect(await isAiGloballyEnabled(settings({}))).toBe(false); + expect(await isAiGloballyEnabled(settings({ globallyEnabled: true }))).toBe( + true, ); }); - it("blocks everyone when the admin global is off", async () => { - settingsRepository.getBoolean.mockResolvedValue(false); - userPreferenceRepository.findByUserId.mockResolvedValue({ - aiAssistantEnabled: true, - aiReadOnlyCommands: true, - }); + it("blocks everyone when the admin switch is off", async () => { + const reader = settings( + { globallyEnabled: false }, + { enabled: true, allowReadOnlyCommands: true }, + ); - const access = await resolveAiAccess("user-1"); + const access = await resolveAiAccess(reader, "user-1"); + expect(access).toEqual({ enabled: false, allowReadOnlyCommands: false }); + // The kill switch short-circuits, so the user's choice is never read. + expect(reader.getUser).not.toHaveBeenCalled(); + }); + + it("treats a user who never chose as not enabled", async () => { + const access = await resolveAiAccess( + settings({ globallyEnabled: true }), + "user-1", + ); expect(access.enabled).toBe(false); - expect(access.allowReadOnlyCommands).toBe(false); - // The kill switch short-circuits, so the preference is never consulted. - expect(userPreferenceRepository.findByUserId).not.toHaveBeenCalled(); - }); - - it("blocks a user who has not enabled it", async () => { - settingsRepository.getBoolean.mockResolvedValue(true); - userPreferenceRepository.findByUserId.mockResolvedValue({ - aiAssistantEnabled: false, - }); - - expect((await resolveAiAccess("user-1")).enabled).toBe(false); - }); - - it("treats never-asked as not enabled", async () => { - // Null means the user was never shown the choice, which is not consent. - settingsRepository.getBoolean.mockResolvedValue(true); - userPreferenceRepository.findByUserId.mockResolvedValue({ - aiAssistantEnabled: null, - }); - - expect((await resolveAiAccess("user-1")).enabled).toBe(false); - }); - - it("treats a missing preference row as not enabled", async () => { - settingsRepository.getBoolean.mockResolvedValue(true); - userPreferenceRepository.findByUserId.mockResolvedValue(null); - - expect((await resolveAiAccess("user-1")).enabled).toBe(false); }); it("allows only when both gates are open", async () => { - settingsRepository.getBoolean.mockResolvedValue(true); - userPreferenceRepository.findByUserId.mockResolvedValue({ - aiAssistantEnabled: true, - aiReadOnlyCommands: true, - }); - - const access = await resolveAiAccess("user-1"); - - expect(access.enabled).toBe(true); - expect(access.allowReadOnlyCommands).toBe(true); + const access = await resolveAiAccess( + settings( + { globallyEnabled: true }, + { enabled: true, allowReadOnlyCommands: true }, + ), + "user-1", + ); + expect(access).toEqual({ enabled: true, allowReadOnlyCommands: true }); }); it("keeps read-only commands off unless separately opted in", async () => { - settingsRepository.getBoolean.mockResolvedValue(true); - userPreferenceRepository.findByUserId.mockResolvedValue({ - aiAssistantEnabled: true, - aiReadOnlyCommands: null, - }); - - const access = await resolveAiAccess("user-1"); - - expect(access.enabled).toBe(true); + const access = await resolveAiAccess( + settings({ globallyEnabled: true }, { enabled: true }), + "user-1", + ); expect(access.allowReadOnlyCommands).toBe(false); }); + + it("reads the private endpoint list, with defaults when unset", async () => { + expect(await readPrivateAllowlist(settings({}))).toEqual( + DEFAULT_PRIVATE_ALLOWLIST, + ); + expect( + await readPrivateAllowlist( + settings({ privateEndpoints: "ollama.lan\n10.0.0.5" }), + ), + ).toEqual(["ollama.lan", "10.0.0.5"]); + }); +}); + +describe("createAiGate", () => { + function response() { + const res = { + statusCode: 200, + body: undefined as unknown, + status(code: number) { + res.statusCode = code; + return { json: (body: unknown) => (res.body = body) }; + }, + }; + return res; + } + + it("answers 401 without a user and 403 while the assistant is off", async () => { + const next = vi.fn(); + const noUser = response(); + await createAiGate(settings({ globallyEnabled: true }), () => undefined)( + {}, + noUser, + next, + ); + expect(noUser.statusCode).toBe(401); + + const off = response(); + await createAiGate(settings({ globallyEnabled: false }), () => "user-1")( + {}, + off, + next, + ); + expect(off.statusCode).toBe(403); + expect(next).not.toHaveBeenCalled(); + }); + + it("passes an opted-in user through with their access attached", async () => { + const next = vi.fn(); + const req: { aiAccess?: unknown } = {}; + await createAiGate( + settings({ globallyEnabled: true }, { enabled: true }), + () => "user-1", + )(req, response(), next); + expect(next).toHaveBeenCalledWith(); + expect(req.aiAccess).toEqual({ + enabled: true, + allowReadOnlyCommands: false, + }); + }); }); diff --git a/plugins/ai/tests/backend/helpers.ts b/plugins/ai/tests/backend/helpers.ts new file mode 100644 index 000000000..80dd6ec23 --- /dev/null +++ b/plugins/ai/tests/backend/helpers.ts @@ -0,0 +1,181 @@ +import http from "node:http"; +import type { AddressInfo } from "node:net"; +import { fileURLToPath } from "node:url"; +import express, { type Router } from "express"; +import { + createMockCtx, + createTestDb, + type MockPluginContext, + type TestDb, +} from "@termix/plugin-sdk/testing"; +import type { PluginManifest } from "@termix/plugin-sdk/manifest"; +import manifestJson from "../../manifest.json"; + +export const pluginDir = fileURLToPath(new URL("../..", import.meta.url)); +export const manifest = manifestJson as unknown as PluginManifest; + +export const ALL_PERMISSIONS = [ + "ai.use", + "ai.manage_providers", + "ai.apply_proposals", +]; + +export interface TestServer { + db: TestDb; + mock: MockPluginContext; + request: ( + method: string, + path: string, + options?: { user?: string; body?: unknown }, + // Route bodies vary per test; asserting on them is the point. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ) => Promise<{ status: number; body: any }>; + /** Turns the assistant on for the server and opts `userId` in. */ + enableFor: (userId: string) => Promise; + close: () => Promise; +} + +/** + * The plugin activated against a real in-memory database, its router mounted + * on an express app the way core mounts it: JSON parsed, the acting user set + * from the request, everything else left to the plugin. + */ +export async function startServer( + options: { + permissions?: string[]; + services?: Record; + fetch?: (url: string, init?: unknown) => Promise; + } = {}, +): Promise { + const db = await createTestDb(pluginDir); + for (const user of ["user-1", "user-2"]) { + db.sqlite + .prepare("INSERT INTO users (id, username) VALUES (?, ?)") + .run(user, user); + } + + let router: Router | null = null; + const mock = createMockCtx({ + pluginId: manifest.id, + manifest, + capabilities: manifest.capabilities, + db: db.database, + router: () => (router = express.Router()), + permissions: options.permissions ?? ALL_PERMISSIONS, + services: options.services, + fetch: options.fetch as never, + hosts: [ + { + id: 1, + userId: "user-1", + name: "web-1", + ip: "10.0.0.1", + port: 22, + username: "root", + tags: "prod", + folder: null, + authType: "password", + }, + ], + }); + + const { activate } = await import("../../src/backend/index.js"); + await activate(mock.ctx); + + const app = express(); + app.use(express.json()); + app.use((req, _res, next) => { + mock.setActor(req.header("x-test-user") ?? undefined); + next(); + }); + app.use((req, res, next) => router!(req, res, next)); + + const server = http.createServer(app); + await new Promise((resolve) => server.listen(0, resolve)); + const { port } = server.address() as AddressInfo; + + return { + db, + mock, + async request(method, path, { user = "user-1", body: given } = {}) { + const body = method === "GET" ? undefined : given; + const response = await fetch(`http://127.0.0.1:${port}${path}`, { + method, + headers: { + "x-test-user": user, + ...(body !== undefined ? { "content-type": "application/json" } : {}), + }, + body: body !== undefined ? JSON.stringify(body) : undefined, + }); + const text = await response.text(); + let parsed: unknown = null; + try { + parsed = text ? JSON.parse(text) : null; + } catch { + parsed = text; + } + return { status: response.status, body: parsed }; + }, + async enableFor(userId) { + await mock.ctx.settings.set("globallyEnabled", true); + await mock.ctx.settings.setUser(userId, "enabled", true); + }, + async close() { + await new Promise((resolve) => server.close(() => resolve())); + db.close(); + }, + }; +} + +// The four tables as core's SQLite bootstrap created them before 2.9.0, with +// the indexes performance-indexes.ts added. +export const LEGACY_DDL = ` + CREATE TABLE 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) + ); + CREATE TABLE 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 + ); + CREATE TABLE 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 + ); + CREATE TABLE 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 + ); + CREATE UNIQUE INDEX idx_ai_providers_user_label ON ai_providers (user_id, label); + CREATE INDEX idx_ai_conversations_user ON ai_conversations (user_id, updated_at); + CREATE INDEX idx_ai_messages_conversation ON ai_messages (conversation_id, created_at); + CREATE INDEX idx_ai_proposals_user ON ai_proposals (user_id, status); + CREATE INDEX idx_ai_proposals_conversation ON ai_proposals (conversation_id); +`; diff --git a/plugins/ai/tests/backend/migrations.test.ts b/plugins/ai/tests/backend/migrations.test.ts new file mode 100644 index 000000000..9b7c3bf90 --- /dev/null +++ b/plugins/ai/tests/backend/migrations.test.ts @@ -0,0 +1,118 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { createTestDb, type TestDb } from "@termix/plugin-sdk/testing"; +import { LEGACY_DDL, pluginDir } from "./helpers"; + +let db: TestDb | null = null; + +afterEach(() => { + db?.close(); + db = null; +}); + +const tables = (sqlite: TestDb["sqlite"]) => + ( + sqlite + .prepare( + "SELECT name FROM sqlite_master WHERE type = 'table' AND name LIKE '%ai_%' ORDER BY name", + ) + .all() as Array<{ name: string }> + ).map((row) => row.name); + +const indexes = (sqlite: TestDb["sqlite"], table: string) => + ( + sqlite + .prepare( + "SELECT name FROM sqlite_master WHERE type = 'index' AND tbl_name = ? AND name LIKE 'idx_%' ORDER BY name", + ) + .all(table) as Array<{ name: string }> + ).map((row) => row.name); + +describe("ai adoption migration", () => { + it("renames the legacy tables and keeps every row, index and cascade", async () => { + db = await createTestDb(pluginDir, { + before: (sqlite) => { + sqlite.exec(LEGACY_DDL); + sqlite + .prepare("INSERT INTO users (id, username) VALUES ('u1', 'u1')") + .run(); + sqlite.exec(` + INSERT INTO ai_providers (id, user_id, provider_type, label, api_key, api_key_prefix) + VALUES (3, 'u1', 'ollama', 'Local', 'enc-key', 'abc123'); + INSERT INTO ai_conversations (id, user_id, title) VALUES (5, 'u1', 'Disk'); + INSERT INTO ai_messages (conversation_id, role, content) VALUES (5, 'user', 'why full'); + INSERT INTO ai_proposals (conversation_id, user_id, kind) VALUES (5, 'u1', 'propose_run_command'); + `); + }, + }); + + expect(tables(db.sqlite)).toEqual([ + "p_ai_conversations", + "p_ai_messages", + "p_ai_proposals", + "p_ai_providers", + ]); + + const provider = db.sqlite + .prepare("SELECT * FROM p_ai_providers WHERE id = 3") + .get() as Record; + // The key stays until core's boot migration moves it into ctx.secrets. + expect(provider).toMatchObject({ label: "Local", api_key: "enc-key" }); + + expect(indexes(db.sqlite, "p_ai_providers")).toEqual([ + "idx_ai_providers_user_label", + ]); + expect(indexes(db.sqlite, "p_ai_proposals")).toEqual([ + "idx_ai_proposals_conversation", + "idx_ai_proposals_user", + ]); + + // The rename carried the foreign keys: a conversation still takes its + // messages and proposals with it. + db.sqlite.prepare("DELETE FROM p_ai_conversations WHERE id = 5").run(); + const count = (table: string) => + ( + db!.sqlite.prepare(`SELECT COUNT(*) AS n FROM ${table}`).get() as { + n: number; + } + ).n; + expect(count("p_ai_messages")).toBe(0); + expect(count("p_ai_proposals")).toBe(0); + }); + + it("creates fresh tables when there is nothing to adopt", async () => { + db = await createTestDb(pluginDir); + + expect(tables(db.sqlite)).toEqual([ + "p_ai_conversations", + "p_ai_messages", + "p_ai_proposals", + "p_ai_providers", + ]); + const columns = ( + db.sqlite.prepare("PRAGMA table_info(p_ai_providers)").all() as Array<{ + name: string; + }> + ).map((column) => column.name); + expect(columns).not.toContain("api_key"); + + db.sqlite + .prepare("INSERT INTO users (id, username) VALUES ('u1', 'u1')") + .run(); + db.sqlite + .prepare("INSERT INTO p_ai_conversations (id, user_id) VALUES (1, 'u1')") + .run(); + db.sqlite + .prepare( + "INSERT INTO p_ai_messages (conversation_id, role) VALUES (1, 'user')", + ) + .run(); + db.sqlite.prepare("DELETE FROM p_ai_conversations WHERE id = 1").run(); + expect( + ( + db.sqlite.prepare("SELECT COUNT(*) AS n FROM p_ai_messages").get() as { + n: number; + } + ).n, + ).toBe(0); + }); +}); diff --git a/plugins/ai/tests/backend/providers/provider-http.test.ts b/plugins/ai/tests/backend/providers/provider-http.test.ts index 1fd571536..4cf7ba642 100644 --- a/plugins/ai/tests/backend/providers/provider-http.test.ts +++ b/plugins/ai/tests/backend/providers/provider-http.test.ts @@ -1,49 +1,64 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { describe, expect, it, vi } from "vitest"; +import { createProviderFetch } from "../../../src/backend/providers/http.js"; +import { PRIVATE_DESTINATION_MESSAGE } from "../../../src/backend/egress.js"; -const fetchWithProxy = vi.fn(); -const getFetchDispatcher = vi.fn(); -const safeOutboundFetch = vi.fn(); -const readPrivateAllowlist = vi.fn(); -const evaluateEgress = vi.fn(); -const globalFetch = vi.fn(); - -vi.mock("../../../../../src/backend/utils/proxy-agent.js", () => ({ - fetchWithProxy, - getFetchDispatcher, -})); -vi.mock("../../../../../src/backend/utils/safe-outbound-fetch.js", () => ({ - safeOutboundFetch, -})); -vi.mock("../../../src/backend/egress.js", () => ({ - readPrivateAllowlist, - evaluateEgress, -})); - -const { providerFetch } = - await import("../../../src/backend/providers/http.js"); - -describe("providerFetch", () => { - beforeEach(() => { - vi.clearAllMocks(); - vi.stubGlobal("fetch", globalFetch); - readPrivateAllowlist.mockResolvedValue(["192.168.1.50"]); - }); - - it("uses the matching Undici fetch implementation for private providers", async () => { - const response = { ok: true, status: 200 } as Response; - const init = { method: "GET" }; - evaluateEgress.mockReturnValue({ allowed: true, isPrivate: true }); - fetchWithProxy.mockResolvedValue(response); +describe("createProviderFetch", () => { + it("sends provider requests through ctx.fetch with the admin allowlist", async () => { + const response = new Response("{}"); + const fetch = vi.fn(async () => response); + const controller = new AbortController(); + const providerFetch = createProviderFetch(fetch, ["192.168.1.50"]); await expect( - providerFetch("http://192.168.1.50:11434/api/tags", init), + providerFetch("http://192.168.1.50:11434/api/chat", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: "{}", + signal: controller.signal, + }), ).resolves.toBe(response); - expect(fetchWithProxy).toHaveBeenCalledWith( - "http://192.168.1.50:11434/api/tags", - init, + expect(fetch).toHaveBeenCalledWith( + "http://192.168.1.50:11434/api/chat", + expect.objectContaining({ + method: "POST", + headers: { "content-type": "application/json" }, + body: "{}", + signal: controller.signal, + allowPrivateHosts: ["192.168.1.50"], + }), ); - expect(globalFetch).not.toHaveBeenCalled(); - expect(safeOutboundFetch).not.toHaveBeenCalled(); + }); + + it("refuses a private address the admin has not allowed, before any request", async () => { + const fetch = vi.fn(); + const providerFetch = createProviderFetch(fetch, ["localhost"]); + + await expect( + providerFetch("http://10.0.0.9:11434/api/tags", { method: "GET" }), + ).rejects.toThrow(PRIVATE_DESTINATION_MESSAGE); + expect(fetch).not.toHaveBeenCalled(); + }); + + it("explains a hostname that resolved to a private address", async () => { + const fetch = vi.fn(async () => { + throw new Error("Private destinations are not allowed"); + }); + const providerFetch = createProviderFetch(fetch, []); + + await expect( + providerFetch("https://ollama.example.com/api/tags", { method: "GET" }), + ).rejects.toThrow(PRIVATE_DESTINATION_MESSAGE); + }); + + it("passes other failures through unchanged", async () => { + const fetch = vi.fn(async () => { + throw new Error("socket hang up"); + }); + const providerFetch = createProviderFetch(fetch, []); + + await expect( + providerFetch("https://api.openai.com/v1/models", { method: "GET" }), + ).rejects.toThrow("socket hang up"); }); }); diff --git a/plugins/ai/tests/backend/providers/providers.test.ts b/plugins/ai/tests/backend/providers/providers.test.ts index 4a982f712..e2d6113e6 100644 --- a/plugins/ai/tests/backend/providers/providers.test.ts +++ b/plugins/ai/tests/backend/providers/providers.test.ts @@ -1,15 +1,9 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import type { ChatChunk } from "../../../src/backend/providers/types.js"; +// Adapters get the egress-checked fetch through their config. const providerFetch = vi.fn(); -vi.mock("../../../src/backend/providers/http.js", async () => { - const actual = await vi.importActual< - typeof import("../../../src/backend/providers/http.js") - >("../../../src/backend/providers/http.js"); - return { ...actual, providerFetch }; -}); - const { openAiAdapter } = await import("../../../src/backend/providers/openai.js"); const { ollamaAdapter } = @@ -69,7 +63,10 @@ describe("openAiAdapter", () => { ); const chunks = await collect( - openAiAdapter.streamChat({ providerType: "openai" }, REQUEST), + openAiAdapter.streamChat( + { fetch: providerFetch, providerType: "openai" }, + REQUEST, + ), ); expect(chunks.filter((c) => c.type === "text")).toEqual([ @@ -89,7 +86,10 @@ describe("openAiAdapter", () => { ); const chunks = await collect( - openAiAdapter.streamChat({ providerType: "openai" }, REQUEST), + openAiAdapter.streamChat( + { fetch: providerFetch, providerType: "openai" }, + REQUEST, + ), ); const call = chunks.find((c) => c.type === "tool_call"); @@ -110,7 +110,10 @@ describe("openAiAdapter", () => { ); const chunks = await collect( - openAiAdapter.streamChat({ providerType: "openai" }, REQUEST), + openAiAdapter.streamChat( + { fetch: providerFetch, providerType: "openai" }, + REQUEST, + ), ); expect(chunks.find((c) => c.type === "tool_call")).toMatchObject({ @@ -122,7 +125,7 @@ describe("openAiAdapter", () => { await expect( collect( openAiAdapter.streamChat( - { providerType: "openai_compatible" }, + { fetch: providerFetch, providerType: "openai_compatible" }, REQUEST, ), ), @@ -143,7 +146,10 @@ describe("ollamaAdapter", () => { ); const chunks = await collect( - ollamaAdapter.streamChat({ providerType: "ollama" }, REQUEST), + ollamaAdapter.streamChat( + { fetch: providerFetch, providerType: "ollama" }, + REQUEST, + ), ); expect(chunks.filter((c) => c.type === "text")).toHaveLength(2); @@ -160,7 +166,10 @@ describe("ollamaAdapter", () => { ); const chunks = await collect( - ollamaAdapter.streamChat({ providerType: "ollama" }, REQUEST), + ollamaAdapter.streamChat( + { fetch: providerFetch, providerType: "ollama" }, + REQUEST, + ), ); const calls = chunks.filter((c) => c.type === "tool_call") as any[]; @@ -196,7 +205,7 @@ describe("geminiAdapter", () => { const chunks = await collect( geminiAdapter.streamChat( - { providerType: "gemini", apiKey: "k" }, + { fetch: providerFetch, providerType: "gemini", apiKey: "k" }, REQUEST, ), ); @@ -211,7 +220,7 @@ describe("geminiAdapter", () => { await collect( geminiAdapter.streamChat( - { providerType: "gemini", apiKey: "k" }, + { fetch: providerFetch, providerType: "gemini", apiKey: "k" }, { ...REQUEST, messages: [ @@ -271,7 +280,12 @@ describe("assertOk error messages", () => { ); await expect( - collect(openAiAdapter.streamChat({ providerType: "openai" }, REQUEST)), + collect( + openAiAdapter.streamChat( + { fetch: providerFetch, providerType: "openai" }, + REQUEST, + ), + ), ).rejects.toThrow("Function call is missing a signature."); }); @@ -284,7 +298,12 @@ describe("assertOk error messages", () => { ); await expect( - collect(openAiAdapter.streamChat({ providerType: "openai" }, REQUEST)), + collect( + openAiAdapter.streamChat( + { fetch: providerFetch, providerType: "openai" }, + REQUEST, + ), + ), ).rejects.toThrow(/rate limit reached/i); }); @@ -294,7 +313,12 @@ describe("assertOk error messages", () => { ); await expect( - collect(openAiAdapter.streamChat({ providerType: "openai" }, REQUEST)), + collect( + openAiAdapter.streamChat( + { fetch: providerFetch, providerType: "openai" }, + REQUEST, + ), + ), ).rejects.toThrow(/rejected the API key/i); }); @@ -302,7 +326,12 @@ describe("assertOk error messages", () => { providerFetch.mockResolvedValue(errorResponse(500, "upstream exploded")); await expect( - collect(openAiAdapter.streamChat({ providerType: "openai" }, REQUEST)), + collect( + openAiAdapter.streamChat( + { fetch: providerFetch, providerType: "openai" }, + REQUEST, + ), + ), ).rejects.toThrow("upstream exploded"); }); }); diff --git a/plugins/ai/tests/backend/routes.test.ts b/plugins/ai/tests/backend/routes.test.ts new file mode 100644 index 000000000..82710ea0c --- /dev/null +++ b/plugins/ai/tests/backend/routes.test.ts @@ -0,0 +1,216 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { startServer, type TestServer } from "./helpers"; + +let server: TestServer | null = null; + +afterEach(async () => { + await server?.close(); + server = null; +}); + +describe("ai routes", () => { + it("reports status without the gate, and blocks everything else while off", async () => { + server = await startServer(); + + const status = await server.request("GET", "/status"); + expect(status.status).toBe(200); + expect(status.body).toEqual({ + globallyEnabled: false, + enabled: false, + allowReadOnlyCommands: false, + }); + + expect((await server.request("GET", "/providers")).status).toBe(403); + }); + + it("lets a user opt in only while the server allows it", async () => { + server = await startServer(); + + const refused = await server.request("PUT", "/opt-in", { + body: { enabled: true }, + }); + expect(refused.status).toBe(403); + + await server.mock.ctx.settings.set("globallyEnabled", true); + const accepted = await server.request("PUT", "/opt-in", { + body: { enabled: true }, + }); + expect(accepted.status).toBe(200); + expect(await server.mock.ctx.settings.getUser("user-1", "enabled")).toBe( + true, + ); + }); + + it("keeps a provider's key in ctx.secrets, never in the row or the response", async () => { + server = await startServer(); + await server.enableFor("user-1"); + + const created = await server.request("POST", "/providers", { + body: { + providerType: "openai", + label: "Work", + apiKey: "sk-secret-value", + defaultModel: "gpt-5", + }, + }); + expect(created.status).toBe(201); + expect(created.body.provider.apiKeyPrefix).toBe("sk-sec"); + expect(JSON.stringify(created.body)).not.toContain("sk-secret-value"); + + const id = created.body.provider.id; + expect(server.mock.secretStore.get(`user-1:provider:${id}`)).toBe( + "sk-secret-value", + ); + const row = server.db.sqlite + .prepare("SELECT * FROM p_ai_providers WHERE id = ?") + .get(id) as Record; + expect(JSON.stringify(row)).not.toContain("sk-secret-value"); + + const list = await server.request("GET", "/providers"); + expect(list.body.providers).toHaveLength(1); + + // Another user sees none of it. + await server.enableFor("user-2"); + const other = await server.request("GET", "/providers", { user: "user-2" }); + expect(other.body.providers).toEqual([]); + + const removed = await server.request("DELETE", `/providers/${id}`); + expect(removed.status).toBe(200); + expect(server.mock.secretStore.has(`user-1:provider:${id}`)).toBe(false); + }); + + it("validates a new provider", async () => { + server = await startServer(); + await server.enableFor("user-1"); + + const unknown = await server.request("POST", "/providers", { + body: { providerType: "nope", label: "x" }, + }); + expect(unknown.status).toBe(400); + + const noKey = await server.request("POST", "/providers", { + body: { providerType: "anthropic", label: "x" }, + }); + expect(noKey.status).toBe(400); + }); + + it("rejects and applies proposals only once, for their owner", async () => { + server = await startServer(); + await server.enableFor("user-1"); + + server.db.sqlite + .prepare( + "INSERT INTO p_ai_conversations (id, user_id, title) VALUES (1, 'user-1', 't')", + ) + .run(); + server.db.sqlite + .prepare( + `INSERT INTO p_ai_proposals (id, conversation_id, user_id, kind, payload) + VALUES (1, 1, 'user-1', 'propose_create_host', '{"name":"db","ip":"10.0.0.2"}'), + (2, 1, 'user-1', 'propose_delete_host', '{"hostId":1}')`, + ) + .run(); + + // Not theirs. + await server.enableFor("user-2"); + expect( + (await server.request("POST", "/proposals/1/reject", { user: "user-2" })) + .status, + ).toBe(404); + + const rejected = await server.request("POST", "/proposals/2/reject"); + expect(rejected.status).toBe(200); + expect((await server.request("POST", "/proposals/2/reject")).status).toBe( + 404, + ); + + const conversation = await server.request("GET", "/conversations/1"); + expect(conversation.status).toBe(200); + expect( + conversation.body.proposals.map((p: { id: number }) => p.id), + ).toEqual([2, 1]); + + const deleted = await server.request("DELETE", "/conversations/1"); + expect(deleted.status).toBe(200); + const left = server.db.sqlite + .prepare("SELECT COUNT(*) AS n FROM p_ai_proposals") + .get() as { n: number }; + expect(left.n).toBe(0); + }); + + it.each([ + ["GET", "/providers", "ai.use"], + ["POST", "/providers", "ai.manage_providers"], + ["PATCH", "/providers/1", "ai.manage_providers"], + ["DELETE", "/providers/1", "ai.manage_providers"], + ["POST", "/probe-models", "ai.manage_providers"], + ["GET", "/providers/1/models", "ai.use"], + ["GET", "/conversations", "ai.use"], + ["GET", "/conversations/1", "ai.use"], + ["DELETE", "/conversations/1", "ai.use"], + ["POST", "/chat/stream", "ai.use"], + ["POST", "/proposals/1/apply", "ai.apply_proposals"], + ["POST", "/proposals/1/mark-run-in-terminal", "ai.apply_proposals"], + ["POST", "/proposals/1/reject", "ai.use"], + ["PUT", "/opt-in", "ai.use"], + ])("%s %s answers 403 without %s", async (method, path, permission) => { + server = await startServer({ + permissions: [ + "ai.use", + "ai.manage_providers", + "ai.apply_proposals", + ].filter((entry) => entry !== permission), + }); + await server.enableFor("user-1"); + const response = await server.request(method, path, { body: {} }); + expect(response.status).toBe(403); + }); +}); + +describe("chat stream", () => { + it("offers the model only the tools whose plugins are running", async () => { + const bodies: Array<{ tools?: Array<{ function?: { name: string } }> }> = + []; + const encoder = new TextEncoder(); + server = await startServer({ + services: { "snippets.access": { list: async () => [] } }, + fetch: async (_url, init) => { + bodies.push(JSON.parse((init as { body: string }).body)); + const stream = new ReadableStream({ + start(controller) { + controller.enqueue( + encoder.encode( + 'data: {"choices":[{"delta":{"content":"hi"}}]}\n\ndata: [DONE]\n\n', + ), + ); + controller.close(); + }, + }); + return new Response(stream, { status: 200 }); + }, + }); + await server.enableFor("user-1"); + + const created = await server.request("POST", "/providers", { + body: { + providerType: "openai", + label: "Work", + apiKey: "sk-test", + defaultModel: "gpt-5", + }, + }); + + const reply = await server.request("POST", "/chat/stream", { + body: { providerId: created.body.provider.id, message: "hello" }, + }); + expect(reply.status).toBe(200); + expect(String(reply.body)).toContain('"type":"done"'); + + const offered = (bodies[0].tools ?? []).map((tool) => tool.function?.name); + expect(offered).toContain("list_hosts"); + expect(offered).toContain("list_snippets"); + expect(offered).not.toContain("list_fleets"); + expect(offered).not.toContain("list_homepage_items"); + expect(offered).not.toContain("propose_create_automation"); + }); +}); diff --git a/plugins/ai/tests/backend/tools/proposal-executor.test.ts b/plugins/ai/tests/backend/tools/proposal-executor.test.ts index fdb8e9883..f36c70198 100644 --- a/plugins/ai/tests/backend/tools/proposal-executor.test.ts +++ b/plugins/ai/tests/backend/tools/proposal-executor.test.ts @@ -1,76 +1,70 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -const hostRepository = { - create: vi.fn(), - findByIdForUser: vi.fn(), - updateForUser: vi.fn(), - deleteForUser: vi.fn(), -}; -const snippetRepository = { - createSnippet: vi.fn(), - findOwnedById: vi.fn(), - updateSnippet: vi.fn(), - deleteSnippet: vi.fn(), -}; -const automationsAccess = { create: vi.fn() }; -const fleetsAccess = { create: vi.fn(), addMember: vi.fn() }; - -vi.mock("../../../../../src/backend/database/repositories/factory.js", () => ({ - createCurrentHostRepository: () => hostRepository, - createCurrentSnippetRepository: () => snippetRepository, -})); - -const resolveHostById = vi.fn(); -vi.mock("../../../../../src/backend/hosts/host-resolver.js", () => ({ - resolveHostById: (...args: unknown[]) => resolveHostById(...args), -})); - const execCommand = vi.fn(); vi.mock("@termix/plugin-sdk/host-commands", async (importOriginal) => ({ ...(await importOriginal()), execCommand: (...args: unknown[]) => execCommand(...args), })); -vi.mock("../../../src/backend/ssh.js", () => ({ - withSshConnection: async ( - _host: unknown, - _options: unknown, - run: (client: unknown) => Promise, - ) => run({}), -})); const { applyProposal } = await import("../../../src/backend/tools/executor.js"); -const { setPluginServices } = await import("../../../src/backend/services.js"); + +const hosts = { + create: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + checkAccess: vi.fn(), +}; +const automationsAccess = { create: vi.fn() }; +const fleetsAccess = { create: vi.fn(), addMember: vi.fn() }; +const snippetsAccess = { + create: vi.fn(), + get: vi.fn(), + update: vi.fn(), + remove: vi.fn(), +}; +const withConnection = vi.fn( + async ( + _host: unknown, + _options: unknown, + run: (client: unknown) => unknown, + ) => run({}), +); + +let provided: Record = {}; +let granted = new Set(); + +/** What the executor gets from ctx: the approving user's view of core. */ +function deps() { + return { + hosts, + services: { + providers: (service: string) => (service in provided ? [""] : []), + get: (service: string) => provided[service], + }, + rbac: { has: async (permission: string) => granted.has(permission) }, + ssh: { withConnection }, + notify: {}, + } as never; +} + +beforeEach(() => { + vi.clearAllMocks(); + provided = {}; + granted = new Set(["hosts.create", "hosts.edit"]); +}); describe("applyProposal", () => { - beforeEach(() => { - vi.clearAllMocks(); - setPluginServices(null); - }); - it("refuses a kind that is not a real tool", async () => { // The stored payload is treated as untrusted even though the server wrote // it, because a proposal can outlive the release that created it. await expect( - applyProposal("propose_delete_everything", {}, "user-1"), + applyProposal("propose_delete_everything", {}, deps()), ).rejects.toThrow("Unknown proposal kind"); }); - function withAutomations() { - setPluginServices({ - get: (service: string) => { - if (service !== "automations.access") - throw new Error(`unexpected ${service}`); - return automationsAccess as never; - }, - provide: vi.fn(), - } as never); - } - it("hands an automation to the automations plugin, which validates it", async () => { - // The plugin runs its own route's validator, so a definition the model - // invented is held to the same standard as a hand-written one. - withAutomations(); + provided["automations.access"] = automationsAccess; automationsAccess.create.mockRejectedValue( new Error("Unknown or missing trigger kind"), ); @@ -81,7 +75,7 @@ describe("applyProposal", () => { name: "bad", definition: { trigger: { kind: "nonsense" }, steps: [] }, }, - "user-1", + deps(), ), ).rejects.toThrow("Unknown or missing trigger kind"); }); @@ -91,13 +85,13 @@ describe("applyProposal", () => { applyProposal( "propose_create_automation", { name: "x", definition: {} }, - "user-1", + deps(), ), ).rejects.toThrow("The automations plugin is not available"); }); it("creates a valid automation disabled so it cannot fire unwatched", async () => { - withAutomations(); + provided["automations.access"] = automationsAccess; automationsAccess.create.mockResolvedValue({ id: 5, name: "nightly" }); const result = await applyProposal( @@ -109,7 +103,7 @@ describe("applyProposal", () => { steps: [{ id: "s1", type: "wait", seconds: 1 }], }, }, - "user-1", + deps(), ); expect(result.ok).toBe(true); @@ -118,119 +112,100 @@ describe("applyProposal", () => { ); }); - it("runs an approved command only on a host the user can reach", async () => { - // resolveHostById returns null when the connect-level permission check - // fails, so an unreachable host never gets as far as an SSH attempt. - resolveHostById.mockResolvedValue(null); - - await expect( - applyProposal( - "propose_run_command", - { hostId: 9, command: "uptime" }, - "user-1", - ), - ).rejects.toThrow("Host not found"); - expect(execCommand).not.toHaveBeenCalled(); - }); - - it("returns command output on success", async () => { - resolveHostById.mockResolvedValue({ id: 9, ip: "10.0.0.9" }); + it("runs an approved command through ctx.ssh on the host id", async () => { + // ctx.ssh resolves the host for the approving user, with the connect + // access check, so the executor never holds a host row itself. execCommand.mockResolvedValue({ stdout: "up 3 days", stderr: "", code: 0 }); const result = await applyProposal( "propose_run_command", { hostId: 9, command: "uptime" }, - "user-1", + deps(), ); + expect(withConnection).toHaveBeenCalledWith( + 9, + { pool: "ai", purpose: "fleet" }, + expect.any(Function), + ); expect(result.ok).toBe(true); expect(result.summary).toContain("up 3 days"); }); - it("resolves the host rather than using a raw repository row", async () => { - // A raw row has no decrypted auth and an unresolved jumpHosts field, which - // made the SSH factory fail with a jump host error on hosts that have none. - resolveHostById.mockResolvedValue({ id: 9, ip: "10.0.0.9" }); - execCommand.mockResolvedValue({ stdout: "ok", stderr: "", code: 0 }); + it("fails when the user cannot reach the host", async () => { + withConnection.mockRejectedValueOnce(new Error("Host not found")); - await applyProposal( - "propose_run_command", - { hostId: 9, command: "uptime" }, - "user-1", - ); - - expect(resolveHostById).toHaveBeenCalledWith(9, "user-1"); - expect(hostRepository.findByIdForUser).not.toHaveBeenCalled(); + await expect( + applyProposal( + "propose_run_command", + { hostId: 9, command: "uptime" }, + deps(), + ), + ).rejects.toThrow("Host not found"); + expect(execCommand).not.toHaveBeenCalled(); }); it("surfaces a non-zero exit rather than reporting success", async () => { - resolveHostById.mockResolvedValue({ id: 9, ip: "10.0.0.9" }); execCommand.mockResolvedValue({ stdout: "", stderr: "denied", code: 1 }); await expect( applyProposal( "propose_run_command", { hostId: 9, command: "cat /etc/shadow" }, - "user-1", + deps(), ), ).rejects.toThrow("code 1"); }); - it("creates a host through the normal repository", async () => { - hostRepository.create.mockResolvedValue({ id: 7, name: "web-1" }); + it("creates a host through ctx.hosts, with tags as core stores them", async () => { + hosts.create.mockResolvedValue({ id: 7, name: "web-1" }); const result = await applyProposal( "propose_create_host", - { name: "web-1", ip: "10.0.0.5", port: 22, tags: ["prod"] }, - "user-1", + { name: "web-1", ip: "10.0.0.5", port: 22, tags: ["prod", "eu"] }, + deps(), ); expect(result.ok).toBe(true); - expect(hostRepository.create).toHaveBeenCalledWith( + expect(hosts.create).toHaveBeenCalledWith( expect.objectContaining({ - userId: "user-1", name: "web-1", ip: "10.0.0.5", + tags: "prod,eu", }), ); }); + it("refuses to create a host without hosts.create", async () => { + granted = new Set(); + await expect( + applyProposal( + "propose_create_host", + { name: "web-1", ip: "10.0.0.5" }, + deps(), + ), + ).rejects.toThrow("permission"); + expect(hosts.create).not.toHaveBeenCalled(); + }); + it("rejects a host payload missing required fields", async () => { await expect( - applyProposal("propose_create_host", { name: "web-1" }, "user-1"), + applyProposal("propose_create_host", { name: "web-1" }, deps()), ).rejects.toThrow("ip is required"); - expect(hostRepository.create).not.toHaveBeenCalled(); + expect(hosts.create).not.toHaveBeenCalled(); }); - it("scopes an update to the approving user", async () => { - hostRepository.findByIdForUser.mockResolvedValue({ id: 7 }); - hostRepository.updateForUser.mockResolvedValue({ id: 7 }); - - await applyProposal( - "propose_update_host", - { hostId: 7, changes: { name: "renamed" } }, - "user-1", - ); - - expect(hostRepository.findByIdForUser).toHaveBeenCalledWith("user-1", 7); - expect(hostRepository.updateForUser).toHaveBeenCalledWith( - "user-1", - 7, - expect.objectContaining({ name: "renamed" }), - ); - }); - - it("refuses to update a host the user does not own", async () => { - hostRepository.findByIdForUser.mockResolvedValue(null); + it("updates through ctx.hosts, which refuses a host the user does not own", async () => { + hosts.update.mockResolvedValue(null); await expect( applyProposal( "propose_update_host", { hostId: 999, changes: { name: "x" } }, - "user-1", + deps(), ), ).rejects.toThrow("Host not found"); - expect(hostRepository.updateForUser).not.toHaveBeenCalled(); + expect(hosts.update).toHaveBeenCalledWith(999, { name: "x" }); }); it("rejects a non-numeric id rather than coercing it", async () => { @@ -238,30 +213,46 @@ describe("applyProposal", () => { applyProposal( "propose_update_host", { hostId: "7; DROP TABLE hosts", changes: { name: "x" } }, - "user-1", + deps(), ), ).rejects.toThrow("hostId must be a positive integer"); }); - it("only adds fleet members the approving user owns", async () => { - setPluginServices({ - get: (service: string) => { - if (service !== "fleets.access") - throw new Error(`unexpected ${service}`); - return fleetsAccess as never; - }, - provide: vi.fn(), - } as never); - fleetsAccess.create.mockResolvedValue({ id: 3, name: "prod" }); - hostRepository.findByIdForUser.mockImplementation( - async (_userId: string, hostId: number) => - hostId === 1 ? { id: 1 } : null, + it("reports nothing to change on an empty update", async () => { + const result = await applyProposal( + "propose_update_host", + { hostId: 7, changes: {} }, + deps(), ); + expect(result.ok).toBe(false); + expect(hosts.update).not.toHaveBeenCalled(); + }); + + it("deletes through ctx.hosts", async () => { + hosts.delete.mockResolvedValue(true); + const result = await applyProposal( + "propose_delete_host", + { hostId: 7, reason: "old" }, + deps(), + ); + expect(result.ok).toBe(true); + expect(hosts.delete).toHaveBeenCalledWith(7); + }); + + it("only adds fleet members the approving user owns", async () => { + provided["fleets.access"] = fleetsAccess; + fleetsAccess.create.mockResolvedValue({ id: 3, name: "prod" }); + hosts.checkAccess.mockImplementation(async (hostId: number) => ({ + hasAccess: true, + isOwner: hostId === 1, + isShared: hostId !== 1, + })); + const result = await applyProposal( "propose_create_fleet", { name: "prod", hostIds: [1, 2] }, - "user-1", + deps(), ); expect(fleetsAccess.addMember).toHaveBeenCalledTimes(1); @@ -269,22 +260,22 @@ describe("applyProposal", () => { expect(result.summary).toContain("1 host"); }); - it("fails clearly when the fleets plugin is disabled", async () => { + it("fails clearly when the fleets plugin is off", async () => { await expect( - applyProposal("propose_create_fleet", { name: "prod" }, "user-1"), - ).rejects.toThrow("fleets plugin is disabled"); + applyProposal("propose_create_fleet", { name: "prod" }, deps()), + ).rejects.toThrow("The fleets plugin is not available"); }); - it("reports nothing to change on an empty update", async () => { - hostRepository.findByIdForUser.mockResolvedValue({ id: 7 }); + it("edits a snippet through the snippets plugin", async () => { + provided["snippets.access"] = snippetsAccess; + snippetsAccess.get.mockResolvedValue({ id: 4, name: "a" }); - const result = await applyProposal( - "propose_update_host", - { hostId: 7, changes: {} }, - "user-1", + await applyProposal( + "propose_update_snippet", + { snippetId: 4, changes: { name: "b" } }, + deps(), ); - expect(result.ok).toBe(false); - expect(hostRepository.updateForUser).not.toHaveBeenCalled(); + expect(snippetsAccess.update).toHaveBeenCalledWith(4, { name: "b" }); }); }); diff --git a/plugins/ai/tests/frontend/AiProviderSettings.test.tsx b/plugins/ai/tests/frontend/AiProviderSettings.test.tsx index ed3e9b766..e6514b72d 100644 --- a/plugins/ai/tests/frontend/AiProviderSettings.test.tsx +++ b/plugins/ai/tests/frontend/AiProviderSettings.test.tsx @@ -17,7 +17,8 @@ const api = vi.hoisted(() => ({ })); vi.mock("../../src/frontend/ai-api", () => api); -vi.mock("react-i18next", () => ({ +vi.mock("react-i18next", async (importOriginal) => ({ + ...(await importOriginal()), useTranslation: () => ({ t: (key: string) => key }), })); vi.mock("sonner", () => ({ diff --git a/plugins/ai/tests/frontend/activate.test.tsx b/plugins/ai/tests/frontend/activate.test.tsx index 4c4d894da..95f86c429 100644 --- a/plugins/ai/tests/frontend/activate.test.tsx +++ b/plugins/ai/tests/frontend/activate.test.tsx @@ -57,6 +57,11 @@ describe(`${manifest.id} activate`, () => { expect(app.registered.actions()).toEqual([]); }); + it("registers the providers list as its custom settings field", async () => { + rendered = await renderWithApp(plugin, { manifest, locales }); + expect(rendered.registered.settingsComponents()).toEqual(["providers"]); + }); + it("adds the assistant tab once AI is on for the user", async () => { rendered = await renderWithApp(plugin, { manifest, locales }); await waitFor(() => expect(rendered!.registered.tabs()).toEqual(["ai"])); diff --git a/plugins/ai/tests/frontend/use-ai-availability.test.ts b/plugins/ai/tests/frontend/use-ai-availability.test.ts deleted file mode 100644 index 1398dba5e..000000000 --- a/plugins/ai/tests/frontend/use-ai-availability.test.ts +++ /dev/null @@ -1,109 +0,0 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; -import { renderHook, waitFor, act } from "@testing-library/react"; - -const getAiStatus = vi.fn(); -vi.mock("../../src/frontend/ai-api", () => ({ getAiStatus })); - -const { useAiAvailability, notifyAiStatusChanged } = - await import("../../src/frontend/use-ai-availability"); - -beforeEach(() => { - getAiStatus.mockReset(); -}); - -afterEach(() => { - vi.restoreAllMocks(); -}); - -describe("useAiAvailability", () => { - it("starts hidden and unloaded before the status answers", () => { - getAiStatus.mockReturnValue(new Promise(() => {})); - const { result } = renderHook(() => useAiAvailability()); - expect(result.current).toEqual({ - globallyEnabled: false, - userEnabled: false, - loaded: false, - }); - }); - - it("reports both gates open when the admin and the user enabled it", async () => { - getAiStatus.mockResolvedValue({ - globallyEnabled: true, - enabled: true, - allowReadOnlyCommands: false, - }); - const { result } = renderHook(() => useAiAvailability()); - await waitFor(() => expect(result.current.loaded).toBe(true)); - expect(result.current.globallyEnabled).toBe(true); - expect(result.current.userEnabled).toBe(true); - }); - - it("keeps userEnabled false when the user has not opted in", async () => { - getAiStatus.mockResolvedValue({ - globallyEnabled: true, - enabled: false, - allowReadOnlyCommands: false, - }); - const { result } = renderHook(() => useAiAvailability()); - await waitFor(() => expect(result.current.loaded).toBe(true)); - expect(result.current.globallyEnabled).toBe(true); - expect(result.current.userEnabled).toBe(false); - }); - - it("the admin kill switch wins over the user opt-in", async () => { - getAiStatus.mockResolvedValue({ - globallyEnabled: false, - enabled: true, - allowReadOnlyCommands: true, - }); - const { result } = renderHook(() => useAiAvailability()); - await waitFor(() => expect(result.current.loaded).toBe(true)); - expect(result.current.globallyEnabled).toBe(false); - expect(result.current.userEnabled).toBe(false); - }); - - it("treats a failed status call as off", async () => { - getAiStatus.mockRejectedValue(new Error("boom")); - const { result } = renderHook(() => useAiAvailability()); - await waitFor(() => expect(result.current.loaded).toBe(true)); - expect(result.current.globallyEnabled).toBe(false); - expect(result.current.userEnabled).toBe(false); - }); - - it("re-reads the status when notified", async () => { - getAiStatus.mockResolvedValue({ - globallyEnabled: true, - enabled: true, - allowReadOnlyCommands: false, - }); - const { result } = renderHook(() => useAiAvailability()); - await waitFor(() => expect(result.current.globallyEnabled).toBe(true)); - - getAiStatus.mockResolvedValue({ - globallyEnabled: false, - enabled: true, - allowReadOnlyCommands: false, - }); - act(() => notifyAiStatusChanged()); - await waitFor(() => expect(result.current.globallyEnabled).toBe(false)); - expect(result.current.userEnabled).toBe(false); - }); - - it("also re-reads on the older hiddenRailTabsChanged event", async () => { - getAiStatus.mockResolvedValue({ - globallyEnabled: true, - enabled: false, - allowReadOnlyCommands: false, - }); - const { result } = renderHook(() => useAiAvailability()); - await waitFor(() => expect(result.current.loaded).toBe(true)); - - getAiStatus.mockResolvedValue({ - globallyEnabled: true, - enabled: true, - allowReadOnlyCommands: false, - }); - act(() => window.dispatchEvent(new Event("hiddenRailTabsChanged"))); - await waitFor(() => expect(result.current.userEnabled).toBe(true)); - }); -}); diff --git a/plugins/ai/tsconfig.json b/plugins/ai/tsconfig.json index 1d3e42542..7b35314c3 100644 --- a/plugins/ai/tsconfig.json +++ b/plugins/ai/tsconfig.json @@ -1,10 +1,4 @@ { "extends": "@termix/plugin-sdk/tsconfig.plugin.json", - "compilerOptions": { - "paths": { - "@/types/*": ["../../src/types/*"], - "@/*": ["../../src/ui/*"] - } - }, "include": ["src/**/*.ts", "src/**/*.tsx", "tests/**/*.ts", "tests/**/*.tsx"] } diff --git a/plugins/fleets/src/backend/index.ts b/plugins/fleets/src/backend/index.ts index f4dfcd2a2..2599c2250 100644 --- a/plugins/fleets/src/backend/index.ts +++ b/plugins/fleets/src/backend/index.ts @@ -38,6 +38,14 @@ export async function activate(ctx: PluginContext) { registerFleetRoutes(ctx.http.router(), repo, ctx); + // The service is gated on fleets.view; changing a fleet also needs + // fleets.manage, as its routes do. + async function requireManage(): Promise { + if (!(await ctx.rbac.has("manage"))) { + throw new Error("Missing permission fleets.manage"); + } + } + const service: FleetsService = { list: async () => { const userId = ctx.currentActor(); @@ -54,12 +62,16 @@ export async function activate(ctx: PluginContext) { create: async (input) => { const userId = ctx.currentActor(); if (!userId) throw new Error("fleets.access.create needs an actor"); + await requireManage(); const created = await repo.create(userId, input); return { id: created.id, name: created.name }; }, addMember: async (fleetId, hostId) => { const userId = ctx.currentActor(); if (!userId) throw new Error("fleets.access.addMember needs an actor"); + await requireManage(); + // A host joins a fleet only if the caller can see it. + if (!(await ctx.hosts.get(hostId))) throw new Error("Host not found"); const fleet = await repo.findById(userId, fleetId); if (!fleet) throw new Error("Fleet not found"); await repo.addMember(fleetId, hostId); diff --git a/plugins/fleets/tests/backend/service.test.ts b/plugins/fleets/tests/backend/service.test.ts new file mode 100644 index 000000000..3af44340f --- /dev/null +++ b/plugins/fleets/tests/backend/service.test.ts @@ -0,0 +1,38 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { startServer, type TestServer } from "./helpers"; + +let server: TestServer | null = null; + +afterEach(async () => { + await server?.close(); + server = null; +}); + +interface FleetsService { + create: (input: { name: string }) => Promise<{ id: number; name: string }>; + addMember: (fleetId: number, hostId: number) => Promise; +} + +describe("fleets.access", () => { + it("needs fleets.manage to change a fleet", async () => { + server = await startServer({ permissions: ["fleets.view"] }); + const service = server.mock.services.get("fleets.access") as FleetsService; + + await expect( + server.mock.ctx.asUser("user-1", () => service.create({ name: "prod" })), + ).rejects.toThrow("fleets.manage"); + }); + + it("adds only hosts the caller can see", async () => { + server = await startServer(); + const service = server.mock.services.get("fleets.access") as FleetsService; + const run = (fn: () => Promise) => + server!.mock.ctx.asUser("user-1", fn); + + const fleet = await run(() => service.create({ name: "prod" })); + await run(() => service.addMember(fleet.id, 10)); + await expect(run(() => service.addMember(fleet.id, 99))).rejects.toThrow( + "Host not found", + ); + }); +}); diff --git a/plugins/snippets/src/backend/index.ts b/plugins/snippets/src/backend/index.ts index 3753de417..dacfc36fc 100644 --- a/plugins/snippets/src/backend/index.ts +++ b/plugins/snippets/src/backend/index.ts @@ -91,6 +91,15 @@ export async function activate(ctx: PluginContext) { return repo.findAccessibleSharedSnippet(id, userId, roleIds); } + // The service is gated on snippets.view; a write also needs the same + // permission its route requires, so a caller cannot write through here + // what the user could not write in the panel. + async function requirePermission(permission: string): Promise { + if (!(await ctx.rbac.has(permission))) { + throw new Error(`Missing permission snippets.${permission}`); + } + } + const service: SnippetsService = { list: async () => { const userId = ctx.currentActor(); @@ -127,18 +136,21 @@ export async function activate(ctx: PluginContext) { create: async (input) => { const userId = ctx.currentActor(); if (!userId) throw new Error("snippets.access.create needs an actor"); + await requirePermission("create"); const created = await repo.createSnippet(userId, input); return { id: created.id, name: created.name }; }, update: async (id, changes) => { const userId = ctx.currentActor(); if (!userId) throw new Error("snippets.access.update needs an actor"); + await requirePermission("edit"); const result = await repo.updateSnippet(userId, id, changes); if (!result) throw new Error("Snippet not found"); }, remove: async (id) => { const userId = ctx.currentActor(); if (!userId) throw new Error("snippets.access.remove needs an actor"); + await requirePermission("delete"); const existing = await repo.deleteSnippet(userId, id); if (existing?.syncId) { await ctx.sync.recordTombstone(userId, "snippets", existing.syncId); diff --git a/plugins/snippets/src/frontend/index.tsx b/plugins/snippets/src/frontend/index.tsx index 2c2b1ffdb..cf271bec3 100644 --- a/plugins/snippets/src/frontend/index.tsx +++ b/plugins/snippets/src/frontend/index.tsx @@ -76,6 +76,14 @@ export function activate(app: TermixApp): void { : null; }) as never); + // For pickers in other plugins (the AI assistant's @-mentions): id and name. + app.registerAction("snippets.list", (async () => { + const snippets = await createSnippetsApi(app.api) + .list() + .catch(() => []); + return snippets.map((snippet) => ({ id: snippet.id, name: snippet.name })); + }) as never); + app.registerAction("snippets.execute", (async ( snippetId: number, hostId: number, diff --git a/plugins/snippets/tests/backend/activate.test.ts b/plugins/snippets/tests/backend/activate.test.ts index 0c222b86e..134756e4c 100644 --- a/plugins/snippets/tests/backend/activate.test.ts +++ b/plugins/snippets/tests/backend/activate.test.ts @@ -56,6 +56,27 @@ describe("snippets activate", () => { expect(otherUsersList).toEqual([]); }); + it("refuses service writes the user could not make in the panel", async () => { + server = await startServer({ permissions: ["snippets.view"] }); + const service = server.mock.services.get("snippets.access") as { + create: (input: { name: string; content: string }) => Promise; + update: (id: number, changes: { name: string }) => Promise; + remove: (id: number) => Promise; + }; + const asUser = (fn: () => Promise) => + server!.mock.ctx.asUser("user-1", fn); + + await expect( + asUser(() => service.create({ name: "x", content: "ls" })), + ).rejects.toThrow("snippets.create"); + await expect( + asUser(() => service.update(1, { name: "y" })), + ).rejects.toThrow("snippets.edit"); + await expect(asUser(() => service.remove(1))).rejects.toThrow( + "snippets.delete", + ); + }); + it("wipes a user's snippets when user.data_wiped fires", async () => { // events:core only to simulate core firing the topic in this test; the // plugin's own manifest never declares it, since it only subscribes. diff --git a/scripts/plugin-boundary-allowlist.json b/scripts/plugin-boundary-allowlist.json index ea8f2ec6e..88679f539 100644 --- a/scripts/plugin-boundary-allowlist.json +++ b/scripts/plugin-boundary-allowlist.json @@ -1,15 +1,6 @@ { "ui-to-plugin": [], "plugin-frontend-to-core": [ - "plugins/ai/src/frontend/AiPanel.tsx", - "plugins/ai/src/frontend/AiProviderSettings.tsx", - "plugins/ai/src/frontend/ProposalCard.tsx", - "plugins/ai/src/frontend/ai-api.ts", - "plugins/ai/src/frontend/settings/AiUserSettings.tsx", - "plugins/ai/src/frontend/terminal/TerminalAiPanel.tsx", - "plugins/ai/src/frontend/terminal/TerminalProposalCard.tsx", - "plugins/ai/src/frontend/use-ai-stream.ts", - "plugins/ai/src/frontend/useMentions.ts", "plugins/file-manager/src/frontend/FileManager.tsx", "plugins/file-manager/src/frontend/FileManagerContextMenu.tsx", "plugins/file-manager/src/frontend/FileManagerDialogs.tsx", @@ -67,14 +58,6 @@ "plugins/web-endpoint/src/frontend/web-endpoint-api.ts" ], "plugin-to-core": [ - "plugins/ai/src/backend/egress.ts", - "plugins/ai/src/backend/engine.ts", - "plugins/ai/src/backend/gating.ts", - "plugins/ai/src/backend/index.ts", - "plugins/ai/src/backend/providers/http.ts", - "plugins/ai/src/backend/routes.ts", - "plugins/ai/src/backend/tools/executor.ts", - "plugins/ai/src/backend/tools/read-tools.ts", "plugins/file-manager/src/backend/index.ts", "plugins/file-manager/src/backend/operation-routes.ts", "plugins/proxmox/src/backend/routes.ts", diff --git a/src/backend/database/db/index.ts b/src/backend/database/db/index.ts index a1ce343e6..fbcee7216 100644 --- a/src/backend/database/db/index.ts +++ b/src/backend/database/db/index.ts @@ -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); diff --git a/src/backend/database/db/performance-indexes.ts b/src/backend/database/db/performance-indexes.ts index 4ee72d1ef..d5b3d0c79 100644 --- a/src/backend/database/db/performance-indexes.ts +++ b/src/backend/database/db/performance-indexes.ts @@ -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 { diff --git a/src/backend/database/db/schema.mysql.ts b/src/backend/database/db/schema.mysql.ts index 2079a61a2..f05c8d4bd 100644 --- a/src/backend/database/db/schema.mysql.ts +++ b/src/backend/database/db/schema.mysql.ts @@ -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 / 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)`), }, diff --git a/src/backend/database/db/schema.pg.ts b/src/backend/database/db/schema.pg.ts index 8a3adfc32..82da4ae5f 100644 --- a/src/backend/database/db/schema.pg.ts +++ b/src/backend/database/db/schema.pg.ts @@ -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 / 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`), }, diff --git a/src/backend/database/db/schema.ts b/src/backend/database/db/schema.ts index 36ac06b5c..ecde19fe7 100644 --- a/src/backend/database/db/schema.ts +++ b/src/backend/database/db/schema.ts @@ -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 --- diff --git a/src/backend/database/repositories/ai-repository.ts b/src/backend/database/repositories/ai-repository.ts deleted file mode 100644 index f76b6a5d0..000000000 --- a/src/backend/database/repositories/ai-repository.ts +++ /dev/null @@ -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, - ) {} - - /** - * 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 { - 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 { - 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 { - 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 { - 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>, - ): Promise { - const existing = await this.findProvider(id, userId); - if (!existing) return null; - - const updates: Record = { 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 { - 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 { - 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 { - 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 { - 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 { - const updates: Record = { 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - // 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 { - await this.onWrite?.(); - } -} diff --git a/src/backend/database/repositories/factory.ts b/src/backend/database/repositories/factory.ts index a3e99f7b2..f409d518d 100644 --- a/src/backend/database/repositories/factory.ts +++ b/src/backend/database/repositories/factory.ts @@ -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(), diff --git a/src/backend/database/repositories/plugin-settings-repository.ts b/src/backend/database/repositories/plugin-settings-repository.ts index 4a9fbe1a9..7199a5556 100644 --- a/src/backend/database/repositories/plugin-settings-repository.ts +++ b/src/backend/database/repositories/plugin-settings-repository.ts @@ -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. diff --git a/src/backend/database/repositories/user-repository.ts b/src/backend/database/repositories/user-repository.ts index f6fcf7bfe..3c87d61e1 100644 --- a/src/backend/database/repositories/user-repository.ts +++ b/src/backend/database/repositories/user-repository.ts @@ -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 diff --git a/src/backend/database/routes/delete-user-data.ts b/src/backend/database/routes/delete-user-data.ts index f8dfe83af..a36940140 100644 --- a/src/backend/database/routes/delete-user-data.ts +++ b/src/backend/database/routes/delete-user-data.ts @@ -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); diff --git a/src/backend/database/routes/host-bulk-routes.ts b/src/backend/database/routes/host-bulk-routes.ts index ef7157ba3..37ac21ec0 100644 --- a/src/backend/database/routes/host-bulk-routes.ts +++ b/src/backend/database/routes/host-bulk-routes.ts @@ -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, diff --git a/src/backend/database/routes/host-normalizers.ts b/src/backend/database/routes/host-normalizers.ts index 6eb14edbd..63c08e045 100644 --- a/src/backend/database/routes/host-normalizers.ts +++ b/src/backend/database/routes/host-normalizers.ts @@ -178,7 +178,6 @@ export type NormalizedImportedHost = Record & { 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, diff --git a/src/backend/database/routes/host.ts b/src/backend/database/routes/host.ts index da7726048..adacb1052 100644 --- a/src/backend/database/routes/host.ts +++ b/src/backend/database/routes/host.ts @@ -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, { diff --git a/src/backend/database/routes/user-preferences.ts b/src/backend/database/routes/user-preferences.ts index a19750582..8e564f9df 100644 --- a/src/backend/database/routes/user-preferences.ts +++ b/src/backend/database/routes/user-preferences.ts @@ -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) diff --git a/src/backend/hosts/delete-host.ts b/src/backend/hosts/delete-host.ts new file mode 100644 index 000000000..f3f15c5e9 --- /dev/null +++ b/src/backend/hosts/delete-host.ts @@ -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 { + 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 }; +} diff --git a/src/backend/plugins/ctx-hosts.ts b/src/backend/plugins/ctx-hosts.ts index 597cb226b..ba311d44b 100644 --- a/src/backend/plugins/ctx-hosts.ts +++ b/src/backend/plugins/ctx-hosts.ts @@ -254,6 +254,35 @@ export function createPluginHosts({ manifest, bag, audit }: Deps): PluginHosts { : null; }, + delete: async (hostId: number): Promise => { + 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 => { await requireWrite(); const userId = actingUser(); diff --git a/src/backend/plugins/ctx-notify.ts b/src/backend/plugins/ctx-notify.ts index df24513a6..264892f7c 100644 --- a/src/backend/plugins/ctx-notify.ts +++ b/src/backend/plugins/ctx-notify.ts @@ -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, diff --git a/src/backend/plugins/ctx.ts b/src/backend/plugins/ctx.ts index 2aa051729..61b1b1884 100644 --- a/src/backend/plugins/ctx.ts +++ b/src/backend/plugins/ctx.ts @@ -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, diff --git a/src/backend/starter.ts b/src/backend/starter.ts index 05ef99a9a..8e915d47c 100644 --- a/src/backend/starter.ts +++ b/src/backend/starter.ts @@ -397,6 +397,10 @@ async function provisionLocalDesktopUserIfNeeded(): Promise { 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", diff --git a/src/backend/tests/database/plugin-settings-cascade.test.ts b/src/backend/tests/database/plugin-settings-cascade.test.ts index 39890bde2..f9f7ddbd7 100644 --- a/src/backend/tests/database/plugin-settings-cascade.test.ts +++ b/src/backend/tests/database/plugin-settings-cascade.test.ts @@ -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); diff --git a/src/backend/tests/plugins/ctx-hosts.test.ts b/src/backend/tests/plugins/ctx-hosts.test.ts index ccf31b545..fdb11a1e0 100644 --- a/src/backend/tests/plugins/ctx-hosts.test.ts +++ b/src/backend/tests/plugins/ctx-hosts.test.ts @@ -25,6 +25,8 @@ const h = vi.hoisted(() => ({ statusReports: [] as Array>, statusPorts: [] as string[], statusPortsRemoved: [] as string[], + userPermissions: new Set(), + 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 = []; diff --git a/src/backend/tests/plugins/ctx-notify.test.ts b/src/backend/tests/plugins/ctx-notify.test.ts index ac76e637f..f5119f807 100644 --- a/src/backend/tests/plugins/ctx-notify.test.ts +++ b/src/backend/tests/plugins/ctx-notify.test.ts @@ -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); + }); }); diff --git a/src/backend/tests/plugins/ctx-secrets.test.ts b/src/backend/tests/plugins/ctx-secrets.test.ts new file mode 100644 index 000000000..e3cf12f32 --- /dev/null +++ b/src/backend/tests/plugins/ctx-secrets.test.ts @@ -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(); +const auditEntries: Array> = []; + +const state = vi.hoisted(() => ({ + rows: new Map(), +})); + +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) => { + 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); + }); +}); diff --git a/src/backend/tests/utils/ai-settings-migration.test.ts b/src/backend/tests/utils/ai-settings-migration.test.ts new file mode 100644 index 000000000..15119afc1 --- /dev/null +++ b/src/backend/tests/utils/ai-settings-migration.test.ts @@ -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(), + preferences: [] as Array>, + hosts: [] as Array>, + providers: new Map>>(), + pluginInstalled: true, + deks: new Set(), +})); + +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:" 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([]); + }); +}); diff --git a/src/backend/utils/crypto-migration/ai-settings-migration.ts b/src/backend/utils/crypto-migration/ai-settings-migration.ts new file mode 100644 index 000000000..6be9961a8 --- /dev/null +++ b/src/backend/utils/crypto-migration/ai-settings-migration.ts @@ -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:" 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(query: ReturnType): Promise { + try { + return await selectRows(query); + } catch { + // A fresh install never had the table or column. + return []; + } +} + +export async function runAiSettingsMigration(): Promise { + 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 => { + 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), + }); +} diff --git a/src/backend/utils/field-crypto.ts b/src/backend/utils/field-crypto.ts index 1528aea60..6cab1fc36 100644 --- a/src/backend/utils/field-crypto.ts +++ b/src/backend/utils/field-crypto.ts @@ -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( diff --git a/src/types/index.ts b/src/types/index.ts index a0d663d92..3ee8a2157 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -159,7 +159,6 @@ export interface HostFeatureFlags { enableFileManager: boolean; // SSH only enableTmuxMonitor: boolean; // SSH only enableTerminalToolbar: boolean; // SSH, RDP, VNC, and Telnet - enableAiAssistant: boolean; // SSH only enableRemoteDesktop: boolean; // RDP, VNC only } @@ -217,7 +216,6 @@ export type Host = { scpLegacy?: boolean; enableTmuxMonitor: boolean; enableTerminalToolbar: boolean; - enableAiAssistant: boolean; showTerminalInSidebar: boolean; showFileManagerInSidebar: boolean; showTunnelInSidebar: boolean; @@ -368,7 +366,6 @@ export interface HostData { enableProxmox?: boolean; enableTmuxMonitor?: boolean; enableTerminalToolbar?: boolean; - enableAiAssistant?: boolean; proxmoxConfig?: ProxmoxConfig | Record | null; enableProxmoxStats?: boolean; proxmoxStatsConfig?: ProxmoxStatsConfig | Record | null; diff --git a/src/types/ui-types.ts b/src/types/ui-types.ts index 2cb148d10..70aafa08b 100644 --- a/src/types/ui-types.ts +++ b/src/types/ui-types.ts @@ -104,7 +104,6 @@ export type Host = { enableProxmox: boolean; enableTmuxMonitor: boolean; enableTerminalToolbar: boolean; - enableAiAssistant: boolean; proxmoxConfig?: { source?: { source: "proxmox"; @@ -264,7 +263,6 @@ export type KnownTabType = | "history" | "ssh-tools" | "automations" - | "ai" | "split-screen"; /** diff --git a/src/ui/AppShell.tsx b/src/ui/AppShell.tsx index 1694fb04b..44ef0dd64 100644 --- a/src/ui/AppShell.tsx +++ b/src/ui/AppShell.tsx @@ -2494,7 +2494,6 @@ export function AppShell({ enableProxmoxStats: false, enableTmuxMonitor: false, enableTerminalToolbar: false, - enableAiAssistant: false, enableSsh: false, sshPort: 22, serverTunnels: [], diff --git a/src/ui/api/open-tabs-api.ts b/src/ui/api/open-tabs-api.ts index b4a594d24..565a6ca97 100644 --- a/src/ui/api/open-tabs-api.ts +++ b/src/ui/api/open-tabs-api.ts @@ -139,8 +139,6 @@ export interface UserPreferences { disableUpdateCheck?: boolean | null; confirmTabClose?: boolean | null; hiddenRailTabs?: string | null; - aiAssistantEnabled?: boolean | null; - aiReadOnlyCommands?: boolean | null; compactHostView?: boolean | null; statusColorScheme?: string | null; customThemes?: string | null; diff --git a/src/ui/lib/host-to-ssh-host.ts b/src/ui/lib/host-to-ssh-host.ts index 1a7458f3a..8716a4cd0 100644 --- a/src/ui/lib/host-to-ssh-host.ts +++ b/src/ui/lib/host-to-ssh-host.ts @@ -27,7 +27,6 @@ export function hostToSSHHost(h: Host): SSHHost { enableTunnel: h.enableTunnel ?? false, enableFileManager: h.enableFileManager ?? false, enableTerminalToolbar: h.enableTerminalToolbar ?? true, - enableAiAssistant: h.enableAiAssistant ?? false, enableWebUi: h.enableWebUi ?? false, webUiConfig: (h.pluginSettings?.["web-endpoint"]?.webUiConfig as SSHHost["webUiConfig"] | undefined) ?? { endpoints: [] }, diff --git a/src/ui/lib/plugin-transport.ts b/src/ui/lib/plugin-transport.ts index b0f10b78c..dd8fbb129 100644 --- a/src/ui/lib/plugin-transport.ts +++ b/src/ui/lib/plugin-transport.ts @@ -20,6 +20,7 @@ import type { AxiosInstance } from "axios"; import { authApi, createRemoteOriginApiInstance } from "@/main-axios"; import { getBasePath } from "@/lib/base-path"; import { isElectron } from "@/lib/electron"; +import { getDeviceId } from "@/lib/device-id"; import { websocketAuthProtocols } from "@/lib/ws-auth"; import { buildOriginWsUrl, @@ -75,6 +76,32 @@ export function createPluginApi(pluginId: string): AxiosInstance { }) as AxiosInstance; } +/** + * A raw fetch on this plugin's /plugin-api mount point, for a response the + * caller reads as a stream (server-sent events). axios buffers the whole + * body, so it cannot. Sends the same auth the shared client does. + */ +export function pluginFetch( + pluginId: string, + path: string, + init: RequestInit = {}, +): Promise { + const base = (authApi.defaults.baseURL ?? "").replace(/\/+$/, ""); + const headers = new Headers(init.headers); + const deviceId = getDeviceId(); + if (deviceId) headers.set("X-Termix-Device-ID", deviceId); + if (isElectron()) { + headers.set("X-Electron-App", "true"); + const jwt = localStorage.getItem("jwt"); + if (jwt) headers.set("Authorization", `Bearer ${jwt}`); + } + return fetch(`${base}${pluginApiPath(pluginId, path)}`, { + credentials: "include", + ...init, + headers, + }); +} + const remotePluginApis = new Map(); /** diff --git a/src/ui/locales/en.json b/src/ui/locales/en.json index 706ad0dce..c38fed337 100644 --- a/src/ui/locales/en.json +++ b/src/ui/locales/en.json @@ -1041,8 +1041,6 @@ "connectionToolbar": "Connection Toolbar", "showConnectionToolbar": "Show connection toolbar", "showConnectionToolbarDesc": "Show the toolbar in this host's SSH, RDP, VNC, and Telnet sessions. You can also hide it temporarily from the toolbar itself.", - "enableAiAssistant": "Enable AI Assistant", - "enableAiAssistantDesc": "Allow the AI assistant to help in this host's terminal. Off by default, and still needs AI enabled for your account.", "proxmoxLastSyncStatus": { "success": "Success" }, diff --git a/src/ui/locales/translated/af_ZA.json b/src/ui/locales/translated/af_ZA.json index f63d2986d..5b51d3e53 100644 --- a/src/ui/locales/translated/af_ZA.json +++ b/src/ui/locales/translated/af_ZA.json @@ -1046,8 +1046,6 @@ "connectionToolbar": "Verbindingsnutsbalk", "showConnectionToolbar": "Wys verbindingsbalk", "showConnectionToolbarDesc": "Wys die nutsbalk in hierdie gasheer se SSH-, RDP-, VNC- en Telnet-sessies. Jy kan dit ook tydelik van die nutsbalk self versteek.", - "enableAiAssistant": "Aktiveer KI-assistent", - "enableAiAssistantDesc": "Laat die KI-assistent toe om in hierdie gasheer se terminaal te help. Standaard afgeskakel, en KI moet steeds vir jou rekening geaktiveer word.", "proxmoxLastSyncStatus": { "success": "Sukses" }, diff --git a/src/ui/locales/translated/ar_SA.json b/src/ui/locales/translated/ar_SA.json index 2c8075401..eae448f7f 100644 --- a/src/ui/locales/translated/ar_SA.json +++ b/src/ui/locales/translated/ar_SA.json @@ -1046,8 +1046,6 @@ "connectionToolbar": "شريط أدوات الاتصال", "showConnectionToolbar": "إظهار شريط أدوات الاتصال", "showConnectionToolbarDesc": "يمكنك إظهار شريط الأدوات في جلسات SSH وRDP وVNC وTelnet الخاصة بهذا المضيف. كما يمكنك إخفاؤه مؤقتًا من شريط الأدوات نفسه.", - "enableAiAssistant": "تفعيل مساعد الذكاء الاصطناعي", - "enableAiAssistantDesc": "اسمح للمساعد الذكي بالمساعدة في طرفية هذا المضيف. هذه الميزة معطلة افتراضيًا، ولا يزال يتعين تفعيلها لحسابك.", "proxmoxLastSyncStatus": { "success": "نجاح" }, diff --git a/src/ui/locales/translated/bg_BG.json b/src/ui/locales/translated/bg_BG.json index d20b48e67..9e80d0ac1 100644 --- a/src/ui/locales/translated/bg_BG.json +++ b/src/ui/locales/translated/bg_BG.json @@ -1046,8 +1046,6 @@ "connectionToolbar": "Инструменти за свързване", "showConnectionToolbar": "Показване на лентата с инструменти за връзки", "showConnectionToolbarDesc": "Показване на лентата с инструменти в SSH, RDP, VNC и Telnet сесиите на този хост. Можете също така временно да я скриете от самата лента с инструменти.", - "enableAiAssistant": "Активиране на AI асистент", - "enableAiAssistantDesc": "Разрешете на AI асистента да помага в терминала на този хост. Изключено по подразбиране и все още изисква AI да е активиран за вашия акаунт.", "proxmoxLastSyncStatus": { "success": "Успех" }, diff --git a/src/ui/locales/translated/bn_BD.json b/src/ui/locales/translated/bn_BD.json index a3eb6de8c..2e28bff72 100644 --- a/src/ui/locales/translated/bn_BD.json +++ b/src/ui/locales/translated/bn_BD.json @@ -1046,8 +1046,6 @@ "connectionToolbar": "সংযোগ টুলবার", "showConnectionToolbar": "সংযোগ টুলবার দেখান", "showConnectionToolbarDesc": "এই হোস্টের SSH, RDP, VNC, এবং Telnet সেশনগুলিতে টুলবারটি দেখান। আপনি টুলবারটি থেকেই এটিকে সাময়িকভাবে লুকাতেও পারেন।", - "enableAiAssistant": "এআই অ্যাসিস্ট্যান্ট সক্রিয় করুন", - "enableAiAssistantDesc": "এই হোস্টের টার্মিনালে সাহায্য করার জন্য এআই অ্যাসিস্ট্যান্টকে অনুমতি দিন। এটি ডিফল্টরূপে বন্ধ থাকে এবং আপনার অ্যাকাউন্টের জন্য এআই চালু করা প্রয়োজন।", "proxmoxLastSyncStatus": { "success": "সাফল্য" }, diff --git a/src/ui/locales/translated/ca_ES.json b/src/ui/locales/translated/ca_ES.json index 55670c7a5..58c800e2c 100644 --- a/src/ui/locales/translated/ca_ES.json +++ b/src/ui/locales/translated/ca_ES.json @@ -1046,8 +1046,6 @@ "connectionToolbar": "Barra d'eines de connexió", "showConnectionToolbar": "Mostra la barra d'eines de connexió", "showConnectionToolbarDesc": "Mostra la barra d'eines a les sessions SSH, RDP, VNC i Telnet d'aquest amfitrió. També la pots ocultar temporalment des de la barra d'eines.", - "enableAiAssistant": "Activa l'Assistent d'IA", - "enableAiAssistantDesc": "Permet que l'assistent d'IA ajudi al terminal d'aquest amfitrió. Desactivat per defecte, però encara cal que la IA estigui activada per al teu compte.", "proxmoxLastSyncStatus": { "success": "Èxit" }, diff --git a/src/ui/locales/translated/cs_CZ.json b/src/ui/locales/translated/cs_CZ.json index f709ebfbd..f8c7300a3 100644 --- a/src/ui/locales/translated/cs_CZ.json +++ b/src/ui/locales/translated/cs_CZ.json @@ -1046,8 +1046,6 @@ "connectionToolbar": "Panel nástrojů pro připojení", "showConnectionToolbar": "Zobrazit panel nástrojů pro připojení", "showConnectionToolbarDesc": "Zobrazit panel nástrojů v relacích SSH, RDP, VNC a Telnet tohoto hostitele. Můžete jej také dočasně skrýt přímo v panelu nástrojů.", - "enableAiAssistant": "Povolit asistenta s umělou inteligencí", - "enableAiAssistantDesc": "Povolit asistentovi s umělou inteligencí pomáhat v terminálu tohoto hostitele. Ve výchozím nastavení vypnuto, ale stále vyžaduje, aby byla pro váš účet povolena umělá inteligence.", "proxmoxLastSyncStatus": { "success": "Úspěch" }, diff --git a/src/ui/locales/translated/da_DK.json b/src/ui/locales/translated/da_DK.json index 4855ecdab..4d98e4c9e 100644 --- a/src/ui/locales/translated/da_DK.json +++ b/src/ui/locales/translated/da_DK.json @@ -1046,8 +1046,6 @@ "connectionToolbar": "Forbindelsesværktøjslinje", "showConnectionToolbar": "Vis forbindelsesværktøjslinjen", "showConnectionToolbarDesc": "Vis værktøjslinjen i denne værts SSH-, RDP-, VNC- og Telnet-sessioner. Du kan også skjule den midlertidigt fra selve værktøjslinjen.", - "enableAiAssistant": "Aktivér AI-assistent", - "enableAiAssistantDesc": "Tillad AI-assistenten at hjælpe i denne værts terminal. Deaktiveret som standard, og kræver stadig, at AI er aktiveret for din konto.", "proxmoxLastSyncStatus": { "success": "Succes" }, diff --git a/src/ui/locales/translated/de_DE.json b/src/ui/locales/translated/de_DE.json index bc47ca570..154b51978 100644 --- a/src/ui/locales/translated/de_DE.json +++ b/src/ui/locales/translated/de_DE.json @@ -1046,8 +1046,6 @@ "connectionToolbar": "Verbindungssymbolleiste", "showConnectionToolbar": "Verbindungssymbolleiste anzeigen", "showConnectionToolbarDesc": "Die Symbolleiste wird in den SSH-, RDP-, VNC- und Telnet-Sitzungen dieses Hosts angezeigt. Sie kann auch vorübergehend in der Symbolleiste selbst ausgeblendet werden.", - "enableAiAssistant": "KI-Assistent aktivieren", - "enableAiAssistantDesc": "Erlauben Sie dem KI-Assistenten, im Terminal dieses Hosts Hilfe zu leisten. Standardmäßig deaktiviert; die KI muss weiterhin für Ihr Konto aktiviert sein.", "proxmoxLastSyncStatus": { "success": "Erfolg" }, diff --git a/src/ui/locales/translated/el_GR.json b/src/ui/locales/translated/el_GR.json index 7effaf226..7d76d53dc 100644 --- a/src/ui/locales/translated/el_GR.json +++ b/src/ui/locales/translated/el_GR.json @@ -1046,8 +1046,6 @@ "connectionToolbar": "Γραμμή εργαλείων σύνδεσης", "showConnectionToolbar": "Εμφάνιση γραμμής εργαλείων σύνδεσης", "showConnectionToolbarDesc": "Εμφάνιση της γραμμής εργαλείων στις συνεδρίες SSH, RDP, VNC και Telnet αυτού του κεντρικού υπολογιστή. Μπορείτε επίσης να την αποκρύψετε προσωρινά από την ίδια τη γραμμή εργαλείων.", - "enableAiAssistant": "Ενεργοποίηση Βοηθού Τεχνητής Νοημοσύνης", - "enableAiAssistantDesc": "Επιτρέψτε στον βοηθό τεχνητής νοημοσύνης να βοηθά στο τερματικό αυτού του κεντρικού υπολογιστή. Απενεργοποιημένο από προεπιλογή και εξακολουθεί να χρειάζεται ενεργοποιημένη την τεχνητή νοημοσύνη για τον λογαριασμό σας.", "proxmoxLastSyncStatus": { "success": "Επιτυχία" }, diff --git a/src/ui/locales/translated/es_ES.json b/src/ui/locales/translated/es_ES.json index bacd9cf95..148ba6970 100644 --- a/src/ui/locales/translated/es_ES.json +++ b/src/ui/locales/translated/es_ES.json @@ -1046,8 +1046,6 @@ "connectionToolbar": "Barra de herramientas de conexión", "showConnectionToolbar": "Mostrar barra de herramientas de conexión", "showConnectionToolbarDesc": "Muestra la barra de herramientas en las sesiones SSH, RDP, VNC y Telnet de este host. También puedes ocultarla temporalmente desde la propia barra de herramientas.", - "enableAiAssistant": "Habilitar el asistente de IA", - "enableAiAssistantDesc": "Permite que el asistente de IA te ayude en la terminal de este host. Está desactivado por defecto y aún así necesitas tener la IA habilitada para tu cuenta.", "proxmoxLastSyncStatus": { "success": "Correcto" }, diff --git a/src/ui/locales/translated/fi_FI.json b/src/ui/locales/translated/fi_FI.json index 00cd1fda6..02d17a249 100644 --- a/src/ui/locales/translated/fi_FI.json +++ b/src/ui/locales/translated/fi_FI.json @@ -1046,8 +1046,6 @@ "connectionToolbar": "Yhteystyökalurivi", "showConnectionToolbar": "Näytä yhteystyökalurivi", "showConnectionToolbarDesc": "Näytä työkalupalkki tämän isännän SSH-, RDP-, VNC- ja Telnet-istunnoissa. Voit myös piilottaa sen tilapäisesti itse työkalupalkista.", - "enableAiAssistant": "Ota tekoälyavustaja käyttöön", - "enableAiAssistantDesc": "Salli tekoälyavustaja auttaa tämän isännän terminaalissa. Oletusarvoisesti pois päältä, mutta tekoälyn on silti oltava käytössä tililläsi.", "proxmoxLastSyncStatus": { "success": "Menestys" }, diff --git a/src/ui/locales/translated/fr_FR.json b/src/ui/locales/translated/fr_FR.json index 500c582c5..07c25d1d8 100644 --- a/src/ui/locales/translated/fr_FR.json +++ b/src/ui/locales/translated/fr_FR.json @@ -1046,8 +1046,6 @@ "connectionToolbar": "Barre d'outils de connexion", "showConnectionToolbar": "Afficher la barre d'outils de connexion", "showConnectionToolbarDesc": "Afficher la barre d'outils dans les sessions SSH, RDP, VNC et Telnet de cet hôte. Vous pouvez également la masquer temporairement depuis la barre d'outils elle-même.", - "enableAiAssistant": "Activer l'assistant IA", - "enableAiAssistantDesc": "Autoriser l'assistant IA à vous aider dans le terminal de cet hôte. Désactivé par défaut ; l'IA doit être activée pour votre compte.", "proxmoxLastSyncStatus": { "success": "Succès" }, diff --git a/src/ui/locales/translated/he_IL.json b/src/ui/locales/translated/he_IL.json index 39def40f4..b0121f2a1 100644 --- a/src/ui/locales/translated/he_IL.json +++ b/src/ui/locales/translated/he_IL.json @@ -1046,8 +1046,6 @@ "connectionToolbar": "סרגל הכלים לחיבור", "showConnectionToolbar": "הצג סרגל הכלים של החיבור", "showConnectionToolbarDesc": "הצג את סרגל הכלים בהפעלות SSH, RDP, VNC ו-Telnet של מחשב מארח זה. ניתן גם להסתיר אותו באופן זמני מסרגל הכלים עצמו.", - "enableAiAssistant": "הפעל את עוזר הבינה המלאכותית", - "enableAiAssistantDesc": "אפשר לעוזר הבינה המלאכותית לסייע במסוף של מארח זה. כבוי כברירת מחדל, ועדיין נדרשת הפעלת בינה מלאכותית עבור חשבונך.", "proxmoxLastSyncStatus": { "success": "הַצלָחָה" }, diff --git a/src/ui/locales/translated/hi_IN.json b/src/ui/locales/translated/hi_IN.json index 329193a93..411de61b9 100644 --- a/src/ui/locales/translated/hi_IN.json +++ b/src/ui/locales/translated/hi_IN.json @@ -1046,8 +1046,6 @@ "connectionToolbar": "कनेक्शन टूलबार", "showConnectionToolbar": "कनेक्शन टूलबार दिखाएँ", "showConnectionToolbarDesc": "इस होस्ट के SSH, RDP, VNC और Telnet सेशन में टूलबार दिखाएँ। आप इसे टूलबार से अस्थायी रूप से छिपा भी सकते हैं।", - "enableAiAssistant": "एआई असिस्टेंट को सक्षम करें", - "enableAiAssistantDesc": "इस होस्ट के टर्मिनल में एआई सहायक को मदद करने की अनुमति दें। यह डिफ़ॉल्ट रूप से बंद है, लेकिन आपके खाते के लिए एआई को सक्षम करना आवश्यक है।", "proxmoxLastSyncStatus": { "success": "सफलता" }, diff --git a/src/ui/locales/translated/hu_HU.json b/src/ui/locales/translated/hu_HU.json index 002409180..8aebb0cbc 100644 --- a/src/ui/locales/translated/hu_HU.json +++ b/src/ui/locales/translated/hu_HU.json @@ -1046,8 +1046,6 @@ "connectionToolbar": "Kapcsolat eszköztár", "showConnectionToolbar": "Kapcsolat eszköztár megjelenítése", "showConnectionToolbarDesc": "Eszköztár megjelenítése a gazdagép SSH, RDP, VNC és Telnet munkameneteiben. Ideiglenesen el is rejtheti magáról az eszköztárról.", - "enableAiAssistant": "AI asszisztens engedélyezése", - "enableAiAssistantDesc": "Engedélyezi a mesterséges intelligencia asszisztensének a gazdagép terminálján való segítséget. Alapértelmezés szerint ki van kapcsolva, de továbbra is engedélyeznie kell a mesterséges intelligenciát a fiókjában.", "proxmoxLastSyncStatus": { "success": "Siker" }, diff --git a/src/ui/locales/translated/id_ID.json b/src/ui/locales/translated/id_ID.json index 27feaedd8..4065a0c4f 100644 --- a/src/ui/locales/translated/id_ID.json +++ b/src/ui/locales/translated/id_ID.json @@ -1046,8 +1046,6 @@ "connectionToolbar": "Toolbar Koneksi", "showConnectionToolbar": "Tampilkan bilah alat koneksi", "showConnectionToolbarDesc": "Tampilkan toolbar di sesi SSH, RDP, VNC, dan Telnet host ini. Anda juga dapat menyembunyikannya sementara dari toolbar itu sendiri.", - "enableAiAssistant": "Aktifkan Asisten AI", - "enableAiAssistantDesc": "Izinkan asisten AI untuk membantu di terminal host ini. Secara default dinonaktifkan, dan AI tetap perlu diaktifkan untuk akun Anda.", "proxmoxLastSyncStatus": { "success": "Kesuksesan" }, diff --git a/src/ui/locales/translated/it_IT.json b/src/ui/locales/translated/it_IT.json index 0ae1d6bc3..6182a131e 100644 --- a/src/ui/locales/translated/it_IT.json +++ b/src/ui/locales/translated/it_IT.json @@ -1046,8 +1046,6 @@ "connectionToolbar": "Barra degli strumenti di connessione", "showConnectionToolbar": "Mostra la barra degli strumenti di connessione", "showConnectionToolbarDesc": "Mostra la barra degli strumenti nelle sessioni SSH, RDP, VNC e Telnet di questo host. Puoi anche nasconderla temporaneamente dalla barra degli strumenti stessa.", - "enableAiAssistant": "Assistente AI per l'innovazione", - "enableAiAssistantDesc": "Consenti all'assistente IA di fornire assistenza nel terminale di questo host. Disattivato per impostazione predefinita, ma è necessario abilitare l'IA per il tuo account.", "proxmoxLastSyncStatus": { "success": "Riuscita" }, diff --git a/src/ui/locales/translated/ja_JP.json b/src/ui/locales/translated/ja_JP.json index 00730b4bb..cc7f97d43 100644 --- a/src/ui/locales/translated/ja_JP.json +++ b/src/ui/locales/translated/ja_JP.json @@ -1046,8 +1046,6 @@ "connectionToolbar": "接続ツールバー", "showConnectionToolbar": "接続ツールバーを表示する", "showConnectionToolbarDesc": "このホストのSSH、RDP、VNC、およびTelnetセッションでツールバーを表示します。ツールバー自体から一時的に非表示にすることもできます。", - "enableAiAssistant": "AIアシスタントを有効にする", - "enableAiAssistantDesc": "このホストのターミナルでAIアシスタントによる支援を許可します。デフォルトでは無効になっており、アカウントでAIを有効にする必要があります。", "proxmoxLastSyncStatus": { "success": "成功" }, diff --git a/src/ui/locales/translated/ko_KR.json b/src/ui/locales/translated/ko_KR.json index 04efdcc28..8ff9ede97 100644 --- a/src/ui/locales/translated/ko_KR.json +++ b/src/ui/locales/translated/ko_KR.json @@ -1046,8 +1046,6 @@ "connectionToolbar": "연결 도구 모음", "showConnectionToolbar": "연결 도구 모음 표시", "showConnectionToolbarDesc": "이 호스트의 SSH, RDP, VNC 및 Telnet 세션에 툴바를 표시합니다. 툴바 자체에서 툴바를 일시적으로 숨길 수도 있습니다.", - "enableAiAssistant": "AI 어시스턴트 활성화", - "enableAiAssistantDesc": "이 호스트의 터미널에서 AI 도우미의 도움을 받을 수 있도록 허용합니다. 기본적으로 비활성화되어 있으며, 계정에 AI 기능이 활성화되어 있어야 합니다.", "proxmoxLastSyncStatus": { "success": "성공" }, diff --git a/src/ui/locales/translated/nl_NL.json b/src/ui/locales/translated/nl_NL.json index c105ea247..7d2970923 100644 --- a/src/ui/locales/translated/nl_NL.json +++ b/src/ui/locales/translated/nl_NL.json @@ -1046,8 +1046,6 @@ "connectionToolbar": "Verbindingswerkbalk", "showConnectionToolbar": "Verbindingswerkbalk weergeven", "showConnectionToolbarDesc": "Toon de werkbalk in de SSH-, RDP-, VNC- en Telnet-sessies van deze host. Je kunt de werkbalk ook tijdelijk verbergen via de werkbalk zelf.", - "enableAiAssistant": "AI-assistent inschakelen", - "enableAiAssistantDesc": "Sta de AI-assistent toe om te helpen in de terminal van deze host. Standaard uitgeschakeld, maar AI moet nog steeds voor uw account zijn ingeschakeld.", "proxmoxLastSyncStatus": { "success": "Succes" }, diff --git a/src/ui/locales/translated/no_NO.json b/src/ui/locales/translated/no_NO.json index 6072ee413..4eee01c42 100644 --- a/src/ui/locales/translated/no_NO.json +++ b/src/ui/locales/translated/no_NO.json @@ -1046,8 +1046,6 @@ "connectionToolbar": "Tilkoblingsverktøylinje", "showConnectionToolbar": "Vis tilkoblingsverktøylinjen", "showConnectionToolbarDesc": "Vis verktøylinjen i denne vertens SSH-, RDP-, VNC- og Telnet-økter. Du kan også skjule den midlertidig fra selve verktøylinjen.", - "enableAiAssistant": "Aktiver AI-assistent", - "enableAiAssistantDesc": "Tillat at AI-assistenten hjelper til i denne vertens terminal. Av som standard, og krever fortsatt at AI er aktivert for kontoen din.", "proxmoxLastSyncStatus": { "success": "Suksess" }, diff --git a/src/ui/locales/translated/pl_PL.json b/src/ui/locales/translated/pl_PL.json index 1d0d89a74..0ba625af3 100644 --- a/src/ui/locales/translated/pl_PL.json +++ b/src/ui/locales/translated/pl_PL.json @@ -1046,8 +1046,6 @@ "connectionToolbar": "Pasek narzędzi połączenia", "showConnectionToolbar": "Pokaż pasek narzędzi połączenia", "showConnectionToolbarDesc": "Pokaż pasek narzędzi w sesjach SSH, RDP, VNC i Telnet tego hosta. Możesz go również tymczasowo ukryć na samym pasku narzędzi.", - "enableAiAssistant": "Włącz asystenta AI", - "enableAiAssistantDesc": "Zezwól asystentowi AI na pomoc w terminalu tego hosta. Domyślnie wyłączone, ale nadal wymaga włączenia AI na Twoim koncie.", "proxmoxLastSyncStatus": { "success": "Powodzenie" }, diff --git a/src/ui/locales/translated/pt_BR.json b/src/ui/locales/translated/pt_BR.json index 5841cf149..c06f22e00 100644 --- a/src/ui/locales/translated/pt_BR.json +++ b/src/ui/locales/translated/pt_BR.json @@ -1046,8 +1046,6 @@ "connectionToolbar": "Barra de ferramentas de conexão", "showConnectionToolbar": "Mostrar barra de ferramentas de conexão", "showConnectionToolbarDesc": "Exiba a barra de ferramentas nas sessões SSH, RDP, VNC e Telnet deste host. Você também pode ocultá-la temporariamente na própria barra de ferramentas.", - "enableAiAssistant": "Ativar o assistente de IA", - "enableAiAssistantDesc": "Permita que o assistente de IA ajude no terminal deste host. Desativado por padrão, mas ainda precisa que a IA esteja ativada para sua conta.", "proxmoxLastSyncStatus": { "success": "Sucesso" }, diff --git a/src/ui/locales/translated/pt_PT.json b/src/ui/locales/translated/pt_PT.json index ba440b8e0..3007fcebb 100644 --- a/src/ui/locales/translated/pt_PT.json +++ b/src/ui/locales/translated/pt_PT.json @@ -1046,8 +1046,6 @@ "connectionToolbar": "Barra de ferramentas de ligação", "showConnectionToolbar": "Mostrar barra de ferramentas de ligação", "showConnectionToolbarDesc": "Apresente a barra de ferramentas nas sessões SSH, RDP, VNC e Telnet deste host. Também pode ocultá-la temporariamente na própria barra de ferramentas.", - "enableAiAssistant": "Ativar o assistente de IA", - "enableAiAssistantDesc": "Permita que o assistente de IA ajude no terminal deste host. Desativado por defeito, mas ainda precisa que a IA esteja ativada para a sua conta.", "proxmoxLastSyncStatus": { "success": "Sucesso" }, diff --git a/src/ui/locales/translated/ro_RO.json b/src/ui/locales/translated/ro_RO.json index 354e0e594..41a5179e9 100644 --- a/src/ui/locales/translated/ro_RO.json +++ b/src/ui/locales/translated/ro_RO.json @@ -1046,8 +1046,6 @@ "connectionToolbar": "Bara de instrumente Conexiune", "showConnectionToolbar": "Afișează bara de instrumente pentru conexiuni", "showConnectionToolbarDesc": "Afișează bara de instrumente în sesiunile SSH, RDP, VNC și Telnet ale acestei gazde. De asemenea, o poți ascunde temporar din bara de instrumente.", - "enableAiAssistant": "Activează Asistentul AI", - "enableAiAssistantDesc": "Permiteți asistentului AI să vă ajute în terminalul acestei gazde. Dezactivat în mod implicit, dar este nevoie ca AI să fie activat pentru contul dvs.", "proxmoxLastSyncStatus": { "success": "Succes" }, diff --git a/src/ui/locales/translated/ru_RU.json b/src/ui/locales/translated/ru_RU.json index 2cd9e9616..a1a873956 100644 --- a/src/ui/locales/translated/ru_RU.json +++ b/src/ui/locales/translated/ru_RU.json @@ -1046,8 +1046,6 @@ "connectionToolbar": "Панель инструментов подключения", "showConnectionToolbar": "Показать панель инструментов подключения", "showConnectionToolbarDesc": "Отображать панель инструментов в сеансах SSH, RDP, VNC и Telnet этого хоста. Также можно временно скрыть её с самой панели инструментов.", - "enableAiAssistant": "Включить ИИ-помощника", - "enableAiAssistantDesc": "Разрешите помощнику с искусственным интеллектом помогать на терминале этого хоста. По умолчанию отключено, но для вашей учетной записи ИИ все равно должен быть включен.", "proxmoxLastSyncStatus": { "success": "Успешно" }, diff --git a/src/ui/locales/translated/sr_SP.json b/src/ui/locales/translated/sr_SP.json index b73acaac7..25df220d0 100644 --- a/src/ui/locales/translated/sr_SP.json +++ b/src/ui/locales/translated/sr_SP.json @@ -1046,8 +1046,6 @@ "connectionToolbar": "Трака са алаткама за повезивање", "showConnectionToolbar": "Прикажи траку са алаткама за повезивање", "showConnectionToolbarDesc": "Прикажи траку са алаткама у SSH, RDP, VNC и Telnet сесијама овог хоста. Такође је можете привремено сакрити са саме траке са алаткама.", - "enableAiAssistant": "Омогући AI асистент", - "enableAiAssistantDesc": "Дозволите вештачкој интелигенцији да помогне у терминалу овог хоста. Искључено је подразумевано, а и даље је потребно да вештачка интелигенција буде омогућена за ваш налог.", "proxmoxLastSyncStatus": { "success": "Успех" }, diff --git a/src/ui/locales/translated/sv_SE.json b/src/ui/locales/translated/sv_SE.json index 682599ad7..826633a99 100644 --- a/src/ui/locales/translated/sv_SE.json +++ b/src/ui/locales/translated/sv_SE.json @@ -1046,8 +1046,6 @@ "connectionToolbar": "Anslutningsverktygsfält", "showConnectionToolbar": "Visa anslutningsverktygsfältet", "showConnectionToolbarDesc": "Visa verktygsfältet i den här värdens SSH-, RDP-, VNC- och Telnet-sessioner. Du kan också dölja det tillfälligt från själva verktygsfältet.", - "enableAiAssistant": "Aktivera AI-assistenten", - "enableAiAssistantDesc": "Tillåt AI-assistenten att hjälpa till i den här värdens terminal. Av som standard och kräver fortfarande att AI är aktiverat för ditt konto.", "proxmoxLastSyncStatus": { "success": "Framgång" }, diff --git a/src/ui/locales/translated/th_TH.json b/src/ui/locales/translated/th_TH.json index 5f4be09cc..789533bb0 100644 --- a/src/ui/locales/translated/th_TH.json +++ b/src/ui/locales/translated/th_TH.json @@ -1046,8 +1046,6 @@ "connectionToolbar": "แถบเครื่องมือการเชื่อมต่อ", "showConnectionToolbar": "แสดงแถบเครื่องมือการเชื่อมต่อ", "showConnectionToolbarDesc": "แสดงแถบเครื่องมือในเซสชัน SSH, RDP, VNC และ Telnet ของโฮสต์นี้ คุณยังสามารถซ่อนแถบเครื่องมือชั่วคราวได้จากแถบเครื่องมือโดยตรง", - "enableAiAssistant": "เปิดใช้งานผู้ช่วย AI", - "enableAiAssistantDesc": "อนุญาตให้ผู้ช่วย AI ช่วยเหลือในเทอร์มินัลของโฮสต์นี้ โดยค่าเริ่มต้นจะปิดอยู่ และยังคงต้องเปิดใช้งาน AI สำหรับบัญชีของคุณก่อน", "proxmoxLastSyncStatus": { "success": "ความสำเร็จ" }, diff --git a/src/ui/locales/translated/tr_TR.json b/src/ui/locales/translated/tr_TR.json index d5b145981..49205f30c 100644 --- a/src/ui/locales/translated/tr_TR.json +++ b/src/ui/locales/translated/tr_TR.json @@ -1046,8 +1046,6 @@ "connectionToolbar": "Bağlantı Araç Çubuğu", "showConnectionToolbar": "Bağlantı araç çubuğunu göster", "showConnectionToolbarDesc": "Bu sunucunun SSH, RDP, VNC ve Telnet oturumlarında araç çubuğunu gösterin. Ayrıca, araç çubuğundan geçici olarak gizleyebilirsiniz.", - "enableAiAssistant": "Yapay Zeka Asistanını Etkinleştir", - "enableAiAssistantDesc": "Bu sunucunun terminalinde yapay zeka asistanının yardımcı olmasına izin verin. Varsayılan olarak kapalıdır ve hesabınız için yapay zekanın etkinleştirilmiş olması gerekir.", "proxmoxLastSyncStatus": { "success": "Başarılı" }, diff --git a/src/ui/locales/translated/uk_UA.json b/src/ui/locales/translated/uk_UA.json index 70194bd2d..e167fef81 100644 --- a/src/ui/locales/translated/uk_UA.json +++ b/src/ui/locales/translated/uk_UA.json @@ -1046,8 +1046,6 @@ "connectionToolbar": "Панель інструментів з’єднання", "showConnectionToolbar": "Показати панель інструментів з’єднань", "showConnectionToolbarDesc": "Показувати панель інструментів у сеансах SSH, RDP, VNC та Telnet цього хоста. Ви також можете тимчасово приховати її з самої панелі інструментів.", - "enableAiAssistant": "Увімкнути помічника зі штучним інтелектом", - "enableAiAssistantDesc": "Дозволити помічнику зі штучним інтелектом допомагати в терміналі цього хоста. Вимкнено за замовчуванням, але все одно потребує ввімкнення штучного інтелекту для вашого облікового запису.", "proxmoxLastSyncStatus": { "success": "Успіх" }, diff --git a/src/ui/locales/translated/vi_VN.json b/src/ui/locales/translated/vi_VN.json index f97ecfbec..a368c7b72 100644 --- a/src/ui/locales/translated/vi_VN.json +++ b/src/ui/locales/translated/vi_VN.json @@ -1046,8 +1046,6 @@ "connectionToolbar": "Thanh công cụ kết nối", "showConnectionToolbar": "Hiển thị thanh công cụ kết nối", "showConnectionToolbarDesc": "Hiển thị thanh công cụ trong các phiên SSH, RDP, VNC và Telnet của máy chủ này. Bạn cũng có thể tạm thời ẩn nó từ chính thanh công cụ.", - "enableAiAssistant": "Kích hoạt Trợ lý AI", - "enableAiAssistantDesc": "Cho phép trợ lý AI hỗ trợ trong thiết bị đầu cuối của máy chủ này. Mặc định là tắt, nhưng bạn vẫn cần bật AI cho tài khoản của mình.", "proxmoxLastSyncStatus": { "success": "Thành công" }, diff --git a/src/ui/locales/translated/zh_CN.json b/src/ui/locales/translated/zh_CN.json index 888013434..c853bace9 100644 --- a/src/ui/locales/translated/zh_CN.json +++ b/src/ui/locales/translated/zh_CN.json @@ -1046,8 +1046,6 @@ "connectionToolbar": "连接工具栏", "showConnectionToolbar": "显示连接工具栏", "showConnectionToolbarDesc": "在主机的 SSH、RDP、VNC 和 Telnet 会话中显示工具栏。您也可以暂时将其从工具栏本身隐藏。", - "enableAiAssistant": "启用人工智能助手", - "enableAiAssistantDesc": "允许 AI 助手在此主机的终端上提供帮助。默认情况下处于关闭状态,但仍需在您的帐户中启用 AI 功能。", "proxmoxLastSyncStatus": { "success": "成功" }, diff --git a/src/ui/locales/translated/zh_TW.json b/src/ui/locales/translated/zh_TW.json index af4d34389..eb1645f0e 100644 --- a/src/ui/locales/translated/zh_TW.json +++ b/src/ui/locales/translated/zh_TW.json @@ -1046,8 +1046,6 @@ "connectionToolbar": "連接工具列", "showConnectionToolbar": "顯示連接工具列", "showConnectionToolbarDesc": "在主機的 SSH、RDP、VNC 和 Telnet 會話中顯示工具列。您也可以暫時將其從工具列本身隱藏。", - "enableAiAssistant": "啟用人工智慧助手", - "enableAiAssistantDesc": "允許 AI 助理在此主機的終端機上提供協助。預設為關閉狀態,但仍需在您的帳戶中啟用 AI 功能。", "proxmoxLastSyncStatus": { "success": "成功" }, diff --git a/src/ui/plugin-host/app.ts b/src/ui/plugin-host/app.ts index f7654aed5..02a1e9db9 100644 --- a/src/ui/plugin-host/app.ts +++ b/src/ui/plugin-host/app.ts @@ -31,7 +31,7 @@ import { registerAction, registerSlotContribution, } from "@/shell/action-registry"; -import { pluginApiFor, pluginWsUrl } from "@/lib/plugin-transport"; +import { pluginApiFor, pluginFetch, pluginWsUrl } from "@/lib/plugin-transport"; import { registerPluginComponent } from "./component-registry"; import type { WidgetTypeDefinition } from "@/types/homepage-types"; import type { LucideIcon } from "lucide-react"; @@ -387,6 +387,7 @@ export function createPluginApp( origin as never, pluginHostBridge.getApi(pluginId) as never, ) as unknown as TermixApp["api"], + fetch: (path, init) => pluginFetch(pluginId, path, init), wsUrl: (path, options) => pluginWsUrl(pluginId, path, options as never) as ReturnType< TermixApp["wsUrl"] diff --git a/src/ui/sidebar/HostEditorData.ts b/src/ui/sidebar/HostEditorData.ts index 079ee4c04..7a1485a70 100644 --- a/src/ui/sidebar/HostEditorData.ts +++ b/src/ui/sidebar/HostEditorData.ts @@ -165,7 +165,6 @@ export function createHostEditorForm( scpLegacy: host?.scpLegacy ?? false, enableTmuxMonitor: host?.enableTmuxMonitor ?? false, enableTerminalToolbar: host?.enableTerminalToolbar ?? true, - enableAiAssistant: host?.enableAiAssistant ?? false, enableProxmox: (proxmoxSettings.enableProxmox as boolean) ?? false, // The proxmox tab fills in its own defaults when this is unset. proxmoxConfig: @@ -428,7 +427,6 @@ export function buildHostEditorPayload( scpLegacy: form.scpLegacy, enableTmuxMonitor: form.enableTmuxMonitor, enableTerminalToolbar: form.enableTerminalToolbar, - enableAiAssistant: form.enableAiAssistant, enableProxmox: form.enableProxmox, proxmoxConfig: form.enableProxmox || form.proxmoxConfig?.source diff --git a/src/ui/sidebar/HostEditorGeneralTab.tsx b/src/ui/sidebar/HostEditorGeneralTab.tsx index 5a1480e8f..c7e63e7b0 100644 --- a/src/ui/sidebar/HostEditorGeneralTab.tsx +++ b/src/ui/sidebar/HostEditorGeneralTab.tsx @@ -905,15 +905,6 @@ export function HostEditorGeneralTab({ onChange={(value) => setField("enableTerminalToolbar", value)} /> - - setField("enableAiAssistant", value)} - /> - diff --git a/src/ui/sidebar/HostManagerData.ts b/src/ui/sidebar/HostManagerData.ts index 630b8f835..1b771f142 100644 --- a/src/ui/sidebar/HostManagerData.ts +++ b/src/ui/sidebar/HostManagerData.ts @@ -101,7 +101,6 @@ export function sshHostToHost(h: SSHHostWithStatus): Host { (proxmoxSettings.enableProxmoxStats as boolean) ?? false, enableTmuxMonitor: h.enableTmuxMonitor ?? false, enableTerminalToolbar: h.enableTerminalToolbar ?? true, - enableAiAssistant: h.enableAiAssistant ?? false, proxmoxConfig: (proxmoxSettings.proxmoxConfig as Host["proxmoxConfig"]) ?? null, proxmoxStatsConfig: diff --git a/src/ui/sidebar/UserProfilePanel.tsx b/src/ui/sidebar/UserProfilePanel.tsx index 0ca848a4a..4c5560aaa 100644 --- a/src/ui/sidebar/UserProfilePanel.tsx +++ b/src/ui/sidebar/UserProfilePanel.tsx @@ -536,8 +536,6 @@ export function UserProfilePanel({ storageMode?: string | null; commandAutocomplete?: boolean | null; commandPaletteEnabled?: boolean | null; - aiAssistantEnabled?: boolean | null; - aiReadOnlyCommands?: boolean | null; showHostTags?: boolean | null; hostTrayOnClick?: boolean | null; compactHostView?: boolean | null; diff --git a/src/ui/sidebar/host-export-payload.ts b/src/ui/sidebar/host-export-payload.ts index c52cf7c91..7f3b0a76a 100644 --- a/src/ui/sidebar/host-export-payload.ts +++ b/src/ui/sidebar/host-export-payload.ts @@ -42,7 +42,6 @@ export const FIELD_GROUP_KEYS: Record = { "enableTerminal", "enableCommandHistory", "enableTerminalToolbar", - "enableAiAssistant", "enableFileManager", "enableWebUi", "enableProxmox", diff --git a/src/ui/sidebar/quick-connect-host.ts b/src/ui/sidebar/quick-connect-host.ts index 17ea3a50a..172a9d28a 100644 --- a/src/ui/sidebar/quick-connect-host.ts +++ b/src/ui/sidebar/quick-connect-host.ts @@ -35,7 +35,6 @@ export function createQuickConnectHost(input: QuickConnectInput): Host { enableFileManager: false, enableTunnel: false, enableTerminalToolbar: false, - enableAiAssistant: false, enableSsh: false, domain: input.domain, pluginSettings: { @@ -74,7 +73,6 @@ export function createQuickConnectHost(input: QuickConnectInput): Host { enableProxmoxStats: false, enableTmuxMonitor: false, enableTerminalToolbar: true, - enableAiAssistant: false, enableSsh: true, sshPort: input.port, }; @@ -103,7 +101,6 @@ export function quickConnectHostToPayload(host: Host): SSHHostData { enableProxmox: host.enableProxmox, enableTmuxMonitor: host.enableTmuxMonitor, enableTerminalToolbar: host.enableTerminalToolbar, - enableAiAssistant: host.enableAiAssistant, showTerminalInSidebar: true, showFileManagerInSidebar: true, showTunnelInSidebar: true, diff --git a/src/ui/tests/lib/plugin-transport.test.ts b/src/ui/tests/lib/plugin-transport.test.ts new file mode 100644 index 000000000..b125d2c8f --- /dev/null +++ b/src/ui/tests/lib/plugin-transport.test.ts @@ -0,0 +1,63 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const state = vi.hoisted(() => ({ electron: false })); + +vi.mock("@/main-axios", () => ({ + authApi: { defaults: { baseURL: "http://server:8080/" } }, + createRemoteOriginApiInstance: vi.fn(), +})); +vi.mock("@/lib/electron", () => ({ isElectron: () => state.electron })); +vi.mock("@/lib/device-id", () => ({ getDeviceId: () => "device-1" })); + +import { pluginFetch } from "@/lib/plugin-transport"; + +const fetchMock = vi.fn(async () => new Response("ok")); + +beforeEach(() => { + state.electron = false; + fetchMock.mockClear(); + vi.stubGlobal("fetch", fetchMock); +}); + +afterEach(() => { + vi.unstubAllGlobals(); + localStorage.clear(); +}); + +describe("pluginFetch", () => { + it("fetches the plugin's own mount point with the session cookie", async () => { + await pluginFetch("ai", "chat/stream", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: "{}", + }); + + const [url, init] = fetchMock.mock.calls[0] as unknown as [ + string, + RequestInit, + ]; + expect(url).toBe("http://server:8080/plugin-api/ai/chat/stream"); + expect(init.credentials).toBe("include"); + expect(init.method).toBe("POST"); + const headers = init.headers as Headers; + expect(headers.get("content-type")).toBe("application/json"); + expect(headers.get("x-termix-device-id")).toBe("device-1"); + expect(headers.get("authorization")).toBeNull(); + }); + + it("sends the stored token in the desktop app, like the shared client", async () => { + state.electron = true; + localStorage.setItem("jwt", "token-1"); + + await pluginFetch("ai", "/status"); + + const [url, init] = fetchMock.mock.calls[0] as unknown as [ + string, + RequestInit, + ]; + expect(url).toBe("http://server:8080/plugin-api/ai/status"); + const headers = init.headers as Headers; + expect(headers.get("x-electron-app")).toBe("true"); + expect(headers.get("authorization")).toBe("Bearer token-1"); + }); +}); diff --git a/src/ui/tests/sidebar/HostCredentialList.test.tsx b/src/ui/tests/sidebar/HostCredentialList.test.tsx index d7fe7aba5..7145f2886 100644 --- a/src/ui/tests/sidebar/HostCredentialList.test.tsx +++ b/src/ui/tests/sidebar/HostCredentialList.test.tsx @@ -44,7 +44,6 @@ function makeHost(overrides: Partial): Host { authType: "credential", enableTerminal: true, enableTerminalToolbar: true, - enableAiAssistant: false, enableCommandHistory: true, enableTunnel: false, serverTunnels: [], diff --git a/src/ui/tests/sidebar/HostExportDialog.test.tsx b/src/ui/tests/sidebar/HostExportDialog.test.tsx index 3cbb77dc6..f56d31f44 100644 --- a/src/ui/tests/sidebar/HostExportDialog.test.tsx +++ b/src/ui/tests/sidebar/HostExportDialog.test.tsx @@ -52,7 +52,6 @@ function sshHost( enableFileManager: false, enableTmuxMonitor: false, enableTerminalToolbar: true, - enableAiAssistant: false, showTerminalInSidebar: true, showFileManagerInSidebar: false, showTunnelInSidebar: false,