diff --git a/drizzle/mysql/0059_military_purple_man.sql b/drizzle/mysql/0059_military_purple_man.sql new file mode 100644 index 000000000..24f0d9bdc --- /dev/null +++ b/drizzle/mysql/0059_military_purple_man.sql @@ -0,0 +1,59 @@ +CREATE TABLE `sync_conflicts` ( + `id` int AUTO_INCREMENT NOT NULL, + `user_id` varchar(255) NOT NULL, + `entity_type` text NOT NULL, + `sync_id` text NOT NULL, + `local_row` text NOT NULL, + `server_revision` int NOT NULL, + `created_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP), + CONSTRAINT `sync_conflicts_id` PRIMARY KEY(`id`) +); +--> statement-breakpoint +CREATE TABLE `sync_link` ( + `id` int AUTO_INCREMENT NOT NULL, + `user_id` varchar(255) NOT NULL, + `server_url` text NOT NULL, + `server_name` text, + `server_version` text, + `session_token` text, + `custom_headers` text, + `basic_auth` text, + `allow_invalid_certificate` boolean NOT NULL DEFAULT false, + `remote_user_id` text, + `remote_username` text, + `account` text, + `scope` text, + `known_types` text, + `cursor` int NOT NULL DEFAULT 0, + `status` text NOT NULL DEFAULT ('idle'), + `last_error` text, + `linked_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP), + `last_sync_at` text, + CONSTRAINT `sync_link_id` PRIMARY KEY(`id`) +); +--> statement-breakpoint +CREATE TABLE `sync_records` ( + `id` int AUTO_INCREMENT NOT NULL, + `user_id` varchar(255) NOT NULL, + `entity_type` varchar(255) NOT NULL, + `sync_id` varchar(255) NOT NULL, + `revision` int NOT NULL DEFAULT 0, + `seq` int NOT NULL DEFAULT 0, + `hash` text, + `deleted` boolean NOT NULL DEFAULT false, + `error` text, + `error_hash` text, + `updated_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP), + CONSTRAINT `sync_records_id` PRIMARY KEY(`id`), + CONSTRAINT `idx_sync_records_user_entity_sync` UNIQUE(`user_id`,`entity_type`,`sync_id`) +); +--> statement-breakpoint +ALTER TABLE `ssh_data` ADD `local_only` boolean DEFAULT false NOT NULL;--> statement-breakpoint +ALTER TABLE `ssh_data` ADD `shared_source` text;--> statement-breakpoint +ALTER TABLE `ssh_credentials` ADD `shared_source` text;--> statement-breakpoint +ALTER TABLE `ssh_folders` ADD `local_only` boolean DEFAULT false NOT NULL;--> statement-breakpoint +ALTER TABLE `sync_conflicts` ADD CONSTRAINT `sync_conflicts_user_id_users_id_fk` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE `sync_link` ADD CONSTRAINT `sync_link_user_id_users_id_fk` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE `sync_records` ADD CONSTRAINT `sync_records_user_id_users_id_fk` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +CREATE INDEX `idx_sync_conflicts_user` ON `sync_conflicts` (`user_id`);--> statement-breakpoint +CREATE INDEX `idx_sync_records_user_seq` ON `sync_records` (`user_id`,`seq`); \ No newline at end of file diff --git a/drizzle/mysql/0060_mean_gorgon.sql b/drizzle/mysql/0060_mean_gorgon.sql new file mode 100644 index 000000000..1d163e491 --- /dev/null +++ b/drizzle/mysql/0060_mean_gorgon.sql @@ -0,0 +1 @@ +DROP TABLE `sync_tombstones`; \ No newline at end of file diff --git a/drizzle/mysql/meta/0059_snapshot.json b/drizzle/mysql/meta/0059_snapshot.json new file mode 100644 index 000000000..f8540d7b7 --- /dev/null +++ b/drizzle/mysql/meta/0059_snapshot.json @@ -0,0 +1,4798 @@ +{ + "version": "5", + "dialect": "mysql", + "id": "9cfab73f-3e3c-48da-a029-31684e9e5c25", + "prevId": "c1215a3d-3ad7-4968-8efc-5904a0b42549", + "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": "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": "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": "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": { + "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": {} + }, + "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": "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": { + "folder_access_id": { + "name": "folder_access_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "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": "text", + "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": "text", + "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 + }, + "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 + }, + "jump_hosts": { + "name": "jump_hosts", + "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 + }, + "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 + }, + "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 + }, + "local_only": { + "name": "local_only", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "shared_source": { + "name": "shared_source", + "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_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_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": {} + }, + "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": "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": "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": "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": "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": "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": { + "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": {} + }, + "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": "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": "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 + }, + "shared_source": { + "name": "shared_source", + "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_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": "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": "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 + }, + "local_only": { + "name": "local_only", + "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_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": {} + }, + "sync_conflicts": { + "name": "sync_conflicts", + "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": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "local_row": { + "name": "local_row", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "server_revision": { + "name": "server_revision", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "idx_sync_conflicts_user": { + "name": "idx_sync_conflicts_user", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "sync_conflicts_user_id_users_id_fk": { + "name": "sync_conflicts_user_id_users_id_fk", + "tableFrom": "sync_conflicts", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "sync_conflicts_id": { + "name": "sync_conflicts_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "sync_link": { + "name": "sync_link", + "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 + }, + "server_url": { + "name": "server_url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "server_name": { + "name": "server_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "server_version": { + "name": "server_version", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "session_token": { + "name": "session_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "custom_headers": { + "name": "custom_headers", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "basic_auth": { + "name": "basic_auth", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "allow_invalid_certificate": { + "name": "allow_invalid_certificate", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "remote_user_id": { + "name": "remote_user_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "remote_username": { + "name": "remote_username", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "account": { + "name": "account", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "known_types": { + "name": "known_types", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cursor": { + "name": "cursor", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "('idle')" + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "linked_at": { + "name": "linked_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "last_sync_at": { + "name": "last_sync_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "sync_link_user_id_users_id_fk": { + "name": "sync_link_user_id_users_id_fk", + "tableFrom": "sync_link", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "sync_link_id": { + "name": "sync_link_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "sync_records": { + "name": "sync_records", + "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": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sync_id": { + "name": "sync_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "revision": { + "name": "revision", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "seq": { + "name": "seq", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "hash": { + "name": "hash", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "deleted": { + "name": "deleted", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "error_hash": { + "name": "error_hash", + "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": { + "idx_sync_records_user_entity_sync": { + "name": "idx_sync_records_user_entity_sync", + "columns": [ + "user_id", + "entity_type", + "sync_id" + ], + "isUnique": true + }, + "idx_sync_records_user_seq": { + "name": "idx_sync_records_user_seq", + "columns": [ + "user_id", + "seq" + ], + "isUnique": false + } + }, + "foreignKeys": { + "sync_records_user_id_users_id_fk": { + "name": "sync_records_user_id_users_id_fk", + "tableFrom": "sync_records", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "sync_records_id": { + "name": "sync_records_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": "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": { + "sync_tombstones_id": { + "name": "sync_tombstones_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": "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": { + "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')" + }, + "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": {} + } + }, + "views": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "tables": {}, + "indexes": {} + } +} \ No newline at end of file diff --git a/drizzle/mysql/meta/0060_snapshot.json b/drizzle/mysql/meta/0060_snapshot.json new file mode 100644 index 000000000..ff91bf8eb --- /dev/null +++ b/drizzle/mysql/meta/0060_snapshot.json @@ -0,0 +1,4731 @@ +{ + "version": "5", + "dialect": "mysql", + "id": "c63e0b45-fd7d-4537-89b2-4b6faba92145", + "prevId": "9cfab73f-3e3c-48da-a029-31684e9e5c25", + "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": "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": "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": "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": { + "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": {} + }, + "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": "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": { + "folder_access_id": { + "name": "folder_access_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "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": "text", + "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": "text", + "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 + }, + "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 + }, + "jump_hosts": { + "name": "jump_hosts", + "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 + }, + "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 + }, + "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 + }, + "local_only": { + "name": "local_only", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "shared_source": { + "name": "shared_source", + "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_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_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": {} + }, + "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": "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": "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": "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": "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": "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": { + "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": {} + }, + "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": "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": "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 + }, + "shared_source": { + "name": "shared_source", + "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_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": "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": "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 + }, + "local_only": { + "name": "local_only", + "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_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": {} + }, + "sync_conflicts": { + "name": "sync_conflicts", + "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": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "local_row": { + "name": "local_row", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "server_revision": { + "name": "server_revision", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "idx_sync_conflicts_user": { + "name": "idx_sync_conflicts_user", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "sync_conflicts_user_id_users_id_fk": { + "name": "sync_conflicts_user_id_users_id_fk", + "tableFrom": "sync_conflicts", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "sync_conflicts_id": { + "name": "sync_conflicts_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "sync_link": { + "name": "sync_link", + "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 + }, + "server_url": { + "name": "server_url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "server_name": { + "name": "server_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "server_version": { + "name": "server_version", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "session_token": { + "name": "session_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "custom_headers": { + "name": "custom_headers", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "basic_auth": { + "name": "basic_auth", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "allow_invalid_certificate": { + "name": "allow_invalid_certificate", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "remote_user_id": { + "name": "remote_user_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "remote_username": { + "name": "remote_username", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "account": { + "name": "account", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "known_types": { + "name": "known_types", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cursor": { + "name": "cursor", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "('idle')" + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "linked_at": { + "name": "linked_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "last_sync_at": { + "name": "last_sync_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "sync_link_user_id_users_id_fk": { + "name": "sync_link_user_id_users_id_fk", + "tableFrom": "sync_link", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "sync_link_id": { + "name": "sync_link_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "sync_records": { + "name": "sync_records", + "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": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sync_id": { + "name": "sync_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "revision": { + "name": "revision", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "seq": { + "name": "seq", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "hash": { + "name": "hash", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "deleted": { + "name": "deleted", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "error_hash": { + "name": "error_hash", + "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": { + "idx_sync_records_user_entity_sync": { + "name": "idx_sync_records_user_entity_sync", + "columns": [ + "user_id", + "entity_type", + "sync_id" + ], + "isUnique": true + }, + "idx_sync_records_user_seq": { + "name": "idx_sync_records_user_seq", + "columns": [ + "user_id", + "seq" + ], + "isUnique": false + } + }, + "foreignKeys": { + "sync_records_user_id_users_id_fk": { + "name": "sync_records_user_id_users_id_fk", + "tableFrom": "sync_records", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "sync_records_id": { + "name": "sync_records_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": "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": { + "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')" + }, + "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": {} + } + }, + "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 b8818d06f..aa2589701 100644 --- a/drizzle/mysql/meta/_journal.json +++ b/drizzle/mysql/meta/_journal.json @@ -414,6 +414,20 @@ "when": 1790370152568, "tag": "0058_boring_avengers", "breakpoints": true + }, + { + "idx": 59, + "version": "5", + "when": 1790393785782, + "tag": "0059_military_purple_man", + "breakpoints": true + }, + { + "idx": 60, + "version": "5", + "when": 1790393789429, + "tag": "0060_mean_gorgon", + "breakpoints": true } ] } \ No newline at end of file diff --git a/drizzle/postgres/0060_conscious_juggernaut.sql b/drizzle/postgres/0060_conscious_juggernaut.sql new file mode 100644 index 000000000..a619125c2 --- /dev/null +++ b/drizzle/postgres/0060_conscious_juggernaut.sql @@ -0,0 +1,56 @@ +CREATE TABLE "sync_conflicts" ( + "id" serial PRIMARY KEY NOT NULL, + "user_id" varchar(255) NOT NULL, + "entity_type" text NOT NULL, + "sync_id" text NOT NULL, + "local_row" text NOT NULL, + "server_revision" integer NOT NULL, + "created_at" text DEFAULT CURRENT_TIMESTAMP NOT NULL +); +--> statement-breakpoint +CREATE TABLE "sync_link" ( + "id" serial PRIMARY KEY NOT NULL, + "user_id" varchar(255) NOT NULL, + "server_url" text NOT NULL, + "server_name" text, + "server_version" text, + "session_token" text, + "custom_headers" text, + "basic_auth" text, + "allow_invalid_certificate" boolean DEFAULT false NOT NULL, + "remote_user_id" text, + "remote_username" text, + "account" text, + "scope" text, + "known_types" text, + "cursor" integer DEFAULT 0 NOT NULL, + "status" text DEFAULT 'idle' NOT NULL, + "last_error" text, + "linked_at" text DEFAULT CURRENT_TIMESTAMP NOT NULL, + "last_sync_at" text +); +--> statement-breakpoint +CREATE TABLE "sync_records" ( + "id" serial PRIMARY KEY NOT NULL, + "user_id" varchar(255) NOT NULL, + "entity_type" varchar(255) NOT NULL, + "sync_id" varchar(255) NOT NULL, + "revision" integer DEFAULT 0 NOT NULL, + "seq" integer DEFAULT 0 NOT NULL, + "hash" text, + "deleted" boolean DEFAULT false NOT NULL, + "error" text, + "error_hash" text, + "updated_at" text DEFAULT CURRENT_TIMESTAMP NOT NULL +); +--> statement-breakpoint +ALTER TABLE "ssh_data" ADD COLUMN "local_only" boolean DEFAULT false NOT NULL;--> statement-breakpoint +ALTER TABLE "ssh_data" ADD COLUMN "shared_source" text;--> statement-breakpoint +ALTER TABLE "ssh_credentials" ADD COLUMN "shared_source" text;--> statement-breakpoint +ALTER TABLE "ssh_folders" ADD COLUMN "local_only" boolean DEFAULT false NOT NULL;--> statement-breakpoint +ALTER TABLE "sync_conflicts" ADD CONSTRAINT "sync_conflicts_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "sync_link" ADD CONSTRAINT "sync_link_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "sync_records" ADD CONSTRAINT "sync_records_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +CREATE INDEX "idx_sync_conflicts_user" ON "sync_conflicts" USING btree ("user_id");--> statement-breakpoint +CREATE UNIQUE INDEX "idx_sync_records_user_entity_sync" ON "sync_records" USING btree ("user_id","entity_type","sync_id");--> statement-breakpoint +CREATE INDEX "idx_sync_records_user_seq" ON "sync_records" USING btree ("user_id","seq"); \ No newline at end of file diff --git a/drizzle/postgres/0061_red_lila_cheney.sql b/drizzle/postgres/0061_red_lila_cheney.sql new file mode 100644 index 000000000..f1781c69c --- /dev/null +++ b/drizzle/postgres/0061_red_lila_cheney.sql @@ -0,0 +1 @@ +DROP TABLE "sync_tombstones" CASCADE; \ No newline at end of file diff --git a/drizzle/postgres/meta/0060_snapshot.json b/drizzle/postgres/meta/0060_snapshot.json new file mode 100644 index 000000000..bdac343e3 --- /dev/null +++ b/drizzle/postgres/meta/0060_snapshot.json @@ -0,0 +1,4747 @@ +{ + "id": "802f9a0f-b9d5-4ff3-94d8-136102290eb8", + "prevId": "450d62e2-5f13-4336-b1d1-980b7adc4eeb", + "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": "text", + "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": "text", + "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": "text", + "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.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": "text", + "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.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": "text", + "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": "text", + "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 + }, + "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 + }, + "jump_hosts": { + "name": "jump_hosts", + "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 + }, + "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 + }, + "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 + }, + "local_only": { + "name": "local_only", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "shared_source": { + "name": "shared_source", + "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_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_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.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": "text", + "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": "text", + "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": "text", + "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.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": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "folder": { + "name": "folder", + "type": "text", + "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 + }, + "shared_source": { + "name": "shared_source", + "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_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": "text", + "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 + }, + "local_only": { + "name": "local_only", + "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_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.sync_conflicts": { + "name": "sync_conflicts", + "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": "text", + "primaryKey": false, + "notNull": true + }, + "local_row": { + "name": "local_row", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "server_revision": { + "name": "server_revision", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_sync_conflicts_user": { + "name": "idx_sync_conflicts_user", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sync_conflicts_user_id_users_id_fk": { + "name": "sync_conflicts_user_id_users_id_fk", + "tableFrom": "sync_conflicts", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sync_link": { + "name": "sync_link", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "server_url": { + "name": "server_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "server_name": { + "name": "server_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "server_version": { + "name": "server_version", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "session_token": { + "name": "session_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "custom_headers": { + "name": "custom_headers", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "basic_auth": { + "name": "basic_auth", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "allow_invalid_certificate": { + "name": "allow_invalid_certificate", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "remote_user_id": { + "name": "remote_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "remote_username": { + "name": "remote_username", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "account": { + "name": "account", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "known_types": { + "name": "known_types", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cursor": { + "name": "cursor", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'idle'" + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "linked_at": { + "name": "linked_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "last_sync_at": { + "name": "last_sync_at", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "sync_link_user_id_users_id_fk": { + "name": "sync_link_user_id_users_id_fk", + "tableFrom": "sync_link", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sync_records": { + "name": "sync_records", + "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": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "sync_id": { + "name": "sync_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "seq": { + "name": "seq", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "hash": { + "name": "hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "deleted": { + "name": "deleted", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_hash": { + "name": "error_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_sync_records_user_entity_sync": { + "name": "idx_sync_records_user_entity_sync", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sync_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_sync_records_user_seq": { + "name": "idx_sync_records_user_seq", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "seq", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sync_records_user_id_users_id_fk": { + "name": "sync_records_user_id_users_id_fk", + "tableFrom": "sync_records", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "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": "text", + "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.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": "text", + "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'" + }, + "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 + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/drizzle/postgres/meta/0061_snapshot.json b/drizzle/postgres/meta/0061_snapshot.json new file mode 100644 index 000000000..dee0d4ed8 --- /dev/null +++ b/drizzle/postgres/meta/0061_snapshot.json @@ -0,0 +1,4689 @@ +{ + "id": "065046c4-afda-45ff-9e64-61c97ac52b99", + "prevId": "802f9a0f-b9d5-4ff3-94d8-136102290eb8", + "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": "text", + "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": "text", + "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": "text", + "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.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": "text", + "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.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": "text", + "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": "text", + "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 + }, + "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 + }, + "jump_hosts": { + "name": "jump_hosts", + "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 + }, + "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 + }, + "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 + }, + "local_only": { + "name": "local_only", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "shared_source": { + "name": "shared_source", + "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_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_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.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": "text", + "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": "text", + "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": "text", + "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.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": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "folder": { + "name": "folder", + "type": "text", + "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 + }, + "shared_source": { + "name": "shared_source", + "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_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": "text", + "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 + }, + "local_only": { + "name": "local_only", + "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_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.sync_conflicts": { + "name": "sync_conflicts", + "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": "text", + "primaryKey": false, + "notNull": true + }, + "local_row": { + "name": "local_row", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "server_revision": { + "name": "server_revision", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_sync_conflicts_user": { + "name": "idx_sync_conflicts_user", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sync_conflicts_user_id_users_id_fk": { + "name": "sync_conflicts_user_id_users_id_fk", + "tableFrom": "sync_conflicts", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sync_link": { + "name": "sync_link", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "server_url": { + "name": "server_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "server_name": { + "name": "server_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "server_version": { + "name": "server_version", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "session_token": { + "name": "session_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "custom_headers": { + "name": "custom_headers", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "basic_auth": { + "name": "basic_auth", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "allow_invalid_certificate": { + "name": "allow_invalid_certificate", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "remote_user_id": { + "name": "remote_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "remote_username": { + "name": "remote_username", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "account": { + "name": "account", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "known_types": { + "name": "known_types", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cursor": { + "name": "cursor", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'idle'" + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "linked_at": { + "name": "linked_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "last_sync_at": { + "name": "last_sync_at", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "sync_link_user_id_users_id_fk": { + "name": "sync_link_user_id_users_id_fk", + "tableFrom": "sync_link", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sync_records": { + "name": "sync_records", + "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": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "sync_id": { + "name": "sync_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "seq": { + "name": "seq", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "hash": { + "name": "hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "deleted": { + "name": "deleted", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_hash": { + "name": "error_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_sync_records_user_entity_sync": { + "name": "idx_sync_records_user_entity_sync", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sync_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_sync_records_user_seq": { + "name": "idx_sync_records_user_seq", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "seq", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sync_records_user_id_users_id_fk": { + "name": "sync_records_user_id_users_id_fk", + "tableFrom": "sync_records", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "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": "text", + "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'" + }, + "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 + } + }, + "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 ad870beb3..1f61464c3 100644 --- a/drizzle/postgres/meta/_journal.json +++ b/drizzle/postgres/meta/_journal.json @@ -421,6 +421,20 @@ "when": 1790370151997, "tag": "0059_square_korg", "breakpoints": true + }, + { + "idx": 60, + "version": "7", + "when": 1790393784687, + "tag": "0060_conscious_juggernaut", + "breakpoints": true + }, + { + "idx": 61, + "version": "7", + "when": 1790393788357, + "tag": "0061_red_lila_cheney", + "breakpoints": true } ] } \ No newline at end of file diff --git a/drizzle/sqlite/0055_shiny_squadron_sinister.sql b/drizzle/sqlite/0055_shiny_squadron_sinister.sql new file mode 100644 index 000000000..5f44d3f33 --- /dev/null +++ b/drizzle/sqlite/0055_shiny_squadron_sinister.sql @@ -0,0 +1,56 @@ +CREATE TABLE `sync_conflicts` ( + `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, + `user_id` text NOT NULL, + `entity_type` text NOT NULL, + `sync_id` text NOT NULL, + `local_row` text NOT NULL, + `server_revision` integer NOT NULL, + `created_at` text DEFAULT CURRENT_TIMESTAMP NOT NULL, + FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE INDEX `idx_sync_conflicts_user` ON `sync_conflicts` (`user_id`);--> statement-breakpoint +CREATE TABLE `sync_link` ( + `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, + `user_id` text NOT NULL, + `server_url` text NOT NULL, + `server_name` text, + `server_version` text, + `session_token` text, + `custom_headers` text, + `basic_auth` text, + `allow_invalid_certificate` integer DEFAULT false NOT NULL, + `remote_user_id` text, + `remote_username` text, + `account` text, + `scope` text, + `known_types` text, + `cursor` integer DEFAULT 0 NOT NULL, + `status` text DEFAULT 'idle' NOT NULL, + `last_error` text, + `linked_at` text DEFAULT CURRENT_TIMESTAMP NOT NULL, + `last_sync_at` text, + FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE TABLE `sync_records` ( + `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, + `user_id` text NOT NULL, + `entity_type` text NOT NULL, + `sync_id` text NOT NULL, + `revision` integer DEFAULT 0 NOT NULL, + `seq` integer DEFAULT 0 NOT NULL, + `hash` text, + `deleted` integer DEFAULT false NOT NULL, + `error` text, + `error_hash` text, + `updated_at` text DEFAULT CURRENT_TIMESTAMP NOT NULL, + FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE UNIQUE INDEX `idx_sync_records_user_entity_sync` ON `sync_records` (`user_id`,`entity_type`,`sync_id`);--> statement-breakpoint +CREATE INDEX `idx_sync_records_user_seq` ON `sync_records` (`user_id`,`seq`);--> statement-breakpoint +ALTER TABLE `ssh_data` ADD `local_only` integer DEFAULT false NOT NULL;--> statement-breakpoint +ALTER TABLE `ssh_data` ADD `shared_source` text;--> statement-breakpoint +ALTER TABLE `ssh_credentials` ADD `shared_source` text;--> statement-breakpoint +ALTER TABLE `ssh_folders` ADD `local_only` integer DEFAULT false NOT NULL; \ No newline at end of file diff --git a/drizzle/sqlite/0056_rare_luckman.sql b/drizzle/sqlite/0056_rare_luckman.sql new file mode 100644 index 000000000..1d163e491 --- /dev/null +++ b/drizzle/sqlite/0056_rare_luckman.sql @@ -0,0 +1 @@ +DROP TABLE `sync_tombstones`; \ No newline at end of file diff --git a/drizzle/sqlite/meta/0055_snapshot.json b/drizzle/sqlite/meta/0055_snapshot.json new file mode 100644 index 000000000..8c67eb220 --- /dev/null +++ b/drizzle/sqlite/meta/0055_snapshot.json @@ -0,0 +1,4526 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "fd50c2d1-0479-4e2c-b69b-702e5f556af0", + "prevId": "d75eef8e-3848-41fb-ae57-2b42d7064a32", + "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": {} + }, + "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": {} + }, + "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 + }, + "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 + }, + "jump_hosts": { + "name": "jump_hosts", + "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 + }, + "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 + }, + "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 + }, + "local_only": { + "name": "local_only", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "shared_source": { + "name": "shared_source", + "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_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": {} + }, + "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": {} + }, + "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 + }, + "shared_source": { + "name": "shared_source", + "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 + }, + "local_only": { + "name": "local_only", + "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": { + "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": {} + }, + "sync_conflicts": { + "name": "sync_conflicts", + "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 + }, + "local_row": { + "name": "local_row", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "server_revision": { + "name": "server_revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_sync_conflicts_user": { + "name": "idx_sync_conflicts_user", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "sync_conflicts_user_id_users_id_fk": { + "name": "sync_conflicts_user_id_users_id_fk", + "tableFrom": "sync_conflicts", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "sync_link": { + "name": "sync_link", + "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 + }, + "server_url": { + "name": "server_url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "server_name": { + "name": "server_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "server_version": { + "name": "server_version", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "session_token": { + "name": "session_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "custom_headers": { + "name": "custom_headers", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "basic_auth": { + "name": "basic_auth", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "allow_invalid_certificate": { + "name": "allow_invalid_certificate", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "remote_user_id": { + "name": "remote_user_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "remote_username": { + "name": "remote_username", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "account": { + "name": "account", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "known_types": { + "name": "known_types", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cursor": { + "name": "cursor", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'idle'" + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "linked_at": { + "name": "linked_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "last_sync_at": { + "name": "last_sync_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "sync_link_user_id_users_id_fk": { + "name": "sync_link_user_id_users_id_fk", + "tableFrom": "sync_link", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "sync_records": { + "name": "sync_records", + "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 + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "seq": { + "name": "seq", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "hash": { + "name": "hash", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "deleted": { + "name": "deleted", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "error_hash": { + "name": "error_hash", + "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": { + "idx_sync_records_user_entity_sync": { + "name": "idx_sync_records_user_entity_sync", + "columns": [ + "user_id", + "entity_type", + "sync_id" + ], + "isUnique": true + }, + "idx_sync_records_user_seq": { + "name": "idx_sync_records_user_seq", + "columns": [ + "user_id", + "seq" + ], + "isUnique": false + } + }, + "foreignKeys": { + "sync_records_user_id_users_id_fk": { + "name": "sync_records_user_id_users_id_fk", + "tableFrom": "sync_records", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "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": {} + }, + "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'" + }, + "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": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/drizzle/sqlite/meta/0056_snapshot.json b/drizzle/sqlite/meta/0056_snapshot.json new file mode 100644 index 000000000..6a3067fae --- /dev/null +++ b/drizzle/sqlite/meta/0056_snapshot.json @@ -0,0 +1,4466 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "52cd54b8-5e91-4e63-b200-c97e34415027", + "prevId": "fd50c2d1-0479-4e2c-b69b-702e5f556af0", + "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": {} + }, + "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": {} + }, + "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 + }, + "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 + }, + "jump_hosts": { + "name": "jump_hosts", + "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 + }, + "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 + }, + "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 + }, + "local_only": { + "name": "local_only", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "shared_source": { + "name": "shared_source", + "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_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": {} + }, + "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": {} + }, + "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 + }, + "shared_source": { + "name": "shared_source", + "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 + }, + "local_only": { + "name": "local_only", + "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": { + "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": {} + }, + "sync_conflicts": { + "name": "sync_conflicts", + "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 + }, + "local_row": { + "name": "local_row", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "server_revision": { + "name": "server_revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_sync_conflicts_user": { + "name": "idx_sync_conflicts_user", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "sync_conflicts_user_id_users_id_fk": { + "name": "sync_conflicts_user_id_users_id_fk", + "tableFrom": "sync_conflicts", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "sync_link": { + "name": "sync_link", + "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 + }, + "server_url": { + "name": "server_url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "server_name": { + "name": "server_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "server_version": { + "name": "server_version", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "session_token": { + "name": "session_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "custom_headers": { + "name": "custom_headers", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "basic_auth": { + "name": "basic_auth", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "allow_invalid_certificate": { + "name": "allow_invalid_certificate", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "remote_user_id": { + "name": "remote_user_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "remote_username": { + "name": "remote_username", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "account": { + "name": "account", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "known_types": { + "name": "known_types", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cursor": { + "name": "cursor", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'idle'" + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "linked_at": { + "name": "linked_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "last_sync_at": { + "name": "last_sync_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "sync_link_user_id_users_id_fk": { + "name": "sync_link_user_id_users_id_fk", + "tableFrom": "sync_link", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "sync_records": { + "name": "sync_records", + "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 + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "seq": { + "name": "seq", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "hash": { + "name": "hash", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "deleted": { + "name": "deleted", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "error_hash": { + "name": "error_hash", + "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": { + "idx_sync_records_user_entity_sync": { + "name": "idx_sync_records_user_entity_sync", + "columns": [ + "user_id", + "entity_type", + "sync_id" + ], + "isUnique": true + }, + "idx_sync_records_user_seq": { + "name": "idx_sync_records_user_seq", + "columns": [ + "user_id", + "seq" + ], + "isUnique": false + } + }, + "foreignKeys": { + "sync_records_user_id_users_id_fk": { + "name": "sync_records_user_id_users_id_fk", + "tableFrom": "sync_records", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "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'" + }, + "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": {} + } + }, + "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 aa8728d27..9d80f5dbc 100644 --- a/drizzle/sqlite/meta/_journal.json +++ b/drizzle/sqlite/meta/_journal.json @@ -386,6 +386,20 @@ "when": 1790370151385, "tag": "0054_superb_guardian", "breakpoints": true + }, + { + "idx": 55, + "version": "6", + "when": 1790393783561, + "tag": "0055_shiny_squadron_sinister", + "breakpoints": true + }, + { + "idx": 56, + "version": "6", + "when": 1790393787230, + "tag": "0056_rare_luckman", + "breakpoints": true } ] } \ No newline at end of file diff --git a/electron/desktop-settings.cjs b/electron/desktop-settings.cjs new file mode 100644 index 000000000..4c9590793 --- /dev/null +++ b/electron/desktop-settings.cjs @@ -0,0 +1,80 @@ +// Desktop-only settings kept next to the app's data, and the server address +// older versions saved, so linking can offer it again. + +const { app } = require("electron"); +const fs = require("fs"); +const path = require("path"); + +function dataPath(filename) { + return path.join(app.getPath("userData"), filename); +} + +function readJson(filePath, fallback) { + try { + if (!fs.existsSync(filePath)) return fallback; + return JSON.parse(fs.readFileSync(filePath, "utf8")); + } catch { + return fallback; + } +} + +function writeJson(filePath, value) { + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + const temporaryPath = `${filePath}.${process.pid}-${Date.now()}.tmp`; + try { + fs.writeFileSync(temporaryPath, JSON.stringify(value, null, 2), { + mode: 0o600, + }); + fs.renameSync(temporaryPath, filePath); + } catch (error) { + fs.rmSync(temporaryPath, { force: true }); + throw error; + } +} + +const DEFAULTS = { defaultConnectionOrigin: "local" }; + +function getDesktopSettings() { + return { ...DEFAULTS, ...readJson(dataPath("desktop-settings.json"), {}) }; +} + +function saveDesktopSettings(settings) { + writeJson(dataPath("desktop-settings.json"), { + ...getDesktopSettings(), + ...settings, + }); + return { success: true }; +} + +// The server a 2.8 or older desktop was pointed at, if any. +function getPreviousServerUrl() { + for (const file of ["remote-sync-config.json", "server-config.json"]) { + const config = readJson(dataPath(file), null); + if (config && typeof config.serverUrl === "string" && config.serverUrl) { + return config.serverUrl; + } + } + return null; +} + +// The old sync engine's session and cursors are useless to the new one, +// and the session is a credential, so they go. +function removeOldSyncFiles() { + for (const file of [ + "remote-sync-credential.json", + "remote-sync-state.json", + ]) { + try { + fs.rmSync(dataPath(file), { force: true }); + } catch { + // Best effort. + } + } +} + +module.exports = { + getDesktopSettings, + saveDesktopSettings, + getPreviousServerUrl, + removeOldSyncFiles, +}; diff --git a/electron/linked-server.cjs b/electron/linked-server.cjs new file mode 100644 index 000000000..219df606d --- /dev/null +++ b/electron/linked-server.cjs @@ -0,0 +1,98 @@ +// The server this desktop is linked to, as the embedded backend reports it. +// The backend owns the link (and its secrets); main only keeps what it needs +// to reach that one origin: the proxy headers and basic auth a reverse proxy +// in front of it wants, whether its certificate may be self-signed, and the +// session for requests main makes itself (C2S tunnels). + +let linked = null; + +function getOrigin(url) { + try { + return new URL(url).origin; + } catch { + return null; + } +} + +function setLinkedServer(config) { + if (!config || !config.origin) { + linked = null; + return; + } + linked = { + origin: config.origin, + serverUrl: config.serverUrl || config.origin, + token: config.token || null, + headers: Array.isArray(config.headers) ? config.headers : [], + basicAuth: + config.basicAuth && config.basicAuth.username ? config.basicAuth : null, + allowInvalidCertificate: !!config.allowInvalidCertificate, + }; +} + +function getLinkedServer() { + return linked; +} + +function isLinkedOrigin(url) { + return !!linked && getOrigin(url) === linked.origin; +} + +function basicAuthValue(basicAuth) { + return `Basic ${Buffer.from( + `${basicAuth.username}:${basicAuth.password || ""}`, + ).toString("base64")}`; +} + +// Adds the link's proxy headers to a request bound for the linked origin. +// With basic auth in front, Authorization belongs to the proxy, so a Termix +// bearer token moves into the jwt cookie, which Termix reads first. +function applyLinkHeaders(url, headers) { + if (!isLinkedOrigin(url)) return headers; + const out = { ...headers }; + for (const header of linked.headers) { + if (header && header.name && header.value) out[header.name] = header.value; + } + if (linked.basicAuth) { + const authKey = Object.keys(out).find( + (key) => key.toLowerCase() === "authorization", + ); + const current = authKey ? String(out[authKey]) : ""; + if (authKey) delete out[authKey]; + const bearer = current.match(/^Bearer\s+(.+)$/i); + if (bearer) { + const cookieKey = + Object.keys(out).find((key) => key.toLowerCase() === "cookie") || + "Cookie"; + const existing = out[cookieKey] ? `${out[cookieKey]}; ` : ""; + out[cookieKey] = `${existing}jwt=${bearer[1]}`; + } + out.Authorization = basicAuthValue(linked.basicAuth); + } + return out; +} + +// Headers for a request main itself makes to the linked server. +function linkedRequestHeaders(extra = {}) { + if (!linked) return { ...extra }; + const base = { "X-Electron-App": "true", ...extra }; + if (linked.token) base.Authorization = `Bearer ${linked.token}`; + return applyLinkHeaders(linked.origin, base); +} + +// Answers a basic auth challenge from the linked origin's proxy. +function answerLogin(authInfo, url) { + if (!linked || !linked.basicAuth) return null; + if (authInfo && authInfo.isProxy) return null; + if (!isLinkedOrigin(url)) return null; + return linked.basicAuth; +} + +module.exports = { + setLinkedServer, + getLinkedServer, + isLinkedOrigin, + applyLinkHeaders, + linkedRequestHeaders, + answerLogin, +}; diff --git a/electron/local-files.cjs b/electron/local-files.cjs index 6bbe37b9e..9b4c47a4e 100644 --- a/electron/local-files.cjs +++ b/electron/local-files.cjs @@ -10,8 +10,8 @@ // Trust boundary: the renderer never supplies a URL or headers. It names an // origin ("local" | "remote") and a route from a fixed allowlist; the main // process resolves the actual Termix backend URL and attaches credentials -// itself (session cookies for the embedded backend, the stored Remote Sync JWT -// for the remote server). Nothing here can be pointed at another host. +// itself (session cookies for the embedded backend, the linked server's +// session). Nothing here can be pointed at another host. const fs = require("fs"); const fsp = require("fs/promises"); @@ -344,12 +344,11 @@ function normalizeHttpBase(candidate, what) { } // Resolves where a transfer may go. Only the two file-manager streaming -// routes are reachable, and only on the embedded backend or the configured -// Remote Sync server; credentials come from the main process, not the caller. +// routes are reachable, and only on the embedded backend or the linked +// server; credentials come from the main process, not the caller. function createTargetResolver({ localBaseUrl = DEFAULT_LOCAL_FILE_MANAGER_BASE, - getRemoteSyncConfig, - getRemoteSyncJwt, + getLinkedServer, }) { return function resolveTransferTarget({ origin, @@ -396,20 +395,13 @@ function createTargetResolver({ } if (origin === "remote") { - const config = - typeof getRemoteSyncConfig === "function" - ? getRemoteSyncConfig() - : null; - if (!config || !config.serverUrl) { - throw new Error("Remote sync server is not configured"); + const linked = + typeof getLinkedServer === "function" ? getLinkedServer() : null; + if (!linked || !linked.serverUrl) { + throw new Error("This device is not linked to a server"); } - const base = normalizeHttpBase( - config.serverUrl, - "Remote sync server URL", - ); - const jwt = - typeof getRemoteSyncJwt === "function" ? getRemoteSyncJwt() : null; - if (jwt) headers.Authorization = `Bearer ${jwt}`; + const base = normalizeHttpBase(linked.serverUrl, "Linked server URL"); + if (linked.token) headers.Authorization = `Bearer ${linked.token}`; return { url: `${base}${FILE_MANAGER_API}${routePath}`, headers }; } @@ -880,8 +872,7 @@ function wrap(handler) { function createLocalFileHandlers({ net, shell, - getRemoteSyncConfig, - getRemoteSyncJwt, + getLinkedServer, localBaseUrl, publishFs, }) { @@ -890,8 +881,7 @@ function createLocalFileHandlers({ } const resolveTransferTarget = createTargetResolver({ localBaseUrl, - getRemoteSyncConfig, - getRemoteSyncJwt, + getLinkedServer, }); const deps = { net, @@ -1020,12 +1010,11 @@ function createLocalFileHandlers({ function registerLocalFileHandlers({ ipcMain, shell }) { // Real Electron wiring; tests build the handlers directly instead. const { net } = require("electron"); - const remoteSync = require("./remote-sync.cjs"); + const { getLinkedServer } = require("./linked-server.cjs"); const handlers = createLocalFileHandlers({ net, shell, - getRemoteSyncConfig: remoteSync.getRemoteSyncConfig, - getRemoteSyncJwt: remoteSync.getRemoteSyncJwt, + getLinkedServer, }); for (const [channel, handler] of Object.entries(handlers)) { ipcMain.handle(channel, handler); diff --git a/electron/main.cjs b/electron/main.cjs index 4e83ed704..1790786e9 100644 --- a/electron/main.cjs +++ b/electron/main.cjs @@ -27,7 +27,8 @@ const { URL, pathToFileURL } = require("url"); const { fork, spawn } = require("child_process"); const pty = require("node-pty"); const WebSocket = require("ws"); -const remoteSync = require("./remote-sync.cjs"); +const linkedServer = require("./linked-server.cjs"); +const desktopSettings = require("./desktop-settings.cjs"); const { launchNativeRdp } = require("./native-rdp.cjs"); const { isCloseActiveTabInput } = require("./keyboard-shortcuts.cjs"); const { quitApp } = require("./app-quit.cjs"); @@ -54,7 +55,7 @@ function closeLocalTerminalsFor(ownerId) { } // The main process's Node.js networking (the `https`/`http` modules used by -// httpFetch below, and the global `fetch` used by remote-sync.cjs) only +// httpFetch below and the global `fetch`) only // trusts Node's bundled Mozilla CA list by default, not the OS/system trust // store. Chromium (the renderer, i.e. the web app and the login iframe) uses // the OS trust store instead, so a certificate that's valid in-browser -- @@ -432,20 +433,6 @@ function isInsecureModeEnabled() { ); } -function getServerConfigPath() { - return path.join(app.getPath("userData"), "server-config.json"); -} - -function getServerConfigSync() { - try { - const configPath = getServerConfigPath(); - if (!fs.existsSync(configPath)) return null; - return JSON.parse(fs.readFileSync(configPath, "utf8")); - } catch { - return null; - } -} - function getOrigin(url) { try { return new URL(url).origin; @@ -493,13 +480,11 @@ function isInvalidCertificateAllowedForUrl(url) { // fall through } - // The only remaining "connected remote server" a self-signed/invalid - // certificate could legitimately apply to is the Remote Sync server - // (also used for C2S tunnel relaying, see getC2SRelayUrl). - const config = remoteSync.getRemoteSyncConfig(); - if (!config?.allowInvalidCertificate || !config?.serverUrl) return false; - - return getOrigin(url) === getOrigin(config.serverUrl); + // Otherwise only the server this desktop is linked to, when its link + // allows a self-signed certificate. + const linked = linkedServer.getLinkedServer(); + if (!linked?.allowInvalidCertificate) return false; + return getOrigin(url) === linked.origin; } function getTlsVerificationOptions(url) { @@ -646,6 +631,10 @@ const isolatedWindows = createIsolatedWindows({ const BACKEND_REQUEST_HANDLERS = { "open-isolated-window": (payload) => isolatedWindows.open(payload), "launch-native-rdp": (payload) => launchNativeRdp(payload), + "sync-proxy-config": (payload) => { + linkedServer.setLinkedServer(payload); + return { success: true }; + }, }; async function handleBackendRequest(msg) { @@ -888,6 +877,16 @@ app.on( }, ); +// A reverse proxy in front of the linked server may ask for basic auth; the +// link's saved username and password answer it. Anything else gets the +// default behaviour. +app.on("login", (event, _webContents, details, authInfo, callback) => { + const credentials = linkedServer.answerLogin(authInfo, details?.url); + if (!credentials) return; + event.preventDefault(); + callback(credentials.username, credentials.password || ""); +}); + function getElectronBuildTimestamp() { try { const buildInfo = require("./build-info.cjs"); @@ -1420,7 +1419,12 @@ function createWindow() { ); } - callback({ requestHeaders: details.requestHeaders }); + callback({ + requestHeaders: linkedServer.applyLinkHeaders( + details.url, + details.requestHeaders, + ), + }); }, ); @@ -1564,111 +1568,6 @@ ipcMain.handle("get-app-version", () => { return app.getVersion(); }); -const GITHUB_API_BASE = "https://api.github.com"; -const REPO_OWNER = "Termix-SSH"; -const REPO_NAME = "Termix"; - -const githubCache = new Map(); -const CACHE_DURATION = 30 * 60 * 1000; - -async function fetchGitHubAPI(endpoint, cacheKey) { - const cached = githubCache.get(cacheKey); - if (cached && Date.now() - cached.timestamp < CACHE_DURATION) { - return { - data: cached.data, - cached: true, - cache_age: Date.now() - cached.timestamp, - }; - } - - try { - const response = await httpFetch(`${GITHUB_API_BASE}${endpoint}`, { - headers: { - Accept: "application/vnd.github+json", - "User-Agent": "TermixElectronUpdateChecker/1.0", - "X-GitHub-Api-Version": "2022-11-28", - }, - timeout: 10000, - }); - - if (!response.ok) { - throw new Error( - `GitHub API error: ${response.status} ${response.statusText}`, - ); - } - - const data = await response.json(); - - githubCache.set(cacheKey, { - data, - timestamp: Date.now(), - }); - - return { - data: data, - cached: false, - }; - } catch (error) { - console.error("Failed to fetch from GitHub API:", error); - throw error; - } -} - -ipcMain.handle("check-electron-update", async () => { - try { - const localVersion = app.getVersion(); - - const releaseData = await fetchGitHubAPI( - `/repos/${REPO_OWNER}/${REPO_NAME}/releases/latest`, - "latest_release_electron", - ); - - const rawTag = releaseData.data.tag_name || releaseData.data.name || ""; - const remoteVersionMatch = rawTag.match(/(\d+\.\d+(\.\d+)?)/); - const remoteVersion = remoteVersionMatch ? remoteVersionMatch[1] : null; - - if (!remoteVersion) { - return { - success: false, - error: "Remote version not found", - localVersion, - }; - } - - const versionComparison = compareSemver(localVersion, remoteVersion); - const status = - versionComparison === null || versionComparison === 0 - ? "up_to_date" - : versionComparison > 0 - ? "beta" - : "requires_update"; - - const result = { - success: true, - status, - localVersion: localVersion, - remoteVersion: remoteVersion, - latest_release: { - tag_name: releaseData.data.tag_name, - name: releaseData.data.name, - published_at: releaseData.data.published_at, - html_url: releaseData.data.html_url, - body: releaseData.data.body, - }, - cached: releaseData.cached, - cache_age: releaseData.cache_age, - }; - - return result; - } catch (error) { - return { - success: false, - error: error.message, - localVersion: app.getVersion(), - }; - } -}); - ipcMain.handle("get-platform", () => { return process.platform; }); @@ -1769,109 +1668,20 @@ ipcMain.handle( }, ); -ipcMain.handle("get-server-config", () => { - try { - return getServerConfigSync(); - } catch (error) { - console.error("Error reading server config:", error); - return null; - } -}); - -ipcMain.handle("save-server-config", (event, config) => { - try { - const userDataPath = app.getPath("userData"); - const configPath = getServerConfigPath(); - - if (!fs.existsSync(userDataPath)) { - fs.mkdirSync(userDataPath, { recursive: true }); - } - - fs.writeFileSync(configPath, JSON.stringify(config, null, 2)); - return { success: true }; - } catch (error) { - console.error("Error saving server config:", error); - return { success: false, error: error.message }; - } -}); - -// --- Remote sync (optional desktop <-> self-hosted server sync) --- - -// Surfaces the pre-standalone-rework server-config.json (if a serverUrl was -// ever set in it) so the renderer can prompt upgraded installs to set up -// Remote Sync -- their hosts live on that old server and won't appear -// locally until sync is enabled. A fresh install never had this file, so -// this is naturally false for anyone who never used the old architecture. -ipcMain.handle("get-legacy-server-config", () => { - const config = getServerConfigSync(); - return { serverUrl: config?.serverUrl || null }; -}); - +// Desktop-only settings, such as whether hosts connect from this device or +// through the linked server by default. ipcMain.handle("get-desktop-settings", () => { - return remoteSync.getDesktopSettings(); + return desktopSettings.getDesktopSettings(); }); ipcMain.handle("save-desktop-settings", (_event, settings) => { - return remoteSync.saveDesktopSettings(settings); + return desktopSettings.saveDesktopSettings(settings); }); -ipcMain.handle("get-remote-sync-config", () => { - return remoteSync.getRemoteSyncConfig(); -}); - -ipcMain.handle("save-remote-sync-config", (_event, config) => { - return remoteSync.saveRemoteSyncConfig(config); -}); - -ipcMain.handle("clear-remote-sync-config", async () => { - const result = remoteSync.clearRemoteSyncConfig(); - remoteSync.clearRemoteSyncJwt(); - remoteSync.getRemoteSyncEngine()?.updateStatus({ - connected: false, - syncing: false, - needsReauth: false, - lastError: null, - }); - return result; -}); - -ipcMain.handle("save-remote-sync-jwt", async (_event, token) => { - const result = remoteSync.saveRemoteSyncJwt(token); - if (result.success) { - remoteSync.getRemoteSyncEngine()?.updateStatus({ - connected: true, - needsReauth: false, - lastError: null, - }); - const status = await remoteSync.getRemoteSyncEngine()?.syncNow(); - return { ...result, status: status || null }; - } - return result; -}); - -ipcMain.handle("get-remote-sync-jwt", () => { - return remoteSync.getRemoteSyncJwt(); -}); - -ipcMain.handle("clear-remote-sync-jwt", () => { - return remoteSync.clearRemoteSyncJwt(); -}); - -ipcMain.handle("get-remote-sync-status", () => { - return remoteSync.getRemoteSyncEngine()?.status || null; -}); - -ipcMain.handle("get-remote-sync-user-info", () => { - return remoteSync.getRemoteSyncUserInfo(); -}); - -ipcMain.handle("remote-sync-now", async () => { - return (await remoteSync.getRemoteSyncEngine()?.syncNow()) || null; -}); - -ipcMain.handle("notify-local-login", (_event, token) => { - remoteSync.getRemoteSyncEngine()?.setLocalJwt(token); - return { success: true }; +// The server an older version of the app was pointed at, offered again when +// linking so upgraded installs do not have to look it up. +ipcMain.handle("get-previous-server-url", () => { + return { serverUrl: desktopSettings.getPreviousServerUrl() }; }); function getC2STunnelConfigPath() { @@ -2040,40 +1850,25 @@ const C2S_WS_HIGH_WATERMARK = 1024 * 1024; const C2S_WS_LOW_WATERMARK = 256 * 1024; const C2S_STREAM_WRITE_LIMIT = 8 * 1024 * 1024; -// C2S (client-to-server) tunnels relay through a connected, self-hosted -// Termix server -- the same "remote server" concept Remote Sync connects -// to, not the always-local embedded backend. There's no separate C2S -// server-URL setting in the UI; it has always shared whatever remote -// server the rest of the app was pointed at. Before the standalone-first -// rework that was server-config.json; now it's remote-sync-config.json, -// since that's the only remaining notion of "a connected remote server." -function getC2SRelayUrl() { - const config = remoteSync.getRemoteSyncConfig(); - const serverUrl = config?.serverUrl; - if (!serverUrl) { - throw new Error( - "No remote Termix server connected -- enable Remote Sync first", - ); - } - - const base = serverUrl.replace(/\/$/, ""); - const relayHttpUrl = `${base}/plugin-ws/tunnels/c2s/stream`; - return relayHttpUrl.replace(/^http:/, "ws:").replace(/^https:/, "wss:"); -} - +// C2S (client-to-server) tunnels relay through the server this desktop is +// linked to, not the embedded backend. function getC2SRemoteBaseUrl() { - const config = remoteSync.getRemoteSyncConfig(); - const serverUrl = config?.serverUrl; + const serverUrl = linkedServer.getLinkedServer()?.serverUrl; if (!serverUrl) { throw new Error( - "No remote Termix server connected -- enable Remote Sync first", + "This device is not linked to a Termix server. Link it in Sync first.", ); } return serverUrl.replace(/\/$/, ""); } +function getC2SRelayUrl() { + const relayHttpUrl = `${getC2SRemoteBaseUrl()}/plugin-ws/tunnels/c2s/stream`; + return relayHttpUrl.replace(/^http:/, "ws:").replace(/^https:/, "wss:"); +} + const C2S_REMOTE_SESSION_EXPIRED_ERROR = - "Remote Termix session expired. Reconnect Remote Sync and try again."; + "This device was signed out of its server. Sign in again from Sync and try again."; function normalizeC2SAuthToken(authToken) { return typeof authToken === "string" ? authToken.trim() : ""; @@ -2111,17 +1906,13 @@ function createC2SFailure(message, fallback) { } async function fetchC2SRemoteJson(pathname) { - const remoteSyncJwt = remoteSync.getRemoteSyncJwt(); - if (!remoteSyncJwt || remoteSync.isJwtExpiredOrExpiringSoon(remoteSyncJwt)) { + if (!linkedServer.getLinkedServer()?.token) { throw new Error(C2S_REMOTE_SESSION_EXPIRED_ERROR); } const url = `${getC2SRemoteBaseUrl()}${pathname}`; - const response = await fetch(url, { - headers: { - Authorization: `Bearer ${remoteSyncJwt}`, - "X-Electron-App": "true", - }, + const response = await httpFetch(url, { + headers: linkedServer.linkedRequestHeaders(), }); if (response.status === 401 || response.status === 403) { throw new Error(C2S_REMOTE_SESSION_EXPIRED_ERROR); @@ -2140,14 +1931,18 @@ async function resolveC2SRemoteSourceHost(tunnel) { : ""; if (!sourceHostSyncId) return normalized; - const data = await fetchC2SRemoteJson("/sync/hosts"); - const remoteHost = (Array.isArray(data?.rows) ? data.rows : []).find( - (row) => row?.syncId === sourceHostSyncId, - ); + let remoteHost = null; + try { + remoteHost = await fetchC2SRemoteJson( + `/sync/v2/hosts/${encodeURIComponent(sourceHostSyncId)}`, + ); + } catch (error) { + if (error.message === C2S_REMOTE_SESSION_EXPIRED_ERROR) throw error; + } const remoteHostId = Number(remoteHost?.id); if (!Number.isInteger(remoteHostId) || remoteHostId < 1) { throw new Error( - "Intermediate Host is not available on the remote Termix server yet. Run Remote Sync for hosts, then try again.", + "The intermediate host is not on the server yet. Sync, then try again.", ); } @@ -2161,18 +1956,11 @@ async function resolveC2SRemoteSourceHost(tunnel) { } async function getC2SRelayHeaders(relayUrl) { + if (linkedServer.getLinkedServer()?.token) { + return linkedServer.linkedRequestHeaders(); + } const headers = { "X-Electron-App": "true" }; - const remoteSyncJwt = remoteSync.getRemoteSyncJwt(); - if (remoteSyncJwt && !remoteSync.isJwtExpiredOrExpiringSoon(remoteSyncJwt)) { - headers.Authorization = `Bearer ${remoteSyncJwt}`; - return headers; - } - - if (remoteSyncJwt) { - throw new Error(C2S_REMOTE_SESSION_EXPIRED_ERROR); - } - if (!mainWindow?.webContents?.session) { throw new Error(C2S_REMOTE_SESSION_EXPIRED_ERROR); } @@ -3779,88 +3567,6 @@ ipcMain.handle("close-external-editor", (_event, editId) => { } }); -async function testServerConnection( - _event, - serverUrl, - allowInvalidCertificate = false, -) { - try { - const normalizedServerUrl = serverUrl.replace(/\/$/, ""); - const healthUrl = `${normalizedServerUrl}/health`; - - // This is a best-effort reachability probe, not a hard gate: a reverse - // proxy doing SSO in front of the real server (Pangolin, Authelia, - // Cloudflare Access, etc.) intercepts this unauthenticated request - // before it ever reaches Termix's own /health route, and returns its - // own login page (HTML, or a redirect) instead of {"status":"ok"}. - // That's a legitimate, working setup -- the login iframe shown right - // after this check is what actually proves the server is real, by - // completing an authenticated round-trip. So any response at all here - // (any status code, any body) means "something is there, let the user - // proceed"; only a network-level failure (nothing answered at all) - // blocks continuing. - try { - const response = await httpFetch(healthUrl, { - method: "GET", - timeout: 10000, - allowInvalidCertificate, - }); - - const data = await response.text(); - const looksLikeHtml = - data.includes("") || - data.includes(""); - - if (response.ok && !looksLikeHtml) { - try { - const healthData = JSON.parse(data); - if ( - healthData && - (healthData.status === "ok" || - healthData.status === "healthy" || - healthData.healthy === true || - healthData.database === "connected") - ) { - return { - success: true, - status: response.status, - testedUrl: healthUrl, - }; - } - } catch (parseError) { - console.log("Health endpoint did not return valid JSON"); - } - } - - // Reachable, but not a recognized Termix health response -- likely a - // proxy/SSO login page in front of the real server. Let the user - // proceed; the login step next will fail clearly if this really - // isn't a Termix server. - return { - success: true, - status: response.status, - testedUrl: healthUrl, - warning: looksLikeHtml - ? "Could not confirm this is a Termix server (the response looked like an HTML page, which can happen behind a login-protected reverse proxy). You can continue, and the next step will fail clearly if this isn't actually a Termix server." - : "Server responded, but not with the expected health check format. Continuing anyway.", - }; - } catch (urlError) { - console.error("Health check failed:", urlError); - return { - success: false, - error: - "Server is not responding. Please ensure the server is running and accessible.", - }; - } - } catch (error) { - return { success: false, error: error.message }; - } -} - -ipcMain.handle("test-server-connection", testServerConnection); - // Local disk browsing + streamed local<->remote transfers for the file // manager's dual-pane mode (see electron/local-files.cjs). registerLocalFileHandlers({ ipcMain, shell }); @@ -3953,7 +3659,7 @@ app.whenReady().then(async () => { createTray(); createWindow(); - remoteSync.initRemoteSync(() => mainWindow); + desktopSettings.removeOldSyncFiles(); logToFile("=== Startup complete ==="); }); diff --git a/electron/preload.js b/electron/preload.js index 8e93de8d0..58f34eedf 100644 --- a/electron/preload.js +++ b/electron/preload.js @@ -1,20 +1,9 @@ -const { contextBridge, ipcRenderer } = require("electron"); +const { contextBridge, ipcRenderer, webFrame } = require("electron"); const ALLOWED_INVOKE_CHANNELS = new Set([ - "check-electron-update", - "clear-remote-sync-config", "get-desktop-settings", - "get-legacy-server-config", - "get-remote-sync-config", - "get-remote-sync-jwt", - "get-remote-sync-status", - "get-remote-sync-user-info", - "notify-local-login", - "remote-sync-now", + "get-previous-server-url", "save-desktop-settings", - "save-remote-sync-config", - "save-remote-sync-jwt", - "test-server-connection", "allow-invalid-certificate-for-origin", ]); @@ -27,6 +16,12 @@ function invokeAllowed(channel, ...args) { contextBridge.exposeInMainWorld("electronAPI", { getAppVersion: () => ipcRenderer.invoke("get-app-version"), + // Kept so the interface size can reset a zoom an earlier build applied. + setZoomFactor: (factor) => { + const value = Number(factor); + if (!Number.isFinite(value)) return; + webFrame.setZoomFactor(Math.min(2, Math.max(0.5, value))); + }, getPlatform: () => ipcRenderer.invoke("get-platform"), getEmbeddedServerStatus: () => ipcRenderer.invoke("get-embedded-server-status"), @@ -59,12 +54,6 @@ contextBridge.exposeInMainWorld("electronAPI", { startC2SAutoStartTunnels: () => ipcRenderer.invoke("start-c2s-autostart-tunnels"), - onRemoteSyncStatusChanged: (callback) => { - const listener = (_event, status) => callback(status); - ipcRenderer.on("remote-sync-status-changed", listener); - return () => - ipcRenderer.removeListener("remote-sync-status-changed", listener); - }, onCloseActiveTab: (callback) => { const listener = () => callback(); ipcRenderer.on("close-active-tab", listener); diff --git a/electron/remote-sync-entities.cjs b/electron/remote-sync-entities.cjs deleted file mode 100644 index 6150fbcc9..000000000 --- a/electron/remote-sync-entities.cjs +++ /dev/null @@ -1,23 +0,0 @@ -// The fallback list, for a server older than GET /sync/entity-types. -// -// Current servers return the entity types and their order, which is the only -// way this process learns about one a plugin added. This array is what those -// servers would have synced anyway, so an old pairing keeps working. -const SYNCED_ENTITY_TYPES = Object.freeze([ - // Ordered by reference dependency: hosts and snippets resolve credential, - // vault and folder syncIds, so those have to exist on the other side first. - "sshCredentials", - "vaultProfiles", - "sshFolders", - "snippetFolders", - "hosts", - "snippets", - "dashboardServiceLinks", - "homepageItems", - "userPreferences", - // networkTopology stores host references by their local numeric id inside - // the topology JSON, so it must sync after hosts have been reconciled. - "networkTopology", -]); - -module.exports = { SYNCED_ENTITY_TYPES }; diff --git a/electron/remote-sync-fetch.cjs b/electron/remote-sync-fetch.cjs deleted file mode 100644 index 7d6c7cd70..000000000 --- a/electron/remote-sync-fetch.cjs +++ /dev/null @@ -1,27 +0,0 @@ -const { Agent, fetch: undiciFetch } = require("undici"); - -function createRemoteSyncFetch(getConfig) { - let invalidCertificateAgent; - return function fetchRemoteSync(url, options = {}) { - const config = getConfig(); - const target = new URL(url); - const allowInvalidCertificate = - config?.allowInvalidCertificate === true && - config.serverUrl && - target.protocol === "https:" && - target.origin === new URL(config.serverUrl).origin; - if (!allowInvalidCertificate) return fetch(url, options); - - invalidCertificateAgent ??= new Agent({ - connect: { rejectUnauthorized: false }, - }); - return undiciFetch(url, { - ...options, - dispatcher: invalidCertificateAgent, - // A redirected request must not inherit the configured origin's TLS exception. - redirect: "error", - }); - }; -} - -module.exports = { createRemoteSyncFetch }; diff --git a/electron/remote-sync.cjs b/electron/remote-sync.cjs deleted file mode 100644 index 744a251dd..000000000 --- a/electron/remote-sync.cjs +++ /dev/null @@ -1,644 +0,0 @@ -// Remote sync engine for the desktop app's optional connection to a -// self-hosted Termix server. Runs entirely in the Electron main process: -// - Holds the remote JWT (safeStorage-encrypted on disk, never exposed to -// the renderer's localStorage) and the local embedded backend's JWT -// (cached in memory only, handed over by the renderer at local-login -// time via notify-local-login). -// - On a timer, pulls + pushes each synced entity type between the -// embedded backend (always localhost:30001) and the configured remote -// server, reconciling by syncId with last-write-wins on updatedAt, and -// propagating tombstones (deletions) in both directions. -// - Pushes connection/sync status to the renderer via IPC so the Settings -// UI and a global banner can reflect it without polling. - -const { app, safeStorage } = require("electron"); -const fs = require("fs"); -const path = require("path"); -const { orderSyncWrites } = require("./sync-write-order.cjs"); -const { SYNCED_ENTITY_TYPES } = require("./remote-sync-entities.cjs"); - -const { createRemoteSyncFetch } = require("./remote-sync-fetch.cjs"); -const fetchRemoteSync = createRemoteSyncFetch(getRemoteSyncConfig); - -const SYNC_INTERVAL_MS = 90 * 1000; -const EMBEDDED_BASE_URL = "http://127.0.0.1:30001"; - -function dataPath(filename) { - return path.join(app.getPath("userData"), filename); -} - -function readJson(filePath, fallback) { - try { - if (!fs.existsSync(filePath)) return fallback; - return JSON.parse(fs.readFileSync(filePath, "utf8")); - } catch { - return fallback; - } -} - -function writeJson(filePath, value) { - const userDataPath = app.getPath("userData"); - if (!fs.existsSync(userDataPath)) { - fs.mkdirSync(userDataPath, { recursive: true }); - } - const temporaryPath = `${filePath}.${process.pid}-${Date.now()}.tmp`; - try { - fs.writeFileSync(temporaryPath, JSON.stringify(value, null, 2), { - mode: 0o600, - }); - fs.renameSync(temporaryPath, filePath); - } catch (error) { - try { - fs.unlinkSync(temporaryPath); - } catch { - // already absent - } - throw error; - } -} - -function getDesktopSettingsPath() { - return dataPath("desktop-settings.json"); -} - -function getRemoteSyncConfigPath() { - return dataPath("remote-sync-config.json"); -} - -function getRemoteSyncCredentialPath() { - return dataPath("remote-sync-credential.json"); -} - -function getRemoteSyncStatePath() { - return dataPath("remote-sync-state.json"); -} - -function getDesktopSettings() { - return readJson(getDesktopSettingsPath(), { - defaultConnectionOrigin: "local", - migrationNoticeAcknowledged: false, - }); -} - -function saveDesktopSettings(settings) { - writeJson(getDesktopSettingsPath(), settings); - return { success: true }; -} - -function getRemoteSyncConfig() { - return readJson(getRemoteSyncConfigPath(), null); -} - -function saveRemoteSyncConfig(config) { - writeJson(getRemoteSyncConfigPath(), config); - return { success: true }; -} - -function clearRemoteSyncConfig() { - try { - fs.unlinkSync(getRemoteSyncConfigPath()); - } catch { - // already absent - } - return { success: true }; -} - -function getSafeStorageAvailable() { - try { - return safeStorage.isEncryptionAvailable(); - } catch { - return false; - } -} - -function saveRemoteSyncJwt(token) { - if (!getSafeStorageAvailable()) { - // Carries a stable reason alongside the message: the renderer has a - // translated explanation for this one, because "no OS keyring" is a - // machine-level problem the user has to go and fix, not something signing - // in again can resolve. - return { - success: false, - reason: "encryption_unavailable", - error: "Encryption unavailable on this system", - }; - } - writeJson(getRemoteSyncCredentialPath(), { - encrypted: true, - value: safeStorage.encryptString(token).toString("base64"), - obtainedAt: new Date().toISOString(), - }); - return { success: true }; -} - -function getRemoteSyncJwt() { - const record = readJson(getRemoteSyncCredentialPath(), null); - if (!record?.encrypted || !getSafeStorageAvailable()) return null; - try { - return safeStorage.decryptString(Buffer.from(record.value, "base64")); - } catch { - return null; - } -} - -function clearRemoteSyncJwt() { - try { - fs.unlinkSync(getRemoteSyncCredentialPath()); - } catch { - // already absent - } - return { success: true }; -} - -async function getRemoteSyncUserInfo() { - const config = getRemoteSyncConfig(); - const token = getRemoteSyncJwt(); - if (!config?.serverUrl || !token || isJwtExpiredOrExpiringSoon(token)) { - return null; - } - - const baseUrl = config.serverUrl.replace(/\/$/, ""); - const userResponse = await fetchRemoteSync(`${baseUrl}/users/me`, { - headers: { Authorization: `Bearer ${token}`, "X-Electron-App": "true" }, - }); - if (!userResponse.ok) return null; - - const user = await userResponse.json(); - const rolesResponse = await fetchRemoteSync( - `${baseUrl}/rbac/users/${encodeURIComponent(user.userId)}/roles`, - { - headers: { Authorization: `Bearer ${token}`, "X-Electron-App": "true" }, - }, - ); - const roles = rolesResponse.ok - ? (await rolesResponse.json()).roles || [] - : []; - - return { - userId: user.userId, - username: user.username, - is_admin: !!user.is_admin, - is_oidc: !!user.is_oidc, - is_dual_auth: !!user.is_dual_auth, - totp_enabled: !!user.totp_enabled, - roles, - }; -} - -function decodeJwtExpiry(token) { - try { - const payloadB64 = token.split(".")[1]; - const payload = JSON.parse( - Buffer.from(payloadB64, "base64").toString("utf8"), - ); - return typeof payload.exp === "number" ? payload.exp * 1000 : null; - } catch { - return null; - } -} - -function isJwtExpiredOrExpiringSoon(token, marginMs = 60 * 1000) { - const expiresAt = decodeJwtExpiry(token); - if (expiresAt === null) return false; - return Date.now() + marginMs >= expiresAt; -} - -class RemoteSyncEngine { - constructor(getMainWindow) { - this.getMainWindow = getMainWindow; - this.localJwt = null; - this.timer = null; - this.startupTimer = null; - this.syncing = false; - this.status = { - connected: false, - syncing: false, - lastSyncedAt: null, - lastError: null, - needsReauth: false, - }; - } - - setLocalJwt(token) { - this.localJwt = token || null; - } - - emitStatus() { - const win = this.getMainWindow?.(); - if (!win || win.isDestroyed()) return; - win.webContents.send("remote-sync-status-changed", this.status); - } - - updateStatus(patch) { - this.status = { ...this.status, ...patch }; - this.emitStatus(); - } - - start() { - const config = getRemoteSyncConfig(); - this.status.connected = !!config?.serverUrl; - if (this.timer) clearInterval(this.timer); - if (this.startupTimer) clearTimeout(this.startupTimer); - this.timer = setInterval(() => this.syncNow(), SYNC_INTERVAL_MS); - if (config?.serverUrl) { - // Fire an initial sync shortly after startup rather than waiting a - // full interval, but don't block app boot on it. - this.startupTimer = setTimeout(() => { - this.startupTimer = null; - this.syncNow(); - }, 5000); - } - } - - stop() { - if (this.timer) { - clearInterval(this.timer); - this.timer = null; - } - if (this.startupTimer) { - clearTimeout(this.startupTimer); - this.startupTimer = null; - } - } - - async syncNow() { - if (this.syncing) return this.status; - const config = getRemoteSyncConfig(); - if (!config?.serverUrl) { - this.updateStatus({ connected: false, syncing: false }); - return this.status; - } - - const remoteJwt = getRemoteSyncJwt(); - if (!remoteJwt) { - this.updateStatus({ - connected: true, - syncing: false, - needsReauth: true, - lastError: "Not signed in to remote server", - }); - return this.status; - } - if (isJwtExpiredOrExpiringSoon(remoteJwt)) { - this.updateStatus({ - connected: true, - syncing: false, - needsReauth: true, - lastError: "Remote session expired", - }); - return this.status; - } - if (!this.localJwt) { - // Local login hasn't handed us a token yet -- this is expected for the - // first tick or two right after a cold boot (renderer hasn't finished - // its own session check yet), but if it never arrives (e.g. a gap in - // whichever code path establishes the local session), sync would - // otherwise silently no-op forever with no visible error. Surface it - // as a normal, non-alarming "not synced yet" status rather than - // leaving lastSyncedAt/lastError untouched. - this.updateStatus({ - connected: true, - syncing: false, - lastError: "Waiting for local session", - }); - return this.status; - } - - this.syncing = true; - this.updateStatus({ connected: true, syncing: true, lastError: null }); - - try { - const state = readJson(getRemoteSyncStatePath(), { entities: {} }); - let sawAuthFailure = false; - - const remoteBase = config.serverUrl.replace(/\/$/, ""); - const entityTypes = await this.resolveEntityTypes(remoteBase, remoteJwt); - - for (const entityType of entityTypes) { - const entityState = state.entities[entityType] || { - lastPulledAt: null, - lastPushedAt: null, - }; - - const result = await this.syncEntity({ - entityType, - remoteBaseUrl: remoteBase, - remoteJwt, - since: entityState.lastPulledAt, - }); - - if (result.authFailure) { - sawAuthFailure = true; - break; - } - - state.entities[entityType] = { - lastPulledAt: result.syncedAt, - lastPushedAt: result.syncedAt, - }; - } - - if (sawAuthFailure) { - this.updateStatus({ - syncing: false, - needsReauth: true, - lastError: "Remote server rejected the session", - }); - return this.status; - } - - writeJson(getRemoteSyncStatePath(), state); - writeJson(getRemoteSyncConfigPath(), { - ...config, - lastSyncedAt: new Date().toISOString(), - lastSyncStatus: "ok", - lastSyncError: null, - }); - - this.updateStatus({ - connected: true, - syncing: false, - needsReauth: false, - lastSyncedAt: new Date().toISOString(), - lastError: null, - }); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - writeJson(getRemoteSyncConfigPath(), { - ...config, - lastSyncStatus: "error", - lastSyncError: message, - }); - this.updateStatus({ syncing: false, lastError: message }); - } finally { - this.syncing = false; - } - - return this.status; - } - - /** - * Which entities to sync, and in what order. - * - * Asked of the remote server, because a plugin installed there can add one - * and this process has no way to know. Intersected with what the embedded - * backend supports: the two sides may have different plugins installed, and - * pushing an entity the local backend has never heard of just 400s. - * - * A server older than this endpoint 404s, and the frozen list is what it - * would have synced anyway. - */ - async resolveEntityTypes(remoteBaseUrl, remoteJwt) { - const fetchTypes = async (base, token) => { - try { - const body = await this.fetchJson(`${base}/sync/entity-types`, token); - const types = (body?.entityTypes || []) - .slice() - .sort((a, b) => (a.order ?? 0) - (b.order ?? 0)) - .map((entity) => entity.type) - .filter((type) => typeof type === "string" && type); - return types.length > 0 ? types : null; - } catch { - return null; - } - }; - - const remote = await fetchTypes(remoteBaseUrl, remoteJwt); - if (!remote) return SYNCED_ENTITY_TYPES; - - const local = await fetchTypes(EMBEDDED_BASE_URL, this.localJwt); - if (!local) return remote; - - const supported = new Set(local); - const shared = remote.filter((type) => supported.has(type)); - return shared.length > 0 ? shared : SYNCED_ENTITY_TYPES; - } - - async fetchJson(url, token, options = {}) { - const res = await fetchRemoteSync(url, { - ...options, - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${token}`, - ...(options.headers || {}), - }, - }); - if (res.status === 401 || res.status === 403) { - const err = new Error(`Auth failed (${res.status})`); - err.authFailure = true; - throw err; - } - if (!res.ok) { - throw new Error(`Request failed (${res.status}): ${url}`); - } - - const text = await res.text(); - // A reverse-proxy SSO in front of the remote server (Pangolin, Authelia, - // etc.) can intercept even an authenticated, Bearer-token'd request and - // serve its own login page instead of forwarding to Termix -- that comes - // back as a normal 200 OK, so the status checks above don't catch it. - // This is NOT the same as needsReauth/a bad Termix JWT: sync runs as a - // plain server-to-server fetch() in this main process, with no browser - // cookie jar at all, so re-authenticating through the login iframe (which - // only affects the renderer's browser session) can never fix this -- - // reconnecting would tell the user to do something that doesn't help. - // The proxy has to allow this traffic through some other way (an API - // bypass rule, a separate hostname/port that isn't proxy-gated, etc.), - // so this gets its own distinct, honest error rather than piggybacking - // on needsReauth or a raw JSON.parse crash. - const looksLikeHtml = - text.includes("") || - text.includes(""); - if (looksLikeHtml) { - const err = new Error( - "The reverse proxy in front of this server is blocking sync traffic with its own login page. Reconnecting won't fix this. The proxy needs to let Termix's API requests through (e.g. an SSO bypass rule for the sync API, or a non-proxied hostname/port for it).", - ); - err.proxyBlocked = true; - throw err; - } - - try { - return JSON.parse(text); - } catch { - throw new Error(`Server returned invalid JSON: ${url}`); - } - } - - async pullSide(baseUrl, token, entityType, since) { - const url = `${baseUrl}/sync/${entityType}${since ? `?since=${encodeURIComponent(since)}` : ""}`; - const data = await this.fetchJson(url, token); - return data.rows || []; - } - - /** - * Every syncId a side currently holds, ignoring the incremental window. - * Used only to decide whether a deletion still has something to delete. - */ - async pullSyncIds(baseUrl, token, entityType) { - const rows = await this.pullSide(baseUrl, token, entityType, null); - return new Set(rows.filter((row) => row.syncId).map((row) => row.syncId)); - } - - async pullTombstones(baseUrl, token, entityType, since) { - const url = `${baseUrl}/sync/${entityType}/tombstones${since ? `?since=${encodeURIComponent(since)}` : ""}`; - const data = await this.fetchJson(url, token); - return data.tombstones || []; - } - - async pushRow(baseUrl, token, entityType, row) { - await this.fetchJson(`${baseUrl}/sync/${entityType}`, token, { - method: "POST", - body: JSON.stringify({ row }), - }); - } - - async pushTombstone(baseUrl, token, entityType, syncId) { - await this.fetchJson(`${baseUrl}/sync/tombstones`, token, { - method: "POST", - body: JSON.stringify({ entityType, syncId }), - }); - } - - async syncEntity({ entityType, remoteBaseUrl, remoteJwt, since }) { - const syncedAt = new Date().toISOString(); - try { - const [localRows, remoteRows, localTombstones, remoteTombstones] = - await Promise.all([ - this.pullSide(EMBEDDED_BASE_URL, this.localJwt, entityType, since), - this.pullSide(remoteBaseUrl, remoteJwt, entityType, since), - this.pullTombstones( - EMBEDDED_BASE_URL, - this.localJwt, - entityType, - since, - ), - this.pullTombstones(remoteBaseUrl, remoteJwt, entityType, since), - ]); - - const tombstonedSyncIds = new Set([ - ...localTombstones.map((t) => t.syncId), - ...remoteTombstones.map((t) => t.syncId), - ]); - - const localBySyncId = new Map( - localRows.filter((r) => r.syncId).map((r) => [r.syncId, r]), - ); - const remoteBySyncId = new Map( - remoteRows.filter((r) => r.syncId).map((r) => [r.syncId, r]), - ); - const allSyncIds = new Set([ - ...localBySyncId.keys(), - ...remoteBySyncId.keys(), - ]); - - const writes = []; - for (const syncId of allSyncIds) { - if (tombstonedSyncIds.has(syncId)) continue; - const localRow = localBySyncId.get(syncId); - const remoteRow = remoteBySyncId.get(syncId); - const localTime = new Date(localRow?.updatedAt || 0).getTime(); - const remoteTime = new Date(remoteRow?.updatedAt || 0).getTime(); - if (localRow && (!remoteRow || localTime > remoteTime)) { - writes.push({ - baseUrl: remoteBaseUrl, - token: remoteJwt, - row: localRow, - }); - } else if (remoteRow && (!localRow || remoteTime > localTime)) { - writes.push({ - baseUrl: EMBEDDED_BASE_URL, - token: this.localJwt, - row: remoteRow, - }); - } - } - for (const { baseUrl, token, row } of orderSyncWrites( - entityType, - writes, - )) { - await this.pushRow(baseUrl, token, entityType, row); - } - - // Apply tombstones to whichever side hasn't already deleted the row. - // - // The presence check cannot use localRows/remoteRows: those are the - // incremental window, and a row deleted on one side while untouched on - // the other is by definition outside it, so every deletion was dropped. - // It also cannot be skipped -- pushing unconditionally makes the - // receiving side record a fresh tombstone, which the next pass would push - // back, forever. So ask the receiving side what it actually still holds, - // and only when there is a deletion to apply. - if (localTombstones.length) { - const remoteSyncIds = await this.pullSyncIds( - remoteBaseUrl, - remoteJwt, - entityType, - ); - for (const tombstone of localTombstones) { - if (remoteSyncIds.has(tombstone.syncId)) { - await this.pushTombstone( - remoteBaseUrl, - remoteJwt, - entityType, - tombstone.syncId, - ); - } - } - } - if (remoteTombstones.length) { - const localSyncIds = await this.pullSyncIds( - EMBEDDED_BASE_URL, - this.localJwt, - entityType, - ); - for (const tombstone of remoteTombstones) { - if (localSyncIds.has(tombstone.syncId)) { - await this.pushTombstone( - EMBEDDED_BASE_URL, - this.localJwt, - entityType, - tombstone.syncId, - ); - } - } - } - - return { syncedAt }; - } catch (error) { - if (error?.authFailure) { - return { syncedAt, authFailure: true }; - } - throw error; - } - } -} - -let engine = null; - -function initRemoteSync(getMainWindow) { - engine = new RemoteSyncEngine(getMainWindow); - engine.start(); - return engine; -} - -function getRemoteSyncEngine() { - return engine; -} - -module.exports = { - initRemoteSync, - getRemoteSyncEngine, - getDesktopSettings, - saveDesktopSettings, - getRemoteSyncConfig, - saveRemoteSyncConfig, - clearRemoteSyncConfig, - saveRemoteSyncJwt, - getRemoteSyncJwt, - clearRemoteSyncJwt, - getRemoteSyncUserInfo, - isJwtExpiredOrExpiringSoon, - decodeJwtExpiry, -}; diff --git a/electron/sync-write-order.cjs b/electron/sync-write-order.cjs deleted file mode 100644 index ac46c6ee7..000000000 --- a/electron/sync-write-order.cjs +++ /dev/null @@ -1,49 +0,0 @@ -// Sort writes per destination: response order is lost when both sides are merged. -// -// Keyed on the self-reference field the rows actually carry rather than on the -// entity name, so an entity a plugin adds gets the same treatment without this -// process needing the server's entity registry. -const SELF_REFERENCE_FIELDS = { hosts: "parentHostSyncId" }; - -function selfReferenceField(entityType, writes) { - const known = SELF_REFERENCE_FIELDS[entityType]; - if (known) return known; - // A plugin entity declares its own; detect it from the payload. - const sample = writes.find((write) => write && write.row); - if (!sample) return null; - const candidate = Object.keys(sample.row).find( - (key) => key === `parent${entityType}SyncId`, - ); - return candidate || null; -} - -function orderSyncWrites(entityType, writes) { - const field = selfReferenceField(entityType, writes); - if (!field) return writes; - - const destinations = new Map(); - for (const write of writes) { - if (!destinations.has(write.baseUrl)) { - destinations.set(write.baseUrl, new Map()); - } - destinations.get(write.baseUrl).set(write.row.syncId, write); - } - const ordered = []; - const visited = new Set(); - const visiting = new Set(); - const visit = (write) => { - if (visited.has(write)) return; - if (visiting.has(write)) - throw new Error("Cyclic parent host sync dependency"); - visiting.add(write); - const parent = destinations.get(write.baseUrl).get(write.row[field]); - if (parent) visit(parent); - visiting.delete(write); - visited.add(write); - ordered.push(write); - }; - writes.forEach(visit); - return ordered; -} - -module.exports = { orderSyncWrites }; diff --git a/package-lock.json b/package-lock.json index a7ab38020..f0de70ba5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -129,6 +129,7 @@ "jsdom": "^30.0.1", "lint-staged": "^17.2.0", "lucide-react": "^1.33.0", + "postcss-pxtorem": "^6.1.0", "prettier": "3.9.8", "radix-ui": "^1.6.7", "react": "^19.2.8", @@ -15420,6 +15421,16 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/postcss-pxtorem": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/postcss-pxtorem/-/postcss-pxtorem-6.1.0.tgz", + "integrity": "sha512-ROODSNci9ADal3zUcPHOF/K83TiCgNSPXQFSbwyPHNV8ioHIE4SaC+FPOufd8jsr5jV2uIz29v1Uqy1c4ov42g==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "postcss": "^8.0.0" + } + }, "node_modules/postcss/node_modules/nanoid": { "version": "3.3.18", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", diff --git a/package.json b/package.json index cb13d5a48..bda7bbe2a 100644 --- a/package.json +++ b/package.json @@ -171,6 +171,7 @@ "jsdom": "^30.0.1", "lint-staged": "^17.2.0", "lucide-react": "^1.33.0", + "postcss-pxtorem": "^6.1.0", "prettier": "3.9.8", "radix-ui": "^1.6.7", "react": "^19.2.8", diff --git a/packages/plugin-sdk/ARCHITECTURE.md b/packages/plugin-sdk/ARCHITECTURE.md index b07af3eb6..3f1a69139 100644 --- a/packages/plugin-sdk/ARCHITECTURE.md +++ b/packages/plugin-sdk/ARCHITECTURE.md @@ -195,7 +195,7 @@ regenerated `schema.pg.ts`/`schema.mysql.ts` plus drizzle migrations. **What stays in core.** After 2.9.0, `schema.ts`, the SQLite DDL in `db/index.ts`, `performance-indexes.ts`, the repository factory and the -drizzle migrations hold only these 36 tables. Anything else is a plugin's, +drizzle migrations hold only these 38 tables. Anything else is a plugin's, under `p__`. | Area | Tables | @@ -206,7 +206,7 @@ under `p__`. | Credentials | `ssh_credentials`, `ssh_credential_usage` | | Sharing and RBAC | `roles`, `user_roles`, `host_access`, `folder_access`, `credential_access`, `shared_host_secrets`, `shared_host_auth_overrides`, `shared_credential_secrets`, `rbac_known_permissions`, `rbac_applied_defaults` | | Audit | `audit_logs` | -| Sync | `sync_tombstones` | +| Sync | `sync_records`, `sync_conflicts`, `sync_link` | | Plugin runtime | `plugins`, `plugin_permission_grants`, `plugin_registries`, `plugin_install_counts`, `plugin_storage`, `plugin_settings`, `plugin_migrations` | A few core tables still carry columns a plugin reads through its own host or @@ -222,24 +222,72 @@ they are sealed with this server's keys. An import reads each table by its `p_` name, or by the legacy name it adopted for a file written before 2.9. **Sync entities are registered, not hardcoded.** `ctx.sync.registerEntity` -adds an entity to remote sync; core registers its own the same way in -`database/routes/sync-entities.ts`. The wire names are unchanged, because -existing tombstones and rows on the far side match on those strings. The -Electron client asks the server for the ordered list and falls back to its -frozen array for a server that predates the endpoint. +adds an entity to sync between a linked desktop and its server; core +registers its own the same way in `src/backend/sync/entities.ts`. The wire +names are part of the protocol: records on both sides match on them, so never +rename one. -`shouldSync(row)` leaves rows out in both directions: they are not pulled, and -a push, update or tombstone aimed at one is refused with 400. It exists for -state that belongs to one install, such as the workspaces plugin's "Last -Session" row, which would otherwise leave a user with one from each side. +**How sync works.** A desktop links to one server account (Sync in the rail) +and keeps an offline copy of that account's data. Nothing records a change as +it happens. On both sides a reconciler hashes every registered row the user +owns and compares it with `sync_records`: -**Deletes need a tombstone.** Registering an entity does not make a delete -propagate on its own: a pull reads `sync_tombstones` for the entity's wire -name, so a plugin route that deletes a synced row calls -`ctx.sync.recordTombstone(userId, entityType, syncId)` right after, the way -core's own delete routes call `SyncTombstoneRepository.record`. **B2** added -this method to the SDK; the snippets plugin's folder and snippet deletes are -its first callers. +- On a server, a new, changed or missing row bumps the record's `revision` + and gives it the next `seq`. `GET /sync/v2/changes?cursor=` pages through + records after a cursor, so a desktop never compares timestamps from two + clocks. +- On a desktop, a row whose hash differs from the one recorded at the last + sync is a local edit to push. `POST /sync/v2/push` carries the revision the + desktop last saw; if the server moved on, the op is a conflict, the + server's version wins and the local one is kept in `sync_conflicts` for the + user to restore. +- Every op gets its own result, so one refused row never holds up the rest. + A refused row is not sent again until it changes. +- `GET /sync/v2/events` is a server-sent event stream that tells a linked + desktop to pull now. + +Because deletes are found by reconciliation, a plugin needs nothing beyond +the registration: a registered row that is gone is a delete. There is no +tombstone table any more. + +**What a registration says.** + +- `references` translate local ids to syncIds on the way out and back. A + `field` is a column (`credentialId` with `syncField: "credentialSyncId"`) + or a path into a JSON column (`jumpHosts[].hostId`, + `layout.panes[].hostId`); `idType: "string"` keeps an id stored as a + string. A syncId with no row on the receiving side keeps the value the + column already has, and a row written before its target is written again + once the target exists. +- `encryptedFields` are decrypted with the owner's data key for the wire and + encrypted again on arrival. A field that cannot be decrypted keeps the row + out of the feed; it is never sent empty. +- `readOnlyFields` stay on their device: never sent, never overwritten, not + part of the hash. +- `shouldSync(row)` leaves rows out in both directions, for state that + belongs to one install, such as the workspaces plugin's "Last Session" row. +- `permissions` (`create`, `update`, `delete`) are checked on the server + before a pushed change is written. +- `serialize` and `deserialize` run after `references`, for anything they + cannot express. `afterWrite` runs after a synced row is written and + `remove` replaces the plain delete, for rows with side effects of their + own. +- `answersTo` names a plugin entity by a name core uses without knowing the + plugin: core's host quick actions point at `commandSnippet`, which the + snippets plugin's `snippets` entity answers to. + +The manifest lists the wire names under `contributes.syncEntities`, so a +server can name them while the plugin is off and a desktop can list them in +its sync settings. + +**Plugins on a linked desktop.** The manifest's `desktop` field says how a +linked desktop treats a plugin. `mirror` (the default) follows the server: a +plugin installed there is downloaded from `/sync/v2/plugins//package`, +verified like any `.tmxplug`, installed, and switched on or off with the +server, with the capabilities an admin granted it there. `local` is decided +on each desktop (serial ports), and `server` never runs on a linked desktop +(ACME, sign-in methods, automations). While linked, the admin toggle for a +mirrored plugin answers 409. ### 6. HTTP and WebSockets @@ -445,7 +493,7 @@ allowlist (`CORE_SETTINGS_ALLOWLIST`) rather than the whole settings table. secret field. - Host fields take `enableDefault` (on the host section), `defaultFrom`, `shareRead` and `ownerOnly`; see the manifest reference. A host settings - save also bumps the host's `updated_at`, so remote sync carries it. + save also changes the host's sync hash, so sync carries it. ### 8. UI @@ -573,7 +621,7 @@ boundary and Suspense. | `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 | -| `desktop` | `available`, `remoteServerUrl()` and `onRemoteServerChange`: whether the frontend runs in the desktop app and which remote server it syncs with (**C6**) | +| `desktop` | `available`, `remoteServerUrl()` and `onRemoteServerChange`: whether the frontend runs in the desktop app and which server it is linked to (**C6**) | | `registerComponent` | A component other plugins and core render by id with `PluginComponent` | | `onSettingsChanged` | Called when one of the plugin's settings changes | @@ -2166,7 +2214,7 @@ authority for signatures. | `kv.get/set/delete/list` | `kv:own` | Small key/value state | | `files.dataDir()` | `files:own` | A per-plugin folder, `/plugin-data/` | | `db.define/client/refs/persist/dialect` | `db:own` (not `dialect`) | The plugin's own tables through Drizzle; `persist` flushes SQLite to disk | -| `sync.registerEntity/recordTombstone` | none | Adds a table to remote sync, and records a delete for it | +| `sync.registerEntity` | none | Adds a table to sync between a linked desktop and its server | | `registry.provide/consume/revoke` | none | The low-level named registry services sit on | | `services.provide/get/providers` | per-service RBAC | Typed, versioned cross-plugin services, checked per call | | `secrets.get/set/delete/seal/unseal` | `secrets:own` | The plugin's own encrypted values | @@ -3058,7 +3106,7 @@ upgrade on all three engines, and the checks in step 15 are green. (`TOPICS.userDeleted` in `src/backend/plugins/events.ts`), emitted from `deleteUserAndRelatedData` right where the old direct repository call used to sit. `host.deleted` already existed (B7). - - `sync-entities.ts`, if core synced it (step 9). + - `src/backend/sync/entities.ts`, if core synced it (step 9). - Anything else `grep -rn "\|" src` finds, including core tests and `src/types`. 4. `npm run schema:generate` (the pg and mysql schema variants), then @@ -3131,13 +3179,13 @@ is not ready to drop. ### 9. Sync entities If core synced the data, or it is personal configuration that should follow a -user between the desktop app and a server, register it in `activate`: -`ctx.sync.registerEntity({ type, table, order, ... })`. Keep an existing wire -name exactly and remove core's registration from `sync-entities.ts`. Leave -`electron/remote-sync-entities.cjs` alone: it is the fallback for servers -older than the entity-types endpoint, which still sync the core entity. Give -an entity that references others a higher `order` than they have. Use `shouldSync` for rows that belong to one -install (workspaces leaves out its `last_session` row). +user between a linked desktop and its server, register it in `activate`: +`ctx.sync.registerEntity({ type, table, order, ... })` and list the wire name +under `contributes.syncEntities`. Keep an existing wire name exactly and +remove core's registration from `src/backend/sync/entities.ts`. Give an +entity that references others a higher `order` than they have. Use +`shouldSync` for rows that belong to one install (workspaces leaves out its +`last_session` row). Deletes need no extra call. ### 10. Cross-plugin and core callers diff --git a/packages/plugin-sdk/cli/commands/build.mjs b/packages/plugin-sdk/cli/commands/build.mjs index d7ac52d68..f2eebbc74 100644 --- a/packages/plugin-sdk/cli/commands/build.mjs +++ b/packages/plugin-sdk/cli/commands/build.mjs @@ -17,6 +17,37 @@ const FRONTEND_ENTRIES = [ "src/frontend/index.js", ]; +/** + * A bundled CommonJS package that calls require("react/jsx-runtime") (or any + * other host-provided package) cannot reach it: the package is external, and + * esbuild's ESM output has no require, so the plugin throws "Dynamic require + * is not supported" as soon as it loads. Each such require is pointed at a + * small ES module that imports the external and re-exports it, which esbuild + * can hand to CommonJS code. + */ +export function externalRequireInterop(externals) { + const names = new Set(externals.filter((name) => !name.includes("*"))); + return { + name: "termix-external-require", + setup(build) { + build.onResolve({ filter: /.*/ }, (args) => { + if (args.kind !== "require-call" || !names.has(args.path)) return; + return { path: args.path, namespace: "termix-external-require" }; + }); + build.onLoad( + { filter: /.*/, namespace: "termix-external-require" }, + (args) => { + const id = JSON.stringify(args.path); + return { + contents: `import * as mod from ${id};\nexport * from ${id};\nexport default mod.default ?? mod;\n`, + loader: "js", + }; + }, + ); + }, + }; +} + export async function build({ cwd }) { const manifest = readManifest(cwd); const pluginId = manifest.id ?? path.basename(cwd); @@ -74,7 +105,21 @@ export async function build({ cwd }) { sourcemap: true, logLevel: "warning", external: FRONTEND_EXTERNALS, - plugins: [staticUrlImports({ outDir })], + // Vite replaces these in the dev server; a plugin bundle has to have + // them too, or code that reads them throws when the plugin loads. + define: { + "process.env.NODE_ENV": '"production"', + "import.meta.env.DEV": "false", + "import.meta.env.PROD": "true", + "import.meta.env.MODE": '"production"', + "import.meta.env.SSR": "false", + "import.meta.env": + '{"DEV":false,"PROD":true,"MODE":"production","SSR":false,"BASE_URL":"/"}', + }, + plugins: [ + externalRequireInterop(FRONTEND_EXTERNALS), + staticUrlImports({ outDir }), + ], }); } diff --git a/packages/plugin-sdk/schema/manifest.schema.json b/packages/plugin-sdk/schema/manifest.schema.json index ad72eddec..a495a8c4c 100644 --- a/packages/plugin-sdk/schema/manifest.schema.json +++ b/packages/plugin-sdk/schema/manifest.schema.json @@ -279,6 +279,16 @@ "guest": { "type": "boolean" }, + "syncEntities": { + "type": "array", + "minItems": 1, + "uniqueItems": true, + "description": "Wire names of the sync entities this plugin registers.", + "items": { + "type": "string", + "pattern": "^[a-zA-Z][a-zA-Z0-9]*$" + } + }, "guestViews": { "type": "array", "minItems": 1, @@ -955,6 +965,11 @@ "enum": ["linux", "win32", "darwin"] } }, + "desktop": { + "type": "string", + "enum": ["mirror", "local", "server"], + "description": "How a desktop linked to a server treats this plugin: mirror follows the server, local is decided on each desktop, server never runs on a linked desktop. Defaults to mirror." + }, "nativeDependencies": { "type": "array", "minItems": 1, diff --git a/packages/plugin-sdk/src/backend.ts b/packages/plugin-sdk/src/backend.ts index 2aec0a561..ae9aa19db 100644 --- a/packages/plugin-sdk/src/backend.ts +++ b/packages/plugin-sdk/src/backend.ts @@ -132,64 +132,104 @@ export interface PluginDatabase { export type SyncRow = Record; export interface SyncEntityReference { - /** Stored column holding a local numeric id, e.g. "credentialId". */ + /** + * Where a local numeric id lives. A plain column ("credentialId"), or a + * path into a JSON column: "jumpHosts[].hostId" walks every item of the + * jumpHosts array, "layout.panes[].hostId" walks nested objects. A JSON + * column may be stored as a string or as parsed JSON. + */ field: string; - /** Wire field holding the portable id, e.g. "credentialSyncId". */ - syncField: string; + /** + * Wire field holding the portable id, e.g. "credentialSyncId". Only for a + * plain column; a JSON path is rewritten in place. + */ + syncField?: string; /** The entity the id points at. */ entityType: string; + /** How a JSON path stores the local id. Defaults to "number". */ + idType?: "number" | "string"; +} + +export interface SyncEntityPermissions { + /** Checked before a pushed row that does not exist yet is written. */ + create?: string; + /** Checked before a pushed change to an existing row is written. */ + update?: string; + /** Checked before a pushed delete runs. */ + delete?: string; +} + +export interface SyncWriteEvent { + /** The local id of the row that was written. */ + id: number | null; + userId: string; + /** The row as it arrived, before references were resolved. */ + wire: SyncRow; + created: boolean; } export interface SyncEntityRegistration { - /** Stable wire name. Never change it: tombstones and remote rows match on it. */ + /** Stable wire name. Never change it: records on both sides match on it. */ type: string; /** The table, as returned by ctx.db.define. */ table: unknown; /** Column holding the owning user id. Defaults to "userId". */ userColumn?: string; - /** Fields DataCrypto translates between plaintext wire and encrypted row. */ + /** Fields stored encrypted with the owner's data key. Plaintext on the wire. */ encryptedFields?: readonly string[]; /** Local-id to sync-id translations applied on the way out and back. */ references?: readonly SyncEntityReference[]; /** Lower sorts first. Reference targets must sort before their referrers. */ order?: number; - /** Fields that must never be overwritten by an inbound payload. */ + /** + * Fields that stay on this side: never sent, never overwritten by an + * inbound row, and ignored when deciding whether a row changed. + */ readOnlyFields?: readonly string[]; /** One row per user rather than many, keyed on the owner. */ singleton?: boolean; + /** + * Names core uses when its own rows point at this entity without knowing + * which plugin provides it, e.g. "commandSnippet" for a host quick action. + */ + answersTo?: readonly string[]; /** * Rows this returns false for are left out of sync in both directions, for * state that belongs to one install (a per-device "last session"). */ shouldSync?: (row: SyncRow) => boolean; + /** RBAC permissions a pushed change needs on the server. */ + permissions?: SyncEntityPermissions; /** - * Escape hatch for a row whose references are not plain columns, such as ids - * embedded in a JSON blob. Runs instead of `references`, not alongside it. + * Runs after `references` on the way out, for anything they cannot + * express. Gets the row with references already translated. */ serialize?: ( row: SyncRow, resolveSyncId: (entityType: string, id: number) => Promise, ) => Promise; + /** Runs after `references` on the way in. */ deserialize?: ( row: SyncRow, resolveId: (entityType: string, syncId: string) => Promise, ) => Promise; + /** Runs after a synced row was written on this side. */ + afterWrite?: (event: SyncWriteEvent) => Promise; + /** + * Replaces the plain delete of a synced row, for rows with cleanup of their + * own. Gets the stored row. + */ + remove?: (row: SyncRow, userId: string) => Promise; } -/** Adds an entity to remote sync between a desktop backend and a server. */ +/** + * Adds an entity to sync between a linked desktop and its server. + * + * Deletes need nothing extra: core notices a registered row is gone and + * sends the delete itself. + */ export interface PluginSync { registerEntity: (entity: SyncEntityRegistration) => void; - /** - * Records that a row was deleted, so a pull on the other side of remote - * sync removes it too. Call it with the row's syncId right after deleting - * it, for a registered entity whose rows can be deleted (a no-op silently - * loses deletes across devices otherwise). A falsy syncId is ignored. - */ - recordTombstone: ( - userId: string, - entityType: string, - syncId: string | null | undefined, - ) => Promise; } export interface PluginRegistry { diff --git a/packages/plugin-sdk/src/manifest.ts b/packages/plugin-sdk/src/manifest.ts index 55ae53395..6e6db333c 100644 --- a/packages/plugin-sdk/src/manifest.ts +++ b/packages/plugin-sdk/src/manifest.ts @@ -337,8 +337,27 @@ export interface PluginContributions { "simple" | "balanced" | "advanced", Record >; + /** + * Wire names of the sync entities this plugin registers with + * ctx.sync.registerEntity, so a server can name them while the plugin is + * off and a desktop can list them in its sync settings. + */ + syncEntities?: string[]; } +/** + * How a desktop linked to a server treats this plugin. + * - "mirror": follows the server, installed and switched on or off with it. + * - "local": each desktop decides for itself (a serial port is local). + * - "server": only makes sense on a server, never runs on a linked desktop. + */ +export type PluginDesktopMode = "mirror" | "local" | "server"; +export const PLUGIN_DESKTOP_MODES: readonly PluginDesktopMode[] = [ + "mirror", + "local", + "server", +]; + export interface PluginHttpContribution { /** * Old URLs something outside Termix still calls (a webhook a third party @@ -405,6 +424,8 @@ export interface PluginManifest { * dependencies". */ nativeDependencies?: string[]; + /** How a linked desktop treats this plugin. Defaults to "mirror". */ + desktop?: PluginDesktopMode; } export const DEFAULT_BACKEND_ENTRY = "dist/backend.js"; @@ -439,6 +460,7 @@ const ALLOWED_TOP_LEVEL = new Set([ "locales", "platforms", "nativeDependencies", + "desktop", ]); const ALLOWED_CONTRIBUTES = new Set([ @@ -455,6 +477,7 @@ const ALLOWED_CONTRIBUTES = new Set([ "http", "uiPresets", "auth", + "syncEntities", ]); const ALLOWED_SETTINGS_FIELD = [ @@ -587,6 +610,14 @@ export function validateManifest(manifest: unknown): string[] { validateCapabilities(m.capabilities, errors); validatePlatforms(m.platforms, errors); validateNativeDependencies(m.nativeDependencies, errors); + if ( + m.desktop !== undefined && + !PLUGIN_DESKTOP_MODES.includes(m.desktop as PluginDesktopMode) + ) { + errors.push( + `Field "desktop" must be one of: ${PLUGIN_DESKTOP_MODES.join(", ")}`, + ); + } validateDependencyMap(m.dependencies, "dependencies", errors); validateDependencyMap(m.optionalDependencies, "optionalDependencies", errors); validateProvides(m.provides, errors); @@ -761,6 +792,23 @@ function validateProvides(provides: unknown, errors: string[]): void { const PROVIDER_NAME_PATTERN = /^[a-z][a-z0-9-]*$/; +function validateSyncEntities(value: unknown, errors: string[]) { + const where = "contributes.syncEntities"; + if (!Array.isArray(value) || value.length === 0) { + errors.push(`${where} must be a non-empty array`); + return; + } + const seen = new Set(); + for (const name of value) { + if (typeof name !== "string" || !/^[a-zA-Z][a-zA-Z0-9]*$/.test(name)) { + errors.push(`${where} entries must be letters and digits`); + continue; + } + if (seen.has(name)) errors.push(`${where} duplicates "${name}"`); + seen.add(name); + } +} + function validateNameList(value: unknown, where: string, errors: string[]) { if (!Array.isArray(value) || value.length === 0) { errors.push(`${where} must be a non-empty array`); @@ -888,6 +936,9 @@ function validateContributes( errors.push('Field "contributes.guestViews" needs "contributes.guest"'); } } + if (contributes.syncEntities !== undefined) { + validateSyncEntities(contributes.syncEntities, errors); + } validatePermissions(contributes.permissions, pluginId, errors); validateActions(contributes.actions, errors); validateActionSlots(contributes.actionSlots, errors); diff --git a/packages/plugin-sdk/src/testing.ts b/packages/plugin-sdk/src/testing.ts index ad6080397..59cff4b37 100644 --- a/packages/plugin-sdk/src/testing.ts +++ b/packages/plugin-sdk/src/testing.ts @@ -260,8 +260,6 @@ export interface FakePluginContext { tables: PluginTableDefinition[]; /** Sync entities registered through ctx.sync.registerEntity, in order. */ syncEntities: SyncEntityRegistration[]; - /** Tombstones recorded through ctx.sync.recordTombstone, in order. */ - tombstones: Array<{ userId: string; entityType: string; syncId: string }>; /** * WebSocket routes registered through ctx.ws, in order, with the handler * and options, so a test can hand a route a fake socket. @@ -412,11 +410,6 @@ export function createFakeContext( const kv = new Map(); const tables: PluginTableDefinition[] = []; const syncEntities: SyncEntityRegistration[] = []; - const tombstones: Array<{ - userId: string; - entityType: string; - syncId: string; - }> = []; const wsRoutes: FakeWsRoute[] = []; const httpRouters: Array = []; const listeners = new Map void>>(); @@ -626,10 +619,6 @@ export function createFakeContext( registerEntity: (entity) => { syncEntities.push(entity); }, - recordTombstone: async (userId, entityType, syncId) => { - if (!syncId) return; - tombstones.push({ userId, entityType, syncId }); - }, }, registry: { @@ -1241,7 +1230,6 @@ export function createFakeContext( kv, tables, syncEntities, - tombstones, wsRoutes, httpRouters, settings, diff --git a/plugins/acme-ssl/manifest.json b/plugins/acme-ssl/manifest.json index 2772f456f..9cf571fca 100644 --- a/plugins/acme-ssl/manifest.json +++ b/plugins/acme-ssl/manifest.json @@ -117,5 +117,6 @@ } }, "locales": "locales", - "platforms": ["linux", "win32", "darwin"] + "platforms": ["linux", "win32", "darwin"], + "desktop": "server" } diff --git a/plugins/automations/manifest.json b/plugins/automations/manifest.json index 9c0ae8ee7..b01122c39 100644 --- a/plugins/automations/manifest.json +++ b/plugins/automations/manifest.json @@ -125,5 +125,6 @@ "platforms": ["linux", "win32", "darwin"], "dependencies": { "snippets": "^1.0.0" - } + }, + "desktop": "server" } diff --git a/plugins/file-manager/src/frontend/api/ssh-file-operations-api.ts b/plugins/file-manager/src/frontend/api/ssh-file-operations-api.ts index 5557b49d6..65441c21f 100644 --- a/plugins/file-manager/src/frontend/api/ssh-file-operations-api.ts +++ b/plugins/file-manager/src/frontend/api/ssh-file-operations-api.ts @@ -44,7 +44,7 @@ type ConnectErrorResponse = { }; /** - * The interactive-auth branches /ssh/connect can take before a session exists. + * The interactive-auth branches /connect can take before a session exists. * Callers switch on these, so they cannot stay behind Record. */ export interface SSHConnectResult { @@ -106,7 +106,7 @@ export async function connectSSH( ): Promise { try { const response = await getFileManagerApiForSession(sessionId).post( - "/ssh/connect", + "/connect", { sessionId, ...config }, { timeout: 120000 }, ); @@ -156,7 +156,7 @@ export async function disconnectSSH( ): Promise> { try { const response = await getFileManagerApiForSession(sessionId).post( - "/ssh/disconnect", + "/disconnect", { sessionId }, ); return response.data; @@ -173,7 +173,7 @@ export async function verifySSHTOTP( ): Promise> { try { const response = await getFileManagerApiForSession(sessionId).post( - "/ssh/connect-totp", + "/connect-totp", { sessionId, totpCode }, ); return response.data; @@ -187,7 +187,7 @@ export async function verifySSHBrowserSignIn( ): Promise> { try { const response = await getFileManagerApiForSession(sessionId).post( - "/ssh/connect-browser-sign-in", + "/connect-browser-sign-in", { sessionId }, ); return response.data; @@ -201,7 +201,7 @@ export async function getSSHStatus( ): Promise<{ connected: boolean }> { try { const response = await getFileManagerApiForSession(sessionId).get( - "/ssh/status", + "/status", { params: { sessionId } }, ); return response.data; @@ -215,7 +215,7 @@ export async function keepSSHAlive( ): Promise> { try { const response = await getFileManagerApiForSession(sessionId).post( - "/ssh/keepalive", + "/keepalive", { sessionId }, ); return response.data; @@ -235,7 +235,7 @@ export async function listSSHFiles( async () => { try { const response = await getFileManagerApiForSession(sessionId).get( - "/ssh/listFiles", + "/listFiles", { params: { sessionId, path } }, ); return response.data || { files: [], path }; @@ -253,7 +253,7 @@ export async function identifySSHSymlink( ): Promise<{ path: string; target: string; type: "directory" | "file" }> { try { const response = await getFileManagerApiForSession(sessionId).get( - "/ssh/identifySymlink", + "/identifySymlink", { params: { sessionId, path } }, ); return response.data; @@ -268,7 +268,7 @@ export async function resolveSSHPath( ): Promise { try { const response = await getFileManagerApiForSession(sessionId).get( - "/ssh/resolvePath", + "/resolvePath", { params: { sessionId, path } }, ); return response.data?.resolvedPath || path; @@ -288,7 +288,7 @@ export async function readSSHFile( async () => { try { const response = await getFileManagerApiForSession(sessionId).get( - "/ssh/readFile", + "/readFile", { params: { sessionId, path } }, ); return response.data; @@ -326,7 +326,7 @@ export async function writeSSHFile( ): Promise> { try { const response = await getFileManagerApiForSession(sessionId).post( - "/ssh/writeFile", + "/writeFile", { sessionId, path, content, hostId, userId }, ); @@ -385,7 +385,7 @@ export async function uploadSSHFile( const chunkBlob = file.slice(start, end); const response = await getFileManagerApiForSession(sessionId).post( - "/ssh/uploadFileChunk", + "/uploadFileChunk", chunkBlob, { params: { @@ -429,7 +429,7 @@ export async function uploadSSHFile( form.append("file", file, fileName); const response = await getFileManagerApiForSession(sessionId).postForm( - "/ssh/uploadFileStream", + "/uploadFileStream", form, { timeout: 0, @@ -470,7 +470,7 @@ export async function downloadSSHFile( ): Promise { try { const response = await getFileManagerApiForSession(sessionId).post( - "/ssh/downloadFile", + "/downloadFile", { sessionId, path: filePath, @@ -496,7 +496,7 @@ export async function downloadSSHFileStream( onProgress?: (event: DownloadProgressEvent) => void, ): Promise { const response = await getFileManagerApiForSession(sessionId).post( - "/ssh/downloadFileStream", + "/downloadFileStream", { sessionId, path: filePath }, { responseType: "blob", @@ -521,7 +521,7 @@ export async function createSSHFile( ): Promise> { try { const response = await getFileManagerApiForSession(sessionId).post( - "/ssh/createFile", + "/createFile", { sessionId, path, fileName, content, hostId, userId }, ); return response.data; @@ -539,7 +539,7 @@ export async function createSSHFolder( ): Promise> { try { const response = await getFileManagerApiForSession(sessionId).post( - "/ssh/createFolder", + "/createFolder", { sessionId, path, folderName, hostId, userId }, ); return response.data; @@ -558,7 +558,7 @@ export async function deleteSSHItem( ): Promise> { try { const response = await getFileManagerApiForSession(sessionId).delete( - "/ssh/deleteItem", + "/deleteItem", { data: { sessionId, @@ -599,7 +599,7 @@ export async function getSSHTrash(sessionId: string): Promise<{ }> { try { const response = await getFileManagerApiForSession(sessionId).get( - "/ssh/trash", + "/trash", { params: { sessionId } }, ); return response.data; @@ -611,7 +611,7 @@ export async function getSSHTrash(sessionId: string): Promise<{ export async function restoreSSHTrashItem(sessionId: string, id: string) { try { const response = await getFileManagerApiForSession(sessionId).post( - `/ssh/trash/${encodeURIComponent(id)}/restore`, + `/trash/${encodeURIComponent(id)}/restore`, { sessionId }, ); return response.data; @@ -626,7 +626,7 @@ export async function permanentlyDeleteSSHTrashItem( ) { try { await getFileManagerApiForSession(sessionId).delete( - `/ssh/trash/${encodeURIComponent(id)}`, + `/trash/${encodeURIComponent(id)}`, { data: { sessionId } }, ); } catch (error) { @@ -636,7 +636,7 @@ export async function permanentlyDeleteSSHTrashItem( export async function emptySSHTrash(sessionId: string) { try { - await getFileManagerApiForSession(sessionId).delete("/ssh/trash", { + await getFileManagerApiForSession(sessionId).delete("/trash", { data: { sessionId }, }); } catch (error) { @@ -649,7 +649,7 @@ export async function updateSSHTrashRetention( retentionDays: number, ) { try { - await getFileManagerApiForSession(sessionId).put("/ssh/trash-retention", { + await getFileManagerApiForSession(sessionId).put("/trash-retention", { retentionDays, }); } catch (error) { @@ -687,7 +687,7 @@ export async function copySSHItem( ): Promise { try { const response = await getFileManagerApiForSession(sessionId).post( - "/ssh/copyItem", + "/copyItem", { sessionId, sourcePath, @@ -715,7 +715,7 @@ export async function renameSSHItem( ): Promise> { try { const response = await getFileManagerApiForSession(sessionId).put( - "/ssh/renameItem", + "/renameItem", { sessionId, oldPath, newName, hostId, userId }, ); invalidateCachedFileContent(sessionId, oldPath); @@ -738,7 +738,7 @@ export async function moveSSHItem( ): Promise> { try { const response = await getFileManagerApiForSession(sessionId).put( - "/ssh/moveItem", + "/moveItem", { sessionId, oldPath, @@ -777,7 +777,7 @@ export async function changeSSHPermissions( }); const response = await getFileManagerApiForSession(sessionId).post( - "/ssh/changePermissions", + "/changePermissions", { sessionId, path, permissions, hostId, userId }, ); @@ -819,7 +819,7 @@ export async function extractSSHArchive( }); const response = await getFileManagerApiForSession(sessionId).post( - "/ssh/extractArchive", + "/extractArchive", { sessionId, archivePath, extractPath, hostId, userId }, ); @@ -863,7 +863,7 @@ export async function compressSSHFiles( }); const response = await getFileManagerApiForSession(sessionId).post( - "/ssh/compressFiles", + "/compressFiles", { sessionId, paths, diff --git a/plugins/file-manager/tests/backend/route-paths.test.ts b/plugins/file-manager/tests/backend/route-paths.test.ts new file mode 100644 index 000000000..df78fbba5 --- /dev/null +++ b/plugins/file-manager/tests/backend/route-paths.test.ts @@ -0,0 +1,43 @@ +import fs from "node:fs"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; +import { pluginDir } from "./helpers"; + +function read(relative: string): string { + return fs.readFileSync(path.join(pluginDir, relative), "utf8"); +} + +/** Every "/path" the frontend's SSH client sends, without its parameters. */ +function frontendPaths(): string[] { + const source = read("src/frontend/api/ssh-file-operations-api.ts"); + const paths = new Set(); + for (const match of source.matchAll(/["`](\/[A-Za-z][\w-]*(?:\/[\w-]+)*)/g)) { + paths.add(match[1]); + } + return [...paths]; +} + +/** Every route the backend's express app registers. */ +function backendPaths(): Set { + const paths = new Set(); + const dir = path.join(pluginDir, "src/backend"); + for (const file of fs + .readdirSync(dir) + .filter((name) => name.endsWith(".ts")) + .map((name) => `src/backend/${name}`)) { + for (const match of read(file).matchAll( + /app\.(?:get|post|put|delete|patch)\(\s*"(\/[^"]*)"/g, + )) { + paths.add(match[1].replace(/\/:[\w]+$/, "")); + } + } + return paths; +} + +describe("file manager routes", () => { + it("serves every path the frontend's SSH client calls", () => { + const served = backendPaths(); + const missing = frontendPaths().filter((p) => !served.has(p)); + expect(missing).toEqual([]); + }); +}); diff --git a/plugins/file-manager/tests/frontend/api/ssh-file-operations-api.test.ts b/plugins/file-manager/tests/frontend/api/ssh-file-operations-api.test.ts index 3fe272c12..356d7cdf7 100644 --- a/plugins/file-manager/tests/frontend/api/ssh-file-operations-api.test.ts +++ b/plugins/file-manager/tests/frontend/api/ssh-file-operations-api.test.ts @@ -50,7 +50,7 @@ describe("chunked SSH file uploads", () => { expect(fileManagerApiMock.post).toHaveBeenCalledTimes(193); expect(fileManagerApiMock.post).toHaveBeenNthCalledWith( 1, - "/ssh/uploadFileChunk", + "/uploadFileChunk", expect.any(Blob), { params: { @@ -65,7 +65,7 @@ describe("chunked SSH file uploads", () => { }, ); expect(fileManagerApiMock.post).toHaveBeenLastCalledWith( - "/ssh/uploadFileChunk", + "/uploadFileChunk", expect.any(Blob), expect.objectContaining({ params: expect.objectContaining({ offset: 1.5 * 1024 * 1024 * 1024 }), diff --git a/plugins/homepage/manifest.json b/plugins/homepage/manifest.json index 0d1584c08..8b6c5fd08 100644 --- a/plugins/homepage/manifest.json +++ b/plugins/homepage/manifest.json @@ -47,6 +47,7 @@ ], "actionSlots": [ { "id": "dashboard.secondaryView", "accepts": ["component"] } - ] + ], + "syncEntities": ["dashboardServiceLinks", "homepageItems"] } } diff --git a/plugins/homepage/src/backend/index.ts b/plugins/homepage/src/backend/index.ts index 62a5ba984..821247018 100644 --- a/plugins/homepage/src/backend/index.ts +++ b/plugins/homepage/src/backend/index.ts @@ -26,7 +26,19 @@ export async function activate(ctx: PluginContext) { registerHomepageRoutes(router, repo, ctx); registerOutboundRoutes(router, ctx); - ctx.sync.registerEntity({ type: "homepageItems", table: items, order: 80 }); + ctx.sync.registerEntity({ + type: "homepageItems", + table: items, + order: 80, + // A tile inside a folder points at the folder's row. + references: [ + { + field: "folderId", + syncField: "folderSyncId", + entityType: "homepageItems", + }, + ], + }); ctx.sync.registerEntity({ type: "dashboardServiceLinks", table: serviceLinks, diff --git a/plugins/homepage/src/backend/routes.ts b/plugins/homepage/src/backend/routes.ts index 7ff16408f..a6f89db3e 100644 --- a/plugins/homepage/src/backend/routes.ts +++ b/plugins/homepage/src/backend/routes.ts @@ -219,7 +219,6 @@ export function registerHomepageRoutes( try { const deleted = await repo.deleteItem(userId, id); if (!deleted) return res.status(404).json({ error: "Not found" }); - await ctx.sync.recordTombstone(userId, "homepageItems", deleted.syncId); res.json({ message: "Homepage item deleted" }); } catch (err) { logError( @@ -506,11 +505,6 @@ export function registerHomepageRoutes( try { const deleted = await repo.deleteServiceLink(userId, id); if (!deleted) return res.status(404).json({ error: "Not found" }); - await ctx.sync.recordTombstone( - userId, - "dashboardServiceLinks", - deleted.syncId, - ); res.json({ message: "Service link deleted" }); } catch (err) { logError( diff --git a/plugins/host-metrics/src/frontend/cards/managers/ProcessInspectorCard.tsx b/plugins/host-metrics/src/frontend/cards/managers/ProcessInspectorCard.tsx index 4a71bfbc5..4723ee819 100644 --- a/plugins/host-metrics/src/frontend/cards/managers/ProcessInspectorCard.tsx +++ b/plugins/host-metrics/src/frontend/cards/managers/ProcessInspectorCard.tsx @@ -161,7 +161,9 @@ export function ProcessInspectorCard({ hostId }: { hostId: number | null }) { {depth > 0 && ( └ diff --git a/plugins/ldap/manifest.json b/plugins/ldap/manifest.json index ed92a9fd4..0c32c1e1a 100644 --- a/plugins/ldap/manifest.json +++ b/plugins/ldap/manifest.json @@ -45,5 +45,6 @@ } }, "locales": "locales", - "platforms": ["linux", "win32", "darwin"] + "platforms": ["linux", "win32", "darwin"], + "desktop": "server" } diff --git a/plugins/network-topology/manifest.json b/plugins/network-topology/manifest.json index a2bef83bf..d475dc082 100644 --- a/plugins/network-topology/manifest.json +++ b/plugins/network-topology/manifest.json @@ -45,7 +45,8 @@ "descriptionKey": "permissions.use.description", "defaultRoles": ["admin", "user"] } - ] + ], + "syncEntities": ["networkTopology"] }, "locales": "locales", "platforms": ["linux", "win32", "darwin"] diff --git a/plugins/remote-desktop/src/frontend/GuacamoleApp.tsx b/plugins/remote-desktop/src/frontend/GuacamoleApp.tsx index 0714e1f7a..d48226c84 100644 --- a/plugins/remote-desktop/src/frontend/GuacamoleApp.tsx +++ b/plugins/remote-desktop/src/frontend/GuacamoleApp.tsx @@ -34,6 +34,7 @@ import { Input, PasswordInput, isElectron, + linkedServerUrl, resolveConnectionOrigin, useConnectionLog, type ConnectionOrigin, @@ -309,10 +310,7 @@ const GuacamoleAppInner = React.forwardRef< { defaultRemote: true }, ); if (resolvedOrigin === "remote") { - const remoteConfig = (await window.electronAPI?.invoke?.( - "get-remote-sync-config", - )) as { serverUrl?: string } | null; - if (!remoteConfig?.serverUrl) { + if (!(await linkedServerUrl())) { throw new Error(t("errors.remoteServerRequired")); } } diff --git a/plugins/serial/manifest.json b/plugins/serial/manifest.json index eee47562c..ca1790e68 100644 --- a/plugins/serial/manifest.json +++ b/plugins/serial/manifest.json @@ -34,5 +34,6 @@ "icon": "Usb" } ] - } + }, + "desktop": "local" } diff --git a/plugins/snippets/manifest.json b/plugins/snippets/manifest.json index a518e9c68..ff811f983 100644 --- a/plugins/snippets/manifest.json +++ b/plugins/snippets/manifest.json @@ -81,6 +81,7 @@ "default": true } ] - } + }, + "syncEntities": ["snippetFolders", "snippets"] } } diff --git a/plugins/snippets/src/backend/index.ts b/plugins/snippets/src/backend/index.ts index dacfc36fc..6402a05e0 100644 --- a/plugins/snippets/src/backend/index.ts +++ b/plugins/snippets/src/backend/index.ts @@ -74,6 +74,8 @@ export async function activate(ctx: PluginContext) { type: "snippets", table: snippetsTable, order: 60, + // Host quick actions and keybindings in core point at snippets by this. + answersTo: ["commandSnippet"], }); // The user row survives a password-reset data wipe, so it never triggers @@ -152,9 +154,6 @@ export async function activate(ctx: PluginContext) { 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); - } return !!existing; }, }; diff --git a/plugins/snippets/src/backend/routes.ts b/plugins/snippets/src/backend/routes.ts index cf9c142bc..27d80dad0 100644 --- a/plugins/snippets/src/backend/routes.ts +++ b/plugins/snippets/src/backend/routes.ts @@ -319,12 +319,7 @@ export function registerSnippetRoutes( try { const folderName = decodeURIComponent(name); - const deleted = await repo.deleteFolder(userId, folderName); - await ctx.sync.recordTombstone( - userId, - "snippetFolders", - deleted?.syncId, - ); + await repo.deleteFolder(userId, folderName); res.json({ success: true }); } catch (err) { logError( @@ -921,7 +916,6 @@ export function registerSnippetRoutes( const existing = await repo.deleteSnippet(userId, parseInt(id, 10)); if (!existing) return res.status(404).json({ error: "Snippet not found" }); - await ctx.sync.recordTombstone(userId, "snippets", existing.syncId); res.json({ success: true }); } catch (err) { logError(ctx, "Failed to delete snippet", err, "snippet_delete_failed"); diff --git a/plugins/sso/manifest.json b/plugins/sso/manifest.json index 6a16850ca..1fdc8254c 100644 --- a/plugins/sso/manifest.json +++ b/plugins/sso/manifest.json @@ -64,5 +64,6 @@ } }, "locales": "locales", - "platforms": ["linux", "win32", "darwin"] + "platforms": ["linux", "win32", "darwin"], + "desktop": "server" } diff --git a/plugins/termix-identity/tests/frontend/activate.test.tsx b/plugins/termix-identity/tests/frontend/activate.test.tsx index c31772e78..adcdf09ba 100644 --- a/plugins/termix-identity/tests/frontend/activate.test.tsx +++ b/plugins/termix-identity/tests/frontend/activate.test.tsx @@ -12,6 +12,12 @@ import locales from "../../locales/en.json"; const manifest = manifestJson as unknown as PluginManifest; +const linked = vi.hoisted(() => ({ serverUrl: null as string | null })); +vi.mock("@/lib/linked-server", () => ({ + getLinkedSession: async () => + linked.serverUrl ? { serverUrl: linked.serverUrl, token: "t" } : null, +})); + let rendered: RenderedPluginApp | null = null; type ElectronWindow = { electronAPI?: unknown }; @@ -95,10 +101,10 @@ describe(`${manifest.id} activate`, () => { }); it("hides the rail item in a standalone desktop app", async () => { + linked.serverUrl = null; (window as unknown as ElectronWindow).electronAPI = { isElectron: true, invoke: vi.fn(async () => null), - onRemoteSyncStatusChanged: () => () => {}, }; rendered = await renderWithApp(plugin, { manifest, locales }); await waitFor(() => @@ -108,24 +114,19 @@ describe(`${manifest.id} activate`, () => { ); }); - it("shows the rail item once the desktop app has a remote server", async () => { - let notify = () => {}; - let serverUrl: string | null = null; + it("shows the rail item once the desktop app is linked to a server", async () => { + linked.serverUrl = null; (window as unknown as ElectronWindow).electronAPI = { isElectron: true, - invoke: vi.fn(async () => (serverUrl ? { serverUrl } : null)), - onRemoteSyncStatusChanged: (callback: () => void) => { - notify = callback; - return () => {}; - }, + invoke: vi.fn(async () => null), }; rendered = await renderWithApp(plugin, { manifest, locales }); await waitFor(() => expect(rendered!.registered.railItems()[0]?.hidden).toBe(true), ); - serverUrl = "https://termix.example"; - notify(); + linked.serverUrl = "https://termix.example"; + window.dispatchEvent(new CustomEvent("termix:sync-changed")); await waitFor(() => expect(rendered!.registered.railItems()).toEqual([ expect.objectContaining({ id: "termix-id", hidden: false }), diff --git a/plugins/totp/manifest.json b/plugins/totp/manifest.json index d935fff24..78f530fe7 100644 --- a/plugins/totp/manifest.json +++ b/plugins/totp/manifest.json @@ -27,5 +27,6 @@ } }, "locales": "locales", - "platforms": ["linux", "win32", "darwin"] + "platforms": ["linux", "win32", "darwin"], + "desktop": "server" } diff --git a/plugins/vault/manifest.json b/plugins/vault/manifest.json index 2fa389fad..16a8384ec 100644 --- a/plugins/vault/manifest.json +++ b/plugins/vault/manifest.json @@ -74,7 +74,8 @@ "to": "/oidc/callback" } ] - } + }, + "syncEntities": ["vaultProfiles"] }, "locales": "locales", "platforms": ["linux", "win32", "darwin"] diff --git a/plugins/vault/src/backend/profile-store.ts b/plugins/vault/src/backend/profile-store.ts index 2cc5ffa24..a31707ca8 100644 --- a/plugins/vault/src/backend/profile-store.ts +++ b/plugins/vault/src/backend/profile-store.ts @@ -134,7 +134,6 @@ export function createProfileStore( await db.delete(tokensTable).where(eq(tokensTable.profileId, row.id)); await db.delete(table).where(eq(table.id, row.id)); await ctx.db.persist(); - await ctx.sync.recordTombstone(row.userId, SYNC_ENTITY, row.syncId); }, }; } diff --git a/plugins/vault/tests/backend/vault.test.ts b/plugins/vault/tests/backend/vault.test.ts index 8d76cb873..67a17cd4e 100644 --- a/plugins/vault/tests/backend/vault.test.ts +++ b/plugins/vault/tests/backend/vault.test.ts @@ -124,7 +124,7 @@ describe("profiles", () => { expect((await server.request("GET", "/profiles")).body).toEqual([]); }); - it("records a tombstone and drops cached certificates on delete", async () => { + it("drops cached certificates on delete", async () => { server = await startServer(); const { id } = await profileOnHost(); server.db.sqlite @@ -132,17 +132,9 @@ describe("profiles", () => { "INSERT INTO p_vault_tokens (user_id, profile_id, ssh_cert, private_key, expires_at) VALUES (?, ?, ?, ?, ?)", ) .run("user-1", id, "c", "k", "2999-01-01T00:00:00.000Z"); - const syncId = ( - server.db.sqlite - .prepare("SELECT sync_id FROM p_vault_profiles WHERE id = ?") - .get(id) as { sync_id: string } - ).sync_id; await server.request("DELETE", `/profiles/${id}`); - expect(server.mock.tombstones).toEqual([ - { userId: "user-1", entityType: "vaultProfiles", syncId }, - ]); expect( server.db.sqlite .prepare("SELECT COUNT(*) AS n FROM p_vault_tokens") diff --git a/plugins/webauthn/manifest.json b/plugins/webauthn/manifest.json index 9135bc461..fff8f2786 100644 --- a/plugins/webauthn/manifest.json +++ b/plugins/webauthn/manifest.json @@ -27,5 +27,6 @@ } }, "locales": "locales", - "platforms": ["linux", "win32", "darwin"] + "platforms": ["linux", "win32", "darwin"], + "desktop": "server" } diff --git a/plugins/workspaces/manifest.json b/plugins/workspaces/manifest.json index daf38f12b..16ec3c50f 100644 --- a/plugins/workspaces/manifest.json +++ b/plugins/workspaces/manifest.json @@ -39,6 +39,7 @@ "descriptionKey": "permissions.use.description", "defaultRoles": ["admin", "user"] } - ] + ], + "syncEntities": ["workspaces"] } } diff --git a/postcss.config.cjs b/postcss.config.cjs new file mode 100644 index 000000000..cc02a14e1 --- /dev/null +++ b/postcss.config.cjs @@ -0,0 +1,23 @@ +/** + * The interface size setting scales the root font size, so every length has + * to be in rem to follow it. Tailwind classes like text-[10px] and w-[240px] + * are written in px, so they are converted here against the 14px Normal root. + * + * Left alone: 1px lines (hairline borders and dividers stay crisp), media + * queries (breakpoints follow the window, not the text size), the root size + * rules themselves, and third-party CSS such as xterm's, which measures in px. + */ +module.exports = { + plugins: { + "postcss-pxtorem": { + rootValue: 14, + unitPrecision: 5, + propList: ["*"], + selectorBlackList: [/^html(\.|$)/, /^:root/], + replace: true, + mediaQuery: false, + minPixelValue: 2, + exclude: /node_modules/i, + }, + }, +}; diff --git a/scripts/electron-security-boundary.test.ts b/scripts/electron-security-boundary.test.ts index 58151d9a0..1d1d0049b 100644 --- a/scripts/electron-security-boundary.test.ts +++ b/scripts/electron-security-boundary.test.ts @@ -19,8 +19,22 @@ describe("Electron security boundary", () => { ); }); - it("allows the renderer to hand the local session to Remote Sync", () => { - expect(main).toContain('ipcMain.handle("notify-local-login"'); - expect(preload).toContain('"notify-local-login"'); + it("never hands the linked server's session to the renderer over IPC", () => { + // The embedded backend owns the link; the renderer asks it, with its + // own local session, rather than main. + for (const channel of [ + "get-remote-sync-jwt", + "save-remote-sync-jwt", + "notify-local-login", + ]) { + expect(main).not.toContain(`"${channel}"`); + expect(preload).not.toContain(`"${channel}"`); + } + }); + + it("scopes proxy headers and basic auth to the linked origin", () => { + expect(main).toContain('"sync-proxy-config"'); + expect(main).toContain("linkedServer.applyLinkHeaders("); + expect(main).toContain("linkedServer.answerLogin("); }); }); diff --git a/scripts/plugin-build.test.ts b/scripts/plugin-build.test.ts index a82b5c34b..75b1472b7 100644 --- a/scripts/plugin-build.test.ts +++ b/scripts/plugin-build.test.ts @@ -2,6 +2,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; +import { pathToFileURL } from "node:url"; import { build } from "../packages/plugin-sdk/cli/commands/build.mjs"; const cleanups: Array<() => void> = []; @@ -39,4 +40,47 @@ describe("termix-plugin build", () => { expect(pkg).toEqual({ type: "module" }); expect(fs.existsSync(path.join(dir, "dist", "backend.js"))).toBe(true); }); + + it("loads a bundled CommonJS package that requires a host-provided one, with Vite's build constants", async () => { + vi.spyOn(console, "log").mockImplementation(() => {}); + const dir = fixturePlugin(); + const dep = path.join(dir, "node_modules", "cjs-dep"); + fs.mkdirSync(dep, { recursive: true }); + fs.writeFileSync( + path.join(dep, "package.json"), + JSON.stringify({ name: "cjs-dep", main: "index.js" }), + ); + // What react-xtermjs does: a CommonJS require of React's JSX runtime. + fs.writeFileSync( + path.join(dep, "index.js"), + 'const runtime = require("react/jsx-runtime");\nexports.render = () => runtime.jsx("div", {});\n', + ); + fs.mkdirSync(path.join(dir, "src", "frontend"), { recursive: true }); + fs.writeFileSync( + path.join(dir, "src", "frontend", "index.ts"), + 'import { render } from "cjs-dep";\nexport const rendered = render();\nexport const dev = import.meta.env.DEV;\nexport const mode = process.env.NODE_ENV;\n', + ); + + await build({ cwd: dir }); + + // Stand in for the import map: point the external at a stub module. + const stub = path.join(dir, "jsx-runtime.mjs"); + fs.writeFileSync( + stub, + "export const jsx = (type) => ({ type });\nexport const jsxs = jsx;\n", + ); + const bundle = fs + .readFileSync(path.join(dir, "dist", "frontend.js"), "utf8") + .replaceAll( + '"react/jsx-runtime"', + JSON.stringify(pathToFileURL(stub).href), + ); + const runnable = path.join(dir, "dist", "runnable.mjs"); + fs.writeFileSync(runnable, bundle); + + const mod = await import(pathToFileURL(runnable).href); + expect(mod.rendered).toEqual({ type: "div" }); + expect(mod.dev).toBe(false); + expect(mod.mode).toBe("production"); + }); }); diff --git a/scripts/postcss-rem.test.ts b/scripts/postcss-rem.test.ts new file mode 100644 index 000000000..e4eb0bb48 --- /dev/null +++ b/scripts/postcss-rem.test.ts @@ -0,0 +1,43 @@ +import { createRequire } from "node:module"; +import postcss from "postcss"; +import { describe, expect, it } from "vitest"; + +const require = createRequire(import.meta.url); +const config = require("../postcss.config.cjs") as { + plugins: Record; +}; +const pxtorem = require("postcss-pxtorem"); + +async function convert(css: string, from = "src/ui/index.css") { + const result = await postcss([ + pxtorem(config.plugins["postcss-pxtorem"]), + ]).process(css, { from }); + return result.css; +} + +describe("interface size conversion", () => { + it("turns px into rem against the 14px Normal root", async () => { + expect(await convert(".a{font-size:10px;width:280px}")).toBe( + ".a{font-size:0.71429rem;width:20rem}", + ); + }); + + it("keeps hairlines, breakpoints and the root size rules in px", async () => { + expect(await convert(".b{border-width:1px}")).toBe(".b{border-width:1px}"); + expect(await convert("@media (min-width:768px){.c{gap:7px}}")).toBe( + "@media (min-width:768px){.c{gap:0.5rem}}", + ); + expect(await convert("html.fs-lg{font-size:17px}")).toBe( + "html.fs-lg{font-size:17px}", + ); + }); + + it("leaves third-party CSS alone", async () => { + expect( + await convert( + ".xterm{padding:4px}", + "node_modules/@xterm/xterm/css/xterm.css", + ), + ).toBe(".xterm{padding:4px}"); + }); +}); diff --git a/src/backend/database/database.ts b/src/backend/database/database.ts index 7755d8a1d..9a0391e5a 100644 --- a/src/backend/database/database.ts +++ b/src/backend/database/database.ts @@ -16,7 +16,9 @@ import hostSidebarPreferencesRoutes from "./routes/host-sidebar-preferences.js"; import credentialSidebarPreferencesRoutes from "./routes/credential-sidebar-preferences.js"; import uiPreferencesRoutes from "./routes/ui-preferences.js"; import { registerAuditLogRoutes } from "./routes/audit-log-routes.js"; -import syncRoutes from "./routes/sync.js"; +import syncRoutes from "../sync/server/routes.js"; +import syncLinkRoutes from "../sync/client/routes.js"; +import { syncChangeWatcher } from "../sync/server/change-watcher.js"; import dashboardRoutes from "./routes/dashboard-routes.js"; import { mountPluginApi, @@ -33,6 +35,13 @@ import path from "path"; import os from "os"; import "dotenv/config"; import { databaseLogger, apiLogger } from "../utils/logger.js"; +import { getLocalVersion } from "../utils/app-version.js"; +import { + compareSemver, + fetchGitHubAPI, + REPO_NAME, + REPO_OWNER, +} from "../utils/latest-release.js"; import { AuthManager } from "../utils/auth-manager.js"; import { DataCrypto } from "../utils/data-crypto.js"; import { DatabaseFileEncryption } from "../utils/database-file-encryption.js"; @@ -55,13 +64,7 @@ import { import { withCurrentSqliteForeignKeysDisabled } from "./repositories/sqlite-foreign-keys.js"; import { applyPluginHostImportSettings } from "./routes/host-plugin-settings.js"; import { parseUserAgent } from "../utils/user-agent-parser.js"; -import { getProxyAgent } from "../utils/proxy-agent.js"; -import type { - CacheEntry, - GitHubRelease, - GitHubAPIResponse, - AuthenticatedRequest, -} from "../../types/index.js"; +import type { GitHubRelease, AuthenticatedRequest } from "../../types/index.js"; import { DatabaseSaveTrigger } from "./db/index.js"; import Database from "better-sqlite3"; import { fileURLToPath } from "url"; @@ -147,116 +150,6 @@ const upload = multer({ }, }); -class GitHubCache { - private cache: Map = new Map(); - private readonly CACHE_DURATION = 30 * 60 * 1000; - - set(key: string, data: T): void { - const now = Date.now(); - this.cache.set(key, { - data, - timestamp: now, - expiresAt: now + this.CACHE_DURATION, - }); - } - - get(key: string): T | null { - const entry = this.cache.get(key); - if (!entry) { - return null; - } - - if (Date.now() > entry.expiresAt) { - this.cache.delete(key); - return null; - } - - return entry.data as T; - } -} - -const githubCache = new GitHubCache(); - -function parseSemver( - version: string | undefined, -): [number, number, number] | null { - const match = String(version || "").match(/(\d+)\.(\d+)(?:\.(\d+))?/); - if (!match) return null; - - return [Number(match[1]), Number(match[2]), Number(match[3] || 0)]; -} - -function compareSemver( - a: string | undefined, - b: string | undefined, -): number | null { - const parsedA = parseSemver(a); - const parsedB = parseSemver(b); - if (!parsedA || !parsedB) return null; - - for (let i = 0; i < 3; i += 1) { - if (parsedA[i] > parsedB[i]) return 1; - if (parsedA[i] < parsedB[i]) return -1; - } - - return 0; -} - -const GITHUB_API_BASE = "https://api.github.com"; -const REPO_OWNER = "Termix-SSH"; -const REPO_NAME = "Termix"; - -async function fetchGitHubAPI( - endpoint: string, - cacheKey: string, -): Promise> { - const cachedEntry = githubCache.get>(cacheKey); - if (cachedEntry) { - return { - data: cachedEntry.data, - cached: true, - cache_age: Date.now() - cachedEntry.timestamp, - }; - } - - try { - const url = `${GITHUB_API_BASE}${endpoint}`; - const response = await fetch(url, { - headers: { - Accept: "application/vnd.github+json", - "User-Agent": "TermixUpdateChecker/1.0", - "X-GitHub-Api-Version": "2022-11-28", - }, - dispatcher: getProxyAgent(url), - }); - - if (!response.ok) { - throw new Error( - `GitHub API error: ${response.status} ${response.statusText}`, - ); - } - - const data = (await response.json()) as T; - const cacheData: CacheEntry = { - data, - timestamp: Date.now(), - expiresAt: Date.now() + 30 * 60 * 1000, - }; - githubCache.set(cacheKey, cacheData); - - return { - data: data, - cached: false, - }; - } catch (error) { - databaseLogger.error(`Failed to fetch from GitHub API`, error, { - operation: "github_api", - endpoint, - }); - throw error; - } -} - // Skipped for /plugin-api: a plugin router brings its own parsers with its own // limit (see plugins/http.ts), and parsing here first would consume the body // and silently cap every plugin at this limit instead. @@ -279,6 +172,7 @@ app.use((_req, res, next) => { res.setHeader("Cache-Control", "no-store"); next(); }); +app.use(syncChangeWatcher); /** * @openapi @@ -343,51 +237,7 @@ app.get("/.well-known/acme-challenge/:token", acmeChallengeHandler); * description: Fetch error. */ app.get("/version", authenticateJWT, async (req, res) => { - let localVersion = process.env.VERSION; - - if (!localVersion) { - const versionSources = [ - () => { - try { - const packagePath = path.resolve(process.cwd(), "package.json"); - const packageJson = JSON.parse(fs.readFileSync(packagePath, "utf8")); - return packageJson.version; - } catch { - return null; - } - }, - () => { - try { - const packagePath = path.resolve("/app", "package.json"); - const packageJson = JSON.parse(fs.readFileSync(packagePath, "utf8")); - return packageJson.version; - } catch { - return null; - } - }, - () => { - try { - const packagePath = path.resolve(__dirname, "../../../package.json"); - const packageJson = JSON.parse(fs.readFileSync(packagePath, "utf8")); - return packageJson.version; - } catch { - return null; - } - }, - ]; - - for (const getVersion of versionSources) { - try { - const foundVersion = getVersion(); - if (foundVersion && foundVersion !== "unknown") { - localVersion = foundVersion; - break; - } - } catch { - continue; - } - } - } + const localVersion = getLocalVersion(); if (!localVersion) { databaseLogger.error("No version information available", undefined, { @@ -1567,6 +1417,7 @@ app.use("/credential-sidebar/preferences", credentialSidebarPreferencesRoutes); app.use("/ui-preferences", uiPreferencesRoutes); registerAuditLogRoutes(app, authenticateJWT); app.use("/sync", syncRoutes); +app.use("/sync", syncLinkRoutes); app.use("/dashboard", dashboardRoutes); app.use("/plugins", pluginRoutes); app.use( diff --git a/src/backend/database/db/index.ts b/src/backend/database/db/index.ts index bb4b6976a..d3b3a6804 100644 --- a/src/backend/database/db/index.ts +++ b/src/backend/database/db/index.ts @@ -1598,28 +1598,80 @@ const migrateSchema = () => { } } + addColumnIfNotExists( + "ssh_data", + "local_only", + "INTEGER NOT NULL DEFAULT 0", + ); + addColumnIfNotExists("ssh_data", "shared_source", "TEXT"); + addColumnIfNotExists("ssh_credentials", "shared_source", "TEXT"); + addColumnIfNotExists( + "ssh_folders", + "local_only", + "INTEGER NOT NULL DEFAULT 0", + ); + try { - sqlite.prepare("SELECT id FROM sync_tombstones LIMIT 1").get(); - } catch { - try { - sqlite.exec(` - CREATE TABLE IF NOT EXISTS sync_tombstones ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, - entity_type TEXT NOT NULL, - sync_id TEXT NOT NULL, - deleted_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP - ); - `); - sqlite.exec( - "CREATE INDEX IF NOT EXISTS idx_sync_tombstones_user_entity ON sync_tombstones(user_id, entity_type)", + sqlite.exec(` + DROP TABLE IF EXISTS sync_tombstones; + + CREATE TABLE IF NOT EXISTS sync_records ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + entity_type TEXT NOT NULL, + sync_id TEXT NOT NULL, + revision INTEGER NOT NULL DEFAULT 0, + seq INTEGER NOT NULL DEFAULT 0, + hash TEXT, + deleted INTEGER NOT NULL DEFAULT 0, + error TEXT, + error_hash TEXT, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP ); - } catch (createError) { - databaseLogger.warn("Failed to create sync_tombstones table", { - operation: "schema_migration", - error: createError, - }); - } + CREATE UNIQUE INDEX IF NOT EXISTS idx_sync_records_user_entity_sync + ON sync_records(user_id, entity_type, sync_id); + CREATE INDEX IF NOT EXISTS idx_sync_records_user_seq + ON sync_records(user_id, seq); + + CREATE TABLE IF NOT EXISTS sync_conflicts ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + entity_type TEXT NOT NULL, + sync_id TEXT NOT NULL, + local_row TEXT NOT NULL, + server_revision INTEGER NOT NULL, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP + ); + CREATE INDEX IF NOT EXISTS idx_sync_conflicts_user + ON sync_conflicts(user_id); + + CREATE TABLE IF NOT EXISTS sync_link ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + server_url TEXT NOT NULL, + server_name TEXT, + server_version TEXT, + session_token TEXT, + custom_headers TEXT, + basic_auth TEXT, + allow_invalid_certificate INTEGER NOT NULL DEFAULT 0, + remote_user_id TEXT, + remote_username TEXT, + account TEXT, + scope TEXT, + known_types TEXT, + cursor INTEGER NOT NULL DEFAULT 0, + status TEXT NOT NULL DEFAULT 'idle', + last_error TEXT, + linked_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + last_sync_at TEXT + ); + `); + } catch (createError) { + databaseLogger.warn("Failed to create sync tables", { + operation: "schema_migration", + error: createError, + }); } // --- sync end --- diff --git a/src/backend/database/db/schema.mysql.ts b/src/backend/database/db/schema.mysql.ts index c25c41b58..ab7a8b3a2 100644 --- a/src/backend/database/db/schema.mysql.ts +++ b/src/backend/database/db/schema.mysql.ts @@ -251,6 +251,11 @@ export const hosts = mysqlTable( // databases (the embedded backend and a connected remote server) during // sync -- local autoincrement ids collide across instances. syncId: varchar("sync_id", { length: 255 }).unique(), + // Desktop only: a host kept on this device that never goes to the server. + localOnly: boolean("local_only").notNull().default(false), + // Desktop only: set on the read-only copy of a host someone shared with + // the linked account. JSON with the share's owner and permission level. + sharedSource: text("shared_source"), createdAt: text("created_at") .notNull() @@ -303,6 +308,7 @@ export const sshCredentials = mysqlTable( usageCount: int("usage_count").notNull().default(0), lastUsed: text("last_used"), syncId: varchar("sync_id", { length: 255 }).unique(), + sharedSource: text("shared_source"), createdAt: text("created_at") .notNull() .default(sql`(CURRENT_TIMESTAMP)`), @@ -353,6 +359,7 @@ export const sshFolders = mysqlTable( // to name sort, same convention as hosts.sortOrder. sortOrder: int("sort_order"), syncId: varchar("sync_id", { length: 255 }).unique(), + localOnly: boolean("local_only").notNull().default(false), createdAt: text("created_at") .notNull() .default(sql`(CURRENT_TIMESTAMP)`), @@ -707,19 +714,100 @@ export const uiPreferences = mysqlTable("ui_preferences", { }); // --- sync begin --- -// Records a delete for a synced entity type so the other side of a sync -// pair (embedded desktop backend <-> connected remote server) learns about -// the deletion instead of re-creating the row on its next pull. -export const syncTombstones = mysqlTable("sync_tombstones", { +/** + * One row per synced record a user owns, on both ends of a sync link. + * + * On a server, revision counts the record's changes and seq is the position + * in the user's change feed, which is what a desktop's cursor points at. On a + * linked desktop, revision is the last server revision this device saw and + * hash is what the record looked like then, so a different hash now means a + * local edit waiting to be pushed. + */ +export const syncRecords = mysqlTable( + "sync_records", + { + id: int("id").autoincrement().primaryKey(), + userId: varchar("user_id", { length: 255 }) + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + entityType: varchar("entity_type", { length: 255 }).notNull(), + syncId: varchar("sync_id", { length: 255 }).notNull(), + revision: int("revision").notNull().default(0), + seq: int("seq").notNull().default(0), + hash: text("hash"), + deleted: boolean("deleted").notNull().default(false), + // Desktop only: why the server refused the last push of this record, and + // the hash that was refused, so the same content is not re-sent forever. + error: text("error"), + errorHash: text("error_hash"), + updatedAt: text("updated_at") + .notNull() + .default(sql`(CURRENT_TIMESTAMP)`), + }, + (table) => [ + uniqueIndex("idx_sync_records_user_entity_sync").on( + table.userId, + table.entityType, + table.syncId, + ), + index("idx_sync_records_user_seq").on(table.userId, table.seq), + ], +); + +/** + * Desktop only: a local edit that lost to a newer server edit. The server + * version was applied; this keeps the local one so the user can pick it. + */ +export const syncConflicts = mysqlTable( + "sync_conflicts", + { + id: int("id").autoincrement().primaryKey(), + userId: varchar("user_id", { length: 255 }) + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + entityType: text("entity_type").notNull(), + syncId: text("sync_id").notNull(), + localRow: text("local_row").notNull(), + serverRevision: int("server_revision").notNull(), + createdAt: text("created_at") + .notNull() + .default(sql`(CURRENT_TIMESTAMP)`), + }, + (table) => [index("idx_sync_conflicts_user").on(table.userId)], +); + +/** + * Desktop only: the server this install is linked to. One row at most. + * Secrets (session token, proxy headers, basic auth) are encrypted with the + * system key. + */ +export const syncLink = mysqlTable("sync_link", { id: int("id").autoincrement().primaryKey(), userId: varchar("user_id", { length: 255 }) .notNull() .references(() => users.id, { onDelete: "cascade" }), - entityType: text("entity_type").notNull(), - syncId: text("sync_id").notNull(), - deletedAt: text("deleted_at") + serverUrl: text("server_url").notNull(), + serverName: text("server_name"), + serverVersion: text("server_version"), + sessionToken: text("session_token"), + customHeaders: text("custom_headers"), + basicAuth: text("basic_auth"), + allowInvalidCertificate: boolean("allow_invalid_certificate") + .notNull() + .default(false), + remoteUserId: text("remote_user_id"), + remoteUsername: text("remote_username"), + // The linked account as the server describes it: roles, admin, permissions. + account: text("account"), + scope: text("scope"), + knownTypes: text("known_types"), + cursor: int("cursor").notNull().default(0), + status: text("status").notNull().default("idle"), + lastError: text("last_error"), + linkedAt: text("linked_at") .notNull() .default(sql`(CURRENT_TIMESTAMP)`), + lastSyncAt: text("last_sync_at"), }); // --- sync end --- diff --git a/src/backend/database/db/schema.pg.ts b/src/backend/database/db/schema.pg.ts index 2bff52634..5c5898c83 100644 --- a/src/backend/database/db/schema.pg.ts +++ b/src/backend/database/db/schema.pg.ts @@ -252,6 +252,11 @@ export const hosts = pgTable( // databases (the embedded backend and a connected remote server) during // sync -- local autoincrement ids collide across instances. syncId: varchar("sync_id", { length: 255 }).unique(), + // Desktop only: a host kept on this device that never goes to the server. + localOnly: boolean("local_only").notNull().default(false), + // Desktop only: set on the read-only copy of a host someone shared with + // the linked account. JSON with the share's owner and permission level. + sharedSource: text("shared_source"), createdAt: text("created_at") .notNull() @@ -304,6 +309,7 @@ export const sshCredentials = pgTable( usageCount: integer("usage_count").notNull().default(0), lastUsed: text("last_used"), syncId: varchar("sync_id", { length: 255 }).unique(), + sharedSource: text("shared_source"), createdAt: text("created_at") .notNull() .default(sql`CURRENT_TIMESTAMP`), @@ -354,6 +360,7 @@ export const sshFolders = pgTable( // to name sort, same convention as hosts.sortOrder. sortOrder: integer("sort_order"), syncId: varchar("sync_id", { length: 255 }).unique(), + localOnly: boolean("local_only").notNull().default(false), createdAt: text("created_at") .notNull() .default(sql`CURRENT_TIMESTAMP`), @@ -708,19 +715,100 @@ export const uiPreferences = pgTable("ui_preferences", { }); // --- sync begin --- -// Records a delete for a synced entity type so the other side of a sync -// pair (embedded desktop backend <-> connected remote server) learns about -// the deletion instead of re-creating the row on its next pull. -export const syncTombstones = pgTable("sync_tombstones", { +/** + * One row per synced record a user owns, on both ends of a sync link. + * + * On a server, revision counts the record's changes and seq is the position + * in the user's change feed, which is what a desktop's cursor points at. On a + * linked desktop, revision is the last server revision this device saw and + * hash is what the record looked like then, so a different hash now means a + * local edit waiting to be pushed. + */ +export const syncRecords = pgTable( + "sync_records", + { + id: serial("id").primaryKey(), + userId: varchar("user_id", { length: 255 }) + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + entityType: varchar("entity_type", { length: 255 }).notNull(), + syncId: varchar("sync_id", { length: 255 }).notNull(), + revision: integer("revision").notNull().default(0), + seq: integer("seq").notNull().default(0), + hash: text("hash"), + deleted: boolean("deleted").notNull().default(false), + // Desktop only: why the server refused the last push of this record, and + // the hash that was refused, so the same content is not re-sent forever. + error: text("error"), + errorHash: text("error_hash"), + updatedAt: text("updated_at") + .notNull() + .default(sql`CURRENT_TIMESTAMP`), + }, + (table) => [ + uniqueIndex("idx_sync_records_user_entity_sync").on( + table.userId, + table.entityType, + table.syncId, + ), + index("idx_sync_records_user_seq").on(table.userId, table.seq), + ], +); + +/** + * Desktop only: a local edit that lost to a newer server edit. The server + * version was applied; this keeps the local one so the user can pick it. + */ +export const syncConflicts = pgTable( + "sync_conflicts", + { + id: serial("id").primaryKey(), + userId: varchar("user_id", { length: 255 }) + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + entityType: text("entity_type").notNull(), + syncId: text("sync_id").notNull(), + localRow: text("local_row").notNull(), + serverRevision: integer("server_revision").notNull(), + createdAt: text("created_at") + .notNull() + .default(sql`CURRENT_TIMESTAMP`), + }, + (table) => [index("idx_sync_conflicts_user").on(table.userId)], +); + +/** + * Desktop only: the server this install is linked to. One row at most. + * Secrets (session token, proxy headers, basic auth) are encrypted with the + * system key. + */ +export const syncLink = pgTable("sync_link", { id: serial("id").primaryKey(), userId: varchar("user_id", { length: 255 }) .notNull() .references(() => users.id, { onDelete: "cascade" }), - entityType: text("entity_type").notNull(), - syncId: text("sync_id").notNull(), - deletedAt: text("deleted_at") + serverUrl: text("server_url").notNull(), + serverName: text("server_name"), + serverVersion: text("server_version"), + sessionToken: text("session_token"), + customHeaders: text("custom_headers"), + basicAuth: text("basic_auth"), + allowInvalidCertificate: boolean("allow_invalid_certificate") + .notNull() + .default(false), + remoteUserId: text("remote_user_id"), + remoteUsername: text("remote_username"), + // The linked account as the server describes it: roles, admin, permissions. + account: text("account"), + scope: text("scope"), + knownTypes: text("known_types"), + cursor: integer("cursor").notNull().default(0), + status: text("status").notNull().default("idle"), + lastError: text("last_error"), + linkedAt: text("linked_at") .notNull() .default(sql`CURRENT_TIMESTAMP`), + lastSyncAt: text("last_sync_at"), }); // --- sync end --- diff --git a/src/backend/database/db/schema.ts b/src/backend/database/db/schema.ts index d8d6a5096..c5eb27168 100644 --- a/src/backend/database/db/schema.ts +++ b/src/backend/database/db/schema.ts @@ -244,6 +244,11 @@ export const hosts = sqliteTable( // databases (the embedded backend and a connected remote server) during // sync -- local autoincrement ids collide across instances. syncId: text("sync_id").unique(), + // Desktop only: a host kept on this device that never goes to the server. + localOnly: integer("local_only", { mode: "boolean" }).notNull().default(false), + // Desktop only: set on the read-only copy of a host someone shared with + // the linked account. JSON with the share's owner and permission level. + sharedSource: text("shared_source"), createdAt: text("created_at") .notNull() @@ -296,6 +301,7 @@ export const sshCredentials = sqliteTable( usageCount: integer("usage_count").notNull().default(0), lastUsed: text("last_used"), syncId: text("sync_id").unique(), + sharedSource: text("shared_source"), createdAt: text("created_at") .notNull() .default(sql`CURRENT_TIMESTAMP`), @@ -346,6 +352,7 @@ export const sshFolders = sqliteTable( // to name sort, same convention as hosts.sortOrder. sortOrder: integer("sort_order"), syncId: text("sync_id").unique(), + localOnly: integer("local_only", { mode: "boolean" }).notNull().default(false), createdAt: text("created_at") .notNull() .default(sql`CURRENT_TIMESTAMP`), @@ -704,19 +711,102 @@ export const uiPreferences = sqliteTable("ui_preferences", { }); // --- sync begin --- -// Records a delete for a synced entity type so the other side of a sync -// pair (embedded desktop backend <-> connected remote server) learns about -// the deletion instead of re-creating the row on its next pull. -export const syncTombstones = sqliteTable("sync_tombstones", { +/** + * One row per synced record a user owns, on both ends of a sync link. + * + * On a server, revision counts the record's changes and seq is the position + * in the user's change feed, which is what a desktop's cursor points at. On a + * linked desktop, revision is the last server revision this device saw and + * hash is what the record looked like then, so a different hash now means a + * local edit waiting to be pushed. + */ +export const syncRecords = sqliteTable( + "sync_records", + { + id: integer("id").primaryKey({ autoIncrement: true }), + userId: text("user_id") + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + entityType: text("entity_type").notNull(), + syncId: text("sync_id").notNull(), + revision: integer("revision").notNull().default(0), + seq: integer("seq").notNull().default(0), + hash: text("hash"), + deleted: integer("deleted", { mode: "boolean" }).notNull().default(false), + // Desktop only: why the server refused the last push of this record, and + // the hash that was refused, so the same content is not re-sent forever. + error: text("error"), + errorHash: text("error_hash"), + updatedAt: text("updated_at") + .notNull() + .default(sql`CURRENT_TIMESTAMP`), + }, + (table) => [ + uniqueIndex("idx_sync_records_user_entity_sync").on( + table.userId, + table.entityType, + table.syncId, + ), + index("idx_sync_records_user_seq").on(table.userId, table.seq), + ], +); + +/** + * Desktop only: a local edit that lost to a newer server edit. The server + * version was applied; this keeps the local one so the user can pick it. + */ +export const syncConflicts = sqliteTable( + "sync_conflicts", + { + id: integer("id").primaryKey({ autoIncrement: true }), + userId: text("user_id") + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + entityType: text("entity_type").notNull(), + syncId: text("sync_id").notNull(), + localRow: text("local_row").notNull(), + serverRevision: integer("server_revision").notNull(), + createdAt: text("created_at") + .notNull() + .default(sql`CURRENT_TIMESTAMP`), + }, + (table) => [index("idx_sync_conflicts_user").on(table.userId)], +); + +/** + * Desktop only: the server this install is linked to. One row at most. + * Secrets (session token, proxy headers, basic auth) are encrypted with the + * system key. + */ +export const syncLink = sqliteTable("sync_link", { id: integer("id").primaryKey({ autoIncrement: true }), userId: text("user_id") .notNull() .references(() => users.id, { onDelete: "cascade" }), - entityType: text("entity_type").notNull(), - syncId: text("sync_id").notNull(), - deletedAt: text("deleted_at") + serverUrl: text("server_url").notNull(), + serverName: text("server_name"), + serverVersion: text("server_version"), + sessionToken: text("session_token"), + customHeaders: text("custom_headers"), + basicAuth: text("basic_auth"), + allowInvalidCertificate: integer("allow_invalid_certificate", { + mode: "boolean", + }) + .notNull() + .default(false), + remoteUserId: text("remote_user_id"), + remoteUsername: text("remote_username"), + // The linked account as the server describes it: roles, admin, permissions. + account: text("account"), + scope: text("scope"), + knownTypes: text("known_types"), + cursor: integer("cursor").notNull().default(0), + status: text("status").notNull().default("idle"), + lastError: text("last_error"), + linkedAt: text("linked_at") .notNull() .default(sql`CURRENT_TIMESTAMP`), + lastSyncAt: text("last_sync_at"), }); // --- sync end --- diff --git a/src/backend/database/repositories/factory.ts b/src/backend/database/repositories/factory.ts index 578534f1e..723a6ebc8 100644 --- a/src/backend/database/repositories/factory.ts +++ b/src/backend/database/repositories/factory.ts @@ -31,7 +31,6 @@ import { SettingsRepository } from "./settings-repository.js"; import { SharedHostAuthOverrideRepository } from "./shared-host-auth-override-repository.js"; import { SharedHostSecretsRepository } from "./shared-host-secrets-repository.js"; import { SshCredentialUsageRepository } from "./ssh-credential-usage-repository.js"; -import { SyncTombstoneRepository } from "./sync-tombstone-repository.js"; import { TrustedDeviceRepository } from "./trusted-device-repository.js"; import { UserDataExportRepository } from "./user-data-export-repository.js"; import { UserPreferenceRepository } from "./user-preference-repository.js"; @@ -138,13 +137,6 @@ export function createCurrentCredentialRepository(): CredentialRepository { ); } -export function createCurrentSyncTombstoneRepository(): SyncTombstoneRepository { - return new SyncTombstoneRepository( - createCurrentRepositoryContext(), - createCurrentRepositoryWriteHook("sync_tombstone_repository_write"), - ); -} - export function createCurrentHostFolderRepository(): HostFolderRepository { return new HostFolderRepository( createCurrentRepositoryContext(), diff --git a/src/backend/database/repositories/host-folder-repository.ts b/src/backend/database/repositories/host-folder-repository.ts index 5e4309301..1d88bcb06 100644 --- a/src/backend/database/repositories/host-folder-repository.ts +++ b/src/backend/database/repositories/host-folder-repository.ts @@ -85,6 +85,28 @@ export class HostFolderRepository { .where(eq(sshFolders.userId, userId)); } + /** + * Keeps a folder, its subfolders and every host in them on this device + * only, or lets them sync again. + */ + async setLocalOnly( + userId: string, + folderName: string, + localOnly: boolean, + ): Promise { + const inFolder = (col: SQLiteColumn) => + or(eq(col, folderName), like(col, `${folderName} / %`)); + await this.context.drizzle + .update(sshFolders) + .set({ localOnly }) + .where(and(eq(sshFolders.userId, userId), inFolder(sshFolders.name))); + await this.context.drizzle + .update(hosts) + .set({ localOnly, updatedAt: new Date().toISOString() }) + .where(and(eq(hosts.userId, userId), inFolder(hosts.folder))); + await this.afterWrite(); + } + async upsertMetadata( userId: string, name: string, diff --git a/src/backend/database/repositories/sync-tombstone-repository.ts b/src/backend/database/repositories/sync-tombstone-repository.ts deleted file mode 100644 index f4e4ddbd3..000000000 --- a/src/backend/database/repositories/sync-tombstone-repository.ts +++ /dev/null @@ -1,73 +0,0 @@ -import { and, eq } from "drizzle-orm"; -import { syncTombstones } from "../db/schema.js"; -import { timestampAtOrAfter } from "../sync-timestamp.js"; -import type { DatabaseContext } from "./database-context.js"; - -export type SyncTombstoneRecord = typeof syncTombstones.$inferSelect; - -/** - * The wire name of a synced entity. - * - * A string rather than a union: the set is whatever core and the installed - * plugins have registered, so it cannot be known at compile time. The column - * was already free-form text with no enum constraint, so nothing in the - * database changes. sync.ts validates a name through the registry before it - * reaches a query. - */ -export type SyncEntityType = string; - -export class SyncTombstoneRepository { - constructor( - private readonly context: DatabaseContext, - private readonly onWrite?: () => void | Promise, - ) {} - - async record( - userId: string, - entityType: SyncEntityType, - syncId: string, - ): Promise { - if (!syncId) return; - await this.context.drizzle.insert(syncTombstones).values({ - userId, - entityType, - syncId, - }); - await this.afterWrite(); - } - - async recordMany( - userId: string, - entityType: SyncEntityType, - syncIds: string[], - ): Promise { - const rows = syncIds.filter(Boolean); - if (rows.length === 0) return; - await this.context.drizzle - .insert(syncTombstones) - .values(rows.map((syncId) => ({ userId, entityType, syncId }))); - await this.afterWrite(); - } - - async listSince( - userId: string, - entityType: SyncEntityType, - since: string | null, - ): Promise { - const conditions = [ - eq(syncTombstones.userId, userId), - eq(syncTombstones.entityType, entityType), - ]; - if (since) - conditions.push(timestampAtOrAfter(syncTombstones.deletedAt, since)); - - return this.context.drizzle - .select() - .from(syncTombstones) - .where(and(...conditions)); - } - - private async afterWrite(): Promise { - await this.onWrite?.(); - } -} diff --git a/src/backend/database/routes/credentials.ts b/src/backend/database/routes/credentials.ts index 9f9439496..1b5cab025 100644 --- a/src/backend/database/routes/credentials.ts +++ b/src/backend/database/routes/credentials.ts @@ -2,6 +2,7 @@ import { getErrorMessage } from "../../utils/error-message.js"; import type { AuthenticatedRequest } from "../../../types/index.js"; import { listCredentialTypes } from "../../hosts/connect/auth-provider-registry.js"; import { ensureCoreSshAuthProviders } from "../../hosts/connect/core-providers.js"; +import { deleteOwnedCredential } from "../../hosts/delete-credential.js"; import express, { type Request, type Response } from "express"; import { authLogger } from "../../utils/logger.js"; import { PermissionManager } from "../../utils/permission-manager.js"; @@ -22,8 +23,9 @@ import { createCurrentRoleRepository, createCurrentHostResolutionRepository, createCurrentHostRepository, - createCurrentSyncTombstoneRepository, } from "../repositories/factory.js"; +import { rejectSharedCopyWrites } from "../../sync/shared-copy-guard.js"; +import { parseSharedSource } from "./host-normalizers.js"; /** Built-in password and key, plus any type a plugin offers for credentials. */ function getCredentialTypes(): string[] { @@ -32,6 +34,7 @@ function getCredentialTypes(): string[] { } const router = express.Router(); +router.use(rejectSharedCopyWrites("credential", /^\/(\d+)$/)); function isNonEmptyString(val: unknown): val is string { return typeof val === "string" && val.trim().length > 0; @@ -816,59 +819,11 @@ router.delete( try { const credentialId = parseInt(id); - const credentialToDelete = - await createCurrentCredentialRepository().findDecryptedByIdForUser( - userId, - credentialId, - ); - - if (!credentialToDelete) { + const deleted = await deleteOwnedCredential(userId, credentialId); + if (!deleted) { return res.status(404).json({ error: "Credential not found" }); } - const hostsUsingCredential = - await createCurrentHostResolutionRepository().listHostsUsingCredentialForUser( - userId, - credentialId, - ); - - if (hostsUsingCredential.length > 0) { - await createCurrentHostRepository().updateManyForUser( - userId, - hostsUsingCredential.map((host) => host.id), - { - credentialId: null, - password: null, - key: null, - keyPassword: null, - authType: "password", - }, - ); - } - - const { SharedHostSecretsManager } = - await import("../../utils/shared-host-secrets-manager.js"); - const sharedSecretsManager = SharedHostSecretsManager.getInstance(); - await sharedSecretsManager.deleteForCredential(credentialId); - - await createCurrentCredentialRepository().deleteForUser( - userId, - credentialId, - ); - if (credentialToDelete.syncId) { - await createCurrentSyncTombstoneRepository().record( - userId, - "sshCredentials", - credentialToDelete.syncId, - ); - } - - // Shares stay in place; re-snapshot so recipients fall back to whatever - // auth the host still has (or lose the stale credential copy). - for (const host of hostsUsingCredential) { - await sharedSecretsManager.resyncHost(host.id); - } - authLogger.success("SSH credential deleted", { operation: "credential_delete_success", userId, @@ -882,7 +837,7 @@ router.delete( action: "delete_credential", resourceType: "credential", resourceId: id, - resourceName: String(credentialToDelete.name ?? id), + resourceName: String(deleted.name ?? id), ipAddress: cdIp, userAgent: cdUa, success: true, @@ -1138,6 +1093,19 @@ function formatCredentialOutput( lastUsed: credential.lastUsed, createdAt: credential.createdAt, updatedAt: credential.updatedAt, + ...sharedCopyFields(credential.sharedSource), + }; +} + +/** A read-only copy of a credential shared with a linked desktop's account. */ +function sharedCopyFields(value: unknown): Record { + const shared = parseSharedSource(value); + if (!shared) return {}; + return { + isShared: true, + sharedCopy: true, + ownerUsername: shared.owner || null, + permissionLevel: shared.permissionLevel === "manage" ? "manage" : "use", }; } diff --git a/src/backend/database/routes/desktop-auto-session.ts b/src/backend/database/routes/desktop-auto-session.ts index af3d786b6..915e1bfd5 100644 --- a/src/backend/database/routes/desktop-auto-session.ts +++ b/src/backend/database/routes/desktop-auto-session.ts @@ -21,6 +21,17 @@ export function isLoopbackRequest(req: Request): boolean { ); } +/** + * Whether this backend may hand out the desktop auto-session at all: the + * embedded desktop backend, or a development backend the desktop app points + * at. A production server never does, whoever asks. + */ +export function allowsDesktopAutoSession( + env: NodeJS.ProcessEnv = process.env, +): boolean { + return env.ELECTRON_EMBEDDED === "true" || env.NODE_ENV !== "production"; +} + export function extractBearerOrCookieToken(req: Request): string | undefined { const cookieToken = (req as Request & { cookies?: Record }) .cookies?.jwt; diff --git a/src/backend/database/routes/host-folder-routes.ts b/src/backend/database/routes/host-folder-routes.ts index 9387929a7..899dcb2d3 100644 --- a/src/backend/database/routes/host-folder-routes.ts +++ b/src/backend/database/routes/host-folder-routes.ts @@ -9,7 +9,6 @@ import { createCurrentRecentActivityRepository, createCurrentRbacAccessRepository, createCurrentSshCredentialUsageRepository, - createCurrentSyncTombstoneRepository, } from "../repositories/factory.js"; import { isNonEmptyString } from "./host-normalizers.js"; @@ -178,6 +177,9 @@ export function registerHostFolderRoutes( * credentialId: * type: integer * nullable: true + * localOnly: + * type: boolean + * description: Desktop only. Keeps the folder and its hosts on this device instead of syncing them. * responses: * 200: * description: Folder metadata updated successfully. @@ -193,7 +195,7 @@ export function registerHostFolderRoutes( requireDataAccess, async (req: Request, res: Response) => { const userId = (req as AuthenticatedRequest).userId; - const { name, color, icon, credentialId } = req.body; + const { name, color, icon, credentialId, localOnly } = req.body; if (!isNonEmptyString(userId) || !name) { return res.status(400).json({ error: "Folder name is required" }); @@ -234,6 +236,13 @@ export function registerHostFolderRoutes( icon, normalizedCredentialId, ); + if (typeof localOnly === "boolean") { + await createCurrentHostFolderRepository().setLocalOnly( + userId, + name, + localOnly, + ); + } if (!created) { databaseLogger.info("Updating SSH folder", { @@ -411,17 +420,9 @@ export function registerHostFolderRoutes( ); } - const { hostSyncIds, folderSyncIds } = - await hostFolderRepository.deleteHostsAndFolderRecords( - userId, - folderName, - ); - const tombstoneRepository = createCurrentSyncTombstoneRepository(); - await tombstoneRepository.recordMany(userId, "hosts", hostSyncIds); - await tombstoneRepository.recordMany( + await hostFolderRepository.deleteHostsAndFolderRecords( userId, - "sshFolders", - folderSyncIds, + folderName, ); try { diff --git a/src/backend/database/routes/host-normalizers.ts b/src/backend/database/routes/host-normalizers.ts index 61f009241..4083d9eec 100644 --- a/src/backend/database/routes/host-normalizers.ts +++ b/src/backend/database/routes/host-normalizers.ts @@ -383,11 +383,35 @@ export function sanitizeHostForRecipient( return reduced; } +/** Who shared a host that a linked desktop holds a read-only copy of. */ +export function parseSharedSource( + value: unknown, +): { owner?: string; permissionLevel?: string } | null { + if (typeof value !== "string" || !value) return null; + try { + const parsed = JSON.parse(value); + return parsed && typeof parsed === "object" ? parsed : {}; + } catch { + return {}; + } +} + export function transformHostResponse( host: Record, ): Record { + const shared = parseSharedSource(host.sharedSource); return { ...host, + sharedSource: undefined, + localOnly: !!host.localOnly, + sharedCopy: !!shared, + ...(shared + ? { + isShared: true, + ownerUsername: shared.owner || undefined, + permissionLevel: shared.permissionLevel || "connect", + } + : {}), tags: typeof host.tags === "string" ? host.tags diff --git a/src/backend/database/routes/host.ts b/src/backend/database/routes/host.ts index d93c3c3dc..5a8be5642 100644 --- a/src/backend/database/routes/host.ts +++ b/src/backend/database/routes/host.ts @@ -67,8 +67,10 @@ import { requiresPersonalHostAuthentication, resolveRecipientSharedHostAuthentication, } from "../../utils/shared-host-auth-resolver.js"; +import { rejectSharedCopyWrites } from "../../sync/shared-copy-guard.js"; const router = express.Router(); +router.use(rejectSharedCopyWrites("host", /^\/db\/host\/(\d+)$/)); const upload = multer({ storage: multer.memoryStorage() }); @@ -199,6 +201,7 @@ router.post( socks5Password, socks5ProxyChain, connectionOrigin, + localOnly, portKnockSequence, overrideCredentialUsername, enableSsh, @@ -311,6 +314,7 @@ router.post( connectionOrigin === "local" || connectionOrigin === "remote" ? connectionOrigin : null, + ...(typeof localOnly === "boolean" ? { localOnly } : {}), portKnockSequence: portKnockSequence ? JSON.stringify(portKnockSequence) : null, @@ -795,6 +799,7 @@ router.put( socks5Password, socks5ProxyChain, connectionOrigin, + localOnly, portKnockSequence, overrideCredentialUsername, enableSsh, @@ -908,6 +913,7 @@ router.put( connectionOrigin === "local" || connectionOrigin === "remote" ? connectionOrigin : null, + ...(typeof localOnly === "boolean" ? { localOnly } : {}), portKnockSequence: portKnockSequence ? JSON.stringify(portKnockSequence) : null, @@ -1454,14 +1460,16 @@ router.get( const result = await Promise.all( data.map(async (row: Record) => { + const transformed = transformHostResponse(row); const baseHost = { - ...transformHostResponse(row), - isShared: !!row.isShared, - permissionLevel: row.permissionLevel || undefined, + ...transformed, + isShared: !!row.isShared || !!transformed.sharedCopy, + permissionLevel: + row.permissionLevel || transformed.permissionLevel || undefined, sharedExpiresAt: row.expiresAt || undefined, ownerUsername: row.isShared ? ownerUsernames.get(row.userId as string) || undefined - : undefined, + : transformed.ownerUsername, }; const resolved = diff --git a/src/backend/database/routes/plugins.ts b/src/backend/database/routes/plugins.ts index cf842f76b..5000d1400 100644 --- a/src/backend/database/routes/plugins.ts +++ b/src/backend/database/routes/plugins.ts @@ -306,6 +306,14 @@ router.get("/", authenticateJWT, async (req: Request, res: Response) => { } }); +async function isManagedByLinkedServer(pluginId: string): Promise { + if (process.env.ELECTRON_EMBEDDED !== "true") return false; + const { getLink } = await import("../../sync/client/link-store.js"); + if (!(await getLink())) return false; + const { isServerManaged } = await import("../../sync/client/plugins.js"); + return isServerManaged(pluginId); +} + /** * @openapi * /plugins/{id}/state: @@ -338,6 +346,8 @@ router.get("/", authenticateJWT, async (req: Request, res: Response) => { * description: The caller lacks admin.plugins.manage. * 404: * description: No such plugin. + * 409: + * description: This desktop is linked to a server, which decides whether the plugin runs. */ router.patch( "/:id/state", @@ -360,6 +370,15 @@ router.patch( return; } + // A linked desktop runs what its server runs. + if (await isManagedByLinkedServer(pluginId)) { + res.status(409).json({ + error: "Managed by the linked server", + code: "MANAGED_BY_SERVER", + }); + return; + } + // Persist first: if the start or stop below throws, the recorded // intent still matches what the user asked for, and the next boot // acts on it rather than silently reverting. diff --git a/src/backend/database/routes/sync-entities.ts b/src/backend/database/routes/sync-entities.ts deleted file mode 100644 index 5a5f9d947..000000000 --- a/src/backend/database/routes/sync-entities.ts +++ /dev/null @@ -1,112 +0,0 @@ -/** - * Core's own sync entities, registered the same way a plugin registers one. - * - * These were a hardcoded ENTITY_CONFIG in sync.ts, a SyncEntityType union in - * the tombstone repository, a REFERENCES map in sync-references.ts and a - * frozen array in electron/. Four lists that had to agree and had no test - * saying they did. Now there is one registration per entity and the rest is - * derived. - * - * The order values reproduce the dependency order the Electron array encoded: - * anything referenced by another entity syncs first. - */ - -import { - hosts, - sshCredentials, - sshFolders, - userPreferences, -} from "../db/schema.js"; -import { - CORE_OWNER, - registerEntity, - listEntities, -} from "../../plugins/sync-registry.js"; - -const CREDENTIAL_REFERENCE = { - field: "credentialId", - syncField: "credentialSyncId", - entityType: "sshCredentials", -} as const; - -const HOST_REFERENCES = [ - CREDENTIAL_REFERENCE, - { - field: "rdpCredentialId", - syncField: "rdpCredentialSyncId", - entityType: "sshCredentials", - }, - { - field: "vncCredentialId", - syncField: "vncCredentialSyncId", - entityType: "sshCredentials", - }, - { - field: "telnetCredentialId", - syncField: "telnetCredentialSyncId", - entityType: "sshCredentials", - }, - { - field: "parentHostId", - syncField: "parentHostSyncId", - entityType: "hosts", - }, -] as const; - -let registered = false; - -/** - * Registers core's entities. Idempotent, because the sync routes and the - * Electron entity endpoint both need the registry primed and neither can - * assume it ran first. - */ -export function registerCoreSyncEntities(): void { - if (registered && listEntities().length > 0) return; - registered = true; - - // 10, 20, 30... so a plugin can slot an entity between two core ones - // without every order value having to be renumbered. - registerEntity(CORE_OWNER, { - type: "sshCredentials", - table: sshCredentials, - order: 10, - }); - - // vaultProfiles (order 20) moved to the vault plugin. Hosts no longer - // reference it: a host's profile is the plugin's profileId host setting. - - registerEntity(CORE_OWNER, { - type: "sshFolders", - table: sshFolders, - order: 30, - references: [CREDENTIAL_REFERENCE], - }); - - registerEntity(CORE_OWNER, { - type: "hosts", - table: hosts, - order: 50, - // connectionOrigin says how this device reached the host, which is never - // true of the other side. - readOnlyFields: ["connectionOrigin"], - references: HOST_REFERENCES, - encryptedFields: ["ssh_data"], - }); - - // dashboardServiceLinks (order 70) and homepageItems (order 80) moved to - // the homepage plugin, which registers them itself at activation. - - registerEntity(CORE_OWNER, { - type: "userPreferences", - table: userPreferences, - order: 90, - singleton: true, - readOnlyFields: ["storageMode"], - }); -} - -/** The tables a reference may point at, derived from the registrations. */ -export const ENCRYPTED_ENTITY_TABLES: Readonly> = { - hosts: "ssh_data", - sshCredentials: "ssh_credentials", -}; diff --git a/src/backend/database/routes/sync-references.ts b/src/backend/database/routes/sync-references.ts deleted file mode 100644 index b09f51a38..000000000 --- a/src/backend/database/routes/sync-references.ts +++ /dev/null @@ -1,123 +0,0 @@ -import type { SyncEntityType } from "../repositories/sync-tombstone-repository.js"; -import { getEntity } from "../../plugins/sync-registry.js"; - -/** - * An entity another entity's rows may point at. - * - * A plain string rather than a union: the set is whatever is registered, and a - * plugin can both register an entity and reference one. sync.ts resolves the - * name through the registry, so an unknown one is an error there rather than a - * silent fallthrough. - */ -export type SyncReferenceEntity = string; - -interface SyncReference { - field: string; - syncField: string; - entityType: SyncReferenceEntity; -} - -export function orderSyncRows( - entityType: SyncEntityType, - rows: Record[], -): Record[] { - // Only an entity that references itself needs its rows ordered among - // themselves. Today that is hosts, through parentHostId. - const selfReference = referencesFor(entityType).find( - (reference) => reference.entityType === entityType, - ); - if (!selfReference) return rows; - - const bySyncId = new Map( - rows - .filter((row) => typeof row.syncId === "string") - .map((row) => [row.syncId as string, row]), - ); - const ordered: Record[] = []; - const visited = new Set>(); - const visiting = new Set>(); - - const visit = (row: Record) => { - if (visited.has(row)) return; - if (visiting.has(row)) return; - visiting.add(row); - - const parentSyncId = row[selfReference.syncField]; - if (typeof parentSyncId === "string") { - const parent = bySyncId.get(parentSyncId); - if (parent) visit(parent); - } - - visiting.delete(row); - visited.add(row); - ordered.push(row); - }; - - rows.forEach(visit); - return ordered; -} - -function referencesFor(entityType: SyncEntityType): readonly SyncReference[] { - return (getEntity(entityType)?.references ?? []) as readonly SyncReference[]; -} - -export async function serializeSyncReferences( - entityType: SyncEntityType, - row: Record, - resolveSyncId: ( - entityType: SyncReferenceEntity, - id: number, - ) => Promise, -): Promise> { - const serialized = { ...row }; - - const custom = getEntity(entityType)?.serialize; - if (custom) return custom(serialized, resolveSyncId); - - for (const reference of referencesFor(entityType)) { - const id = serialized[reference.field]; - serialized[reference.syncField] = - typeof id === "number" - ? await resolveSyncId(reference.entityType, id) - : null; - delete serialized[reference.field]; - } - return serialized; -} - -export async function deserializeSyncReferences( - entityType: SyncEntityType, - row: Record, - resolveId: ( - entityType: SyncReferenceEntity, - syncId: string, - ) => Promise, -): Promise> { - const deserialized = { ...row }; - - const custom = getEntity(entityType)?.deserialize; - if (custom) return custom(deserialized, resolveId); - - for (const reference of referencesFor(entityType)) { - const syncId = deserialized[reference.syncField]; - delete deserialized[reference.syncField]; - delete deserialized[reference.field]; - - if (syncId == null) { - deserialized[reference.field] = null; - continue; - } - if (typeof syncId !== "string") { - throw new Error(`Invalid ${reference.syncField}`); - } - - const id = await resolveId(reference.entityType, syncId); - if (id === null) { - throw new Error( - `Missing ${reference.entityType} dependency ${reference.syncField}=${syncId}`, - ); - } - deserialized[reference.field] = id; - } - return deserialized; -} diff --git a/src/backend/database/routes/sync.ts b/src/backend/database/routes/sync.ts deleted file mode 100644 index fc87f4fe4..000000000 --- a/src/backend/database/routes/sync.ts +++ /dev/null @@ -1,588 +0,0 @@ -import express, { type Request, type Response } from "express"; -import { and, eq, type SQL } from "drizzle-orm"; -import { hosts } from "../db/schema.js"; -import { AuthManager } from "../../utils/auth-manager.js"; -import { DataCrypto } from "../../utils/data-crypto.js"; -import { databaseLogger } from "../../utils/logger.js"; -import { DatabaseSaveTrigger } from "../../utils/database-save-trigger.js"; -import type { AuthenticatedRequest } from "../../../types/index.js"; -import { - createCurrentRepositoryContext, - createCurrentSyncTombstoneRepository, -} from "../repositories/factory.js"; -import type { SyncEntityType } from "../repositories/sync-tombstone-repository.js"; -import { - getEntity, - hasEntity, - listEntities, -} from "../../plugins/sync-registry.js"; -import { - ENCRYPTED_ENTITY_TABLES, - registerCoreSyncEntities, -} from "./sync-entities.js"; -import { - deserializeSyncReferences, - orderSyncRows, - serializeSyncReferences, - type SyncReferenceEntity, -} from "./sync-references.js"; -import { timestampAtOrAfter } from "../sync-timestamp.js"; -import { - insertReturningWhere, - updateReturning, -} from "../repositories/returning.js"; -import { validateParentHostId } from "./host-parent-validation.js"; -import { - exportHostPluginSettings, - importHostPluginSettings, -} from "./sync-host-plugin-settings.js"; - -// Primed at import so every consumer of this module - the routes below, the -// reference resolvers, the Electron entity-types endpoint - sees the same -// registry regardless of which one runs first. -registerCoreSyncEntities(); - -const router = express.Router(); -const authManager = AuthManager.getInstance(); -const authenticateJWT = authManager.createAuthMiddleware(); - -/** - * What a sync entity looks like once it is registered. - * - * The table was a hand-maintained union of ten concrete drizzle types, which a - * plugin-provided table could never join. It is the same deliberate - * approximation repositories/database-context.ts documents: the query-builder - * surface used here is identical across the engines and across tables, and - * every use already casts to reach .syncId and .updatedAt. - */ -type SyncTable = typeof hosts; - -function entityConfig(entityType: SyncEntityType) { - const entity = getEntity(entityType); - if (!entity) { - throw new Error(`Unknown sync entity "${entityType}"`); - } - return { - table: entity.table as SyncTable, - readOnlyFields: entity.readOnlyFields, - singleton: entity.singleton === true, - shouldSync: (row: Record) => isSyncedRow(entityType, row), - }; -} - -/** - * Whether a row takes part in sync. An entity can leave some of its rows out - * (a per-install "last session"): they are not pulled, and a push, update or - * delete aimed at one is refused. - */ -export function isSyncedRow( - entityType: string, - row: Record, -): boolean { - const entity = getEntity(entityType); - if (!entity?.shouldSync) return true; - return entity.shouldSync(row) !== false; -} - -type RepositoryContext = ReturnType; - -export function isValidEntityType(value: unknown): value is SyncEntityType { - if (typeof value !== "string") return false; - registerCoreSyncEntities(); - return hasEntity(value); -} - -/** - * Locates the stored row a sync payload corresponds to. - * - * Read and write have to agree on this. A singleton entity is keyed on its - * owner rather than a sync id, and `user_preferences` — the only singleton — - * has no `id` column at all, so an update cannot fall back to one: `table.id` - * is undefined there and drizzle emits `WHERE = ?`. - */ -export function locateSyncRow( - entityType: SyncEntityType, - userId: string, - syncId: string, -): SQL { - const { table, singleton } = entityConfig(entityType); - - if (singleton) { - return eq(table.userId, userId); - } - - return and( - eq((table as typeof hosts).syncId, syncId), - eq(table.userId, userId), - )!; -} - -/** - * The syncId of a referenced row. - * - * Generic over the registry rather than an if/else over three known entities. - * The old chain ended in an unguarded else that returned vaultProfiles, so a - * fourth reference target would have silently resolved against the wrong - * table; an unregistered entity is an error here instead. - */ -async function findReferenceSyncId( - context: RepositoryContext, - entityType: SyncReferenceEntity, - id: number, - userId: string, -): Promise { - const { table } = entityConfig(entityType as SyncEntityType); - const [row] = await context.drizzle - .select({ syncId: table.syncId }) - .from(table) - .where(and(eq(table.id, id), eq(table.userId, userId))) - .limit(1); - return row?.syncId ?? null; -} - -async function findReferenceId( - context: RepositoryContext, - entityType: SyncReferenceEntity, - syncId: string, - userId: string, -): Promise { - const { table } = entityConfig(entityType as SyncEntityType); - const [row] = await context.drizzle - .select({ id: table.id }) - .from(table) - .where(and(eq(table.syncId, syncId), eq(table.userId, userId))) - .limit(1); - return row?.id ?? null; -} - -function requireUserDataKey(userId: string): Buffer { - return DataCrypto.validateUserAccess(userId); -} - -function decryptIfNeeded( - entityType: SyncEntityType, - row: Record, - userId: string, -): Record { - const tableName = ENCRYPTED_ENTITY_TABLES[entityType]; - if (!tableName) return row; - const userDataKey = DataCrypto.getUserDataKey(userId); - if (!userDataKey) return row; - return DataCrypto.decryptRecord( - tableName, - row, - userId, - userDataKey, - ) as Record; -} - -function encryptIfNeeded( - entityType: SyncEntityType, - row: Record, - userId: string, -): Record { - const tableName = ENCRYPTED_ENTITY_TABLES[entityType]; - if (!tableName) return row; - const userDataKey = requireUserDataKey(userId); - return DataCrypto.encryptRecord( - tableName, - row, - userId, - userDataKey, - ) as Record; -} - -export function stripWritePayload( - entityType: SyncEntityType, - payload: Record, -): Record { - const { readOnlyFields } = entityConfig(entityType); - const clean = { ...payload }; - delete clean.id; - delete clean.userId; - delete clean.syncId; - for (const field of readOnlyFields) delete clean[field]; - return clean; -} - -/** - * @openapi - * /sync/entity-types: - * get: - * summary: List the entity types this server syncs, in dependency order - * description: The desktop app syncs the entities in the order returned here, because an entity referenced by another has to exist on the far side first. A server that predates this endpoint returns 404 and the client falls back to its built-in list. - * tags: - * - Sync - * responses: - * 200: - * description: The ordered entity types. - * 500: - * description: Failed to list entity types. - */ -router.get( - "/entity-types", - authenticateJWT, - async (_req: Request, res: Response) => { - try { - registerCoreSyncEntities(); - return res.json({ - entityTypes: listEntities().map((entity) => ({ - type: entity.type, - order: entity.order, - singleton: entity.singleton === true, - })), - }); - } catch (error) { - databaseLogger.error("Failed to list sync entity types", error, { - operation: "sync_entity_types", - }); - return res.status(500).json({ error: "Failed to list entity types" }); - } - }, -); - -/** - * @openapi - * /sync/{entityType}: - * get: - * summary: Pull synced rows for an entity type - * description: Returns rows owned by the authenticated user whose updatedAt is newer than `since` (or all rows if omitted). Used by the desktop app's remote sync engine to reconcile the embedded backend against a connected remote server. - * tags: - * - Sync - * parameters: - * - in: path - * name: entityType - * required: true - * schema: - * type: string - * - in: query - * name: since - * schema: - * type: string - * responses: - * 200: - * description: Rows updated since the given timestamp. - * 400: - * description: Unknown entity type. - * 500: - * description: Failed to fetch rows. - */ -router.get( - "/:entityType", - authenticateJWT, - async (req: Request, res: Response) => { - const userId = (req as AuthenticatedRequest).userId; - const entityType = req.params.entityType; - if (!isValidEntityType(entityType)) { - return res.status(400).json({ error: "Unknown entity type" }); - } - const since = - typeof req.query.since === "string" && req.query.since - ? req.query.since - : null; - - try { - const { table, singleton, shouldSync } = entityConfig(entityType); - const context = createCurrentRepositoryContext(); - const conditions = [eq(table.userId, userId)]; - if (since && "updatedAt" in table) { - conditions.push( - timestampAtOrAfter((table as typeof hosts).updatedAt, since), - ); - } - - const rows = await context.drizzle - .select() - .from(table as typeof hosts) - .where(and(...conditions)); - - const synced = (rows as Record[]).filter(shouldSync); - - const decrypted = await Promise.all( - synced.map(async (row) => { - const result = await serializeSyncReferences( - entityType, - decryptIfNeeded(entityType, row as Record, userId), - (referenceType, id) => - findReferenceSyncId(context, referenceType, id, userId), - ); - if (entityType === "hosts") { - result.pluginSettings = await exportHostPluginSettings( - row.id as number, - ); - } - return singleton - ? { ...result, syncId: `${entityType}:singleton` } - : result; - }), - ); - - res.json({ rows: orderSyncRows(entityType, decrypted) }); - } catch (err) { - databaseLogger.error(`Failed to pull sync rows for ${entityType}`, err, { - operation: "sync_pull", - entityType, - userId, - }); - res.status(500).json({ error: "Failed to fetch rows" }); - } - }, -); - -/** - * @openapi - * /sync/tombstones: - * post: - * summary: Report a deletion from the other side of a sync pair - * description: Applies a remote deletion locally (if the row still exists) and records the tombstone so future pulls stay consistent. - * tags: - * - Sync - * responses: - * 200: - * description: Deletion applied (or row already absent). - * 400: - * description: Unknown entity type or missing syncId. - * 500: - * description: Failed to apply deletion. - */ -router.post( - "/tombstones", - authenticateJWT, - async (req: Request, res: Response) => { - const userId = (req as AuthenticatedRequest).userId; - const entityType = req.body?.entityType; - const syncId = req.body?.syncId; - if ( - !isValidEntityType(entityType) || - typeof syncId !== "string" || - !syncId - ) { - return res.status(400).json({ error: "Missing entityType or syncId" }); - } - - try { - const { table, shouldSync } = entityConfig(entityType); - const context = createCurrentRepositoryContext(); - const locateRow = locateSyncRow(entityType, userId, syncId); - - const [target] = (await context.drizzle - .select() - .from(table as typeof hosts) - .where(locateRow) - .limit(1)) as Record[]; - if (target && !shouldSync(target)) { - return res.status(400).json({ error: "This row is not synced" }); - } - - await context.drizzle.delete(table as typeof hosts).where(locateRow); - - await createCurrentSyncTombstoneRepository().record( - userId, - entityType, - syncId, - ); - await DatabaseSaveTrigger.forceSave("sync_tombstone_applied"); - - res.json({ success: true }); - } catch (err) { - databaseLogger.error("Failed to apply sync tombstone", err, { - operation: "sync_tombstone_apply", - entityType, - userId, - }); - res.status(500).json({ error: "Failed to apply deletion" }); - } - }, -); - -/** - * @openapi - * /sync/{entityType}: - * post: - * summary: Upsert a synced row by syncId - * description: Creates or updates a row by its syncId. Used by the desktop app's remote sync engine to push local-only or newer rows to the other side of a sync pair. - * tags: - * - Sync - * parameters: - * - in: path - * name: entityType - * required: true - * schema: - * type: string - * responses: - * 200: - * description: Row upserted. - * 400: - * description: Unknown entity type or missing syncId. - * 500: - * description: Failed to upsert row. - */ -router.post( - "/:entityType", - authenticateJWT, - async (req: Request, res: Response) => { - const userId = (req as AuthenticatedRequest).userId; - const entityType = req.params.entityType; - if (!isValidEntityType(entityType)) { - return res.status(400).json({ error: "Unknown entity type" }); - } - const payload = req.body?.row; - const syncId = payload?.syncId; - if (!payload || typeof syncId !== "string" || !syncId) { - return res.status(400).json({ error: "Missing row.syncId" }); - } - - try { - // singleton is still needed below: those tables have no sync_id column - // for the insert to populate. - const { table, singleton, shouldSync } = entityConfig(entityType); - const context = createCurrentRepositoryContext(); - - const locateRow = locateSyncRow(entityType, userId, syncId); - if (!shouldSync(payload)) { - return res.status(400).json({ error: "This row is not synced" }); - } - - const existingRows = await context.drizzle - .select() - .from(table as typeof hosts) - .where(locateRow) - .limit(1); - const existing = existingRows[0] as Record | undefined; - if (existing && !shouldSync(existing)) { - return res.status(400).json({ error: "This row is not synced" }); - } - - const resolvedPayload = await deserializeSyncReferences( - entityType, - payload, - (referenceType, referenceSyncId) => - findReferenceId(context, referenceType, referenceSyncId, userId), - ); - if ( - entityType === "hosts" && - typeof resolvedPayload.parentHostId === "number" - ) { - const parentError = await validateParentHostId( - userId, - typeof existing?.id === "number" ? existing.id : null, - resolvedPayload.parentHostId, - ); - if (parentError) return res.status(400).json({ error: parentError }); - } - const carriedPluginSettings = resolvedPayload.pluginSettings; - delete resolvedPayload.pluginSettings; - const writePayload = stripWritePayload(entityType, resolvedPayload); - const encryptedPayload = encryptIfNeeded( - entityType, - writePayload, - userId, - ); - - // Through the returning helpers, because MySQL has no RETURNING and a - // bare .returning() throws there. - let resultRow: Record; - if (existing) { - const updatedRows = await updateReturning( - context, - table as typeof hosts, - encryptedPayload, - locateRow, - ); - resultRow = updatedRows[0] as Record; - } else { - const insertedRows = await insertReturningWhere( - context, - table as typeof hosts, - (singleton - ? { ...encryptedPayload, userId } - : { - ...encryptedPayload, - userId, - syncId, - }) as typeof hosts.$inferInsert, - locateRow, - ); - resultRow = insertedRows[0] as Record; - } - - if (entityType === "hosts" && typeof resultRow?.id === "number") { - await importHostPluginSettings(resultRow.id, carriedPluginSettings); - } - - await DatabaseSaveTrigger.forceSave("sync_upsert"); - - res.json({ - row: decryptIfNeeded(entityType, resultRow, userId), - created: !existing, - }); - } catch (err) { - databaseLogger.error(`Failed to upsert sync row for ${entityType}`, err, { - operation: "sync_upsert", - entityType, - userId, - }); - res.status(500).json({ error: "Failed to upsert row" }); - } - }, -); - -/** - * @openapi - * /sync/{entityType}/tombstones: - * get: - * summary: Pull deletion tombstones for an entity type - * description: Returns tombstones recorded since `since` so the other side of a sync pair can apply the same deletions. - * tags: - * - Sync - * parameters: - * - in: path - * name: entityType - * required: true - * schema: - * type: string - * - in: query - * name: since - * schema: - * type: string - * responses: - * 200: - * description: Tombstones recorded since the given timestamp. - * 400: - * description: Unknown entity type. - * 500: - * description: Failed to fetch tombstones. - */ -router.get( - "/:entityType/tombstones", - authenticateJWT, - async (req: Request, res: Response) => { - const userId = (req as AuthenticatedRequest).userId; - const entityType = req.params.entityType; - if (!isValidEntityType(entityType)) { - return res.status(400).json({ error: "Unknown entity type" }); - } - const since = - typeof req.query.since === "string" && req.query.since - ? req.query.since - : null; - - try { - const tombstones = await createCurrentSyncTombstoneRepository().listSince( - userId, - entityType, - since, - ); - res.json({ tombstones }); - } catch (err) { - databaseLogger.error( - `Failed to fetch sync tombstones for ${entityType}`, - err, - { operation: "sync_tombstones_pull", entityType, userId }, - ); - res.status(500).json({ error: "Failed to fetch tombstones" }); - } - }, -); - -export default router; diff --git a/src/backend/database/routes/users.ts b/src/backend/database/routes/users.ts index c2e7786a6..b302bf563 100644 --- a/src/backend/database/routes/users.ts +++ b/src/backend/database/routes/users.ts @@ -8,6 +8,7 @@ import { DatabaseSaveTrigger } from "../../utils/database-save-trigger.js"; import { parseUserAgent } from "../../utils/user-agent-parser.js"; import { deleteUserAndRelatedData } from "./delete-user-data.js"; import { + allowsDesktopAutoSession, isLoopbackRequest, extractBearerOrCookieToken, isNativeTokenExportRequest, @@ -545,6 +546,30 @@ router.post("/logout", authenticateJWT, async (req, res) => { } }); +/** + * On a desktop linked to a server, the account it is signed in to there. + * That account is who the user is; the local profile only mirrors it. + */ +async function describeDesktopLink(userId: string) { + if (process.env.ELECTRON_EMBEDDED !== "true") return null; + try { + const { getLink } = await import("../../sync/client/link-store.js"); + const link = await getLink(); + if (!link || link.userId !== userId) return null; + return { + serverUrl: link.serverUrl, + serverName: link.serverName, + username: link.account?.username ?? link.remoteUsername, + isAdmin: !!link.account?.isAdmin, + roles: link.account?.roles ?? [], + permissions: link.account?.permissions ?? [], + status: link.status, + }; + } catch { + return null; + } +} + /** * @openapi * /users/me: @@ -595,6 +620,7 @@ router.get("/me", authenticateJWT, async (req: Request, res: Response) => { user.id, ), show_donation_modal: showDonationModal, + linked: await describeDesktopLink(user.id), }); } catch (err) { authLogger.error("Failed to get username", err); @@ -728,7 +754,7 @@ router.get("/setup-required", async (req, res) => { */ router.post("/internal/auto-session", async (req, res) => { try { - if (!isLoopbackRequest(req)) { + if (!allowsDesktopAutoSession() || !isLoopbackRequest(req)) { authLogger.warn( "Rejected non-loopback attempt to access auto-session endpoint", { source: req.ip }, diff --git a/src/backend/database/sync-timestamp.ts b/src/backend/database/sync-timestamp.ts deleted file mode 100644 index 11d99f997..000000000 --- a/src/backend/database/sync-timestamp.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { sql, type SQLWrapper } from "drizzle-orm"; - -/** - * Sync cursors and stored timestamps do not share a layout. - * - * `updated_at` and `deleted_at` are TEXT columns written both by - * `default(sql`CURRENT_TIMESTAMP`)` ("2026-07-29 10:11:21") and by - * `new Date().toISOString()` ("2026-07-29T10:11:21.123Z"), while the desktop - * sync engine always sends the ISO form as `since`. Comparing those as text is - * decided at position 10, where ' ' (0x20) sorts below 'T' (0x54), so the - * answer depends on which writer produced the row rather than on when it was - * written -- and `column > :isoCursor` is false for every row stored in the - * CURRENT_TIMESTAMP form, however new it is. - * - * Both layouts share a prefix once the separator is levelled, so comparing - * "YYYY-MM-DD HH:MM:SS" on both sides is layout-independent. `replace` and - * `substr` are used rather than `datetime()` to keep the expression portable - * across engines. - */ -const CANONICAL_TIMESTAMP_LENGTH = 19; - -export function normalizeSyncTimestamp(value: string): string { - return value.replace("T", " ").slice(0, CANONICAL_TIMESTAMP_LENGTH); -} - -/** - * `>=` rather than `>`: normalising truncates sub-second precision, so a strict - * comparison would permanently skip rows written in the same second as the - * cursor. Re-sending that boundary second costs nothing -- the sync engine only - * pushes a row when one side is strictly newer, so rows equal on both sides are - * a no-op. - */ -export function timestampAtOrAfter(column: SQLWrapper, since: string) { - return sql`substr(replace(${column}, 'T', ' '), 1, ${CANONICAL_TIMESTAMP_LENGTH}) >= ${normalizeSyncTimestamp(since)}`; -} diff --git a/src/backend/hosts/delete-credential.ts b/src/backend/hosts/delete-credential.ts new file mode 100644 index 000000000..7d905f6e9 --- /dev/null +++ b/src/backend/hosts/delete-credential.ts @@ -0,0 +1,56 @@ +import { + createCurrentCredentialRepository, + createCurrentHostRepository, + createCurrentHostResolutionRepository, +} from "../database/repositories/factory.js"; + +/** + * Deletes a credential the user owns: hosts using it fall back to password + * auth with no secret, and shares are re-snapshotted. Shared by the delete + * route and sync. Null when it does not exist or is not the user's. + */ +export async function deleteOwnedCredential( + userId: string, + credentialId: number, +): Promise<{ name: string | null } | null> { + const credential = + await createCurrentCredentialRepository().findDecryptedByIdForUser( + userId, + credentialId, + ); + if (!credential) return null; + + const hostsUsingCredential = + await createCurrentHostResolutionRepository().listHostsUsingCredentialForUser( + userId, + credentialId, + ); + + if (hostsUsingCredential.length > 0) { + await createCurrentHostRepository().updateManyForUser( + userId, + hostsUsingCredential.map((host) => host.id), + { + credentialId: null, + password: null, + key: null, + keyPassword: null, + authType: "password", + }, + ); + } + + const { SharedHostSecretsManager } = + await import("../utils/shared-host-secrets-manager.js"); + const sharedSecretsManager = SharedHostSecretsManager.getInstance(); + await sharedSecretsManager.deleteForCredential(credentialId); + + await createCurrentCredentialRepository().deleteForUser(userId, credentialId); + + // Shares stay in place; re-snapshot so recipients fall back to whatever + // auth the host still has (or lose the stale credential copy). + for (const host of hostsUsingCredential) { + await sharedSecretsManager.resyncHost(host.id); + } + return { name: (credential.name as string | null) ?? null }; +} diff --git a/src/backend/hosts/delete-host.ts b/src/backend/hosts/delete-host.ts index 7a4bdd608..3dfc870c7 100644 --- a/src/backend/hosts/delete-host.ts +++ b/src/backend/hosts/delete-host.ts @@ -4,7 +4,6 @@ import { createCurrentRbacAccessRepository, createCurrentRecentActivityRepository, createCurrentSshCredentialUsageRepository, - createCurrentSyncTombstoneRepository, } from "../database/repositories/factory.js"; import { pluginEvents, TOPICS } from "../plugins/events.js"; import { sshLogger } from "../utils/logger.js"; @@ -38,14 +37,6 @@ export async function deleteOwnedHost( 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 }); diff --git a/src/backend/notify/core-notify.ts b/src/backend/notify/core-notify.ts new file mode 100644 index 000000000..1f3b8c5b5 --- /dev/null +++ b/src/backend/notify/core-notify.ts @@ -0,0 +1,33 @@ +/** + * Alerts core itself sends (an update is out, a desktop lost its link), + * through whichever plugin serves as the alert hub. With no hub running they + * reach nobody, same as a plugin's. + */ + +import type { PluginNotification } from "@termix/plugin-sdk/backend"; +import { activeNotifyHub } from "../plugins/notify-hub.js"; +import { resolveAudience } from "../plugins/ctx-notify.js"; +import { systemLogger } from "../utils/logger.js"; + +/** Shown as the sender of core's alerts. */ +export const CORE_ALERT_SOURCE = "termix"; + +export async function sendCoreAlert( + notification: PluginNotification & { + audience: NonNullable; + }, +): Promise { + try { + const hub = await activeNotifyHub(); + if (!hub) return; + const recipients = await resolveAudience(notification.audience); + if (recipients.length === 0) return; + await hub.deliver({ source: CORE_ALERT_SOURCE, recipients, notification }); + } catch (error) { + systemLogger.warn("Could not send an alert", { + operation: "core_alert", + category: notification.category, + error: error instanceof Error ? error.message : String(error), + }); + } +} diff --git a/src/backend/plugins/ctx.ts b/src/backend/plugins/ctx.ts index f00305765..6631a2f43 100644 --- a/src/backend/plugins/ctx.ts +++ b/src/backend/plugins/ctx.ts @@ -488,16 +488,6 @@ export function createPluginContext( const dispose = syncRegistry.registerEntity(pluginId, entity); handle.bag.add(dispose, `sync entity ${entity.type}`); }, - recordTombstone: async (userId, entityType, syncId) => { - if (!syncId) return; - const { createCurrentSyncTombstoneRepository } = - await import("../database/repositories/factory.js"); - await createCurrentSyncTombstoneRepository().record( - userId, - entityType, - syncId, - ); - }, }, kv: { diff --git a/src/backend/plugins/index.ts b/src/backend/plugins/index.ts index d6ec75b05..27a3afcbd 100644 --- a/src/backend/plugins/index.ts +++ b/src/backend/plugins/index.ts @@ -478,6 +478,50 @@ export async function deactivatePlugin(pluginId: string): Promise { } } +/** Switches a plugin on or off and records it, the same as the admin route. */ +export async function setPluginEnabled( + pluginId: string, + enabled: boolean, +): Promise { + const { createCurrentPluginRepository } = + await import("../database/repositories/factory.js"); + await createCurrentPluginRepository().update(pluginId, { + state: enabled ? "enabled" : "disabled", + lastError: null, + }); + if (enabled) await activatePlugin(pluginId); + else await deactivatePlugin(pluginId); +} + +/** + * Loads a user plugin from a .tmxplug in the plugins directory and gives it + * a row. It starts disabled, like any plugin that arrives on disk. A copy + * already loaded under the same id is stopped and replaced. + */ +export async function installPluginArtifact( + file: string, +): Promise { + const { loader: pluginLoader } = getPluginRuntime(); + const bundledIds = new Set( + pluginLoader + .list() + .filter((plugin) => plugin.source === "bundled") + .map((plugin) => plugin.id), + ); + const plugin = await pluginLoader.loadArtifact(file, bundledIds); + await seedPlugins([plugin]); + await syncCapabilityGrants([plugin]); + return plugin; +} + +/** Stops a plugin and forgets it, before a new version is installed. */ +export async function unloadPlugin(pluginId: string): Promise { + const { loader: pluginLoader } = getPluginRuntime(); + if (!pluginLoader.get(pluginId)) return; + await deactivatePlugin(pluginId); + pluginLoader.forget(pluginId); +} + export async function shutdownPlugins(): Promise { if (!loader) return; for (const plugin of loader.list()) unregisterPluginHttp(plugin.id); diff --git a/src/backend/plugins/loader.ts b/src/backend/plugins/loader.ts index 6b5dfdfac..f0e2f3296 100644 --- a/src/backend/plugins/loader.ts +++ b/src/backend/plugins/loader.ts @@ -91,6 +91,15 @@ export class PluginLoader { return this.plugins.get(pluginId); } + /** Drops a stopped plugin from the list, so a new copy can load in its place. */ + forget(pluginId: string): void { + const plugin = this.plugins.get(pluginId); + if (plugin?.state === "active" || plugin?.state === "activating") { + throw new Error(`Plugin ${pluginId} is still running`); + } + this.plugins.delete(pluginId); + } + /** Reads and validates one plugin directory. Starts nothing. */ async load(dir: string, source: PluginSource): Promise { const manifestPath = getPluginManifestPath(dir); diff --git a/src/backend/plugins/sync-registry.ts b/src/backend/plugins/sync-registry.ts index 4ec8a1df4..247d430b9 100644 --- a/src/backend/plugins/sync-registry.ts +++ b/src/backend/plugins/sync-registry.ts @@ -1,20 +1,55 @@ /** - * Which entities remote sync carries. + * Which entities sync between a linked desktop and its server. * - * Core registers its ten at boot and a plugin registers its own through - * ctx.sync, so the list is built rather than hardcoded in four places. The - * wire names are unchanged: existing tombstones and rows on the other side of - * a pair match on those strings, so renaming one would orphan data. + * Core registers its own at boot and a plugin registers its own through + * ctx.sync, so the list is built rather than hardcoded. The wire names are + * part of the protocol: records on both sides match on them, so renaming one + * orphans data. * * Order is dependency order, not declaration order. A row referencing another - * entity is resolved by that entity's syncId, so the target has to exist on - * the far side first. That is why every registration carries an explicit - * order rather than relying on registration sequence. + * entity is resolved by that entity's syncId, so the target has to be written + * first. That is why every registration carries an explicit order. */ -import type { SyncEntityRegistration } from "@termix/plugin-sdk/backend"; +import type { + SyncEntityRegistration, + SyncRow, +} from "@termix/plugin-sdk/backend"; -export interface RegisteredSyncEntity extends SyncEntityRegistration { +/** Core-only extras a plugin registration cannot set. */ +export interface CoreSyncEntityOptions { + /** Server-to-desktop only. A desktop never pushes it. */ + readOnly?: boolean; + /** + * Builds the user's rows instead of reading `table`, for data that is not + * a table row the user owns (hosts shared with them, plugin settings). + * Rows come back already on the wire: plaintext, with a syncId. + */ + load?: (userId: string) => Promise; + /** Writes an inbound row, for an entity with `load`. */ + write?: (userId: string, wire: SyncRow) => Promise; + /** Deletes a row by syncId, for an entity with `load`. */ + erase?: (userId: string, syncId: string) => Promise; + /** + * Whether `load` would have returned this syncId if it existed. A row + * `load` cannot see right now (its plugin is off) is not a delete. + */ + covers?: (syncId: string) => boolean; + /** Keys kept off the content hash and out of writes. */ + hashIgnore?: readonly string[]; + /** + * Widens reference lookups past rows the user owns, e.g. a credential + * shared with them is a valid target for their host's credentialId. + */ + canReference?: ( + userId: string, + ownerId: string, + id: number, + ) => Promise; +} + +export interface RegisteredSyncEntity + extends SyncEntityRegistration, CoreSyncEntityOptions { /** Which plugin registered it, or "core". */ owner: string; order: number; @@ -24,6 +59,7 @@ export interface RegisteredSyncEntity extends SyncEntityRegistration { } const entities = new Map(); +const listeners = new Set<() => void>(); /** Core's own entities register under this owner. */ export const CORE_OWNER = "core"; @@ -31,6 +67,7 @@ export const CORE_OWNER = "core"; export function registerEntity( owner: string, entity: SyncEntityRegistration, + core: CoreSyncEntityOptions = {}, ): () => void { if (!entity.type) { throw new Error("A sync entity needs a type"); @@ -43,25 +80,35 @@ export function registerEntity( ); } - entities.set(entity.type, { + const registered: RegisteredSyncEntity = { ...entity, + ...(owner === CORE_OWNER ? core : {}), owner, order: entity.order ?? 100, userColumn: entity.userColumn ?? "userId", readOnlyFields: entity.readOnlyFields ?? [], encryptedFields: entity.encryptedFields ?? [], - }); + }; + entities.set(entity.type, registered); + notify(); return () => { - const current = entities.get(entity.type); // Identity-checked: a plugin that restarted must not revoke the // registration its own restart installed. - if (current && current.owner === owner) entities.delete(entity.type); + if (entities.get(entity.type) === registered) { + entities.delete(entity.type); + notify(); + } }; } export function getEntity(type: string): RegisteredSyncEntity | undefined { - return entities.get(type); + const direct = entities.get(type); + if (direct) return direct; + for (const entity of entities.values()) { + if (entity.answersTo?.includes(type)) return entity; + } + return undefined; } export function hasEntity(type: string): boolean { @@ -76,14 +123,34 @@ export function listEntities(): RegisteredSyncEntity[] { }); } -/** The ordered wire names, which is what the Electron client syncs by. */ export function listEntityTypes(): string[] { return listEntities().map((entity) => entity.type); } export function unregisterByOwner(owner: string): void { + let changed = false; for (const [type, entity] of [...entities]) { - if (entity.owner === owner) entities.delete(type); + if (entity.owner === owner) { + entities.delete(type); + changed = true; + } + } + if (changed) notify(); +} + +/** Called whenever the set of entities changes (a plugin came or went). */ +export function onEntitiesChanged(listener: () => void): () => void { + listeners.add(listener); + return () => listeners.delete(listener); +} + +function notify(): void { + for (const listener of listeners) { + try { + listener(); + } catch { + // A listener's failure is its own problem. + } } } diff --git a/src/backend/starter.ts b/src/backend/starter.ts index 487a9a79f..1a0a54f24 100644 --- a/src/backend/starter.ts +++ b/src/backend/starter.ts @@ -277,6 +277,22 @@ async function provisionLocalDesktopUserIfNeeded(): Promise { const { startAnalyticsHeartbeat } = await import("./utils/analytics.js"); startAnalyticsHeartbeat(); + // After plugins, so their sync entities are registered before a pass. + if (process.env.ELECTRON_EMBEDDED === "true") { + try { + const { startDesktopSync } = await import("./sync/client/engine.js"); + startDesktopSync(); + const { startDesktopUpdateCheck } = + await import("./updates/desktop-update-check.js"); + startDesktopUpdateCheck(); + } catch (error) { + systemLogger.warn("Desktop sync failed to start", { + operation: "sync_start", + error: getErrorMessage(error), + }); + } + } + systemLogger.success("Termix backend started successfully", { operation: "backend_init_complete", port: process.env.PORT || 4090, diff --git a/src/backend/sync/client/electron-proxy.ts b/src/backend/sync/client/electron-proxy.ts new file mode 100644 index 000000000..b10b00d37 --- /dev/null +++ b/src/backend/sync/client/electron-proxy.ts @@ -0,0 +1,36 @@ +import { + isElectronIpcAvailable, + requestFromElectronMain, +} from "../../utils/electron-ipc-bridge.js"; +import type { SyncLink } from "./link-store.js"; + +/** + * Chromium's own requests to the linked server (the sign-in page, remote + * terminals, file transfers) cannot set headers themselves, so Electron's + * main process adds the proxy headers and answers basic auth for that one + * origin. Sent again whenever the link changes; null clears it. + */ +export async function applyElectronProxyConfig( + link: + | (Pick< + SyncLink, + "serverUrl" | "customHeaders" | "basicAuth" | "allowInvalidCertificate" + > & { sessionToken?: string | null; status?: string }) + | null, +): Promise { + if (!isElectronIpcAvailable()) return; + await requestFromElectronMain( + "sync-proxy-config", + link + ? { + origin: new URL(link.serverUrl).origin, + serverUrl: link.serverUrl, + token: + link.status === "signed_out" ? null : (link.sessionToken ?? null), + headers: link.customHeaders ?? [], + basicAuth: link.basicAuth ?? null, + allowInvalidCertificate: !!link.allowInvalidCertificate, + } + : null, + ); +} diff --git a/src/backend/sync/client/engine.ts b/src/backend/sync/client/engine.ts new file mode 100644 index 000000000..8d55d6aa5 --- /dev/null +++ b/src/backend/sync/client/engine.ts @@ -0,0 +1,758 @@ +/** + * The linked desktop's side of sync. + * + * A pass pushes local edits, then pulls what changed on the server after the + * saved cursor, then backfills any entity type this device has never pulled + * (first link, a feature switched on, a category turned back on). Local + * edits are found the same way the server finds changes: by comparing each + * row's hash with the one recorded at the last sync. + */ + +import os from "os"; +import type { SyncRow } from "@termix/plugin-sdk/backend"; +import { + getEntity, + listEntities, + onEntitiesChanged, + type RegisteredSyncEntity, +} from "../../plugins/sync-registry.js"; +import { syncLogger } from "../../utils/logger.js"; +import { sendCoreAlert } from "../../notify/core-notify.js"; +import { registerCoreSyncEntities } from "../entities.js"; +import { + createResolvers, + deleteStoredRow, + loadWireRow, + loadWireRows, + writeWireRow, +} from "../store.js"; +import { + listRecords, + putRecord, + recordKey, + saveConflict, + setRecordError, + type SyncRecord, +} from "../records.js"; +import { orderSelfReferences } from "../wire.js"; +import { onLocalWrite } from "../server/change-watcher.js"; +import { getLink, updateLink, type SyncLink } from "./link-store.js"; +import { RemoteError, remoteFetch, remoteJson } from "./http.js"; +import { mirrorPlugins } from "./plugins.js"; +import { applyElectronProxyConfig } from "./electron-proxy.js"; + +export interface RemoteChange { + seq: number; + entityType: string; + syncId: string; + revision: number; + deleted: boolean; + row: SyncRow | null; +} + +interface ChangesPage { + changes: RemoteChange[]; + nextCursor: number; + hasMore: boolean; + entityTypes: Array<{ + type: string; + owner: string; + order: number; + readOnly: boolean; + }>; +} + +interface PushOp { + entityType: string; + syncId: string; + baseRevision: number; + op: "upsert" | "delete"; + row?: SyncRow; +} + +type PushResult = + | { syncId: string; entityType: string; status: "ok"; revision: number } + | { + syncId: string; + entityType: string; + status: "conflict"; + revision: number; + deleted: boolean; + row: SyncRow | null; + } + | { syncId: string; entityType: string; status: "rejected"; reason: string }; + +const MAX_BATCH_OPS = 200; +const MAX_BATCH_BYTES = 1_000_000; +const MAX_PAGES = 500; +const SAFETY_INTERVAL_MS = 5 * 60_000; +const REFRESH_CHECK_MS = 24 * 60 * 60_000; +const DELETED_HASH = "deleted"; + +export interface EngineState { + running: boolean; + /** Local changes found in the last pass that the server has not taken. */ + pending: number; + /** Entity types the server runs, from the last pull. */ + serverTypes: Array<{ type: string; owner: string; readOnly: boolean }>; + lastPassAt: string | null; +} + +/** An entity this device could sync: registered here, and on the server. */ +function syncable( + entity: RegisteredSyncEntity, + link: SyncLink, + serverTypes: Set | null, +): boolean { + if (link.disabledTypes.includes(entity.type)) return false; + if (serverTypes && !serverTypes.has(entity.type)) return false; + return true; +} + +function sortChanges(changes: RemoteChange[]): RemoteChange[] { + const byType = new Map(); + for (const change of changes) { + const list = byType.get(change.entityType) ?? []; + list.push(change); + byType.set(change.entityType, list); + } + const types = [...byType.keys()].sort( + (a, b) => (getEntity(a)?.order ?? 1e6) - (getEntity(b)?.order ?? 1e6), + ); + const out: RemoteChange[] = []; + for (const type of types) { + const list = byType.get(type)!; + const entity = getEntity(type); + // Only the latest record of each row matters. + const latest = new Map(); + for (const change of list) latest.set(change.syncId, change); + const live = [...latest.values()].filter((change) => !change.deleted); + const gone = [...latest.values()].filter((change) => change.deleted); + if (entity) { + const bySyncId = new Map(live.map((change) => [change.syncId, change])); + const ordered = orderSelfReferences( + entity, + live.map((change) => ({ + ...(change.row ?? {}), + syncId: change.syncId, + })), + ); + out.push(...ordered.map((row) => bySyncId.get(row.syncId as string)!)); + } else { + out.push(...live); + } + out.push(...gone); + } + return out; +} + +export class SyncEngine { + private running = false; + private again = false; + private timer: NodeJS.Timeout | null = null; + private safety: NodeJS.Timeout | null = null; + private backoffMs = 0; + private events: AbortController | null = null; + private eventsFor: string | null = null; + private lastRefreshCheck = 0; + private serverTypes: Set | null = null; + private stopFns: Array<() => void> = []; + private alerted = false; + readonly state: EngineState = { + running: false, + pending: 0, + serverTypes: [], + lastPassAt: null, + }; + + start(): void { + if (this.safety) return; + registerCoreSyncEntities(); + this.safety = setInterval(() => this.request(0), SAFETY_INTERVAL_MS); + this.safety.unref?.(); + this.stopFns.push(onLocalWrite(() => this.request(2000))); + // A plugin that came up may bring entity types to backfill. + this.stopFns.push(onEntitiesChanged(() => this.request(3000))); + this.request(3000); + } + + stop(): void { + if (this.safety) clearInterval(this.safety); + if (this.timer) clearTimeout(this.timer); + this.safety = this.timer = null; + this.closeEvents(); + for (const stop of this.stopFns.splice(0)) stop(); + } + + /** Runs a pass after `delayMs`, folding requests that arrive meanwhile. */ + request(delayMs = 0): void { + if (this.timer) { + if (delayMs > 0) return; + clearTimeout(this.timer); + } + this.timer = setTimeout(() => { + this.timer = null; + void this.run(); + }, delayMs); + this.timer.unref?.(); + } + + /** Runs a pass now and waits for it. */ + async syncNow(): Promise { + if (this.running) { + this.again = true; + while (this.running) await new Promise((r) => setTimeout(r, 100)); + return; + } + await this.run(); + } + + /** Forgets what the server runs, after a relink or unlink. */ + reset(): void { + this.serverTypes = null; + this.state.serverTypes = []; + this.state.pending = 0; + this.alerted = false; + this.backoffMs = 0; + this.closeEvents(); + } + + private async run(): Promise { + if (this.running) { + this.again = true; + return; + } + this.running = this.state.running = true; + try { + await this.pass(); + } finally { + this.running = this.state.running = false; + this.state.lastPassAt = new Date().toISOString(); + if (this.again) { + this.again = false; + this.request(0); + } + } + } + + private async pass(): Promise { + const link = await getLink(); + if (!link?.sessionToken) { + this.closeEvents(); + return; + } + await updateLink({ status: "syncing" }); + + try { + await this.refreshSession(link); + const fresh = (await getLink())!; + await mirrorPlugins(fresh); + + // A first pass pulls before it pushes, so rows both sides already + // have are matched up instead of sent back as edits. + const firstPass = fresh.knownTypes.length === 0; + if (!firstPass) { + await this.push(fresh); + await this.pull(fresh); + } + await this.backfill((await getLink())!); + if (firstPass) await this.push((await getLink())!); + + this.backoffMs = 0; + this.alerted = false; + await updateLink({ + status: "idle", + lastError: null, + lastSyncAt: new Date().toISOString(), + }); + const done = (await getLink())!; + await applyElectronProxyConfig(done).catch(() => {}); + this.openEvents(done); + } catch (error) { + await this.fail(error); + } + } + + private async fail(error: unknown): Promise { + const remote = error instanceof RemoteError ? error : null; + const message = error instanceof Error ? error.message : String(error); + syncLogger.warn("Sync pass failed", { + operation: "sync_pass", + kind: remote?.kind ?? "internal", + error: message, + }); + + if (remote?.kind === "signed_out") { + const link = await updateLink({ + status: "signed_out", + lastError: message, + }); + await applyElectronProxyConfig(link).catch(() => {}); + this.closeEvents(); + if (!this.alerted) { + this.alerted = true; + await sendCoreAlert({ + title: "Sync is paused", + body: "This device was signed out of the server it syncs with. Sign in again from Sync to keep going.", + severity: "warning", + category: "termix.sync.signed_out", + dedupeKey: "sync:signed_out", + audience: "admins", + link: { tab: "sync" }, + }); + } + return; + } + + const offline = remote?.kind === "offline"; + await updateLink({ + status: offline ? "offline" : "error", + lastError: message, + }); + this.backoffMs = Math.min( + this.backoffMs ? this.backoffMs * 2 : 15_000, + 10 * 60_000, + ); + this.request(this.backoffMs); + } + + private async refreshSession(link: SyncLink): Promise { + if (Date.now() - this.lastRefreshCheck < REFRESH_CHECK_MS) return; + const result = await remoteJson<{ token: string | null }>( + link, + "/sync/v2/session/refresh", + { method: "POST", body: {} }, + ); + this.lastRefreshCheck = Date.now(); + if (result.token) await updateLink({ sessionToken: result.token }); + } + + /** Local rows the server does not have in this form yet. */ + async outbox(link: SyncLink): Promise<{ + ops: PushOp[]; + hashes: Map; + }> { + const records = await listRecords(link.userId); + const resolvers = createResolvers(link.userId); + const ops: PushOp[] = []; + const hashes = new Map(); + + for (const entity of listEntities()) { + if (entity.readOnly) continue; + if (!link.knownTypes.includes(entity.type)) continue; + if (!syncable(entity, link, this.serverTypes)) continue; + + const { rows, failed } = await loadWireRows( + entity, + link.userId, + resolvers, + ); + for (const row of rows.values()) { + const key = recordKey(entity.type, row.syncId); + const record = records.get(key); + if (record && !record.deleted && record.hash === row.hash) continue; + if (record?.errorHash === row.hash) continue; + ops.push({ + entityType: entity.type, + syncId: row.syncId, + baseRevision: record?.revision ?? 0, + op: "upsert", + row: row.wire, + }); + hashes.set(key, row.hash); + } + + for (const record of records.values()) { + if (record.entityType !== entity.type || record.deleted) continue; + if (record.revision === 0) continue; + if (rows.has(record.syncId) || failed.has(record.syncId)) continue; + if (entity.covers && !entity.covers(record.syncId)) continue; + if (record.errorHash === DELETED_HASH) continue; + ops.push({ + entityType: entity.type, + syncId: record.syncId, + baseRevision: record.revision, + op: "delete", + }); + } + } + return { ops, hashes }; + } + + private async push(link: SyncLink): Promise { + const { ops, hashes } = await this.outbox(link); + this.state.pending = ops.length; + if (ops.length === 0) return; + + for (const batch of batches(ops)) { + const { results } = await remoteJson<{ results: PushResult[] }>( + link, + "/sync/v2/push", + { method: "POST", body: { ops: batch }, timeoutMs: 120_000 }, + ); + const byKey = new Map( + batch.map((op) => [recordKey(op.entityType, op.syncId), op]), + ); + for (const result of results) { + const key = recordKey(result.entityType, result.syncId); + const op = byKey.get(key); + if (!op) continue; + await this.settle(link, op, result, hashes.get(key) ?? null); + } + this.state.pending = Math.max(0, this.state.pending - batch.length); + } + } + + private async settle( + link: SyncLink, + op: PushOp, + result: PushResult, + hash: string | null, + ): Promise { + const userId = link.userId; + const entity = getEntity(op.entityType); + if (!entity) return; + + if (result.status === "ok") { + await putRecord(userId, op.entityType, op.syncId, { + revision: result.revision, + hash: op.op === "delete" ? null : hash, + deleted: op.op === "delete", + }); + return; + } + + if (result.status === "rejected") { + await setRecordError( + userId, + op.entityType, + op.syncId, + result.reason, + hash ?? DELETED_HASH, + ); + return; + } + + // Conflict: the server moved on since this device last saw the row. Its + // version wins here, and a local edit it replaced is kept for the user. + if (op.op === "upsert" && op.row) { + await saveConflict( + userId, + op.entityType, + op.syncId, + op.row, + result.revision, + ); + } + const resolvers = createResolvers(userId); + if (result.deleted || !result.row) { + await deleteStoredRow(entity, userId, op.syncId); + await putRecord(userId, op.entityType, op.syncId, { + revision: result.revision, + hash: null, + deleted: true, + }); + return; + } + await writeWireRow( + entity, + userId, + { ...result.row, syncId: op.syncId }, + resolvers, + ); + const written = await loadWireRow(entity, userId, op.syncId, resolvers); + await putRecord(userId, op.entityType, op.syncId, { + revision: result.revision, + hash: written?.hash ?? null, + deleted: false, + }); + } + + private async fetchChanges( + link: SyncLink, + cursor: number, + types?: string[], + ): Promise<{ changes: RemoteChange[]; cursor: number }> { + const all: RemoteChange[] = []; + let next = cursor; + for (let page = 0; page < MAX_PAGES; page++) { + const query = new URLSearchParams({ + cursor: String(next), + limit: "1000", + }); + if (types?.length) query.set("types", types.join(",")); + const result = await remoteJson( + link, + `/sync/v2/changes?${query.toString()}`, + { timeoutMs: 120_000 }, + ); + all.push(...result.changes); + next = result.nextCursor; + this.serverTypes = new Set(result.entityTypes.map((entry) => entry.type)); + this.state.serverTypes = result.entityTypes.map((entry) => ({ + type: entry.type, + owner: entry.owner, + readOnly: entry.readOnly, + })); + if (!result.hasMore) break; + } + return { changes: all, cursor: next }; + } + + private async pull(link: SyncLink): Promise { + const { changes, cursor } = await this.fetchChanges(link, link.cursor); + const known = new Set(link.knownTypes); + await this.apply( + link, + changes.filter( + (change) => + known.has(change.entityType) && + !link.disabledTypes.includes(change.entityType), + ), + false, + ); + await updateLink({ cursor }); + } + + /** Pulls every row of types this device has not pulled before. */ + private async backfill(link: SyncLink): Promise { + if (!this.serverTypes) { + await this.fetchChanges(link, Number.MAX_SAFE_INTEGER); + } + const fresh = listEntities() + .map((entity) => entity.type) + .filter( + (type) => + !link.knownTypes.includes(type) && + !link.disabledTypes.includes(type) && + this.serverTypes?.has(type), + ); + if (fresh.length === 0) return; + + const { changes, cursor } = await this.fetchChanges(link, 0, fresh); + await this.apply(link, changes, true); + const current = (await getLink())!; + await updateLink({ + knownTypes: [...new Set([...current.knownTypes, ...fresh])], + // On a first pass the backfill is the whole pull, so it sets the cursor. + ...(link.knownTypes.length === 0 ? { cursor } : {}), + }); + } + + /** + * Writes pulled changes. `adopt` is for a first pull of a type: a local row + * with no record yet takes the server's version without a conflict. + */ + private async apply( + link: SyncLink, + changes: RemoteChange[], + adopt: boolean, + ): Promise { + if (changes.length === 0) return; + const userId = link.userId; + const records = await listRecords(userId); + let missed = false; + const resolvers = createResolvers(userId, () => { + missed = true; + }); + const retry: RemoteChange[] = []; + + for (const change of sortChanges(changes)) { + const entity = getEntity(change.entityType); + if (!entity) continue; + const key = recordKey(change.entityType, change.syncId); + const record = records.get(key); + if (record && record.revision >= change.revision) continue; + + try { + if (!entity.readOnly) { + await this.keepLocalEdit( + entity, + userId, + change, + record, + adopt, + resolvers, + ); + } + if (change.deleted) { + await deleteStoredRow(entity, userId, change.syncId); + await putRecord(userId, change.entityType, change.syncId, { + revision: change.revision, + hash: null, + deleted: true, + }); + continue; + } + missed = false; + await writeWireRow( + entity, + userId, + { ...(change.row ?? {}), syncId: change.syncId }, + resolvers, + ); + if (missed) retry.push(change); + await this.recordApplied(entity, userId, change, resolvers); + } catch (error) { + syncLogger.warn("Could not apply a synced change", { + operation: "sync_apply", + entityType: change.entityType, + syncId: change.syncId, + error: error instanceof Error ? error.message : String(error), + }); + } + } + + // Rows that pointed at something written later in the same pull. + for (const change of retry) { + const entity = getEntity(change.entityType)!; + try { + await writeWireRow( + entity, + userId, + { ...(change.row ?? {}), syncId: change.syncId }, + resolvers, + ); + await this.recordApplied(entity, userId, change, resolvers); + } catch { + // The first write stands. + } + } + } + + private async recordApplied( + entity: RegisteredSyncEntity, + userId: string, + change: RemoteChange, + resolvers: ReturnType, + ): Promise { + const written = entity.readOnly + ? null + : await loadWireRow(entity, userId, change.syncId, resolvers); + await putRecord(userId, change.entityType, change.syncId, { + revision: change.revision, + hash: written?.hash ?? null, + deleted: false, + }); + } + + /** Saves a local edit the incoming change is about to replace. */ + private async keepLocalEdit( + entity: RegisteredSyncEntity, + userId: string, + change: RemoteChange, + record: SyncRecord | undefined, + adopt: boolean, + resolvers: ReturnType, + ): Promise { + if (!record && adopt) return; + const local = await loadWireRow( + entity, + userId, + change.syncId, + resolvers, + ).catch(() => null); + if (!local) return; + if (record && !record.deleted && record.hash === local.hash) return; + await saveConflict( + userId, + change.entityType, + change.syncId, + local.wire, + change.revision, + ); + } + + private openEvents(link: SyncLink): void { + const key = `${link.serverUrl}|${link.sessionToken}`; + if (this.events && this.eventsFor === key) return; + this.closeEvents(); + const controller = new AbortController(); + this.events = controller; + this.eventsFor = key; + void this.listen(link, controller); + } + + private closeEvents(): void { + this.events?.abort(); + this.events = null; + this.eventsFor = null; + } + + private async listen( + link: SyncLink, + controller: AbortController, + ): Promise { + try { + const response = await remoteFetch(link, "/sync/v2/events", { + signal: controller.signal, + }); + if (!response.ok || !response.body) + throw new Error(`HTTP ${response.status}`); + const decoder = new TextDecoder(); + let buffer = ""; + for await (const chunk of response.body) { + buffer += decoder.decode(chunk as Uint8Array, { stream: true }); + let index; + while ((index = buffer.indexOf("\n\n")) >= 0) { + const block = buffer.slice(0, index); + buffer = buffer.slice(index + 2); + if (/^event: change$/m.test(block)) this.request(500); + } + } + } catch { + // Dropped or refused: the safety interval still syncs, and the next + // successful pass reopens the stream. + } + if (this.events === controller) { + this.events = null; + this.eventsFor = null; + if (!controller.signal.aborted) { + setTimeout(() => this.request(0), 30_000).unref?.(); + } + } + } + + /** Where the link's proxy settings have to reach outside the backend. */ + async applyProxyConfig(): Promise { + await applyElectronProxyConfig(await getLink()); + } +} + +function* batches(ops: PushOp[]): Generator { + let batch: PushOp[] = []; + let bytes = 0; + for (const op of ops) { + const size = JSON.stringify(op).length; + if ( + batch.length > 0 && + (batch.length >= MAX_BATCH_OPS || bytes + size > MAX_BATCH_BYTES) + ) { + yield batch; + batch = []; + bytes = 0; + } + batch.push(op); + bytes += size; + } + if (batch.length > 0) yield batch; +} + +let engine: SyncEngine | null = null; + +export function getSyncEngine(): SyncEngine { + if (!engine) engine = new SyncEngine(); + return engine; +} + +/** Starts syncing on a desktop. A server install never links. */ +export function startDesktopSync(): void { + if (process.env.ELECTRON_EMBEDDED !== "true") return; + const instance = getSyncEngine(); + instance.start(); + void instance.applyProxyConfig().catch(() => {}); +} + +export function defaultDeviceName(): string { + return os.hostname() || "Termix Desktop"; +} diff --git a/src/backend/sync/client/http.ts b/src/backend/sync/client/http.ts new file mode 100644 index 000000000..2d5448248 --- /dev/null +++ b/src/backend/sync/client/http.ts @@ -0,0 +1,260 @@ +/** + * Requests from a desktop to the server it is linked to. + * + * The Termix session travels as the jwt cookie rather than an Authorization + * header, so a proxy in front that uses basic auth can have that header to + * itself. Proxy headers (a Cloudflare Access service token, a gateway key) + * are added to every request. + */ + +import { Agent, ProxyAgent, fetch as undiciFetch } from "undici"; +import type { Dispatcher } from "undici"; +import type { BasicAuth, ProxyHeader } from "./link-store.js"; + +export interface RemoteTarget { + serverUrl: string; + sessionToken?: string | null; + customHeaders?: ProxyHeader[]; + basicAuth?: BasicAuth | null; + allowInvalidCertificate?: boolean; +} + +export type RemoteErrorKind = + /** Could not reach the server at all. */ + | "offline" + /** TLS failed and the certificate is not allowed. */ + | "tls" + /** A proxy answered instead of Termix (its login page, a 404 page). */ + | "proxy" + /** The proxy wants basic auth. */ + | "basic_auth" + /** Termix refused the session: revoked, expired or signed out. */ + | "signed_out" + /** Termix answered with an error. */ + | "server"; + +export class RemoteError extends Error { + constructor( + readonly kind: RemoteErrorKind, + message: string, + readonly status?: number, + ) { + super(message); + } +} + +const TLS_CODES = new Set([ + "DEPTH_ZERO_SELF_SIGNED_CERT", + "SELF_SIGNED_CERT_IN_CHAIN", + "UNABLE_TO_VERIFY_LEAF_SIGNATURE", + "UNABLE_TO_GET_ISSUER_CERT_LOCALLY", + "CERT_HAS_EXPIRED", + "ERR_TLS_CERT_ALTNAME_INVALID", + "CERT_UNTRUSTED", +]); + +function envProxy(url: string): string | null { + const proxy = + process.env.https_proxy || + process.env.HTTPS_PROXY || + process.env.http_proxy || + process.env.HTTP_PROXY; + if (!proxy) return null; + const hostname = new URL(url).hostname.toLowerCase(); + const noProxy = (process.env.no_proxy || process.env.NO_PROXY || "") + .split(",") + .map((entry) => + entry + .trim() + .toLowerCase() + .replace(/^\*?\./, ""), + ) + .filter(Boolean); + if ( + noProxy.some( + (entry) => hostname === entry || hostname.endsWith(`.${entry}`), + ) + ) + return null; + return proxy; +} + +const dispatchers = new Map(); + +function dispatcherFor(target: RemoteTarget): Dispatcher { + const insecure = !!target.allowInvalidCertificate; + const proxy = envProxy(target.serverUrl); + const key = `${insecure}|${proxy ?? ""}`; + let dispatcher = dispatchers.get(key); + if (!dispatcher) { + const tls = insecure ? { rejectUnauthorized: false } : {}; + dispatcher = proxy + ? new ProxyAgent({ uri: proxy, requestTls: tls }) + : new Agent({ connect: tls }); + dispatchers.set(key, dispatcher); + } + return dispatcher; +} + +/** "https://x.example/termix/" + "/sync/v2/info" keeps the base path. */ +export function remoteUrl(serverUrl: string, pathname: string): string { + return `${serverUrl.replace(/\/+$/, "")}${pathname}`; +} + +export function normalizeServerUrl(input: string): string | null { + const trimmed = input.trim(); + if (!trimmed) return null; + if ( + /^[a-z][a-z0-9+.-]*:\/\//i.test(trimmed) && + !/^https?:\/\//i.test(trimmed) + ) { + return null; + } + const withScheme = /^https?:\/\//i.test(trimmed) + ? trimmed + : `https://${trimmed}`; + try { + const url = new URL(withScheme); + if (url.protocol !== "http:" && url.protocol !== "https:") return null; + url.hash = ""; + url.search = ""; + return url.toString().replace(/\/+$/, ""); + } catch { + return null; + } +} + +export function requestHeaders(target: RemoteTarget): Record { + const headers: Record = { + "X-Electron-App": "true", + "User-Agent": "Termix-Desktop-Sync", + }; + for (const header of target.customHeaders ?? []) { + if (header.name && header.value) headers[header.name] = header.value; + } + if (target.basicAuth?.username) { + headers.Authorization = `Basic ${Buffer.from( + `${target.basicAuth.username}:${target.basicAuth.password ?? ""}`, + ).toString("base64")}`; + } + if (target.sessionToken) headers.Cookie = `jwt=${target.sessionToken}`; + return headers; +} + +function describeNetworkError(error: unknown): RemoteError { + const cause = (error as { cause?: { code?: string; message?: string } }) + ?.cause; + const code = cause?.code ?? (error as { code?: string })?.code ?? ""; + if (TLS_CODES.has(code) || /certificate/i.test(cause?.message ?? "")) { + return new RemoteError("tls", cause?.message || code); + } + if ((error as Error)?.name === "AbortError") { + return new RemoteError("offline", "The server did not answer in time"); + } + return new RemoteError( + "offline", + cause?.message || (error as Error)?.message || "Could not reach the server", + ); +} + +export interface RemoteRequest { + method?: string; + body?: unknown; + timeoutMs?: number; + signal?: AbortSignal; +} + +/** Sends a request and returns the raw response, classifying failures. */ +export async function remoteFetch( + target: RemoteTarget, + pathname: string, + request: RemoteRequest = {}, +) { + const url = remoteUrl(target.serverUrl, pathname); + const headers = requestHeaders(target); + if (request.body !== undefined) headers["Content-Type"] = "application/json"; + + const controller = new AbortController(); + const timeout = request.timeoutMs + ? setTimeout(() => controller.abort(), request.timeoutMs) + : null; + request.signal?.addEventListener("abort", () => controller.abort(), { + once: true, + }); + + let response; + try { + response = await undiciFetch(url, { + method: request.method ?? "GET", + headers, + body: + request.body !== undefined ? JSON.stringify(request.body) : undefined, + dispatcher: dispatcherFor(target), + signal: controller.signal, + }); + } catch (error) { + throw describeNetworkError(error); + } finally { + if (timeout) clearTimeout(timeout); + } + return response; +} + +/** Sends a request and parses a JSON answer from Termix. */ +export async function remoteJson( + target: RemoteTarget, + pathname: string, + request: RemoteRequest = {}, +): Promise { + const response = await remoteFetch(target, pathname, { + timeoutMs: 60_000, + ...request, + }); + const type = response.headers.get("content-type") ?? ""; + const text = await response.text(); + + if (!type.includes("application/json")) { + if ( + response.status === 401 && + /basic/i.test(response.headers.get("www-authenticate") ?? "") + ) { + throw new RemoteError( + "basic_auth", + "The server asks for a username and password before Termix", + 401, + ); + } + throw new RemoteError( + "proxy", + `Something other than Termix answered (${response.status})`, + response.status, + ); + } + + let data: unknown; + try { + data = text ? JSON.parse(text) : null; + } catch { + throw new RemoteError( + "proxy", + "The server sent invalid JSON", + response.status, + ); + } + + if (response.status === 401) { + throw new RemoteError( + "signed_out", + (data as { error?: string })?.error || "Signed out", + 401, + ); + } + if (!response.ok) { + throw new RemoteError( + "server", + (data as { error?: string })?.error || `Server error ${response.status}`, + response.status, + ); + } + return data as T; +} diff --git a/src/backend/sync/client/link-store.ts b/src/backend/sync/client/link-store.ts new file mode 100644 index 000000000..036c13a0a --- /dev/null +++ b/src/backend/sync/client/link-store.ts @@ -0,0 +1,233 @@ +/** + * The desktop's link to a server, one row in sync_link. Secrets (the session + * token, proxy headers, basic auth) are stored encrypted with the system key. + */ + +import { eq } from "drizzle-orm"; +import { syncLink } from "../../database/db/schema.js"; +import { createCurrentRepositoryContext } from "../../database/repositories/factory.js"; +import { DatabaseSaveTrigger } from "../../utils/database-save-trigger.js"; +import { + decryptSystemSecret, + encryptSystemSecret, +} from "../../utils/system-secret-crypto.js"; + +export type LinkStatus = + "idle" | "syncing" | "offline" | "error" | "signed_out"; + +export interface LinkAccount { + remoteUserId?: string; + username?: string; + isAdmin?: boolean; + roles?: string[]; + permissions?: string[]; +} + +export interface ProxyHeader { + name: string; + value: string; +} + +export interface BasicAuth { + username: string; + password: string; +} + +export interface SyncLink { + id: number; + userId: string; + serverUrl: string; + serverName: string | null; + serverVersion: string | null; + sessionToken: string | null; + customHeaders: ProxyHeader[]; + basicAuth: BasicAuth | null; + allowInvalidCertificate: boolean; + remoteUserId: string | null; + remoteUsername: string | null; + account: LinkAccount | null; + /** Entity types switched off on this device. */ + disabledTypes: string[]; + /** Entity types this device has fully pulled at least once. */ + knownTypes: string[]; + cursor: number; + status: LinkStatus; + lastError: string | null; + linkedAt: string; + lastSyncAt: string | null; +} + +type Row = typeof syncLink.$inferSelect; + +function db() { + return createCurrentRepositoryContext().drizzle; +} + +function parseJson(value: string | null, fallback: T): T { + if (!value) return fallback; + try { + return JSON.parse(value) as T; + } catch { + return fallback; + } +} + +async function decryptJson(value: string | null, fallback: T): Promise { + if (!value) return fallback; + try { + return JSON.parse(await decryptSystemSecret(value)) as T; + } catch { + return fallback; + } +} + +async function fromRow(row: Row): Promise { + let sessionToken: string | null = null; + if (row.sessionToken) { + try { + sessionToken = await decryptSystemSecret(row.sessionToken); + } catch { + sessionToken = null; + } + } + const scope = parseJson<{ disabled?: string[] }>(row.scope, {}); + return { + id: row.id, + userId: row.userId, + serverUrl: row.serverUrl, + serverName: row.serverName, + serverVersion: row.serverVersion, + sessionToken, + customHeaders: await decryptJson(row.customHeaders, []), + basicAuth: await decryptJson(row.basicAuth, null), + allowInvalidCertificate: !!row.allowInvalidCertificate, + remoteUserId: row.remoteUserId, + remoteUsername: row.remoteUsername, + account: parseJson(row.account, null), + disabledTypes: Array.isArray(scope.disabled) ? scope.disabled : [], + knownTypes: parseJson(row.knownTypes, []), + cursor: Number(row.cursor) || 0, + status: (row.status as LinkStatus) || "idle", + lastError: row.lastError, + linkedAt: row.linkedAt, + lastSyncAt: row.lastSyncAt, + }; +} + +export async function getLink(): Promise { + const [row] = await db().select().from(syncLink).limit(1); + return row ? fromRow(row) : null; +} + +export interface NewLink { + userId: string; + serverUrl: string; + serverName?: string | null; + serverVersion?: string | null; + sessionToken: string; + customHeaders?: ProxyHeader[]; + basicAuth?: BasicAuth | null; + allowInvalidCertificate?: boolean; + remoteUserId?: string | null; + remoteUsername?: string | null; +} + +export async function createLink(link: NewLink): Promise { + await db().delete(syncLink); + await db() + .insert(syncLink) + .values({ + userId: link.userId, + serverUrl: link.serverUrl, + serverName: link.serverName ?? null, + serverVersion: link.serverVersion ?? null, + sessionToken: await encryptSystemSecret(link.sessionToken), + customHeaders: link.customHeaders?.length + ? await encryptSystemSecret(JSON.stringify(link.customHeaders)) + : null, + basicAuth: link.basicAuth + ? await encryptSystemSecret(JSON.stringify(link.basicAuth)) + : null, + allowInvalidCertificate: !!link.allowInvalidCertificate, + remoteUserId: link.remoteUserId ?? null, + remoteUsername: link.remoteUsername ?? null, + cursor: 0, + status: "idle", + knownTypes: "[]", + linkedAt: new Date().toISOString(), + }); + await DatabaseSaveTrigger.forceSave("sync_link"); + return (await getLink())!; +} + +export interface LinkPatch { + serverName?: string | null; + serverVersion?: string | null; + sessionToken?: string | null; + customHeaders?: ProxyHeader[]; + basicAuth?: BasicAuth | null; + allowInvalidCertificate?: boolean; + remoteUserId?: string | null; + remoteUsername?: string | null; + account?: LinkAccount | null; + disabledTypes?: string[]; + knownTypes?: string[]; + cursor?: number; + status?: LinkStatus; + lastError?: string | null; + lastSyncAt?: string | null; +} + +export async function updateLink(patch: LinkPatch): Promise { + const current = await getLink(); + if (!current) return null; + const set: Partial = {}; + if (patch.serverName !== undefined) set.serverName = patch.serverName; + if (patch.serverVersion !== undefined) + set.serverVersion = patch.serverVersion; + if (patch.sessionToken !== undefined) { + set.sessionToken = patch.sessionToken + ? await encryptSystemSecret(patch.sessionToken) + : null; + } + if (patch.customHeaders !== undefined) { + set.customHeaders = patch.customHeaders.length + ? await encryptSystemSecret(JSON.stringify(patch.customHeaders)) + : null; + } + if (patch.basicAuth !== undefined) { + set.basicAuth = patch.basicAuth + ? await encryptSystemSecret(JSON.stringify(patch.basicAuth)) + : null; + } + if (patch.allowInvalidCertificate !== undefined) { + set.allowInvalidCertificate = patch.allowInvalidCertificate; + } + if (patch.remoteUserId !== undefined) set.remoteUserId = patch.remoteUserId; + if (patch.remoteUsername !== undefined) { + set.remoteUsername = patch.remoteUsername; + } + if (patch.account !== undefined) { + set.account = patch.account ? JSON.stringify(patch.account) : null; + } + if (patch.disabledTypes !== undefined) { + set.scope = JSON.stringify({ disabled: patch.disabledTypes }); + } + if (patch.knownTypes !== undefined) { + set.knownTypes = JSON.stringify(patch.knownTypes); + } + if (patch.cursor !== undefined) set.cursor = patch.cursor; + if (patch.status !== undefined) set.status = patch.status; + if (patch.lastError !== undefined) set.lastError = patch.lastError; + if (patch.lastSyncAt !== undefined) set.lastSyncAt = patch.lastSyncAt; + if (Object.keys(set).length > 0) { + await db().update(syncLink).set(set).where(eq(syncLink.id, current.id)); + DatabaseSaveTrigger.triggerSave("sync_link"); + } + return getLink(); +} + +export async function deleteLink(): Promise { + await db().delete(syncLink); + await DatabaseSaveTrigger.forceSave("sync_link"); +} diff --git a/src/backend/sync/client/plugins.ts b/src/backend/sync/client/plugins.ts new file mode 100644 index 000000000..204658959 --- /dev/null +++ b/src/backend/sync/client/plugins.ts @@ -0,0 +1,197 @@ +/** + * A linked desktop runs the features its server runs. + * + * For every plugin in "mirror" mode, the server decides: a plugin installed + * there is installed here, on or off to match, with the capabilities an + * admin granted it there. Plugins marked "local" are left to the desktop and + * "server" ones never run on a linked desktop. + */ + +import fs from "fs"; +import path from "path"; +import semver from "semver"; +import type { PluginDesktopMode } from "@termix/plugin-sdk/manifest"; +import { syncLogger } from "../../utils/logger.js"; +import { sendCoreAlert } from "../../notify/core-notify.js"; +import { + getPluginRuntime, + installPluginArtifact, + setPluginEnabled, + unloadPlugin, +} from "../../plugins/index.js"; +import { getPluginsDir } from "../../plugins/paths.js"; +import { + createCurrentPluginPermissionGrantRepository, + createCurrentPluginRepository, +} from "../../database/repositories/factory.js"; +import { remoteFetch, remoteJson } from "./http.js"; +import type { SyncLink } from "./link-store.js"; + +export interface RemotePlugin { + id: string; + name: string; + version: string; + source: "bundled" | "user"; + enabled: boolean; + active: boolean; + desktop: PluginDesktopMode; + syncEntities: string[]; + granted: string[]; +} + +let lastRemote: RemotePlugin[] = []; +const alertedVersions = new Set(); + +/** What the server ran at the last sync, for the sync panel. */ +export function lastRemotePlugins(): RemotePlugin[] { + return lastRemote; +} + +async function isEnabledLocally(pluginId: string): Promise { + const record = await createCurrentPluginRepository().findById(pluginId); + return record?.state === "enabled"; +} + +async function download(link: SyncLink, plugin: RemotePlugin): Promise { + const response = await remoteFetch( + link, + `/sync/v2/plugins/${encodeURIComponent(plugin.id)}/package`, + { timeoutMs: 120_000 }, + ); + if (!response.ok) { + throw new Error(`Could not download ${plugin.id} (${response.status})`); + } + const buffer = Buffer.from(await response.arrayBuffer()); + const dir = getPluginsDir(); + await fs.promises.mkdir(dir, { recursive: true }); + const file = path.join(dir, `${plugin.id}.tmxplug`); + await fs.promises.writeFile(file, buffer); + const signature = response.headers.get("x-termix-signature"); + const sigFile = `${file}.sig`; + if (signature) { + await fs.promises.writeFile( + sigFile, + Buffer.from(signature, "base64").toString("utf8"), + ); + } else { + await fs.promises.rm(sigFile, { force: true }); + } + return file; +} + +async function matchGrants(plugin: RemotePlugin): Promise { + const repository = createCurrentPluginPermissionGrantRepository(); + const local = getPluginRuntime().loader.get(plugin.id); + if (!local || local.source !== "user") return; + const declared = new Set(local.manifest.capabilities); + const wanted = new Set(plugin.granted.filter((cap) => declared.has(cap))); + const existing = await repository.listByPlugin(plugin.id); + for (const grant of existing) { + if (!wanted.has(grant.capability)) { + await repository.revoke(plugin.id, grant.capability); + } + } + const have = new Set(existing.map((grant) => grant.capability)); + for (const capability of wanted) { + if (have.has(capability)) continue; + await repository.grant({ + pluginId: plugin.id, + capability, + grantedBy: null, + source: "admin", + }); + } +} + +async function alertOlderDesktop(plugin: RemotePlugin): Promise { + const key = `${plugin.id}@${plugin.version}`; + if (alertedVersions.has(key)) return; + alertedVersions.add(key); + await sendCoreAlert({ + title: "Update the desktop app", + body: `The server runs a newer ${plugin.name} (${plugin.version}) than this app has. Update the app to get it.`, + severity: "info", + category: "termix.sync.desktop_outdated", + dedupeKey: `sync:outdated:${key}`, + audience: "admins", + }); +} + +async function mirrorOne(link: SyncLink, plugin: RemotePlugin): Promise { + const { loader } = getPluginRuntime(); + let local = loader.get(plugin.id); + const mode = local?.manifest.desktop ?? plugin.desktop ?? "mirror"; + + if (mode === "server") { + if (local && (await isEnabledLocally(plugin.id))) { + await setPluginEnabled(plugin.id, false); + } + return; + } + if (mode !== "mirror") return; + + if (plugin.source === "bundled") { + if (!local || semver.gt(plugin.version, local.manifest.version)) { + await alertOlderDesktop(plugin); + } + if (!local) return; + } else if (plugin.enabled || local) { + const outdated = + !local || + local.source !== "user" || + local.manifest.version !== plugin.version; + if (outdated && (!local || local.source === "user")) { + const wasEnabled = local ? await isEnabledLocally(plugin.id) : false; + if (local) await unloadPlugin(plugin.id); + const file = await download(link, plugin); + local = await installPluginArtifact(file); + if (wasEnabled && !plugin.enabled) { + await setPluginEnabled(plugin.id, false); + } + } + await matchGrants(plugin); + } + + if (!local) return; + if (plugin.enabled !== (await isEnabledLocally(plugin.id))) { + await setPluginEnabled(plugin.id, plugin.enabled); + } else if (plugin.enabled && local.state !== "active") { + await setPluginEnabled(plugin.id, true); + } +} + +export async function mirrorPlugins(link: SyncLink): Promise { + const { plugins } = await remoteJson<{ plugins: RemotePlugin[] }>( + link, + "/sync/v2/plugins", + ); + lastRemote = plugins; + const remoteIds = new Set(plugins.map((plugin) => plugin.id)); + + for (const plugin of plugins) { + try { + await mirrorOne(link, plugin); + } catch (error) { + syncLogger.warn("Could not match a feature to the server", { + operation: "sync_plugin_mirror", + pluginId: plugin.id, + error: error instanceof Error ? error.message : String(error), + }); + } + } + + // A plugin the server no longer has is switched off here. Its data stays. + for (const local of getPluginRuntime().loader.list()) { + if (remoteIds.has(local.id) || local.source !== "user") continue; + if ((local.manifest.desktop ?? "mirror") !== "mirror") continue; + if (await isEnabledLocally(local.id)) { + await setPluginEnabled(local.id, false).catch(() => {}); + } + } +} + +/** Whether a plugin's on/off state belongs to the linked server. */ +export function isServerManaged(pluginId: string): boolean { + const local = getPluginRuntime().loader.get(pluginId); + return (local?.manifest.desktop ?? "mirror") !== "local"; +} diff --git a/src/backend/sync/client/routes.ts b/src/backend/sync/client/routes.ts new file mode 100644 index 000000000..65f8ed438 --- /dev/null +++ b/src/backend/sync/client/routes.ts @@ -0,0 +1,761 @@ +/** + * The desktop's own sync routes, under /sync/link. They only exist in the + * embedded desktop backend; on a server they answer 404. + */ + +import express, { + type NextFunction, + type Request, + type Response, +} from "express"; +import { and, eq, isNotNull } from "drizzle-orm"; +import type { SyncRow } from "@termix/plugin-sdk/backend"; +import type { AuthenticatedRequest } from "../../../types/index.js"; +import { AuthManager } from "../../utils/auth-manager.js"; +import { syncLogger } from "../../utils/logger.js"; +import { hosts, sshCredentials } from "../../database/db/schema.js"; +import { createCurrentRepositoryContext } from "../../database/repositories/factory.js"; +import { getPluginRuntime } from "../../plugins/index.js"; +import { getEntity, listEntities } from "../../plugins/sync-registry.js"; +import { registerCoreSyncEntities } from "../entities.js"; +import { + createResolvers, + deleteStoredRow, + listStoredRows, + regenerateSyncIds, + writeWireRow, +} from "../store.js"; +import { + clearRecordErrors, + deleteConflict, + deleteRecordsForUser, + getConflict, + listConflicts, + listRecordErrors, +} from "../records.js"; +import { SYNC_PROTOCOL_VERSION } from "../protocol.js"; +import { + createLink, + deleteLink, + getLink, + updateLink, + type BasicAuth, + type ProxyHeader, + type SyncLink, +} from "./link-store.js"; +import { + normalizeServerUrl, + RemoteError, + remoteJson, + type RemoteTarget, +} from "./http.js"; +import { defaultDeviceName, getSyncEngine } from "./engine.js"; +import { applyElectronProxyConfig } from "./electron-proxy.js"; +import { lastRemotePlugins } from "./plugins.js"; + +const router = express.Router(); +const authManager = AuthManager.getInstance(); +const authenticateJWT = authManager.createAuthMiddleware(); + +function desktopOnly(_req: Request, res: Response, next: NextFunction) { + if (process.env.ELECTRON_EMBEDDED !== "true") { + res.status(404).json({ error: "Not found" }); + return; + } + next(); +} + +const guard = [desktopOnly, authenticateJWT]; + +function userOf(req: Request): string { + return (req as AuthenticatedRequest).userId; +} + +function readHeaders(value: unknown): ProxyHeader[] { + if (!Array.isArray(value)) return []; + return value + .filter( + (header): header is ProxyHeader => + !!header && + typeof header.name === "string" && + typeof header.value === "string" && + /^[A-Za-z0-9-]{1,100}$/.test(header.name.trim()), + ) + .map((header) => ({ name: header.name.trim(), value: header.value })) + .slice(0, 20); +} + +function readBasicAuth(value: unknown): BasicAuth | null { + if (!value || typeof value !== "object") return null; + const { username, password } = value as Record; + if (typeof username !== "string" || !username) return null; + return { + username, + password: typeof password === "string" ? password : "", + }; +} + +function readTarget(body: Record): RemoteTarget | null { + const serverUrl = + typeof body.serverUrl === "string" + ? normalizeServerUrl(body.serverUrl) + : null; + if (!serverUrl) return null; + return { + serverUrl, + customHeaders: readHeaders(body.customHeaders), + basicAuth: readBasicAuth(body.basicAuth), + allowInvalidCertificate: body.allowInvalidCertificate === true, + }; +} + +/** What the sync panel can show about each entity type. */ +function describeEntities(link: SyncLink | null) { + registerCoreSyncEntities(); + const names = new Map(); + for (const plugin of getPluginRuntime().loader.list()) { + names.set(plugin.id, plugin.manifest.name); + } + for (const plugin of lastRemotePlugins()) { + if (!names.has(plugin.id)) names.set(plugin.id, plugin.name); + } + const serverTypes = getSyncEngine().state.serverTypes; + const seen = new Set(); + const entries = [ + ...listEntities().map((entity) => ({ + type: entity.type, + owner: entity.owner, + readOnly: !!entity.readOnly, + })), + ...serverTypes, + ]; + return entries + .filter((entry) => { + if (seen.has(entry.type) || entry.type === "accountProfile") return false; + seen.add(entry.type); + return true; + }) + .map((entry) => ({ + type: entry.type, + owner: entry.owner, + ownerName: + entry.owner === "core" ? null : (names.get(entry.owner) ?? entry.owner), + readOnly: entry.readOnly, + onServer: serverTypes.some((server) => server.type === entry.type), + onDevice: !!getEntity(entry.type), + enabled: !link?.disabledTypes.includes(entry.type), + })); +} + +async function statusBody() { + const link = await getLink(); + const engine = getSyncEngine(); + if (!link) { + return { linked: false, entities: describeEntities(null) }; + } + return { + linked: true, + serverUrl: link.serverUrl, + serverName: link.serverName, + serverVersion: link.serverVersion, + account: link.account ?? { + username: link.remoteUsername ?? undefined, + }, + status: engine.state.running ? "syncing" : link.status, + lastError: link.lastError, + linkedAt: link.linkedAt, + lastSyncAt: link.lastSyncAt, + pending: engine.state.pending, + conflicts: (await listConflicts(link.userId)).length, + errors: (await listRecordErrors(link.userId)).length, + firstSyncDone: link.knownTypes.length > 0, + allowInvalidCertificate: link.allowInvalidCertificate, + customHeaders: link.customHeaders.map((header) => ({ + name: header.name, + value: "", + set: true, + })), + basicAuth: link.basicAuth + ? { username: link.basicAuth.username, password: "", set: true } + : null, + entities: describeEntities(link), + }; +} + +/** Removes read-only copies of what other users shared with the account. */ +async function removeSharedCopies(userId: string): Promise { + const db = createCurrentRepositoryContext().drizzle; + const sharedHosts = await db + .select({ id: hosts.id }) + .from(hosts) + .where(and(eq(hosts.userId, userId), isNotNull(hosts.sharedSource))); + const { deleteOwnedHost } = await import("../../hosts/delete-host.js"); + for (const host of sharedHosts) await deleteOwnedHost(userId, host.id); + + const sharedCredentials = await db + .select({ id: sshCredentials.id }) + .from(sshCredentials) + .where( + and( + eq(sshCredentials.userId, userId), + isNotNull(sshCredentials.sharedSource), + ), + ); + const { deleteOwnedCredential } = + await import("../../hosts/delete-credential.js"); + for (const credential of sharedCredentials) { + await deleteOwnedCredential(userId, credential.id); + } +} + +/** Deletes every row that syncs, keeping local-only ones. */ +async function wipeSyncedData(userId: string): Promise { + const entities = [...listEntities()].reverse(); + for (const entity of entities) { + if (entity.readOnly) continue; + try { + if (entity.load) { + for (const row of await entity.load(userId)) { + await entity.erase?.(userId, String(row.syncId)); + } + continue; + } + if (!entity.table || entity.singleton) continue; + for (const row of await listStoredRows(entity, userId)) { + if (entity.shouldSync && entity.shouldSync(row) === false) continue; + if (typeof row.syncId !== "string") continue; + await deleteStoredRow(entity, userId, row.syncId); + } + } catch (error) { + syncLogger.warn("Could not clear synced data", { + operation: "sync_wipe", + entityType: entity.type, + error: error instanceof Error ? error.message : String(error), + }); + } + } +} + +function problemFor(error: unknown) { + if (!(error instanceof RemoteError)) { + return { problem: "unreachable", message: String(error) }; + } + switch (error.kind) { + case "tls": + return { problem: "tls_untrusted", message: error.message }; + case "basic_auth": + return { problem: "basic_auth_required", message: error.message }; + case "proxy": + return error.status === 404 + ? { problem: "not_termix", message: error.message } + : { problem: "proxy_login", message: error.message }; + case "signed_out": + case "server": + // A Termix older than 2.9 has no /sync/v2 and answers with an error. + return { problem: "version_mismatch", message: error.message }; + default: + return { problem: "unreachable", message: error.message }; + } +} + +/** + * @openapi + * /sync/link/status: + * get: + * summary: This desktop's link to a server + * description: Desktop only. Whether the app is linked, to which server and account, how syncing is going, and which kinds of data sync. + * tags: + * - Sync + * responses: + * 200: + * description: The link status. + */ +router.get("/link/status", ...guard, async (_req: Request, res: Response) => { + try { + res.json(await statusBody()); + } catch (error) { + syncLogger.error("Failed to read sync status", error, { + operation: "sync_status", + }); + res.status(500).json({ error: "Failed to read sync status" }); + } +}); + +/** + * @openapi + * /sync/link/probe: + * post: + * summary: Check a server address before linking + * description: Desktop only. Tries the server with the given proxy settings and says what is in the way, if anything. On success the proxy settings are also applied to the desktop's browser session so the sign-in page loads. + * tags: + * - Sync + * responses: + * 200: + * description: The server's name and version, or the problem found. + */ +router.post("/link/probe", ...guard, async (req: Request, res: Response) => { + const target = readTarget(req.body ?? {}); + if (!target) return res.json({ ok: false, problem: "invalid_url" }); + try { + const info = await remoteJson<{ + termix?: boolean; + name?: string; + version?: string; + protocol?: number; + }>(target, "/sync/v2/info", { timeoutMs: 15_000 }); + if (!info?.termix) return res.json({ ok: false, problem: "not_termix" }); + if (info.protocol !== SYNC_PROTOCOL_VERSION) { + return res.json({ + ok: false, + problem: "version_mismatch", + version: info.version, + }); + } + await applyElectronProxyConfig({ + serverUrl: target.serverUrl, + customHeaders: target.customHeaders ?? [], + basicAuth: target.basicAuth ?? null, + allowInvalidCertificate: !!target.allowInvalidCertificate, + }).catch(() => {}); + res.json({ + ok: true, + serverUrl: target.serverUrl, + name: info.name ?? "Termix", + version: info.version ?? null, + }); + } catch (error) { + res.json({ ok: false, ...problemFor(error) }); + } +}); + +/** + * @openapi + * /sync/link/preview: + * get: + * summary: How much this desktop has that would sync + * description: Desktop only. Row counts per kind of data, shown before choosing whether to merge this desktop's data into the account or replace it. + * tags: + * - Sync + * responses: + * 200: + * description: Counts per entity type. + */ +router.get("/link/preview", ...guard, async (req: Request, res: Response) => { + const userId = userOf(req); + registerCoreSyncEntities(); + const counts: Record = {}; + for (const entity of listEntities()) { + if (entity.readOnly) continue; + try { + if (entity.load) { + counts[entity.type] = (await entity.load(userId)).length; + continue; + } + if (!entity.table) continue; + counts[entity.type] = (await listStoredRows(entity, userId)).filter( + (row) => !entity.shouldSync || entity.shouldSync(row) !== false, + ).length; + } catch { + counts[entity.type] = 0; + } + } + res.json({ counts }); +}); + +/** + * @openapi + * /sync/link/complete: + * post: + * summary: Link this desktop to a server account + * description: Desktop only. Takes the session from signing in to the server, trades it for a long-lived desktop session, and starts syncing. "merge" uploads this desktop's data into the account; "replace" deletes it here first and takes the account's. + * tags: + * - Sync + * responses: + * 200: + * description: Linked; the new status. + * 400: + * description: Missing server or session. + * 502: + * description: The server refused the session. + */ +router.post("/link/complete", ...guard, async (req: Request, res: Response) => { + const userId = userOf(req); + const body = req.body ?? {}; + const target = readTarget(body); + const token = typeof body.token === "string" ? body.token : ""; + const mode = body.mode === "replace" ? "replace" : "merge"; + if (!target || !token) { + return res.status(400).json({ error: "Missing server or session" }); + } + + try { + const deviceName = + typeof body.deviceName === "string" && body.deviceName.trim() + ? body.deviceName.trim() + : defaultDeviceName(); + const linked = await remoteJson<{ + token: string; + user: { id: string; username: string }; + server?: { version?: string | null }; + }>({ ...target, sessionToken: token }, "/sync/v2/link", { + method: "POST", + body: { deviceName }, + }); + + const previous = await getLink(); + if (previous) { + await removeSharedCopies(previous.userId); + await deleteRecordsForUser(previous.userId); + } + if (mode === "replace") { + await wipeSyncedData(userId); + } + await deleteRecordsForUser(userId); + + const link = await createLink({ + userId, + serverUrl: target.serverUrl, + serverName: typeof body.serverName === "string" ? body.serverName : null, + serverVersion: linked.server?.version ?? null, + sessionToken: linked.token, + customHeaders: target.customHeaders, + basicAuth: target.basicAuth, + allowInvalidCertificate: target.allowInvalidCertificate, + remoteUserId: linked.user.id, + remoteUsername: linked.user.username, + }); + const engine = getSyncEngine(); + engine.reset(); + await applyElectronProxyConfig(link).catch(() => {}); + engine.request(0); + res.json(await statusBody()); + } catch (error) { + syncLogger.warn("Linking to a server failed", { + operation: "sync_link_complete", + error: error instanceof Error ? error.message : String(error), + }); + res.status(502).json({ ...problemFor(error) }); + } +}); + +/** + * @openapi + * /sync/link/relogin: + * post: + * summary: Sign this desktop back in after its session ended + * description: Desktop only. Takes a new sign-in session for the same account and resumes syncing where it stopped. + * tags: + * - Sync + * responses: + * 200: + * description: Signed in again; the new status. + * 409: + * description: The sign-in was for a different account. + */ +router.post("/link/relogin", ...guard, async (req: Request, res: Response) => { + const token = typeof req.body?.token === "string" ? req.body.token : ""; + const link = await getLink(); + if (!link || !token) return res.status(400).json({ error: "Not linked" }); + try { + const linked = await remoteJson<{ + token: string; + user: { id: string; username: string }; + }>({ ...link, sessionToken: token }, "/sync/v2/link", { + method: "POST", + body: { deviceName: defaultDeviceName() }, + }); + if (link.remoteUserId && linked.user.id !== link.remoteUserId) { + return res.status(409).json({ error: "different_account" }); + } + await updateLink({ + sessionToken: linked.token, + status: "idle", + lastError: null, + }); + getSyncEngine().request(0); + res.json(await statusBody()); + } catch (error) { + res.status(502).json({ ...problemFor(error) }); + } +}); + +/** + * @openapi + * /sync/link/unlink: + * post: + * summary: Unlink this desktop from its server + * description: Desktop only. Ends the desktop's session on the server when it can be reached. With keepData the synced data stays on this device as its own; otherwise it is deleted. Copies of what others shared with the account are always removed. + * tags: + * - Sync + * responses: + * 200: + * description: Unlinked. + */ +router.post("/link/unlink", ...guard, async (req: Request, res: Response) => { + const keepData = req.body?.keepData !== false; + const link = await getLink(); + if (!link) return res.json(await statusBody()); + const userId = link.userId; + try { + await remoteJson(link, "/sync/v2/unlink", { + method: "POST", + body: {}, + timeoutMs: 10_000, + }).catch(() => {}); + + const engine = getSyncEngine(); + engine.reset(); + await removeSharedCopies(userId); + if (keepData) { + // New syncIds so this data reads as new if it is ever linked again. + for (const entity of listEntities()) { + if (!entity.readOnly) await regenerateSyncIds(entity, userId); + } + } else { + await wipeSyncedData(userId); + } + await deleteRecordsForUser(userId); + await deleteLink(); + await applyElectronProxyConfig(null).catch(() => {}); + res.json(await statusBody()); + } catch (error) { + syncLogger.error("Unlinking failed", error, { operation: "sync_unlink" }); + res.status(500).json({ error: "Failed to unlink" }); + } +}); + +/** + * @openapi + * /sync/link/sync: + * post: + * summary: Sync now + * description: Desktop only. Runs a sync pass and waits for it. + * tags: + * - Sync + * responses: + * 200: + * description: The status after the pass. + */ +router.post("/link/sync", ...guard, async (_req: Request, res: Response) => { + await getSyncEngine().syncNow(); + res.json(await statusBody()); +}); + +/** + * @openapi + * /sync/link/settings: + * put: + * summary: Change what syncs and how the server is reached + * description: Desktop only. disabledTypes lists the kinds of data that stay on this device. Proxy headers and basic auth left blank keep their saved values. + * tags: + * - Sync + * responses: + * 200: + * description: The new status. + * 400: + * description: Not linked. + */ +router.put("/link/settings", ...guard, async (req: Request, res: Response) => { + const link = await getLink(); + if (!link) return res.status(400).json({ error: "Not linked" }); + const body = req.body ?? {}; + const patch: Parameters[0] = {}; + + if (Array.isArray(body.disabledTypes)) { + const disabled = body.disabledTypes.filter( + (type: unknown): type is string => + typeof type === "string" && type !== "accountProfile", + ); + const changed = new Set([ + ...disabled.filter((type: string) => !link.disabledTypes.includes(type)), + ...link.disabledTypes.filter((type) => !disabled.includes(type)), + ]); + patch.disabledTypes = disabled; + // A type switched back on is pulled again in full. + patch.knownTypes = link.knownTypes.filter((type) => !changed.has(type)); + } + if (Array.isArray(body.customHeaders)) { + const saved = new Map(link.customHeaders.map((h) => [h.name, h.value])); + patch.customHeaders = (body.customHeaders as unknown[]) + .filter( + (header): header is { name: string; value?: string } => + !!header && typeof (header as { name?: unknown }).name === "string", + ) + .map((header) => ({ + name: header.name, + value: header.value || saved.get(header.name) || "", + })) + .filter((header) => header.value); + patch.customHeaders = readHeaders(patch.customHeaders); + } + if (body.basicAuth === null) { + patch.basicAuth = null; + } else if (body.basicAuth && typeof body.basicAuth === "object") { + const next = readBasicAuth(body.basicAuth); + if (next && !next.password && link.basicAuth) { + next.password = link.basicAuth.password; + } + patch.basicAuth = next; + } + if (typeof body.allowInvalidCertificate === "boolean") { + patch.allowInvalidCertificate = body.allowInvalidCertificate; + } + + const updated = await updateLink(patch); + await applyElectronProxyConfig(updated).catch(() => {}); + getSyncEngine().request(0); + res.json(await statusBody()); +}); + +/** + * @openapi + * /sync/link/conflicts: + * get: + * summary: Local edits that lost to a newer edit on the server + * description: Desktop only. The server's version was kept; each entry holds this device's version so it can be restored. + * tags: + * - Sync + * responses: + * 200: + * description: The conflicts, oldest first. + */ +router.get("/link/conflicts", ...guard, async (req: Request, res: Response) => { + const conflicts = await listConflicts(userOf(req)); + res.json({ + conflicts: conflicts.map((conflict) => { + let row: SyncRow = {}; + try { + row = JSON.parse(conflict.localRow); + } catch { + row = {}; + } + return { + id: conflict.id, + entityType: conflict.entityType, + syncId: conflict.syncId, + name: row.name ?? row.title ?? row.label ?? row.ip ?? conflict.syncId, + createdAt: conflict.createdAt, + }; + }), + }); +}); + +/** + * @openapi + * /sync/link/conflicts/{id}: + * post: + * summary: Settle a conflict + * description: Desktop only. keep "mine" puts this device's version back, and the next sync sends it to the server. keep "server" drops it. + * tags: + * - Sync + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: integer + * responses: + * 200: + * description: Settled. + * 404: + * description: No such conflict. + */ +router.post( + "/link/conflicts/:id", + ...guard, + async (req: Request, res: Response) => { + const userId = userOf(req); + const conflict = await getConflict(userId, Number(req.params.id)); + if (!conflict) return res.status(404).json({ error: "Not found" }); + try { + if (req.body?.keep === "mine") { + const entity = getEntity(conflict.entityType); + if (entity && !entity.readOnly) { + const row = JSON.parse(conflict.localRow) as SyncRow; + await writeWireRow( + entity, + userId, + { ...row, syncId: conflict.syncId }, + createResolvers(userId), + ); + } + } + await deleteConflict(userId, conflict.id); + getSyncEngine().request(0); + res.json({ success: true }); + } catch (error) { + syncLogger.warn("Could not settle a sync conflict", { + operation: "sync_conflict", + error: error instanceof Error ? error.message : String(error), + }); + res.status(500).json({ error: "Failed to settle conflict" }); + } + }, +); + +/** + * @openapi + * /sync/link/errors: + * get: + * summary: Changes the server refused + * description: Desktop only. Each entry names the record and why the server refused it. A refused change is not sent again until it changes or is retried. + * tags: + * - Sync + * responses: + * 200: + * description: The refused changes. + */ +router.get("/link/errors", ...guard, async (req: Request, res: Response) => { + const errors = await listRecordErrors(userOf(req)); + res.json({ + errors: errors.map((record) => ({ + entityType: record.entityType, + syncId: record.syncId, + reason: record.error, + })), + }); +}); + +/** + * @openapi + * /sync/link/errors/retry: + * post: + * summary: Send refused changes again + * description: Desktop only. Clears every refusal and syncs. + * tags: + * - Sync + * responses: + * 200: + * description: The status after the pass. + */ +router.post( + "/link/errors/retry", + ...guard, + async (req: Request, res: Response) => { + await clearRecordErrors(userOf(req)); + await getSyncEngine().syncNow(); + res.json(await statusBody()); + }, +); + +/** + * @openapi + * /sync/link/session: + * get: + * summary: The linked server and session, for connecting to it directly + * description: Desktop only. Used when a host is set to connect through the server rather than from this device. + * tags: + * - Sync + * responses: + * 200: + * description: The server address and session token, or nulls when not linked. + */ +router.get("/link/session", ...guard, async (_req: Request, res: Response) => { + const link = await getLink(); + if (!link?.sessionToken || link.status === "signed_out") { + return res.json({ serverUrl: null, token: null }); + } + res.json({ serverUrl: link.serverUrl, token: link.sessionToken }); +}); + +export default router; diff --git a/src/backend/sync/entities.ts b/src/backend/sync/entities.ts new file mode 100644 index 000000000..f979b186f --- /dev/null +++ b/src/backend/sync/entities.ts @@ -0,0 +1,581 @@ +/** + * Core's own sync entities, registered the same way a plugin registers one. + * + * Order values leave room between them (10, 30, 50...) so a plugin can slot + * an entity in without renumbering; anything referenced syncs first. + */ + +import { and, eq } from "drizzle-orm"; +import type { SyncRow } from "@termix/plugin-sdk/backend"; +import type { PluginManifest } from "@termix/plugin-sdk/manifest"; +import { + hosts, + sshCredentials, + sshFolders, + userPreferences, +} from "../database/db/schema.js"; +import { + CORE_OWNER, + listEntities, + registerEntity, +} from "../plugins/sync-registry.js"; +import { FieldCrypto } from "../utils/field-crypto.js"; +import { syncLogger } from "../utils/logger.js"; +import { getPluginRuntime } from "../plugins/index.js"; +import { singletonSyncId } from "./wire.js"; +import { + exportHostPluginSettings, + importHostPluginSettings, +} from "./host-plugin-settings.js"; + +const CREDENTIAL_REFERENCE = { + field: "credentialId", + syncField: "credentialSyncId", + entityType: "sshCredentials", +} as const; + +const HOST_REFERENCES = [ + CREDENTIAL_REFERENCE, + { + field: "rdpCredentialId", + syncField: "rdpCredentialSyncId", + entityType: "sshCredentials", + }, + { + field: "vncCredentialId", + syncField: "vncCredentialSyncId", + entityType: "sshCredentials", + }, + { + field: "telnetCredentialId", + syncField: "telnetCredentialSyncId", + entityType: "sshCredentials", + }, + { + field: "parentHostId", + syncField: "parentHostSyncId", + entityType: "hosts", + }, + { field: "jumpHosts[].hostId", entityType: "hosts" }, + { field: "quickActions[].snippetId", entityType: "commandSnippet" }, +] as const; + +/** Columns that only mean something on the device that holds them. */ +const HOST_LOCAL_FIELDS = [ + "connectionOrigin", + "localOnly", + "sharedSource", + "hostKeyLastVerified", +]; + +let registered = false; + +/** Registers core's entities. Idempotent. */ +export function registerCoreSyncEntities(): void { + if ( + registered && + listEntities().some((entity) => entity.owner === CORE_OWNER) + ) + return; + registered = true; + + registerEntity( + CORE_OWNER, + { + type: "sshCredentials", + table: sshCredentials, + order: 10, + encryptedFields: FieldCrypto.fieldsFor("ssh_credentials"), + readOnlyFields: ["usageCount", "lastUsed", "sharedSource"], + shouldSync: (row) => !row.sharedSource, + permissions: { + create: "credentials.create", + update: "credentials.edit", + delete: "credentials.delete", + }, + afterWrite: async ({ id, userId, created }) => { + if (id === null || created) return; + const { SharedHostSecretsManager } = + await import("../utils/shared-host-secrets-manager.js"); + await SharedHostSecretsManager.getInstance().resyncHostsForCredential( + id, + userId, + ); + const { SharedCredentialSecretsManager } = + await import("../utils/shared-credential-secrets-manager.js"); + await SharedCredentialSecretsManager.getInstance().resyncCredential( + id, + userId, + ); + }, + remove: async (row, userId) => { + const { deleteOwnedCredential } = + await import("../hosts/delete-credential.js"); + await deleteOwnedCredential(userId, row.id as number); + }, + }, + { + // A credential shared with the user can back their own host. + canReference: async (userId, _ownerId, id) => { + const { findUsableCredential } = + await import("../hosts/usable-credential.js"); + return (await findUsableCredential(id, userId)) !== null; + }, + }, + ); + + // vaultProfiles (order 20) belongs to the vault plugin. + + registerEntity(CORE_OWNER, { + type: "sshFolders", + table: sshFolders, + order: 30, + references: [CREDENTIAL_REFERENCE], + readOnlyFields: ["localOnly"], + shouldSync: (row) => !row.localOnly, + permissions: { + create: "hosts.create", + update: "hosts.edit", + delete: "hosts.delete", + }, + }); + + registerEntity( + CORE_OWNER, + { + type: "hosts", + table: hosts, + order: 50, + encryptedFields: FieldCrypto.fieldsFor("ssh_data"), + readOnlyFields: HOST_LOCAL_FIELDS, + references: HOST_REFERENCES, + shouldSync: (row) => !row.localOnly && !row.sharedSource, + permissions: { + create: "hosts.create", + update: "hosts.edit", + delete: "hosts.delete", + }, + serialize: async (row) => ({ + ...row, + pluginSettings: + typeof row.id === "number" + ? await exportHostPluginSettings(row.id) + : {}, + }), + afterWrite: async ({ id, userId, wire }) => { + if (id === null) return; + await importHostPluginSettings(id, wire.pluginSettings); + await dropInvalidParent(userId, id); + const { SharedHostSecretsManager } = + await import("../utils/shared-host-secrets-manager.js"); + await SharedHostSecretsManager.getInstance().resyncHost(id); + }, + remove: async (row, userId) => { + const { deleteOwnedHost } = await import("../hosts/delete-host.js"); + await deleteOwnedHost(userId, row.id as number); + }, + }, + { + canReference: async (userId, _ownerId, id) => { + const { PermissionManager } = + await import("../utils/permission-manager.js"); + const access = await PermissionManager.getInstance().canAccessHost( + userId, + id, + "view", + ); + return access.hasAccess; + }, + }, + ); + + // dashboardServiceLinks (70) and homepageItems (80) belong to homepage. + + registerEntity(CORE_OWNER, { + type: "userPreferences", + table: userPreferences, + order: 90, + singleton: true, + readOnlyFields: ["storageMode"], + references: [ + { + field: "customKeybindings[].action.snippetId", + entityType: "commandSnippet", + idType: "string", + }, + ], + }); + + registerEntity( + CORE_OWNER, + { type: "pluginUserSettings", table: null, order: 95 }, + { + load: loadPluginUserSettings, + write: writePluginUserSetting, + erase: erasePluginUserSetting, + covers: (syncId) => !!activeManifest(syncId.split(":")[0]), + }, + ); + + registerEntity( + CORE_OWNER, + { type: "accountProfile", table: null, order: 5, singleton: true }, + { + readOnly: true, + load: loadAccountProfile, + write: writeAccountProfile, + erase: async () => {}, + }, + ); + + registerEntity( + CORE_OWNER, + { type: "sharedCredentials", table: null, order: 15 }, + { + readOnly: true, + load: loadSharedCredentials, + write: (userId, wire) => writeSharedCopy("sshCredentials", userId, wire), + erase: (userId, syncId) => + eraseSharedCopy("sshCredentials", userId, syncId), + }, + ); + + registerEntity( + CORE_OWNER, + { type: "sharedHosts", table: null, order: 55 }, + { + readOnly: true, + load: loadSharedHosts, + write: (userId, wire) => writeSharedCopy("hosts", userId, wire), + erase: (userId, syncId) => eraseSharedCopy("hosts", userId, syncId), + }, + ); +} + +/** A synced parent link that would make a cycle here is dropped. */ +async function dropInvalidParent(userId: string, hostId: number) { + const { createCurrentRepositoryContext } = + await import("../database/repositories/factory.js"); + const db = createCurrentRepositoryContext().drizzle; + const [row] = await db + .select({ parentHostId: hosts.parentHostId }) + .from(hosts) + .where(and(eq(hosts.id, hostId), eq(hosts.userId, userId))) + .limit(1); + if (typeof row?.parentHostId !== "number") return; + const { validateParentHostId } = + await import("../database/routes/host-parent-validation.js"); + if (await validateParentHostId(userId, hostId, row.parentHostId)) { + await db + .update(hosts) + .set({ parentHostId: null }) + .where(and(eq(hosts.id, hostId), eq(hosts.userId, userId))); + } +} + +function activeManifests(): PluginManifest[] { + try { + return getPluginRuntime() + .loader.list() + .filter((plugin) => plugin.state === "active" && plugin.manifest) + .map((plugin) => plugin.manifest as PluginManifest); + } catch { + return []; + } +} + +function activeManifest(pluginId: string): PluginManifest | undefined { + return activeManifests().find((manifest) => manifest.id === pluginId); +} + +async function loadPluginUserSettings(userId: string): Promise { + const { declaredFields, getSetting } = await import("../plugins/settings.js"); + const { createCurrentPluginSettingsRepository } = + await import("../database/repositories/factory.js"); + const repository = createCurrentPluginSettingsRepository(); + const rows: SyncRow[] = []; + for (const manifest of activeManifests()) { + const fields = declaredFields(manifest, "user"); + if (fields.length === 0) continue; + const stored = new Set( + (await repository.getAll(manifest.id, "user", userId)) + .filter((row) => row.value !== null) + .map((row) => row.key), + ); + for (const field of fields) { + if (!stored.has(field.key)) continue; + rows.push({ + syncId: `${manifest.id}:${field.key}`, + pluginId: manifest.id, + key: field.key, + value: await getSetting(manifest, "user", userId, field.key), + }); + } + } + return rows; +} + +async function writePluginUserSetting(userId: string, wire: SyncRow) { + const manifest = activeManifest(String(wire.pluginId)); + if (!manifest) return; + const { setSetting } = await import("../plugins/settings.js"); + const error = await setSetting( + manifest, + "user", + userId, + String(wire.key), + wire.value, + ); + if (error) throw new Error(error); +} + +async function erasePluginUserSetting(userId: string, syncId: string) { + const [pluginId, ...rest] = syncId.split(":"); + const { createCurrentPluginSettingsRepository } = + await import("../database/repositories/factory.js"); + await createCurrentPluginSettingsRepository().delete( + pluginId, + "user", + userId, + rest.join(":"), + ); +} + +async function loadAccountProfile(userId: string): Promise { + const { createCurrentUserRepository, createCurrentRoleRepository } = + await import("../database/repositories/factory.js"); + const user = await createCurrentUserRepository().findById(userId); + if (!user) return []; + const { PermissionManager } = await import("../utils/permission-manager.js"); + const roles = await createCurrentRoleRepository().listUserRoles(userId); + return [ + { + syncId: singletonSyncId("accountProfile"), + remoteUserId: userId, + username: user.username, + isAdmin: !!user.isAdmin, + roles: roles.map((role) => role.roleName).sort(), + permissions: ( + await PermissionManager.getInstance().getUserPermissions(userId) + ).sort(), + }, + ]; +} + +async function writeAccountProfile(userId: string, wire: SyncRow) { + const { updateLink } = await import("./client/link-store.js"); + await updateLink({ + remoteUserId: (wire.remoteUserId as string) ?? null, + remoteUsername: (wire.username as string) ?? null, + account: { + remoteUserId: wire.remoteUserId as string, + username: wire.username as string, + isAdmin: !!wire.isAdmin, + roles: (wire.roles as string[]) ?? [], + permissions: (wire.permissions as string[]) ?? [], + }, + }); + if (typeof wire.username === "string" && wire.username) { + const { createCurrentUserRepository } = + await import("../database/repositories/factory.js"); + const users = createCurrentUserRepository(); + const current = await users.findById(userId); + if (current && current.username !== wire.username) { + await users.update(userId, { username: wire.username }); + } + } +} + +/** Host columns a shared copy never carries. */ +const SHARED_HOST_DROP = [ + "id", + "userId", + "credentialId", + "rdpCredentialId", + "vncCredentialId", + "telnetCredentialId", + "parentHostId", + "pluginSettings", + "quickActions", + "createdAt", + "updatedAt", + "isShared", + "permissionLevel", + ...HOST_LOCAL_FIELDS, +]; + +async function loadSharedHosts(userId: string): Promise { + const { + createCurrentRbacAccessRepository, + createCurrentRoleRepository, + createCurrentHostResolutionRepository, + createCurrentUserRepository, + } = await import("../database/repositories/factory.js"); + const roleIds = await createCurrentRoleRepository().listUserRoleIds(userId); + const entries = + await createCurrentRbacAccessRepository().listVisibleHostAccessEntries( + userId, + roleIds, + ); + const hostRepository = createCurrentHostResolutionRepository(); + const owned = await hostRepository.listOwnedHostIds(userId); + const levels = new Map(); + for (const entry of entries) { + if (owned.has(entry.hostId)) continue; + if (!levels.has(entry.hostId)) { + levels.set(entry.hostId, entry.permissionLevel); + } + } + if (levels.size === 0) return []; + + const { resolveHostById } = await import("../hosts/host-resolver.js"); + const users = createCurrentUserRepository(); + const ownerNames = new Map(); + const rows: SyncRow[] = []; + + for (const [hostId, permissionLevel] of levels) { + try { + const host = (await resolveHostById( + hostId, + userId, + )) as unknown as SyncRow | null; + if (!host || typeof host.syncId !== "string") continue; + const ownerId = String(host.userId); + if (!ownerNames.has(ownerId)) { + ownerNames.set( + ownerId, + (await users.findById(ownerId))?.username ?? "", + ); + } + + const wire: SyncRow = {}; + for (const [field, value] of Object.entries(host)) { + if (SHARED_HOST_DROP.includes(field)) continue; + wire[field] = + value !== null && typeof value === "object" + ? JSON.stringify(value) + : value; + } + wire.jumpHosts = await jumpHostsToSyncIds(host.jumpHosts); + wire.syncId = host.syncId; + wire.shared = { + hostId, + owner: ownerNames.get(ownerId) ?? "", + permissionLevel, + }; + rows.push(wire); + } catch (error) { + syncLogger.warn("Could not include a shared host in sync", { + operation: "sync_shared_host", + hostId, + error: error instanceof Error ? error.message : String(error), + }); + } + } + return rows; +} + +async function jumpHostsToSyncIds(value: unknown): Promise { + const list = Array.isArray(value) ? value : []; + if (list.length === 0) return null; + const { createCurrentHostRepository } = + await import("../database/repositories/factory.js"); + const repository = createCurrentHostRepository(); + const mapped = []; + for (const item of list) { + const hostId = (item as { hostId?: unknown })?.hostId; + if (typeof hostId !== "number") continue; + const target = await repository.findById(hostId); + if (target?.syncId) mapped.push({ ...item, hostId: target.syncId }); + } + return JSON.stringify(mapped); +} + +async function loadSharedCredentials(userId: string): Promise { + const { + createCurrentCredentialAccessRepository, + createCurrentCredentialRepository, + createCurrentRoleRepository, + createCurrentUserRepository, + } = await import("../database/repositories/factory.js"); + const roleIds = await createCurrentRoleRepository().listUserRoleIds(userId); + const grants = + await createCurrentCredentialAccessRepository().listSharedWithUser( + userId, + roleIds, + ); + if (grants.length === 0) return []; + const { findUsableCredential } = + await import("../hosts/usable-credential.js"); + const credentials = createCurrentCredentialRepository(); + const users = createCurrentUserRepository(); + const rows: SyncRow[] = []; + for (const grant of grants) { + try { + const row = (await credentials.findById( + grant.credentialId, + )) as SyncRow | null; + const usable = (await findUsableCredential( + grant.credentialId, + userId, + )) as unknown as SyncRow | null; + if (!row || !usable || typeof row.syncId !== "string") continue; + rows.push({ + syncId: row.syncId, + name: row.name, + description: row.description ?? null, + tags: row.tags ?? null, + authType: usable.authType, + username: usable.username ?? null, + password: usable.password ?? null, + privateKey: usable.privateKey ?? null, + keyPassword: usable.keyPassword ?? null, + keyType: usable.keyType ?? null, + publicKey: usable.publicKey ?? null, + certPublicKey: usable.certPublicKey ?? null, + shared: { + credentialId: grant.credentialId, + owner: (await users.findById(grant.ownerId))?.username ?? "", + permissionLevel: grant.permissionLevel, + }, + }); + } catch (error) { + syncLogger.warn("Could not include a shared credential in sync", { + operation: "sync_shared_credential", + credentialId: grant.credentialId, + error: error instanceof Error ? error.message : String(error), + }); + } + } + return rows; +} + +/** Stores a read-only copy of something shared with the linked account. */ +async function writeSharedCopy( + entityType: "hosts" | "sshCredentials", + userId: string, + wire: SyncRow, +): Promise { + const { getEntity } = await import("../plugins/sync-registry.js"); + const { writeWireRow, createResolvers } = await import("./store.js"); + const entity = getEntity(entityType); + if (!entity) return; + const { shared, ...rest } = wire; + await writeWireRow(entity, userId, rest, createResolvers(userId), { + sharedSource: JSON.stringify(shared ?? {}), + }); +} + +async function eraseSharedCopy( + entityType: "hosts" | "sshCredentials", + userId: string, + syncId: string, +): Promise { + const { getEntity } = await import("../plugins/sync-registry.js"); + const { deleteStoredRow, findStoredRow } = await import("./store.js"); + const entity = getEntity(entityType); + if (!entity) return; + const row = await findStoredRow(entity, userId, syncId); + if (!row?.sharedSource) return; + await deleteStoredRow(entity, userId, syncId); +} diff --git a/src/backend/database/routes/sync-host-plugin-settings.ts b/src/backend/sync/host-plugin-settings.ts similarity index 91% rename from src/backend/database/routes/sync-host-plugin-settings.ts rename to src/backend/sync/host-plugin-settings.ts index 541dc3da8..f870fe742 100644 --- a/src/backend/database/routes/sync-host-plugin-settings.ts +++ b/src/backend/sync/host-plugin-settings.ts @@ -1,21 +1,21 @@ /** - * Host plugin settings over remote sync. A host row travels with a + * Host plugin settings over sync. A host row travels with a * pluginSettings map of its non-secret values; a plugin whose value refers to * one of its own rows by local id translates it with a registered * ctx.registry.provide(".hostSettingsSync", { exportValue, importValue }). */ -import { consume } from "../../plugins/registry.js"; +import { consume } from "../plugins/registry.js"; import { declaredFields, getAllSettings, setSetting, -} from "../../plugins/settings.js"; -import { databaseLogger } from "../../utils/logger.js"; +} from "../plugins/settings.js"; +import { databaseLogger } from "../utils/logger.js"; import { hostSettingsPlugins, type HostPluginSettings, -} from "./host-plugin-settings.js"; +} from "../database/routes/host-plugin-settings.js"; export interface PluginHostSettingsSync { exportValue?: (key: string, value: unknown) => unknown | Promise; diff --git a/src/backend/sync/protocol.ts b/src/backend/sync/protocol.ts new file mode 100644 index 000000000..86b3fa486 --- /dev/null +++ b/src/backend/sync/protocol.ts @@ -0,0 +1,2 @@ +/** Bumped when a desktop and server can no longer understand each other. */ +export const SYNC_PROTOCOL_VERSION = 1; diff --git a/src/backend/sync/records.ts b/src/backend/sync/records.ts new file mode 100644 index 000000000..c32883644 --- /dev/null +++ b/src/backend/sync/records.ts @@ -0,0 +1,248 @@ +/** + * The per-record sync bookkeeping in sync_records. See the table's comment in + * schema.ts for what the columns mean on a server and on a desktop. + */ + +import { and, asc, eq, gt, inArray, sql } from "drizzle-orm"; +import { syncConflicts, syncRecords } from "../database/db/schema.js"; +import { createCurrentRepositoryContext } from "../database/repositories/factory.js"; +import { DatabaseSaveTrigger } from "../utils/database-save-trigger.js"; + +export type SyncRecord = typeof syncRecords.$inferSelect; +export type SyncConflict = typeof syncConflicts.$inferSelect; + +export function recordKey(entityType: string, syncId: string): string { + return `${entityType}\u0000${syncId}`; +} + +function db() { + return createCurrentRepositoryContext().drizzle; +} + +export async function listRecords( + userId: string, + entityType?: string, +): Promise> { + const rows = await db() + .select() + .from(syncRecords) + .where( + entityType + ? and( + eq(syncRecords.userId, userId), + eq(syncRecords.entityType, entityType), + ) + : eq(syncRecords.userId, userId), + ); + return new Map( + rows.map((row) => [recordKey(row.entityType, row.syncId), row]), + ); +} + +export async function getRecord( + userId: string, + entityType: string, + syncId: string, +): Promise { + const [row] = await db() + .select() + .from(syncRecords) + .where( + and( + eq(syncRecords.userId, userId), + eq(syncRecords.entityType, entityType), + eq(syncRecords.syncId, syncId), + ), + ) + .limit(1); + return row ?? null; +} + +export interface RecordValues { + revision: number; + seq?: number; + hash: string | null; + deleted: boolean; + error?: string | null; + errorHash?: string | null; +} + +export async function putRecord( + userId: string, + entityType: string, + syncId: string, + values: RecordValues, +): Promise { + const set = { + revision: values.revision, + hash: values.hash, + deleted: values.deleted, + error: values.error ?? null, + errorHash: values.errorHash ?? null, + updatedAt: new Date().toISOString(), + ...(values.seq !== undefined ? { seq: values.seq } : {}), + }; + const existing = await getRecord(userId, entityType, syncId); + if (existing) { + await db() + .update(syncRecords) + .set(set) + .where(eq(syncRecords.id, existing.id)); + } else { + await db() + .insert(syncRecords) + .values({ userId, entityType, syncId, seq: 0, ...set }); + } + DatabaseSaveTrigger.triggerSave("sync_record"); +} + +export async function setRecordError( + userId: string, + entityType: string, + syncId: string, + error: string, + errorHash: string, +): Promise { + const existing = await getRecord(userId, entityType, syncId); + if (existing) { + await db() + .update(syncRecords) + .set({ error, errorHash }) + .where(eq(syncRecords.id, existing.id)); + } else { + await db().insert(syncRecords).values({ + userId, + entityType, + syncId, + revision: 0, + seq: 0, + hash: null, + deleted: false, + error, + errorHash, + }); + } + DatabaseSaveTrigger.triggerSave("sync_record"); +} + +export async function clearRecordErrors(userId: string): Promise { + await db() + .update(syncRecords) + .set({ error: null, errorHash: null }) + .where(eq(syncRecords.userId, userId)); +} + +export async function listRecordErrors(userId: string): Promise { + const rows = await db() + .select() + .from(syncRecords) + .where(eq(syncRecords.userId, userId)); + return rows.filter((row) => !!row.error); +} + +export async function listChanges( + userId: string, + cursor: number, + limit: number, + types?: string[], +): Promise { + const conditions = [ + eq(syncRecords.userId, userId), + gt(syncRecords.seq, cursor), + ]; + if (types && types.length > 0) { + conditions.push(inArray(syncRecords.entityType, types)); + } + return db() + .select() + .from(syncRecords) + .where(and(...conditions)) + .orderBy(asc(syncRecords.seq)) + .limit(limit); +} + +export async function deleteRecordsForUser(userId: string): Promise { + await db().delete(syncRecords).where(eq(syncRecords.userId, userId)); + await db().delete(syncConflicts).where(eq(syncConflicts.userId, userId)); + DatabaseSaveTrigger.triggerSave("sync_record"); +} + +let nextSeq: number | null = null; + +/** + * The next position in the change feed. One counter for every user, so a + * seq is never reused; a single process owns it, seeded from the table. + */ +export async function allocateSeq(): Promise { + if (nextSeq === null) { + const [row] = await db() + .select({ max: sql`max(${syncRecords.seq})` }) + .from(syncRecords); + const max = Number(row?.max ?? 0); + nextSeq = (Number.isFinite(max) ? max : 0) + 1; + } + return nextSeq++; +} + +/** Test seam. */ +export function resetSeqAllocator(): void { + nextSeq = null; +} + +export async function saveConflict( + userId: string, + entityType: string, + syncId: string, + localRow: Record, + serverRevision: number, +): Promise { + await db() + .delete(syncConflicts) + .where( + and( + eq(syncConflicts.userId, userId), + eq(syncConflicts.entityType, entityType), + eq(syncConflicts.syncId, syncId), + ), + ); + await db() + .insert(syncConflicts) + .values({ + userId, + entityType, + syncId, + localRow: JSON.stringify(localRow), + serverRevision, + }); + DatabaseSaveTrigger.triggerSave("sync_conflict"); +} + +export async function listConflicts(userId: string): Promise { + return db() + .select() + .from(syncConflicts) + .where(eq(syncConflicts.userId, userId)) + .orderBy(asc(syncConflicts.createdAt)); +} + +export async function getConflict( + userId: string, + id: number, +): Promise { + const [row] = await db() + .select() + .from(syncConflicts) + .where(and(eq(syncConflicts.userId, userId), eq(syncConflicts.id, id))) + .limit(1); + return row ?? null; +} + +export async function deleteConflict( + userId: string, + id: number, +): Promise { + await db() + .delete(syncConflicts) + .where(and(eq(syncConflicts.userId, userId), eq(syncConflicts.id, id))); + DatabaseSaveTrigger.triggerSave("sync_conflict"); +} diff --git a/src/backend/sync/server/change-watcher.ts b/src/backend/sync/server/change-watcher.ts new file mode 100644 index 000000000..c4481c86d --- /dev/null +++ b/src/backend/sync/server/change-watcher.ts @@ -0,0 +1,30 @@ +import type { NextFunction, Request, Response } from "express"; +import { markChanged } from "./feed.js"; + +const READS = new Set(["GET", "HEAD", "OPTIONS"]); +const writeListeners = new Set<() => void>(); + +/** Called after every successful write request, for a linked desktop's engine. */ +export function onLocalWrite(listener: () => void): () => void { + writeListeners.add(listener); + return () => writeListeners.delete(listener); +} + +/** + * Any successful write may change what a linked desktop should have, so it + * nudges the change feed. Sync's own pushes reconcile themselves. + */ +export function syncChangeWatcher( + req: Request, + res: Response, + next: NextFunction, +): void { + if (!READS.has(req.method) && !req.path.startsWith("/sync/")) { + res.on("finish", () => { + if (res.statusCode >= 400) return; + markChanged(); + for (const listener of writeListeners) listener(); + }); + } + next(); +} diff --git a/src/backend/sync/server/feed.ts b/src/backend/sync/server/feed.ts new file mode 100644 index 000000000..5f7de00da --- /dev/null +++ b/src/backend/sync/server/feed.ts @@ -0,0 +1,220 @@ +/** + * The server's change feed. + * + * Nothing records a change as it happens. Instead the reconciler compares + * every synced row a user owns with sync_records and bumps the record of + * anything new, changed or gone. That catches every write path, including + * plugin tables, bulk routes and background jobs, without each of them having + * to remember to report it. + */ + +import type { Response } from "express"; +import type { SyncRow } from "@termix/plugin-sdk/backend"; +import { listEntities } from "../../plugins/sync-registry.js"; +import { syncLogger } from "../../utils/logger.js"; +import { registerCoreSyncEntities } from "../entities.js"; +import { createResolvers, loadWireRows } from "../store.js"; +import { allocateSeq, listRecords, putRecord, recordKey } from "../records.js"; + +const locks = new Map>(); + +/** Runs one user's sync work at a time: reconcile and push must not interleave. */ +export async function withUserLock( + userId: string, + fn: () => Promise, +): Promise { + const previous = locks.get(userId) ?? Promise.resolve(); + const run = previous.catch(() => {}).then(fn); + locks.set(userId, run); + try { + return await run; + } finally { + if (locks.get(userId) === run) locks.delete(userId); + } +} + +interface Snapshot { + at: number; + generation: number; + wire: Map; +} + +const snapshots = new Map(); +const SNAPSHOT_TTL_MS = 60_000; +let generation = 0; + +export interface ReconcileResult { + changed: boolean; + /** Every live row on the wire, by recordKey. */ + wire: Map; +} + +/** + * Brings the user's records up to date. Call inside withUserLock. Skipped + * when nothing was written since the last run and it is recent, unless + * forced. + */ +export async function reconcileUser( + userId: string, + options: { force?: boolean } = {}, +): Promise { + const snapshot = snapshots.get(userId); + if ( + !options.force && + snapshot && + snapshot.generation === generation && + Date.now() - snapshot.at < SNAPSHOT_TTL_MS + ) { + return { changed: false, wire: snapshot.wire }; + } + + registerCoreSyncEntities(); + const startedAt = generation; + const records = await listRecords(userId); + const resolvers = createResolvers(userId); + const wire = new Map(); + let changed = false; + + for (const entity of listEntities()) { + let loaded; + try { + loaded = await loadWireRows(entity, userId, resolvers); + } catch (error) { + // An entity that cannot load right now is left alone rather than read + // as every row deleted. + syncLogger.warn("Could not read a sync entity", { + operation: "sync_reconcile", + entityType: entity.type, + userId, + error: error instanceof Error ? error.message : String(error), + }); + continue; + } + + for (const row of loaded.rows.values()) { + const key = recordKey(entity.type, row.syncId); + wire.set(key, row.wire); + const record = records.get(key); + if (record && !record.deleted && record.hash === row.hash) continue; + await putRecord(userId, entity.type, row.syncId, { + revision: (record?.revision ?? 0) + 1, + seq: await allocateSeq(), + hash: row.hash, + deleted: false, + }); + changed = true; + } + + for (const record of records.values()) { + if (record.entityType !== entity.type || record.deleted) continue; + if (loaded.rows.has(record.syncId) || loaded.failed.has(record.syncId)) { + continue; + } + if (entity.covers && !entity.covers(record.syncId)) continue; + await putRecord(userId, entity.type, record.syncId, { + revision: record.revision + 1, + seq: await allocateSeq(), + hash: null, + deleted: true, + }); + changed = true; + } + } + + snapshots.set(userId, { at: Date.now(), generation: startedAt, wire }); + if (changed) notifyListeners(userId); + return { changed, wire }; +} + +/** Forgets a user's snapshot so the next reconcile reads everything. */ +export function invalidateSnapshot(userId: string): void { + snapshots.delete(userId); +} + +const listeners = new Map>(); +let debounce: NodeJS.Timeout | null = null; +let heartbeat: NodeJS.Timeout | null = null; +let sweep: NodeJS.Timeout | null = null; + +/** + * Something was written. Desktops listening for changes get their + * account reconciled shortly after, since a write by one user (an owner + * editing a shared host) can change what another one sees. + */ +export function markChanged(): void { + generation++; + if (listeners.size === 0) return; + if (debounce) clearTimeout(debounce); + debounce = setTimeout(() => { + debounce = null; + void reconcileListeners(); + }, 1500); + debounce.unref?.(); +} + +async function reconcileListeners(): Promise { + for (const userId of [...listeners.keys()]) { + try { + await withUserLock(userId, () => reconcileUser(userId)); + } catch (error) { + syncLogger.warn("Background sync reconcile failed", { + operation: "sync_reconcile_background", + userId, + error: error instanceof Error ? error.message : String(error), + }); + } + } +} + +function notifyListeners(userId: string): void { + const set = listeners.get(userId); + if (!set) return; + for (const res of set) { + res.write(`event: change\ndata: {}\n\n`); + } +} + +export function addListener(userId: string, res: Response): () => void { + let set = listeners.get(userId); + if (!set) { + set = new Set(); + listeners.set(userId, set); + } + set.add(res); + ensureTimers(); + return () => { + set!.delete(res); + if (set!.size === 0) listeners.delete(userId); + }; +} + +function ensureTimers(): void { + if (!heartbeat) { + heartbeat = setInterval(() => { + for (const set of listeners.values()) { + for (const res of set) res.write(`: ping\n\n`); + } + }, 25_000); + heartbeat.unref?.(); + } + if (!sweep) { + // Background jobs write without a request; this catches them. + sweep = setInterval(() => { + generation++; + void reconcileListeners(); + }, 60_000); + sweep.unref?.(); + } +} + +/** Test seam. */ +export function resetFeed(): void { + snapshots.clear(); + listeners.clear(); + locks.clear(); + generation = 0; + for (const timer of [debounce, heartbeat, sweep]) { + if (timer) clearTimeout(timer); + } + debounce = heartbeat = sweep = null; +} diff --git a/src/backend/sync/server/push.ts b/src/backend/sync/server/push.ts new file mode 100644 index 000000000..e32717393 --- /dev/null +++ b/src/backend/sync/server/push.ts @@ -0,0 +1,250 @@ +/** + * Applying a desktop's pushed changes on the server. + * + * Each op names the revision its sender last saw. If the server has moved on + * since, the op is a conflict and the server's version is sent back instead + * of being overwritten. Every op gets its own result, so one bad row never + * holds up the rest of the batch. + */ + +import type { SyncRow } from "@termix/plugin-sdk/backend"; +import { getEntity } from "../../plugins/sync-registry.js"; +import { PermissionManager } from "../../utils/permission-manager.js"; +import { syncLogger } from "../../utils/logger.js"; +import { + createResolvers, + deleteStoredRow, + loadWireRow, + writeWireRow, +} from "../store.js"; +import { allocateSeq, getRecord, putRecord, recordKey } from "../records.js"; +import { orderSelfReferences } from "../wire.js"; +import { reconcileUser } from "./feed.js"; + +export interface PushOp { + entityType: string; + syncId: string; + baseRevision: number; + op: "upsert" | "delete"; + row?: SyncRow; +} + +export type PushResult = + | { syncId: string; entityType: string; status: "ok"; revision: number } + | { + syncId: string; + entityType: string; + status: "conflict"; + revision: number; + deleted: boolean; + row: SyncRow | null; + } + | { + syncId: string; + entityType: string; + status: "rejected"; + reason: string; + }; + +export const MAX_PUSH_OPS = 500; + +function isPushOp(value: unknown): value is PushOp { + if (!value || typeof value !== "object") return false; + const op = value as Record; + return ( + typeof op.entityType === "string" && + typeof op.syncId === "string" && + op.syncId.length > 0 && + op.syncId.length <= 191 && + typeof op.baseRevision === "number" && + (op.op === "upsert" || op.op === "delete") && + (op.op === "delete" || (!!op.row && typeof op.row === "object")) + ); +} + +export function parsePushOps(body: unknown): PushOp[] | null { + const ops = (body as { ops?: unknown })?.ops; + if (!Array.isArray(ops) || ops.length > MAX_PUSH_OPS) return null; + return ops.every(isPushOp) ? ops : null; +} + +/** Upserts in dependency order, then deletes in reverse, children first. */ +export function orderOps(ops: PushOp[]): PushOp[] { + const order = (op: PushOp) => getEntity(op.entityType)?.order ?? 1_000_000; + const upserts = ops + .filter((op) => op.op === "upsert") + .sort((a, b) => order(a) - order(b)); + const deletes = ops + .filter((op) => op.op === "delete") + .sort((a, b) => order(b) - order(a)); + + const orderedUpserts: PushOp[] = []; + const byType = new Map(); + for (const op of upserts) { + const list = byType.get(op.entityType) ?? []; + list.push(op); + byType.set(op.entityType, list); + } + for (const [type, list] of byType) { + const entity = getEntity(type); + if (!entity) { + orderedUpserts.push(...list); + continue; + } + const bySyncId = new Map(list.map((op) => [op.syncId, op])); + const rows = orderSelfReferences( + entity, + list.map((op) => ({ ...(op.row as SyncRow), syncId: op.syncId })), + ); + for (const row of rows) { + orderedUpserts.push(bySyncId.get(row.syncId as string)!); + } + } + return [...orderedUpserts, ...deletes]; +} + +/** Call inside withUserLock. */ +export async function applyPush( + userId: string, + ops: PushOp[], +): Promise { + const { wire } = await reconcileUser(userId, { force: true }); + const permissions = PermissionManager.getInstance(); + let missed = false; + const resolvers = createResolvers(userId, () => { + missed = true; + }); + const results = new Map(); + // Rows written before something they point at, for a second pass. + const retry: PushOp[] = []; + + for (const op of orderOps(ops)) { + const key = recordKey(op.entityType, op.syncId); + const base = { syncId: op.syncId, entityType: op.entityType }; + const entity = getEntity(op.entityType); + if (!entity) { + results.set(key, { ...base, status: "rejected", reason: "unknown_type" }); + continue; + } + if (entity.readOnly) { + results.set(key, { ...base, status: "rejected", reason: "read_only" }); + continue; + } + + const record = await getRecord(userId, op.entityType, op.syncId); + const currentRevision = record?.revision ?? 0; + if (op.baseRevision !== currentRevision) { + results.set(key, { + ...base, + status: "conflict", + revision: currentRevision, + deleted: !!record?.deleted, + row: record?.deleted ? null : (wire.get(key) ?? null), + }); + continue; + } + + const exists = !!record && !record.deleted; + const permission = + op.op === "delete" + ? entity.permissions?.delete + : exists + ? entity.permissions?.update + : entity.permissions?.create; + if (permission && !(await permissions.hasPermission(userId, permission))) { + results.set(key, { ...base, status: "rejected", reason: "permission" }); + continue; + } + + try { + if (op.op === "delete") { + await deleteStoredRow(entity, userId, op.syncId); + await putRecord(userId, op.entityType, op.syncId, { + revision: currentRevision + 1, + seq: await allocateSeq(), + hash: null, + deleted: true, + }); + wire.delete(key); + results.set(key, { + ...base, + status: "ok", + revision: currentRevision + 1, + }); + continue; + } + + const row = { ...(op.row as SyncRow), syncId: op.syncId }; + if (entity.shouldSync && entity.shouldSync(row) === false) { + results.set(key, { ...base, status: "rejected", reason: "not_synced" }); + continue; + } + missed = false; + await writeWireRow(entity, userId, row, resolvers); + if (missed) retry.push(op); + const written = await loadWireRow(entity, userId, op.syncId, resolvers); + await putRecord(userId, op.entityType, op.syncId, { + revision: currentRevision + 1, + seq: await allocateSeq(), + hash: written?.hash ?? null, + deleted: false, + }); + if (written) wire.set(key, written.wire); + results.set(key, { + ...base, + status: "ok", + revision: currentRevision + 1, + }); + } catch (error) { + const reason = error instanceof Error ? error.message : String(error); + syncLogger.warn("Rejected a pushed sync change", { + operation: "sync_push", + userId, + entityType: op.entityType, + syncId: op.syncId, + error: reason, + }); + results.set(key, { + ...base, + status: "rejected", + reason: `invalid: ${reason}`.slice(0, 500), + }); + } + } + + for (const op of retry) { + const entity = getEntity(op.entityType)!; + try { + await writeWireRow( + entity, + userId, + { ...(op.row as SyncRow), syncId: op.syncId }, + resolvers, + ); + const written = await loadWireRow(entity, userId, op.syncId, resolvers); + const record = await getRecord(userId, op.entityType, op.syncId); + if (record && written) { + await putRecord(userId, op.entityType, op.syncId, { + revision: record.revision, + seq: record.seq, + hash: written.hash, + deleted: false, + }); + } + } catch { + // The first write stands. + } + } + + // Refresh the snapshot so the next pull serves what was just written. + await reconcileUser(userId, { force: true }); + return ops.map( + (op) => + results.get(recordKey(op.entityType, op.syncId)) ?? { + syncId: op.syncId, + entityType: op.entityType, + status: "rejected", + reason: "duplicate", + }, + ); +} diff --git a/src/backend/sync/server/routes.ts b/src/backend/sync/server/routes.ts new file mode 100644 index 000000000..2577021ed --- /dev/null +++ b/src/backend/sync/server/routes.ts @@ -0,0 +1,582 @@ +import express, { type Request, type Response } from "express"; +import fs from "fs"; +import path from "path"; +import os from "os"; +import * as tar from "tar"; +import type { AuthenticatedRequest } from "../../../types/index.js"; +import { AuthManager } from "../../utils/auth-manager.js"; +import { syncLogger } from "../../utils/logger.js"; +import { getLocalVersion } from "../../utils/app-version.js"; +import { getEntity, listEntities } from "../../plugins/sync-registry.js"; +import { getPluginRuntime } from "../../plugins/index.js"; +import { + createCurrentSessionRepository, + createCurrentSettingsRepository, + createCurrentUserRepository, + createCurrentPluginRepository, + createCurrentPluginPermissionGrantRepository, +} from "../../database/repositories/factory.js"; +import { resolveBrandingSettings } from "../../database/routes/branding-settings.js"; +import { registerCoreSyncEntities } from "../entities.js"; +import { listChanges, recordKey } from "../records.js"; +import { addListener, reconcileUser, withUserLock } from "./feed.js"; +import { applyPush, parsePushOps } from "./push.js"; +import { SYNC_PROTOCOL_VERSION } from "../protocol.js"; + +const SESSION_DAYS = 365; +const REFRESH_WITHIN_MS = 60 * 24 * 60 * 60 * 1000; +const MAX_PAGE = 1000; + +const router = express.Router(); +const authManager = AuthManager.getInstance(); +const authenticateJWT = authManager.createAuthMiddleware(); + +registerCoreSyncEntities(); + +function userOf(req: Request): string { + return (req as AuthenticatedRequest).userId; +} + +/** + * @openapi + * /sync/v2/info: + * get: + * summary: Describe this server to a desktop that wants to link to it + * description: Public. Lets the desktop app confirm it reached Termix and not a proxy's login page, and that the sync protocol matches. + * tags: + * - Sync + * responses: + * 200: + * description: Server name, version and sync protocol version. + */ +router.get("/v2/info", async (_req: Request, res: Response) => { + let name = "Termix"; + try { + name = (await resolveBrandingSettings(createCurrentSettingsRepository())) + .appName; + } catch { + // The default name is fine. + } + res.setHeader("X-Termix-Sync", String(SYNC_PROTOCOL_VERSION)); + res.json({ + termix: true, + name, + version: getLocalVersion(), + protocol: SYNC_PROTOCOL_VERSION, + }); +}); + +/** + * @openapi + * /sync/v2/link: + * post: + * summary: Link a desktop to this account + * description: Trades the short login session the desktop just signed in with for a long-lived desktop session, which shows in the account's sessions list and is revoked like any other. The login session is ended. + * tags: + * - Sync + * requestBody: + * content: + * application/json: + * schema: + * type: object + * properties: + * deviceName: + * type: string + * responses: + * 200: + * description: The desktop session token and the account it belongs to. + * 401: + * description: Not signed in. + */ +router.post( + "/v2/link", + authenticateJWT, + async (req: Request, res: Response) => { + const userId = userOf(req); + try { + const user = await createCurrentUserRepository().findById(userId); + if (!user) return res.status(401).json({ error: "User not found" }); + const rawName = + typeof req.body?.deviceName === "string" ? req.body.deviceName : ""; + const deviceName = rawName.trim().slice(0, 100) || "Termix Desktop"; + const token = await authManager.generateJWTToken(userId, { + expiresIn: `${SESSION_DAYS}d`, + deviceType: "desktop", + deviceInfo: `${deviceName} (sync)`, + }); + const loginSession = (req as AuthenticatedRequest).sessionId; + if (loginSession) await authManager.revokeSession(loginSession); + res.json({ + token, + user: { id: user.id, username: user.username }, + server: { version: getLocalVersion() }, + }); + } catch (error) { + syncLogger.error("Failed to link a desktop", error, { + operation: "sync_link", + userId, + }); + res.status(500).json({ error: "Failed to link" }); + } + }, +); + +/** + * @openapi + * /sync/v2/session/refresh: + * post: + * summary: Keep a linked desktop's session from expiring + * description: Returns a fresh desktop session when the current one ends within 60 days, and ends the old one. Otherwise returns null and the desktop keeps its token. + * tags: + * - Sync + * responses: + * 200: + * description: The new token, or null. + * 401: + * description: Session revoked or expired. + */ +router.post( + "/v2/session/refresh", + authenticateJWT, + async (req: Request, res: Response) => { + const userId = userOf(req); + const sessionId = (req as AuthenticatedRequest).sessionId; + try { + if (!sessionId) return res.json({ token: null }); + const session = + await createCurrentSessionRepository().findById(sessionId); + if (!session) return res.status(401).json({ error: "Session not found" }); + const remaining = new Date(session.expiresAt).getTime() - Date.now(); + if (remaining > REFRESH_WITHIN_MS) return res.json({ token: null }); + const token = await authManager.generateJWTToken(userId, { + expiresIn: `${SESSION_DAYS}d`, + deviceType: "desktop", + deviceInfo: session.deviceInfo, + }); + await authManager.revokeSession(sessionId); + res.json({ token }); + } catch (error) { + syncLogger.error("Failed to refresh a desktop session", error, { + operation: "sync_session_refresh", + userId, + }); + res.status(500).json({ error: "Failed to refresh session" }); + } + }, +); + +/** + * @openapi + * /sync/v2/unlink: + * post: + * summary: End a linked desktop's session + * description: Called by a desktop when it unlinks, so its session stops working here right away. + * tags: + * - Sync + * responses: + * 200: + * description: The session was ended. + */ +router.post( + "/v2/unlink", + authenticateJWT, + async (req: Request, res: Response) => { + const sessionId = (req as AuthenticatedRequest).sessionId; + try { + if (sessionId) await authManager.revokeSession(sessionId); + res.json({ success: true }); + } catch (error) { + syncLogger.error("Failed to end a desktop session", error, { + operation: "sync_unlink", + }); + res.status(500).json({ error: "Failed to unlink" }); + } + }, +); + +/** + * @openapi + * /sync/v2/changes: + * get: + * summary: Pull what changed for this account after a cursor + * description: Returns records in change order. Each has the entity type, syncId, revision, whether it is deleted, and the row when it is not. Records of entity types this server does not currently run are skipped but still move the cursor. + * tags: + * - Sync + * parameters: + * - in: query + * name: cursor + * schema: + * type: integer + * - in: query + * name: limit + * schema: + * type: integer + * - in: query + * name: types + * description: Comma-separated entity types, to backfill only those. + * schema: + * type: string + * responses: + * 200: + * description: A page of changes, the next cursor and whether more remain. + * 500: + * description: Failed to read changes. + */ +router.get( + "/v2/changes", + authenticateJWT, + async (req: Request, res: Response) => { + const userId = userOf(req); + const cursor = Math.max(0, Number(req.query.cursor) || 0); + const limit = Math.min( + MAX_PAGE, + Math.max(1, Number(req.query.limit) || MAX_PAGE), + ); + const types = + typeof req.query.types === "string" && req.query.types + ? req.query.types.split(",").filter(Boolean) + : undefined; + + try { + const page = await withUserLock(userId, async () => { + const { wire } = await reconcileUser(userId); + const records = await listChanges(userId, cursor, limit + 1, types); + return { wire, records }; + }); + const hasMore = page.records.length > limit; + const records = page.records.slice(0, limit); + const changes = []; + for (const record of records) { + if (!getEntity(record.entityType)) continue; + const row = record.deleted + ? null + : (page.wire.get(recordKey(record.entityType, record.syncId)) ?? + null); + // A live record with no row failed to serialize; it comes again once it + // changes, rather than arriving as a delete. + if (!record.deleted && !row) continue; + changes.push({ + seq: record.seq, + entityType: record.entityType, + syncId: record.syncId, + revision: record.revision, + deleted: !!record.deleted, + row, + }); + } + res.json({ + changes, + nextCursor: records.length ? records[records.length - 1].seq : cursor, + hasMore, + entityTypes: listEntities().map((entity) => ({ + type: entity.type, + owner: entity.owner, + order: entity.order, + readOnly: !!entity.readOnly, + })), + }); + } catch (error) { + syncLogger.error("Failed to read sync changes", error, { + operation: "sync_changes", + userId, + }); + res.status(500).json({ error: "Failed to read changes" }); + } + }, +); + +/** + * @openapi + * /sync/v2/push: + * post: + * summary: Push a desktop's changes + * description: Each op carries the revision the desktop last saw. Ops are applied in dependency order and each gets its own result, ok with the new revision, conflict with the server's version, or rejected with a reason. + * tags: + * - Sync + * requestBody: + * content: + * application/json: + * schema: + * type: object + * properties: + * ops: + * type: array + * items: + * type: object + * responses: + * 200: + * description: One result per op, in the order sent. + * 400: + * description: Malformed ops, or more than 500. + * 500: + * description: Failed to apply changes. + */ +router.post( + "/v2/push", + authenticateJWT, + async (req: Request, res: Response) => { + const userId = userOf(req); + const ops = parsePushOps(req.body); + if (!ops) return res.status(400).json({ error: "Invalid ops" }); + try { + const results = await withUserLock(userId, () => applyPush(userId, ops)); + res.json({ results }); + } catch (error) { + syncLogger.error("Failed to apply pushed changes", error, { + operation: "sync_push", + userId, + }); + res.status(500).json({ error: "Failed to apply changes" }); + } + }, +); + +/** + * @openapi + * /sync/v2/events: + * get: + * summary: Stream a notice whenever this account's data changes + * description: Server-sent events. A "change" event means there is something new to pull. Comments are sent every 25 seconds to keep proxies from closing the stream. + * tags: + * - Sync + * responses: + * 200: + * description: An event stream. + */ +router.get("/v2/events", authenticateJWT, (req: Request, res: Response) => { + const userId = userOf(req); + res.setHeader("Content-Type", "text/event-stream"); + res.setHeader("Cache-Control", "no-cache, no-transform"); + res.setHeader("Connection", "keep-alive"); + res.setHeader("X-Accel-Buffering", "no"); + res.flushHeaders?.(); + res.write(`event: ready\ndata: {}\n\n`); + const remove = addListener(userId, res); + req.on("close", remove); +}); + +/** + * @openapi + * /sync/v2/hosts/{syncId}: + * get: + * summary: This server's id for a host, by its sync id + * description: For a linked desktop that asks the server to act on a host (a tunnel through it), which needs the server's own id. Only for hosts the caller can see. + * tags: + * - Sync + * parameters: + * - in: path + * name: syncId + * required: true + * schema: + * type: string + * responses: + * 200: + * description: The host's id and name. + * 404: + * description: No such host the caller can see. + */ +router.get( + "/v2/hosts/:syncId", + authenticateJWT, + async (req: Request, res: Response) => { + const userId = userOf(req); + try { + const { createCurrentHostResolutionRepository } = + await import("../../database/repositories/factory.js"); + const hostId = + await createCurrentHostResolutionRepository().findHostIdBySyncId( + String(req.params.syncId), + ); + if (hostId === null) return res.status(404).json({ error: "Not found" }); + const { PermissionManager } = + await import("../../utils/permission-manager.js"); + const access = await PermissionManager.getInstance().canAccessHost( + userId, + hostId, + "view", + ); + if (!access.hasAccess) + return res.status(404).json({ error: "Not found" }); + const { createCurrentHostRepository } = + await import("../../database/repositories/factory.js"); + const row = await createCurrentHostRepository().findById(hostId); + res.json({ id: hostId, name: row?.name ?? row?.ip ?? null }); + } catch (error) { + syncLogger.error("Failed to look up a host by sync id", error, { + operation: "sync_host_lookup", + }); + res.status(500).json({ error: "Failed to look up host" }); + } + }, +); + +/** + * @openapi + * /sync/v2/host-status: + * get: + * summary: Status of the hosts this account can see, keyed by sync id + * description: For a linked desktop whose hosts connect through this server, so it can show their status against its own copies. + * tags: + * - Sync + * responses: + * 200: + * description: Status entries by host sync id. + */ +router.get( + "/v2/host-status", + authenticateJWT, + async (req: Request, res: Response) => { + const userId = userOf(req); + try { + const { hostStatusService } = + await import("../../hosts/status/host-status-service.js"); + const { PermissionManager } = + await import("../../utils/permission-manager.js"); + const { createCurrentHostRepository } = + await import("../../database/repositories/factory.js"); + const statuses = await hostStatusService.statusesFor(userId, null); + const entries = [...statuses.entries()]; + const allowed = + await PermissionManager.getInstance().filterAccessibleHostIds( + userId, + entries.map(([id]) => id), + ); + const hostRepository = createCurrentHostRepository(); + const result: Record = {}; + for (const [id, entry] of entries) { + if (!allowed.has(id)) continue; + const row = await hostRepository.findById(id); + if (row?.syncId) result[row.syncId] = entry; + } + res.json(result); + } catch (error) { + syncLogger.error("Failed to read host status for sync", error, { + operation: "sync_host_status", + }); + res.status(500).json({ error: "Failed to read host status" }); + } + }, +); + +async function listPluginsForDesktop() { + return getPluginRuntime().loader.list(); +} + +/** + * @openapi + * /sync/v2/plugins: + * get: + * summary: The features this server runs, for a linked desktop to mirror + * description: Every installed plugin with its version, where it came from, whether it is on, how a desktop should treat it and which sync entities it owns. + * tags: + * - Sync + * responses: + * 200: + * description: The plugin list. + */ +router.get( + "/v2/plugins", + authenticateJWT, + async (_req: Request, res: Response) => { + try { + const records = new Map( + (await createCurrentPluginRepository().listAll()).map((record) => [ + record.id, + record, + ]), + ); + const grants = createCurrentPluginPermissionGrantRepository(); + const plugins = []; + for (const plugin of await listPluginsForDesktop()) { + plugins.push({ + id: plugin.id, + name: plugin.manifest.name, + version: plugin.manifest.version, + source: plugin.source, + enabled: records.get(plugin.id)?.state === "enabled", + active: plugin.state === "active", + desktop: plugin.manifest.desktop ?? "mirror", + syncEntities: plugin.manifest.contributes?.syncEntities ?? [], + granted: (await grants.listByPlugin(plugin.id)).map( + (grant) => grant.capability, + ), + }); + } + res.json({ plugins }); + } catch (error) { + syncLogger.error("Failed to list plugins for sync", error, { + operation: "sync_plugins", + }); + res.status(500).json({ error: "Failed to list plugins" }); + } + }, +); + +/** + * @openapi + * /sync/v2/plugins/{id}/package: + * get: + * summary: Download an installed plugin so a linked desktop can install it + * description: Only for plugins installed on this server, not bundled ones. Sends the original .tmxplug when there is one, with its signature in the X-Termix-Signature header, otherwise a fresh archive of the plugin folder. + * tags: + * - Sync + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: string + * responses: + * 200: + * description: The plugin archive. + * 404: + * description: No installed plugin with that id. + */ +router.get( + "/v2/plugins/:id/package", + authenticateJWT, + async (req: Request, res: Response) => { + const pluginId = String(req.params.id); + try { + const plugin = (await listPluginsForDesktop()).find( + (candidate) => candidate.id === pluginId, + ); + if (!plugin || plugin.source !== "user") { + return res.status(404).json({ error: "Plugin not found" }); + } + const { getPluginsDir } = await import("../../plugins/paths.js"); + const artifact = path.join(getPluginsDir(), `${pluginId}.tmxplug`); + res.setHeader("Content-Type", "application/gzip"); + if (fs.existsSync(artifact)) { + const sig = `${artifact}.sig`; + if (fs.existsSync(sig)) { + res.setHeader( + "X-Termix-Signature", + Buffer.from(await fs.promises.readFile(sig, "utf8")).toString( + "base64", + ), + ); + } + return fs.createReadStream(artifact).pipe(res); + } + const temp = path.join( + os.tmpdir(), + `termix-plugin-${pluginId}-${Date.now()}.tmxplug`, + ); + await tar.c({ gzip: true, cwd: plugin.dir, file: temp }, ["."]); + const stream = fs.createReadStream(temp); + stream.on("close", () => void fs.promises.rm(temp, { force: true })); + stream.pipe(res); + } catch (error) { + syncLogger.error("Failed to package a plugin for sync", error, { + operation: "sync_plugin_package", + pluginId, + }); + if (!res.headersSent) { + res.status(500).json({ error: "Failed to package plugin" }); + } + } + }, +); + +export default router; diff --git a/src/backend/sync/shared-copy-guard.ts b/src/backend/sync/shared-copy-guard.ts new file mode 100644 index 000000000..5fc2b7270 --- /dev/null +++ b/src/backend/sync/shared-copy-guard.ts @@ -0,0 +1,39 @@ +import type { NextFunction, Request, Response } from "express"; +import { eq } from "drizzle-orm"; +import { hosts, sshCredentials } from "../database/db/schema.js"; +import { createCurrentRepositoryContext } from "../database/repositories/factory.js"; + +/** + * On a linked desktop, hosts and credentials someone shared with the account + * are read-only copies kept in step by sync. Editing or deleting one here + * would be undone by the next sync, so it is refused with a clear reason. + */ +export function rejectSharedCopyWrites( + kind: "host" | "credential", + pattern: RegExp, +) { + const table = kind === "host" ? hosts : sshCredentials; + return async (req: Request, res: Response, next: NextFunction) => { + if (process.env.ELECTRON_EMBEDDED !== "true") return next(); + if (req.method !== "PUT" && req.method !== "DELETE") return next(); + const match = req.path.match(pattern); + if (!match) return next(); + try { + const [row] = await createCurrentRepositoryContext() + .drizzle.select({ sharedSource: table.sharedSource }) + .from(table) + .where(eq(table.id, Number(match[1]))) + .limit(1); + if (row?.sharedSource) { + res.status(403).json({ + error: "This was shared with you and is managed on the server", + code: "SHARED_COPY", + }); + return; + } + } catch { + // Let the route answer for itself. + } + next(); + }; +} diff --git a/src/backend/sync/store.ts b/src/backend/sync/store.ts new file mode 100644 index 000000000..45c94f7aa --- /dev/null +++ b/src/backend/sync/store.ts @@ -0,0 +1,377 @@ +/** + * Reading and writing a registered entity's rows, whatever table they are in. + * + * Every registration names a drizzle table with an id, a syncId (unless it is + * a singleton) and an owner column, and that is all this relies on. Writes + * read the row back by its key rather than using RETURNING, which MySQL lacks. + */ + +import crypto from "crypto"; +import { and, eq, getTableColumns, type SQL } from "drizzle-orm"; +import type { SyncRow } from "@termix/plugin-sdk/backend"; +import { createCurrentRepositoryContext } from "../database/repositories/factory.js"; +import { DataCrypto } from "../utils/data-crypto.js"; +import { SystemCrypto } from "../utils/system-crypto.js"; +import { DatabaseSaveTrigger } from "../utils/database-save-trigger.js"; +import { + getEntity, + type RegisteredSyncEntity, +} from "../plugins/sync-registry.js"; +import { + decryptFields, + deserializeReferences, + encryptFields, + hashWire, + serializeReferences, + singletonSyncId, + toWire, + toWritable, + type ResolveId, + type ResolveSyncId, +} from "./wire.js"; + +/* eslint-disable @typescript-eslint/no-explicit-any */ +type AnyTable = any; + +function tableOf(entity: RegisteredSyncEntity): AnyTable { + if (!entity.table) { + throw new Error(`Sync entity "${entity.type}" has no table`); + } + return entity.table as AnyTable; +} + +function db() { + return createCurrentRepositoryContext().drizzle as any; +} + +function ownerColumn(entity: RegisteredSyncEntity) { + const column = tableOf(entity)[entity.userColumn]; + if (!column) { + throw new Error( + `Sync entity "${entity.type}" has no ${entity.userColumn} column`, + ); + } + return column; +} + +export function locate( + entity: RegisteredSyncEntity, + userId: string, + syncId: string, +): SQL { + const table = tableOf(entity); + if (entity.singleton) return eq(ownerColumn(entity), userId); + return and(eq(table.syncId, syncId), eq(ownerColumn(entity), userId))!; +} + +export function rowSyncId(entity: RegisteredSyncEntity, row: SyncRow): string { + return entity.singleton + ? singletonSyncId(entity.type) + : (row.syncId as string); +} + +let hashKey: Buffer | null = null; + +export async function getHashKey(): Promise { + if (!hashKey) { + const secret = await SystemCrypto.getInstance().getEncryptionKey(); + hashKey = Buffer.from( + crypto.hkdfSync( + "sha256", + secret, + Buffer.alloc(0), + "termix-sync-hash", + 32, + ), + ); + } + return hashKey; +} + +/** Test seam. */ +export function resetHashKey(): void { + hashKey = null; +} + +export async function listStoredRows( + entity: RegisteredSyncEntity, + userId: string, +): Promise { + const rows = (await db() + .select() + .from(tableOf(entity)) + .where(eq(ownerColumn(entity), userId))) as SyncRow[]; + return rows; +} + +export async function findStoredRow( + entity: RegisteredSyncEntity, + userId: string, + syncId: string, +): Promise { + const [row] = (await db() + .select() + .from(tableOf(entity)) + .where(locate(entity, userId, syncId)) + .limit(1)) as SyncRow[]; + return row ?? null; +} + +/** Gives an old row with no syncId one, so it can take part. */ +async function ensureSyncId( + entity: RegisteredSyncEntity, + row: SyncRow, +): Promise { + if (entity.singleton) return singletonSyncId(entity.type); + if (typeof row.syncId === "string" && row.syncId) return row.syncId; + const syncId = crypto.randomUUID(); + const table = tableOf(entity); + await db().update(table).set({ syncId }).where(eq(table.id, row.id)); + row.syncId = syncId; + return syncId; +} + +export interface Resolvers { + resolveSyncId: ResolveSyncId; + resolveId: ResolveId; +} + +/** + * Id and syncId lookups for references, scoped to rows the user owns. A core + * entity can widen that (a credential shared with the user is a valid + * reference target on the server). + */ +export function createResolvers( + userId: string, + onMissing?: (entityType: string, syncId: string) => void, +): Resolvers { + const allowed = async ( + entityType: string, + ownerId: unknown, + id: number, + ): Promise => { + if (ownerId === userId) return true; + const canReference = getEntity(entityType)?.canReference; + return canReference ? canReference(userId, String(ownerId), id) : false; + }; + + return { + resolveSyncId: async (entityType, id) => { + const target = getEntity(entityType); + if (!target?.table || target.singleton) return null; + const table = tableOf(target); + const [row] = await db() + .select({ syncId: table.syncId, owner: ownerColumn(target) }) + .from(table) + .where(eq(table.id, id)) + .limit(1); + if (!row || !(await allowed(entityType, row.owner, id))) return null; + return row.syncId ?? null; + }, + resolveId: async (entityType, syncId) => { + const target = getEntity(entityType); + if (!target?.table || target.singleton) { + onMissing?.(entityType, syncId); + return null; + } + const table = tableOf(target); + const rows = await db() + .select({ id: table.id, owner: ownerColumn(target) }) + .from(table) + .where(eq(table.syncId, syncId)); + const own = rows.find((row: any) => row.owner === userId); + if (own) return own.id; + for (const row of rows) { + if (await allowed(entityType, row.owner, row.id)) return row.id; + } + onMissing?.(entityType, syncId); + return null; + }, + }; +} + +export interface WireRow { + syncId: string; + wire: SyncRow; + hash: string; +} + +export interface LoadResult { + rows: Map; + /** Rows that exist but could not be serialized, so must not read as deleted. */ + failed: Set; +} + +/** The user's rows of one entity, on the wire and hashed. */ +export async function loadWireRows( + entity: RegisteredSyncEntity, + userId: string, + resolvers: Resolvers, + options: { filter?: (row: SyncRow) => boolean } = {}, +): Promise { + const key = await getHashKey(); + const rows = new Map(); + const failed = new Set(); + + if (entity.load) { + for (const wire of await entity.load(userId)) { + const syncId = String(wire.syncId); + rows.set(syncId, { syncId, wire, hash: hashWire(entity, wire, key) }); + } + return { rows, failed }; + } + + const dataKey = DataCrypto.getUserDataKey(userId); + for (const stored of await listStoredRows(entity, userId)) { + if (entity.shouldSync && entity.shouldSync(stored) === false) continue; + if (options.filter && !options.filter(stored)) continue; + const syncId = await ensureSyncId(entity, stored); + try { + const plain = decryptFields(entity, stored, dataKey); + const serialized = await serializeReferences( + entity, + plain, + resolvers.resolveSyncId, + ); + const wire = toWire(entity, serialized, syncId); + rows.set(syncId, { syncId, wire, hash: hashWire(entity, wire, key) }); + } catch { + failed.add(syncId); + } + } + return { rows, failed }; +} + +/** One row on the wire, or null when it is gone or excluded. */ +export async function loadWireRow( + entity: RegisteredSyncEntity, + userId: string, + syncId: string, + resolvers: Resolvers, +): Promise { + if (entity.load) { + const all = await loadWireRows(entity, userId, resolvers); + return all.rows.get(syncId) ?? null; + } + const stored = await findStoredRow(entity, userId, syncId); + if (!stored) return null; + if (entity.shouldSync && entity.shouldSync(stored) === false) return null; + const plain = decryptFields( + entity, + stored, + DataCrypto.getUserDataKey(userId), + ); + const serialized = await serializeReferences( + entity, + plain, + resolvers.resolveSyncId, + ); + const wire = toWire(entity, serialized, syncId); + return { syncId, wire, hash: hashWire(entity, wire, await getHashKey()) }; +} + +function columnsOf(entity: RegisteredSyncEntity): Set { + return new Set(Object.keys(getTableColumns(tableOf(entity)))); +} + +export interface WriteResult { + id: number | null; + created: boolean; +} + +/** + * Writes an inbound wire row for the user. `extra` is set on top of the + * inbound fields, for columns this side owns (a shared copy's source). + */ +export async function writeWireRow( + entity: RegisteredSyncEntity, + userId: string, + wire: SyncRow, + resolvers: Resolvers, + extra: SyncRow = {}, +): Promise { + const syncId = String(wire.syncId); + if (entity.write) { + await entity.write(userId, wire); + DatabaseSaveTrigger.triggerSave("sync_write"); + return { id: null, created: false }; + } + const table = tableOf(entity); + const current = await findStoredRow(entity, userId, syncId); + + const resolved = await deserializeReferences( + entity, + wire, + resolvers.resolveId, + current, + ); + const columns = columnsOf(entity); + const writable = Object.fromEntries( + Object.entries({ ...toWritable(entity, resolved), ...extra }).filter( + ([field]) => columns.has(field), + ), + ); + const encrypted = encryptFields( + entity, + writable, + DataCrypto.getUserDataKey(userId), + current ? String(current.id) : `sync-${syncId}`, + ); + if (columns.has("updatedAt")) encrypted.updatedAt = new Date().toISOString(); + + const where = locate(entity, userId, syncId); + if (current) { + await db().update(table).set(encrypted).where(where); + } else { + const insert: SyncRow = { ...encrypted, [entity.userColumn]: userId }; + if (!entity.singleton) insert.syncId = syncId; + await db().insert(table).values(insert); + } + + const written = await findStoredRow(entity, userId, syncId); + const id = typeof written?.id === "number" ? written.id : null; + if (entity.afterWrite) { + await entity.afterWrite({ id, userId, wire, created: !current }); + } + DatabaseSaveTrigger.triggerSave("sync_write"); + return { id, created: !current }; +} + +/** Deletes the user's row, through the entity's own remove when it has one. */ +export async function deleteStoredRow( + entity: RegisteredSyncEntity, + userId: string, + syncId: string, +): Promise { + if (entity.erase) { + await entity.erase(userId, syncId); + DatabaseSaveTrigger.triggerSave("sync_delete"); + return true; + } + const current = await findStoredRow(entity, userId, syncId); + if (!current) return false; + if (entity.remove) { + await entity.remove(current, userId); + } else { + await db() + .delete(tableOf(entity)) + .where(locate(entity, userId, syncId)); + } + DatabaseSaveTrigger.triggerSave("sync_delete"); + return true; +} + +/** Sets fresh syncIds on every row the user owns, so they read as new. */ +export async function regenerateSyncIds( + entity: RegisteredSyncEntity, + userId: string, +): Promise { + if (entity.singleton || entity.load || !entity.table) return; + const table = tableOf(entity); + for (const row of await listStoredRows(entity, userId)) { + await db() + .update(table) + .set({ syncId: crypto.randomUUID() }) + .where(eq(table.id, row.id)); + } +} diff --git a/src/backend/sync/wire.ts b/src/backend/sync/wire.ts new file mode 100644 index 000000000..8356bebb8 --- /dev/null +++ b/src/backend/sync/wire.ts @@ -0,0 +1,347 @@ +/** + * Turning a stored row into its wire form and back. + * + * On the wire a row has plaintext secrets, a syncId, and syncIds in place of + * every local numeric id it references. Local ids mean nothing on the other + * side of a link, and secrets are encrypted with a key only this side has. + */ + +import crypto from "crypto"; +import type { SyncEntityReference, SyncRow } from "@termix/plugin-sdk/backend"; +import { FieldCrypto } from "../utils/field-crypto.js"; +import type { RegisteredSyncEntity } from "../plugins/sync-registry.js"; + +export type ResolveSyncId = ( + entityType: string, + id: number, +) => Promise; +export type ResolveId = ( + entityType: string, + syncId: string, +) => Promise; + +/** Keys that describe where a row lives rather than what it says. */ +const LOCATION_KEYS = ["id", "syncId", "createdAt", "updatedAt"]; + +export function singletonSyncId(entityType: string): string { + return `${entityType}:singleton`; +} + +interface JsonPath { + column: string; + steps: Array<{ key: string; each: boolean }>; +} + +/** "jumpHosts[].hostId" -> column jumpHosts, then each item, then hostId. */ +export function parseReferencePath(field: string): JsonPath | null { + if (!field.includes(".") && !field.includes("[]")) return null; + const parts = field.split("."); + const head = parts.shift()!; + const headEach = head.endsWith("[]"); + const column = headEach ? head.slice(0, -2) : head; + const steps: JsonPath["steps"] = []; + if (headEach) steps.push({ key: "", each: true }); + for (const part of parts) { + const each = part.endsWith("[]"); + steps.push({ key: each ? part.slice(0, -2) : part, each }); + } + return { column, steps }; +} + +async function rewritePath( + value: unknown, + steps: JsonPath["steps"], + map: (leaf: unknown) => Promise, +): Promise { + if (steps.length === 0) return map(value); + const [step, ...rest] = steps; + + if (step.key === "" && step.each) { + if (!Array.isArray(value)) return value; + return Promise.all(value.map((item) => rewritePath(item, rest, map))); + } + + if (!value || typeof value !== "object" || Array.isArray(value)) { + return value; + } + const object = value as Record; + if (!(step.key in object)) return value; + + const child = object[step.key]; + if (step.each) { + if (!Array.isArray(child)) return value; + return { + ...object, + [step.key]: await Promise.all( + child.map((item) => rewritePath(item, rest, map)), + ), + }; + } + return { ...object, [step.key]: await rewritePath(child, rest, map) }; +} + +/** Rewrites every leaf a JSON path points at, keeping the column's storage form. */ +async function rewriteJsonColumn( + row: SyncRow, + path: JsonPath, + map: (leaf: unknown) => Promise, +): Promise { + const stored = row[path.column]; + if (stored == null || stored === "") return; + + let parsed: unknown = stored; + const asString = typeof stored === "string"; + if (asString) { + try { + parsed = JSON.parse(stored as string); + } catch { + return; + } + } + + const rewritten = await rewritePath(parsed, path.steps, map); + row[path.column] = asString ? JSON.stringify(rewritten) : rewritten; +} + +function wireField(reference: SyncEntityReference): string { + return reference.syncField ?? `${reference.field}SyncId`; +} + +/** Local ids to syncIds. */ +export async function serializeReferences( + entity: RegisteredSyncEntity, + row: SyncRow, + resolveSyncId: ResolveSyncId, +): Promise { + const out = { ...row }; + for (const reference of entity.references ?? []) { + const path = parseReferencePath(reference.field); + if (path) { + await rewriteJsonColumn(out, path, async (leaf) => { + const id = typeof leaf === "string" ? Number(leaf) : leaf; + if (typeof id !== "number" || !Number.isInteger(id)) return leaf; + return (await resolveSyncId(reference.entityType, id)) ?? null; + }); + continue; + } + const id = out[reference.field]; + out[wireField(reference)] = + typeof id === "number" + ? await resolveSyncId(reference.entityType, id) + : null; + delete out[reference.field]; + } + return entity.serialize ? entity.serialize(out, resolveSyncId) : out; +} + +/** + * SyncIds back to local ids. + * + * A syncId with no local row keeps whatever the column already holds here + * (`current`), rather than failing the whole row. That happens when the + * target's category is not synced on this device, or it has not arrived yet. + */ +export async function deserializeReferences( + entity: RegisteredSyncEntity, + row: SyncRow, + resolveId: ResolveId, + current: SyncRow | null, +): Promise { + const out = { ...row }; + for (const reference of entity.references ?? []) { + const path = parseReferencePath(reference.field); + if (path) { + await rewriteJsonColumn(out, path, async (leaf) => { + if (typeof leaf !== "string" || !leaf) return leaf; + const id = await resolveId(reference.entityType, leaf); + if (id === null) return null; + return reference.idType === "string" ? String(id) : id; + }); + continue; + } + const field = wireField(reference); + const syncId = out[field]; + delete out[field]; + delete out[reference.field]; + if (syncId == null || syncId === "") { + out[reference.field] = null; + continue; + } + if (typeof syncId !== "string") continue; + const id = await resolveId(reference.entityType, syncId); + if (id !== null) { + out[reference.field] = id; + } else if (current && reference.field in current) { + out[reference.field] = current[reference.field]; + } else { + out[reference.field] = null; + } + } + return entity.deserialize ? entity.deserialize(out, resolveId) : out; +} + +/** + * Decrypts the entity's secret fields. Throws when one cannot be decrypted: + * sending an empty value instead would wipe the secret on the other side. + */ +export function decryptFields( + entity: RegisteredSyncEntity, + row: SyncRow, + key: Buffer | null, +): SyncRow { + if (entity.encryptedFields.length === 0) return row; + const out = { ...row }; + for (const field of entity.encryptedFields) { + const value = out[field]; + if (typeof value !== "string" || !FieldCrypto.isEncrypted(value)) continue; + if (!key) throw new Error("No data key to decrypt synced fields"); + out[field] = FieldCrypto.decryptField(value, key, String(row.id), field); + } + return out; +} + +export function encryptFields( + entity: RegisteredSyncEntity, + row: SyncRow, + key: Buffer | null, + recordId: string, +): SyncRow { + if (entity.encryptedFields.length === 0) return row; + const out = { ...row }; + for (const field of entity.encryptedFields) { + const value = out[field]; + if (typeof value !== "string" || !value) continue; + if (FieldCrypto.isEncrypted(value)) continue; + if (!key) throw new Error("No data key to encrypt synced fields"); + out[field] = FieldCrypto.encryptField(value, key, recordId, field); + } + return out; +} + +/** What goes on the wire: no local ids, no owner, no fields that stay here. */ +export function toWire( + entity: RegisteredSyncEntity, + row: SyncRow, + syncId: string, +): SyncRow { + const out = { ...row }; + delete out.id; + delete out[entity.userColumn]; + if (entity.userColumn !== "userId") delete out.userId; + for (const field of entity.readOnlyFields) delete out[field]; + out.syncId = syncId; + return out; +} + +/** What may be written from an inbound row. */ +export function toWritable( + entity: RegisteredSyncEntity, + wire: SyncRow, +): SyncRow { + const out = { ...wire }; + delete out.id; + delete out.syncId; + delete out.userId; + delete out[entity.userColumn]; + delete out.createdAt; + delete out.updatedAt; + for (const field of entity.readOnlyFields) delete out[field]; + for (const field of entity.hashIgnore ?? []) delete out[field]; + return out; +} + +function stable(value: unknown): unknown { + if (Array.isArray(value)) return value.map(stable); + if (value && typeof value === "object") { + const object = value as Record; + return Object.keys(object) + .sort() + .reduce>((acc, key) => { + if (object[key] !== undefined) acc[key] = stable(object[key]); + return acc; + }, {}); + } + return value; +} + +/** + * Booleans are stored as 0/1 on SQLite and true/false elsewhere, and JSON + * columns come back as text or parsed depending on the driver. Leveled here + * so the same content always hashes the same. + */ +function normalize(value: unknown): unknown { + if (value === true) return 1; + if (value === false) return 0; + if (value === undefined) return null; + if (typeof value === "string" && /^[[{]/.test(value)) { + try { + return stable(JSON.parse(value)); + } catch { + return value; + } + } + return stable(value); +} + +/** + * Content hash of a wire row, keyed so a stored hash cannot be used to guess + * a weak secret it covers. + */ +export function hashWire( + entity: RegisteredSyncEntity, + wire: SyncRow, + key: Buffer, +): string { + const skip = new Set([ + ...LOCATION_KEYS, + ...entity.readOnlyFields, + ...(entity.hashIgnore ?? []), + ]); + const content: Record = {}; + for (const [field, value] of Object.entries(wire)) { + if (skip.has(field)) continue; + const normalized = normalize(value); + if (normalized === null || normalized === "") continue; + content[field] = normalized; + } + return crypto + .createHmac("sha256", key) + .update(JSON.stringify(stable(content))) + .digest("hex"); +} + +/** Parents before children when an entity references itself. */ +export function orderSelfReferences( + entity: RegisteredSyncEntity, + rows: SyncRow[], +): SyncRow[] { + const self = (entity.references ?? []).find( + (reference) => + reference.entityType === entity.type && + !parseReferencePath(reference.field), + ); + if (!self) return rows; + const field = wireField(self); + const bySyncId = new Map( + rows + .filter((row) => typeof row.syncId === "string") + .map((row) => [row.syncId as string, row]), + ); + const ordered: SyncRow[] = []; + const done = new Set(); + const visiting = new Set(); + const visit = (row: SyncRow) => { + if (done.has(row) || visiting.has(row)) return; + visiting.add(row); + const parent = row[field]; + if (typeof parent === "string") { + const parentRow = bySyncId.get(parent); + if (parentRow) visit(parentRow); + } + visiting.delete(row); + done.add(row); + ordered.push(row); + }; + rows.forEach(visit); + return ordered; +} diff --git a/src/backend/tests/database/repositories/sync-tombstone-repository.test.ts b/src/backend/tests/database/repositories/sync-tombstone-repository.test.ts deleted file mode 100644 index d03deb228..000000000 --- a/src/backend/tests/database/repositories/sync-tombstone-repository.test.ts +++ /dev/null @@ -1,109 +0,0 @@ -import { afterEach, describe, expect, it } from "vitest"; -import { TestSqliteDatabase } from "./test-support.js"; -import { SyncTombstoneRepository } from "../../../database/repositories/sync-tombstone-repository.js"; - -describe("SyncTombstoneRepository", () => { - let adapter: TestSqliteDatabase | null = null; - - afterEach(async () => { - if (adapter) { - await adapter.close(); - adapter = null; - } - }); - - async function createRepository( - onWrite?: () => void | Promise, - ): Promise { - adapter = new TestSqliteDatabase(); - const context = await adapter.connect(); - await adapter.exec(` - INSERT INTO users (id, username, password_hash) - VALUES ('user-1', 'alice', 'hash'), ('user-2', 'bob', 'hash'); - `); - - return new SyncTombstoneRepository(context, onWrite); - } - - it("records a tombstone and lists it back for the owning user", async () => { - let writeCount = 0; - const repo = await createRepository(() => { - writeCount += 1; - }); - - await repo.record("user-1", "hosts", "sync-abc"); - expect(writeCount).toBe(1); - - const rows = await repo.listSince("user-1", "hosts", null); - expect(rows).toHaveLength(1); - expect(rows[0]).toMatchObject({ - userId: "user-1", - entityType: "hosts", - syncId: "sync-abc", - }); - }); - - it("does not record a tombstone for an empty syncId", async () => { - const repo = await createRepository(); - await repo.record("user-1", "hosts", ""); - const rows = await repo.listSince("user-1", "hosts", null); - expect(rows).toHaveLength(0); - }); - - it("recordMany writes multiple tombstones and filters out falsy ids", async () => { - let writeCount = 0; - const repo = await createRepository(() => { - writeCount += 1; - }); - - await repo.recordMany("user-1", "hosts", ["a", "", "b", "c"]); - expect(writeCount).toBe(1); - - const rows = await repo.listSince("user-1", "hosts", null); - expect(rows.map((r) => r.syncId).sort()).toEqual(["a", "b", "c"]); - }); - - it("recordMany is a no-op when given no syncIds", async () => { - let writeCount = 0; - const repo = await createRepository(() => { - writeCount += 1; - }); - - await repo.recordMany("user-1", "hosts", []); - expect(writeCount).toBe(0); - }); - - it("scopes listSince by userId and entityType", async () => { - const repo = await createRepository(); - await repo.record("user-1", "hosts", "sync-1"); - await repo.record("user-1", "snippets", "sync-2"); - await repo.record("user-2", "hosts", "sync-3"); - - const rows = await repo.listSince("user-1", "hosts", null); - expect(rows).toHaveLength(1); - expect(rows[0].syncId).toBe("sync-1"); - }); - - it("filters listSince by the since timestamp", async () => { - const adapterLocal = new TestSqliteDatabase(); - adapter = adapterLocal; - const context = await adapterLocal.connect(); - await adapter.exec(` - INSERT INTO users (id, username, password_hash) - VALUES ('user-1', 'alice', 'hash'); - INSERT INTO sync_tombstones (user_id, entity_type, sync_id, deleted_at) - VALUES - ('user-1', 'hosts', 'old', '2026-01-01T00:00:00.000Z'), - ('user-1', 'hosts', 'new', '2026-06-01T00:00:00.000Z'); - `); - const repo = new SyncTombstoneRepository(context); - - const rows = await repo.listSince( - "user-1", - "hosts", - "2026-03-01T00:00:00.000Z", - ); - expect(rows).toHaveLength(1); - expect(rows[0].syncId).toBe("new"); - }); -}); diff --git a/src/backend/tests/database/repositories/sync-tombstone-since.test.ts b/src/backend/tests/database/repositories/sync-tombstone-since.test.ts deleted file mode 100644 index 89caed123..000000000 --- a/src/backend/tests/database/repositories/sync-tombstone-since.test.ts +++ /dev/null @@ -1,111 +0,0 @@ -import { afterEach, describe, expect, it } from "vitest"; -import { TestSqliteDatabase } from "./test-support.js"; -import { SyncTombstoneRepository } from "../../../database/repositories/sync-tombstone-repository.js"; -import { - normalizeSyncTimestamp, - timestampAtOrAfter, -} from "../../../database/sync-timestamp.js"; -import { sshCredentials } from "../../../database/db/schema.js"; -import type { DatabaseContext } from "../../../database/repositories/database-context.js"; -import { and, eq } from "drizzle-orm"; - -// The desktop sync engine always sends its cursor as new Date().toISOString(). -const ISO_CURSOR = "2026-07-29T09:00:00.000Z"; - -describe("sync cursors across timestamp layouts", () => { - let adapter: TestSqliteDatabase | null = null; - - afterEach(async () => { - if (adapter) { - await adapter.close(); - adapter = null; - } - }); - - /** - * The harness migrates the schema itself, so the seeds below are INSERTs into - * the real tables. Both `sync_tombstones.user_id` and `ssh_credentials.user_id` - * are foreign keys into `users`, which the harness enforces, so the owning row - * has to exist before either seed runs. - */ - async function connect(): Promise<{ - db: TestSqliteDatabase; - context: DatabaseContext; - }> { - const db = new TestSqliteDatabase(); - adapter = db; - const context = await db.connect(); - await db.exec(` - INSERT INTO users (id, username, password_hash) VALUES - ('user-1', 'user', 'hash'); - `); - return { db, context }; - } - - it("normalizes both layouts to one comparable form", () => { - expect(normalizeSyncTimestamp("2026-07-29T10:11:21.123Z")).toBe( - "2026-07-29 10:11:21", - ); - expect(normalizeSyncTimestamp("2026-07-29 10:11:21")).toBe( - "2026-07-29 10:11:21", - ); - }); - - it("returns tombstones recorded after an ISO cursor, whatever layout they were stored in", async () => { - const { db, context } = await connect(); - await db.exec(` - INSERT INTO sync_tombstones (user_id, entity_type, sync_id, deleted_at) VALUES - ('user-1', 'sshCredentials', 'sqlite-layout', '2026-07-29 10:07:32'), - ('user-1', 'sshCredentials', 'iso-layout', '2026-07-29T10:07:32.500Z'), - ('user-1', 'sshCredentials', 'too-old', '2026-07-29 08:00:00'); - `); - - const repo = new SyncTombstoneRepository(context); - const rows = await repo.listSince("user-1", "sshCredentials", ISO_CURSOR); - - expect(rows.map((row) => row.syncId).sort()).toEqual([ - "iso-layout", - "sqlite-layout", - ]); - }); - - it("returns rows written by CURRENT_TIMESTAMP against an ISO cursor", async () => { - const { db, context } = await connect(); - await db.exec(` - INSERT INTO ssh_credentials (user_id, name, auth_type, updated_at) VALUES - ('user-1', 'newer-sqlite-layout', 'password', '2026-07-29 10:11:21'), - ('user-1', 'newer-iso-layout', 'password', '2026-07-29T10:11:21.123Z'), - ('user-1', 'older', 'password', '2026-07-29 08:59:59'); - `); - - const rows = await context.drizzle - .select({ name: sshCredentials.name }) - .from(sshCredentials) - .where( - and( - eq(sshCredentials.userId, "user-1"), - timestampAtOrAfter(sshCredentials.updatedAt, ISO_CURSOR), - ), - ); - - expect(rows.map((row) => row.name).sort()).toEqual([ - "newer-iso-layout", - "newer-sqlite-layout", - ]); - }); - - it("keeps rows written in the same second as the cursor", async () => { - const { db, context } = await connect(); - await db.exec(` - INSERT INTO ssh_credentials (user_id, name, auth_type, updated_at) - VALUES ('user-1', 'same-second', 'password', '2026-07-29 09:00:00'); - `); - - const rows = await context.drizzle - .select({ name: sshCredentials.name }) - .from(sshCredentials) - .where(timestampAtOrAfter(sshCredentials.updatedAt, ISO_CURSOR)); - - expect(rows.map((row) => row.name)).toEqual(["same-second"]); - }); -}); diff --git a/src/backend/tests/database/routes/desktop-auto-session.test.ts b/src/backend/tests/database/routes/desktop-auto-session.test.ts index 3f0685fea..eeaccb1f9 100644 --- a/src/backend/tests/database/routes/desktop-auto-session.test.ts +++ b/src/backend/tests/database/routes/desktop-auto-session.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest"; import type { Request } from "express"; import type { UserRecord } from "../../../database/repositories/user-repository.js"; import { + allowsDesktopAutoSession, isLoopbackRequest, extractBearerOrCookieToken, isNativeTokenExportRequest, @@ -184,3 +185,20 @@ describe("resolveDesktopAutoSessionUser", () => { expect(result).toBe(earliest); }); }); + +describe("allowsDesktopAutoSession", () => { + it("allows the embedded desktop backend and a development backend", () => { + expect( + allowsDesktopAutoSession({ + ELECTRON_EMBEDDED: "true", + NODE_ENV: "production", + }), + ).toBe(true); + expect(allowsDesktopAutoSession({ NODE_ENV: "development" })).toBe(true); + expect(allowsDesktopAutoSession({})).toBe(true); + }); + + it("never allows it on a production server", () => { + expect(allowsDesktopAutoSession({ NODE_ENV: "production" })).toBe(false); + }); +}); diff --git a/src/backend/tests/database/routes/sync-locate-row.test.ts b/src/backend/tests/database/routes/sync-locate-row.test.ts deleted file mode 100644 index 1d4972a39..000000000 --- a/src/backend/tests/database/routes/sync-locate-row.test.ts +++ /dev/null @@ -1,83 +0,0 @@ -import Database from "better-sqlite3"; -import { drizzle } from "drizzle-orm/better-sqlite3"; -import { and, eq } from "drizzle-orm"; -import { SQLiteSyncDialect } from "drizzle-orm/sqlite-core"; -import { describe, expect, it } from "vitest"; -import { locateSyncRow } from "../../../database/routes/sync.js"; -import { hosts, userPreferences } from "../../../database/db/schema.js"; - -/** - * A sync push locates the stored row twice: once to see whether it exists, - * once to write it. Those lookups used to be spelled out separately, and only - * the read knew about singleton entities — the write always keyed on - * `table.id`. - * - * `user_preferences` is the only singleton, and the one synced table whose - * primary key is `user_id` with no `id` column at all. `table.id` was - * therefore `undefined` and drizzle emitted a comparison with nothing on its - * left: `( = ? and "user_preferences"."user_id" = ?)`. The insert branch was - * fine, so the first push of preferences succeeded and every push after it — - * the steady state — failed with `SqliteError: near "=": syntax error`. - */ -describe("locateSyncRow", () => { - const dialect = new SQLiteSyncDialect(); - - const toSql = (condition: Parameters[0]): string => - dialect.sqlToQuery(condition).sql; - - /** `= ?` with no operand to its left — what used to reach SQLite. */ - const EMPTY_LEFT_OPERAND = /(^|\(|\band\b|\bor\b)\s*=\s*\?/; - - it("keys a singleton entity on its owner", () => { - const sql = toSql(locateSyncRow("userPreferences", "user-1", "ignored")); - - expect(sql).toContain('"user_id"'); - expect(sql).not.toContain('"sync_id"'); - expect(sql).not.toMatch(EMPTY_LEFT_OPERAND); - }); - - it("keys a regular entity on its sync id and owner", () => { - const sql = toSql(locateSyncRow("hosts", "user-1", "sync-abc")); - - expect(sql).toContain('"sync_id"'); - expect(sql).toContain('"user_id"'); - expect(sql).not.toMatch(EMPTY_LEFT_OPERAND); - }); - - it("is the shape the id-keyed lookup could not produce", () => { - // Guards the assertions above: the pattern really does catch the old SQL. - const previous = and( - eq((userPreferences as unknown as typeof hosts).id, 1), - eq(userPreferences.userId, "user-1"), - )!; - - expect(toSql(previous)).toMatch(EMPTY_LEFT_OPERAND); - }); - - it("updates a preferences row that already exists", () => { - const sqlite = new Database(":memory:"); - sqlite.exec(` - CREATE TABLE user_preferences ( - user_id TEXT PRIMARY KEY, - theme TEXT - ); - `); - const db = drizzle(sqlite, { schema: { userPreferences } }); - - // The steady state: a row exists, so the push takes the update path. - sqlite - .prepare("INSERT INTO user_preferences (user_id, theme) VALUES (?, ?)") - .run("user-1", "dark"); - - const updated = db - .update(userPreferences) - .set({ theme: "light" }) - .where(locateSyncRow("userPreferences", "user-1", "ignored")) - .returning({ theme: userPreferences.theme }) - .all(); - - expect(updated).toEqual([{ theme: "light" }]); - - sqlite.close(); - }); -}); diff --git a/src/backend/tests/database/routes/sync.test.ts b/src/backend/tests/database/routes/sync.test.ts deleted file mode 100644 index 15ebbca9b..000000000 --- a/src/backend/tests/database/routes/sync.test.ts +++ /dev/null @@ -1,116 +0,0 @@ -import { afterEach, describe, expect, it } from "vitest"; -import syncRouter, { - isSyncedRow, - isValidEntityType, - stripWritePayload, -} from "../../../database/routes/sync.js"; -import { registerEntity } from "../../../plugins/sync-registry.js"; - -describe("sync route order", () => { - it("registers POST /tombstones before the POST /:entityType wildcard", () => { - const postPaths = ( - syncRouter as unknown as { - stack: Array<{ route?: { path: string; methods: { post?: boolean } } }>; - } - ).stack - .filter((layer) => layer.route?.methods?.post) - .map((layer) => layer.route!.path); - - // "/tombstones" is a valid value for :entityType as far as Express is - // concerned, so registering the wildcard first makes the tombstone - // endpoint unreachable -- every deletion push answers 400 "Unknown entity - // type" instead of applying the deletion. - expect(postPaths).toContain("/tombstones"); - expect(postPaths.indexOf("/tombstones")).toBeLessThan( - postPaths.indexOf("/:entityType"), - ); - }); -}); - -describe("isValidEntityType", () => { - it("accepts every whitelisted sync entity type", () => { - for (const type of [ - "hosts", - "sshCredentials", - "sshFolders", - "userPreferences", - ]) { - expect(isValidEntityType(type)).toBe(true); - } - }); - - it("rejects unknown or non-string entity types", () => { - expect(isValidEntityType("hostAccess")).toBe(false); - expect(isValidEntityType("")).toBe(false); - expect(isValidEntityType(undefined)).toBe(false); - expect(isValidEntityType(42)).toBe(false); - }); -}); - -describe("stripWritePayload", () => { - it("strips id, userId, and syncId from every entity type", () => { - const payload = { - id: 1, - userId: "user-1", - syncId: "abc", - name: "prod-db", - }; - expect(stripWritePayload("sshFolders", payload)).toEqual({ - name: "prod-db", - }); - }); - - it("also strips desktop-only fields flagged read-only for hosts", () => { - const payload = { - id: 1, - userId: "user-1", - syncId: "abc", - name: "web", - connectionOrigin: "remote", - }; - expect(stripWritePayload("hosts", payload)).toEqual({ name: "web" }); - }); - - it("keeps preference storage mode local to each device", () => { - expect( - stripWritePayload("userPreferences", { - syncId: "userPreferences:singleton", - theme: "dark", - storageMode: "cloud", - }), - ).toEqual({ theme: "dark" }); - }); - - it("does not mutate the original payload object", () => { - const payload = { id: 1, userId: "user-1", syncId: "abc", name: "x" }; - stripWritePayload("sshFolders", payload); - expect(payload).toEqual({ - id: 1, - userId: "user-1", - syncId: "abc", - name: "x", - }); - }); -}); - -describe("isSyncedRow", () => { - let dispose: (() => void) | null = null; - afterEach(() => { - dispose?.(); - dispose = null; - }); - - it("syncs every row of an entity that does not filter", () => { - expect(isSyncedRow("hosts", { id: 1 })).toBe(true); - }); - - it("leaves out the rows an entity's shouldSync refuses", () => { - dispose = registerEntity("demo", { - type: "demoLayouts", - table: {}, - shouldSync: (row) => row.kind !== "local", - }); - expect(isSyncedRow("demoLayouts", { kind: "shared" })).toBe(true); - expect(isSyncedRow("demoLayouts", { kind: "local" })).toBe(false); - }); -}); diff --git a/src/backend/tests/database/sync-references.test.ts b/src/backend/tests/database/sync-references.test.ts deleted file mode 100644 index 4a8f268da..000000000 --- a/src/backend/tests/database/sync-references.test.ts +++ /dev/null @@ -1,95 +0,0 @@ -import { beforeAll, describe, expect, it } from "vitest"; -import { - deserializeSyncReferences, - orderSyncRows, - serializeSyncReferences, -} from "../../database/routes/sync-references.js"; -import { registerCoreSyncEntities } from "../../database/routes/sync-entities.js"; - -describe("sync references", () => { - // References come from the registry now, not a hardcoded map, so the core - // entities have to be registered before any of this resolves. sync.ts does - // this at import in production. - beforeAll(() => { - registerCoreSyncEntities(); - }); - - it("serializes database-local host IDs as stable sync IDs", async () => { - const row = await serializeSyncReferences( - "hosts", - { - id: 7, - credentialId: 12, - rdpCredentialId: 13, - vncCredentialId: null, - telnetCredentialId: null, - parentHostId: 3, - }, - async (entityType, id) => `${entityType}-${id}`, - ); - - expect(row).toMatchObject({ - credentialSyncId: "sshCredentials-12", - rdpCredentialSyncId: "sshCredentials-13", - vncCredentialSyncId: null, - telnetCredentialSyncId: null, - parentHostSyncId: "hosts-3", - }); - expect(row).not.toHaveProperty("credentialId"); - }); - - it("resolves stable sync IDs to IDs from the receiving database", async () => { - const ids = new Map([ - ["sshCredentials:credential-sync", 91], - ["hosts:parent-sync", 52], - ]); - const row = await deserializeSyncReferences( - "hosts", - { - credentialId: 12, - credentialSyncId: "credential-sync", - rdpCredentialSyncId: null, - vncCredentialSyncId: null, - telnetCredentialSyncId: null, - parentHostSyncId: "parent-sync", - }, - async (entityType, syncId) => ids.get(`${entityType}:${syncId}`) ?? null, - ); - - expect(row).toMatchObject({ - credentialId: 91, - rdpCredentialId: null, - vncCredentialId: null, - telnetCredentialId: null, - parentHostId: 52, - }); - expect(row).not.toHaveProperty("credentialSyncId"); - }); - - it("orders parent hosts before children", () => { - const parent = { syncId: "parent", parentHostSyncId: null }; - const child = { syncId: "child", parentHostSyncId: "parent" }; - const grandchild = { syncId: "grandchild", parentHostSyncId: "child" }; - - expect(orderSyncRows("hosts", [grandchild, child, parent])).toEqual([ - parent, - child, - grandchild, - ]); - }); - - it("leaves other entity types in their existing order", () => { - const rows = [{ syncId: "second" }, { syncId: "first" }]; - expect(orderSyncRows("sshFolders", rows)).toBe(rows); - }); - - it("rejects a row whose referenced dependency has not synced", async () => { - await expect( - deserializeSyncReferences( - "sshFolders", - { credentialSyncId: "missing" }, - async () => null, - ), - ).rejects.toThrow("Missing sshCredentials dependency"); - }); -}); diff --git a/src/backend/tests/database/version-route.test.ts b/src/backend/tests/database/version-route.test.ts index 7d30ad48e..a81bd9f7e 100644 --- a/src/backend/tests/database/version-route.test.ts +++ b/src/backend/tests/database/version-route.test.ts @@ -27,7 +27,7 @@ async function invoke(fetchGitHubAPI: ReturnType) { compilerOptions: { target: ts.ScriptTarget.ES2022 }, }).outputText, { - process: { env: { VERSION: "2.7.1" } }, + getLocalVersion: () => "2.7.1", databaseLogger: { warn: vi.fn(), error: vi.fn() }, fetchGitHubAPI, REPO_OWNER: "Termix-SSH", diff --git a/src/backend/tests/electron/linked-server.test.ts b/src/backend/tests/electron/linked-server.test.ts new file mode 100644 index 000000000..954188205 --- /dev/null +++ b/src/backend/tests/electron/linked-server.test.ts @@ -0,0 +1,85 @@ +import { createRequire } from "node:module"; +import { beforeEach, describe, expect, it } from "vitest"; + +const require = createRequire(import.meta.url); +const linked = require("../../../../electron/linked-server.cjs") as { + setLinkedServer: (config: unknown) => void; + getLinkedServer: () => Record | null; + isLinkedOrigin: (url: string) => boolean; + applyLinkHeaders: ( + url: string, + headers: Record, + ) => Record; + linkedRequestHeaders: ( + extra?: Record, + ) => Record; + answerLogin: ( + authInfo: { isProxy?: boolean } | null, + url: string, + ) => { username: string; password: string } | null; +}; + +const ORIGIN = "https://termix.example"; + +beforeEach(() => { + linked.setLinkedServer(null); +}); + +describe("linked server in Electron main", () => { + it("adds proxy headers only for the linked origin", () => { + linked.setLinkedServer({ + origin: ORIGIN, + headers: [{ name: "CF-Access-Client-Id", value: "id" }], + }); + expect( + linked.applyLinkHeaders(`${ORIGIN}/sync/v2/info`, { Accept: "x" }), + ).toEqual({ Accept: "x", "CF-Access-Client-Id": "id" }); + expect( + linked.applyLinkHeaders("https://elsewhere.example/", { Accept: "x" }), + ).toEqual({ Accept: "x" }); + }); + + it("moves a Termix bearer into the jwt cookie when basic auth is in front", () => { + linked.setLinkedServer({ + origin: ORIGIN, + basicAuth: { username: "gate", password: "pw" }, + }); + const headers = linked.applyLinkHeaders(`${ORIGIN}/host/status`, { + authorization: "Bearer tok", + Cookie: "a=b", + }); + expect(headers.Authorization).toBe( + `Basic ${Buffer.from("gate:pw").toString("base64")}`, + ); + expect(headers.authorization).toBeUndefined(); + expect(headers.Cookie).toBe("a=b; jwt=tok"); + }); + + it("builds main's own request headers with the session", () => { + linked.setLinkedServer({ origin: ORIGIN, token: "tok" }); + expect(linked.linkedRequestHeaders()).toEqual({ + "X-Electron-App": "true", + Authorization: "Bearer tok", + }); + }); + + it("answers basic auth challenges from the linked origin only", () => { + linked.setLinkedServer({ + origin: ORIGIN, + basicAuth: { username: "gate", password: "pw" }, + }); + expect(linked.answerLogin({}, `${ORIGIN}/`)).toEqual({ + username: "gate", + password: "pw", + }); + expect(linked.answerLogin({ isProxy: true }, `${ORIGIN}/`)).toBeNull(); + expect(linked.answerLogin({}, "https://other.example/")).toBeNull(); + }); + + it("forgets everything on unlink", () => { + linked.setLinkedServer({ origin: ORIGIN, token: "tok" }); + linked.setLinkedServer(null); + expect(linked.getLinkedServer()).toBeNull(); + expect(linked.isLinkedOrigin(`${ORIGIN}/`)).toBe(false); + }); +}); diff --git a/src/backend/tests/electron/local-files.test.ts b/src/backend/tests/electron/local-files.test.ts index 0a19fe75e..0887b7c93 100644 --- a/src/backend/tests/electron/local-files.test.ts +++ b/src/backend/tests/electron/local-files.test.ts @@ -44,8 +44,10 @@ const localFiles = require("../../../../electron/local-files.cjs") as { TRANSFER_ROUTES: Record; createTargetResolver: (deps: { localBaseUrl?: string; - getRemoteSyncConfig?: () => { serverUrl?: string } | null; - getRemoteSyncJwt?: () => string | null; + getLinkedServer?: () => { + serverUrl?: string; + token?: string | null; + } | null; }) => (req: { origin?: unknown; route?: unknown; deviceId?: unknown }) => { url: string; headers: Record; @@ -53,8 +55,10 @@ const localFiles = require("../../../../electron/local-files.cjs") as { createLocalFileHandlers: (deps: { net: unknown; shell: unknown; - getRemoteSyncConfig?: () => { serverUrl?: string } | null; - getRemoteSyncJwt?: () => string | null; + getLinkedServer?: () => { + serverUrl?: string; + token?: string | null; + } | null; localBaseUrl?: string; publishFs?: PublishFs; }) => Record< @@ -161,8 +165,10 @@ function startBackend( describe("local-files transfer target resolution", () => { const resolve = localFiles.createTargetResolver({ localBaseUrl: "http://127.0.0.1:30001/plugin-api/file-manager", - getRemoteSyncConfig: () => ({ serverUrl: "https://termix.example.com/" }), - getRemoteSyncJwt: () => "remote-jwt", + getLinkedServer: () => ({ + serverUrl: "https://termix.example.com/", + token: "remote-jwt", + }), }); it("only reaches the two file-manager streaming routes on the local backend", () => { @@ -175,7 +181,7 @@ describe("local-files transfer target resolution", () => { ); }); - it("derives the remote target from the configured sync server and attaches its JWT itself", () => { + it("derives the remote target from the linked server and attaches its session itself", () => { const target = resolve({ origin: "remote", route: "downloadFileStream" }); expect(target.url).toBe( "https://termix.example.com/plugin-api/file-manager/downloadFileStream", @@ -196,15 +202,15 @@ describe("local-files transfer target resolution", () => { expect(() => resolve({})).toThrow(/Unknown transfer route/); }); - it("refuses a remote origin when no sync server is configured or it is not http(s)", () => { + it("refuses a remote origin when the device is not linked or the server is not http(s)", () => { const unconfigured = localFiles.createTargetResolver({ - getRemoteSyncConfig: () => null, + getLinkedServer: () => null, }); expect(() => unconfigured({ origin: "remote", route: "uploadFileStream" }), - ).toThrow(/not configured/); + ).toThrow(/not linked/); const bogus = localFiles.createTargetResolver({ - getRemoteSyncConfig: () => ({ serverUrl: "file:///etc/passwd" }), + getLinkedServer: () => ({ serverUrl: "file:///etc/passwd" }), }); expect(() => bogus({ origin: "remote", route: "uploadFileStream" }), @@ -290,8 +296,7 @@ describe("local-files download boundary", () => { net: fakeNet, shell: {}, localBaseUrl: backend.url, - getRemoteSyncConfig: () => null, - getRemoteSyncJwt: () => null, + getLinkedServer: () => null, }); }); afterAll(() => backend.close()); diff --git a/src/backend/tests/electron/remote-sync-fetch.test.ts b/src/backend/tests/electron/remote-sync-fetch.test.ts deleted file mode 100644 index 2b4677abc..000000000 --- a/src/backend/tests/electron/remote-sync-fetch.test.ts +++ /dev/null @@ -1,101 +0,0 @@ -import { execFileSync } from "node:child_process"; -import { createRequire } from "node:module"; -import { mkdtempSync, readFileSync, rmSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import https from "node:https"; -import type { AddressInfo } from "node:net"; -import { afterAll, beforeAll, expect, it } from "vitest"; - -const require = createRequire(import.meta.url); -const { - createRemoteSyncFetch, -} = require("../../../../electron/remote-sync-fetch.cjs"); -let server: https.Server; -let origin: string; -let directory: string; -let redirectedRequests = 0; -const config = { serverUrl: "", allowInvalidCertificate: false }; -const request = createRemoteSyncFetch(() => config); - -beforeAll(async () => { - directory = mkdtempSync(join(tmpdir(), "termix-sync-tls-")); - const key = join(directory, "key.pem"); - const cert = join(directory, "cert.pem"); - execFileSync( - "openssl", - [ - "req", - "-x509", - "-newkey", - "rsa:2048", - "-nodes", - "-keyout", - key, - "-out", - cert, - "-days", - "1", - "-subj", - "/CN=localhost", - ], - { stdio: "ignore" }, - ); - server = https.createServer( - { key: readFileSync(key), cert: readFileSync(cert) }, - (req, res) => { - if (req.url === "/redirect") { - res.writeHead(302, { Location: `${origin}/redirect-target` }); - } else { - if (req.url === "/redirect-target") redirectedRequests++; - res.setHeader("Content-Type", "application/json"); - res.write( - JSON.stringify({ - authorization: req.headers.authorization, - method: req.method, - }), - ); - } - res.end(); - }, - ); - await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); - origin = `https://127.0.0.1:${(server.address() as AddressInfo).port}`; - config.serverUrl = `${origin}/termix`; -}); - -afterAll(async () => { - server?.closeAllConnections(); - if (server) - await new Promise((resolve) => server.close(() => resolve())); - if (directory) rmSync(directory, { recursive: true, force: true }); -}); - -it("rejects self-signed certificates until enabled, then preserves authenticated requests", async () => { - await expect(request(`${origin}/users/me`)).rejects.toThrow(); - config.allowInvalidCertificate = true; - const response = await request(`${origin}/users/me`, { - headers: { Authorization: "Bearer dummy-token" }, - method: "POST", - body: "{}", - }); - expect(await response.json()).toEqual({ - authorization: "Bearer dummy-token", - method: "POST", - }); - config.allowInvalidCertificate = false; - await expect(request(`${origin}/users/me`)).rejects.toThrow(); -}); - -it("does not apply an exception to another configured origin", async () => { - config.allowInvalidCertificate = true; - config.serverUrl = "https://another.example"; - await expect(request(`${origin}/sync/hosts`)).rejects.toThrow(); - config.serverUrl = origin; -}); - -it("refuses redirects rather than carrying the TLS exception to another request", async () => { - config.allowInvalidCertificate = true; - await expect(request(`${origin}/redirect`)).rejects.toThrow(); - expect(redirectedRequests).toBe(0); -}); diff --git a/src/backend/tests/electron/remote-sync-parent-order.test.ts b/src/backend/tests/electron/remote-sync-parent-order.test.ts deleted file mode 100644 index 7c1c5c6e3..000000000 --- a/src/backend/tests/electron/remote-sync-parent-order.test.ts +++ /dev/null @@ -1,112 +0,0 @@ -import { readFileSync } from "node:fs"; -import { createRequire } from "node:module"; -import { resolve } from "node:path"; -import { runInNewContext } from "node:vm"; -import { describe, expect, it } from "vitest"; -import { deserializeSyncReferences } from "../../database/routes/sync-references.js"; - -const filename = resolve("electron/remote-sync.cjs"); -const localRequire = createRequire(filename); -const { orderSyncWrites } = localRequire("./sync-write-order.cjs"); -const moduleStub = { exports: {} }; -const Engine = runInNewContext( - readFileSync(filename, "utf8") + "\nRemoteSyncEngine;", - { - module: moduleStub, - require: (name: string) => (name === "electron" ? {} : localRequire(name)), - }, -); - -type Row = { - syncId: string; - updatedAt: string; - parentHostSyncId: string | null; -}; -const local = "http://127.0.0.1:30001"; -const remote = "https://remote.example"; - -describe("nested host sync writes", () => { - it.each([local, remote])( - "copies parents before reparented children into %s", - async (destination) => { - const oldChild: Row = { - syncId: "child", - updatedAt: "2026-01-01", - parentHostSyncId: null, - }; - const parent: Row = { - syncId: "parent", - updatedAt: "2026-01-02", - parentHostSyncId: null, - }; - const newChild: Row = { - ...oldChild, - updatedAt: "2026-01-02", - parentHostSyncId: "parent", - }; - const source = destination === local ? remote : local; - const stores = new Map>([ - [destination, new Map([["child", oldChild]])], - [ - source, - new Map([ - ["parent", parent], - ["child", newChild], - ]), - ], - ]); - const engine = new Engine(() => null); - engine.localJwt = "local-token"; - engine.pullSide = async (url: string) => [...stores.get(url)!.values()]; - engine.pullTombstones = async () => []; - const written: string[] = []; - engine.pushRow = async ( - url: string, - _token: string, - entity: "hosts", - row: Row, - ) => { - await deserializeSyncReferences(entity, row, async (_type, id) => - stores.get(url)!.has(id) ? 1 : null, - ); - stores.get(url)!.set(row.syncId, row); - written.push(row.syncId); - }; - await engine.syncEntity({ - entityType: "hosts", - remoteBaseUrl: remote, - remoteJwt: "remote-token", - }); - expect(written).toEqual(["parent", "child"]); - expect(stores.get(destination)!.get("child")!.parentHostSyncId).toBe( - "parent", - ); - written.length = 0; - await engine.syncEntity({ - entityType: "hosts", - remoteBaseUrl: remote, - remoteJwt: "remote-token", - }); - expect(written).toEqual([]); - }, - ); - - it("rejects cyclic write dependencies before any writes are sent", () => { - const writes = [ - { baseUrl: local, row: { syncId: "a", parentHostSyncId: "b" } }, - { baseUrl: local, row: { syncId: "b", parentHostSyncId: "a" } }, - ]; - expect(() => orderSyncWrites("hosts", writes)).toThrow( - "Cyclic parent host", - ); - }); - - it("does not confuse writes to different destinations", () => { - const writes = [ - { baseUrl: local, row: { syncId: "a", parentHostSyncId: "b" } }, - { baseUrl: remote, row: { syncId: "b", parentHostSyncId: "a" } }, - ]; - expect(orderSyncWrites("hosts", writes)).toEqual(writes); - expect(orderSyncWrites("snippets", writes)).toBe(writes); - }); -}); diff --git a/src/backend/tests/fixtures/upgrade/upgrade-mysql.sql b/src/backend/tests/fixtures/upgrade/upgrade-mysql.sql index 6edf60a2d..f750de25e 100644 --- a/src/backend/tests/fixtures/upgrade/upgrade-mysql.sql +++ b/src/backend/tests/fixtures/upgrade/upgrade-mysql.sql @@ -345,6 +345,70 @@ SELECT 1; -- 3.0.0. SELECT 1; +-- ==== core 0059_military_purple_man ==== +CREATE TABLE `sync_conflicts` ( + `id` int AUTO_INCREMENT NOT NULL, + `user_id` varchar(255) NOT NULL, + `entity_type` text NOT NULL, + `sync_id` text NOT NULL, + `local_row` text NOT NULL, + `server_revision` int NOT NULL, + `created_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP), + CONSTRAINT `sync_conflicts_id` PRIMARY KEY(`id`) +); +--> statement-breakpoint +CREATE TABLE `sync_link` ( + `id` int AUTO_INCREMENT NOT NULL, + `user_id` varchar(255) NOT NULL, + `server_url` text NOT NULL, + `server_name` text, + `server_version` text, + `session_token` text, + `custom_headers` text, + `basic_auth` text, + `allow_invalid_certificate` boolean NOT NULL DEFAULT false, + `remote_user_id` text, + `remote_username` text, + `account` text, + `scope` text, + `known_types` text, + `cursor` int NOT NULL DEFAULT 0, + `status` text NOT NULL DEFAULT ('idle'), + `last_error` text, + `linked_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP), + `last_sync_at` text, + CONSTRAINT `sync_link_id` PRIMARY KEY(`id`) +); +--> statement-breakpoint +CREATE TABLE `sync_records` ( + `id` int AUTO_INCREMENT NOT NULL, + `user_id` varchar(255) NOT NULL, + `entity_type` varchar(255) NOT NULL, + `sync_id` varchar(255) NOT NULL, + `revision` int NOT NULL DEFAULT 0, + `seq` int NOT NULL DEFAULT 0, + `hash` text, + `deleted` boolean NOT NULL DEFAULT false, + `error` text, + `error_hash` text, + `updated_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP), + CONSTRAINT `sync_records_id` PRIMARY KEY(`id`), + CONSTRAINT `idx_sync_records_user_entity_sync` UNIQUE(`user_id`,`entity_type`,`sync_id`) +); +--> statement-breakpoint +ALTER TABLE `ssh_data` ADD `local_only` boolean DEFAULT false NOT NULL;--> statement-breakpoint +ALTER TABLE `ssh_data` ADD `shared_source` text;--> statement-breakpoint +ALTER TABLE `ssh_credentials` ADD `shared_source` text;--> statement-breakpoint +ALTER TABLE `ssh_folders` ADD `local_only` boolean DEFAULT false NOT NULL;--> statement-breakpoint +ALTER TABLE `sync_conflicts` ADD CONSTRAINT `sync_conflicts_user_id_users_id_fk` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE `sync_link` ADD CONSTRAINT `sync_link_user_id_users_id_fk` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE `sync_records` ADD CONSTRAINT `sync_records_user_id_users_id_fk` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +CREATE INDEX `idx_sync_conflicts_user` ON `sync_conflicts` (`user_id`);--> statement-breakpoint +CREATE INDEX `idx_sync_records_user_seq` ON `sync_records` (`user_id`,`seq`); + +-- ==== core 0060_mean_gorgon ==== +DROP TABLE `sync_tombstones`; + -- ==== plugin workspaces 0001_adopt_user_workspaces.sql ==== -- workspaces 0001: adopt_user_workspaces -- Generated by termix-plugin migrations. Review before committing. diff --git a/src/backend/tests/fixtures/upgrade/upgrade-postgres.sql b/src/backend/tests/fixtures/upgrade/upgrade-postgres.sql index 4410adcbc..663e67703 100644 --- a/src/backend/tests/fixtures/upgrade/upgrade-postgres.sql +++ b/src/backend/tests/fixtures/upgrade/upgrade-postgres.sql @@ -371,6 +371,67 @@ SELECT 1; -- 3.0.0. SELECT 1; +-- ==== core 0060_conscious_juggernaut ==== +CREATE TABLE "sync_conflicts" ( + "id" serial PRIMARY KEY NOT NULL, + "user_id" varchar(255) NOT NULL, + "entity_type" text NOT NULL, + "sync_id" text NOT NULL, + "local_row" text NOT NULL, + "server_revision" integer NOT NULL, + "created_at" text DEFAULT CURRENT_TIMESTAMP NOT NULL +); +--> statement-breakpoint +CREATE TABLE "sync_link" ( + "id" serial PRIMARY KEY NOT NULL, + "user_id" varchar(255) NOT NULL, + "server_url" text NOT NULL, + "server_name" text, + "server_version" text, + "session_token" text, + "custom_headers" text, + "basic_auth" text, + "allow_invalid_certificate" boolean DEFAULT false NOT NULL, + "remote_user_id" text, + "remote_username" text, + "account" text, + "scope" text, + "known_types" text, + "cursor" integer DEFAULT 0 NOT NULL, + "status" text DEFAULT 'idle' NOT NULL, + "last_error" text, + "linked_at" text DEFAULT CURRENT_TIMESTAMP NOT NULL, + "last_sync_at" text +); +--> statement-breakpoint +CREATE TABLE "sync_records" ( + "id" serial PRIMARY KEY NOT NULL, + "user_id" varchar(255) NOT NULL, + "entity_type" varchar(255) NOT NULL, + "sync_id" varchar(255) NOT NULL, + "revision" integer DEFAULT 0 NOT NULL, + "seq" integer DEFAULT 0 NOT NULL, + "hash" text, + "deleted" boolean DEFAULT false NOT NULL, + "error" text, + "error_hash" text, + "updated_at" text DEFAULT CURRENT_TIMESTAMP NOT NULL +); +--> statement-breakpoint +ALTER TABLE "ssh_data" ADD COLUMN "local_only" boolean DEFAULT false NOT NULL;--> statement-breakpoint +ALTER TABLE "ssh_data" ADD COLUMN "shared_source" text;--> statement-breakpoint +ALTER TABLE "ssh_credentials" ADD COLUMN "shared_source" text;--> statement-breakpoint +ALTER TABLE "ssh_folders" ADD COLUMN "local_only" boolean DEFAULT false NOT NULL;--> statement-breakpoint +ALTER TABLE "sync_conflicts" ADD CONSTRAINT "sync_conflicts_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "sync_link" ADD CONSTRAINT "sync_link_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "sync_records" ADD CONSTRAINT "sync_records_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +CREATE INDEX "idx_sync_conflicts_user" ON "sync_conflicts" USING btree ("user_id");--> statement-breakpoint +CREATE UNIQUE INDEX "idx_sync_records_user_entity_sync" ON "sync_records" USING btree ("user_id","entity_type","sync_id");--> statement-breakpoint +CREATE INDEX "idx_sync_records_user_seq" ON "sync_records" USING btree ("user_id","seq"); + +-- ==== core 0061_red_lila_cheney ==== +DROP TABLE "sync_tombstones" CASCADE; + -- ==== plugin workspaces 0001_adopt_user_workspaces.sql ==== -- workspaces 0001: adopt_user_workspaces -- Generated by termix-plugin migrations. Review before committing. diff --git a/src/backend/tests/notify/core-notify.test.ts b/src/backend/tests/notify/core-notify.test.ts new file mode 100644 index 000000000..b7ac64009 --- /dev/null +++ b/src/backend/tests/notify/core-notify.test.ts @@ -0,0 +1,42 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const hub = vi.hoisted(() => ({ + value: null as null | { deliver: ReturnType }, +})); +const resolveAudience = vi.hoisted(() => vi.fn(async () => ["u1", "u2"])); + +vi.mock("../../plugins/notify-hub.js", () => ({ + activeNotifyHub: async () => hub.value, +})); +vi.mock("../../plugins/ctx-notify.js", () => ({ resolveAudience })); + +const { sendCoreAlert, CORE_ALERT_SOURCE } = + await import("../../notify/core-notify.js"); + +beforeEach(() => { + hub.value = { deliver: vi.fn(async () => ({ recipients: 2 })) }; + resolveAudience.mockClear(); +}); + +describe("core alerts", () => { + it("hands the alert to the hub as coming from Termix", async () => { + await sendCoreAlert({ title: "t", audience: "admins" }); + expect(hub.value!.deliver).toHaveBeenCalledWith({ + source: CORE_ALERT_SOURCE, + recipients: ["u1", "u2"], + notification: { title: "t", audience: "admins" }, + }); + }); + + it("is a no-op with no hub, and never throws", async () => { + hub.value = null; + await expect( + sendCoreAlert({ title: "t", audience: "admins" }), + ).resolves.toBeUndefined(); + + hub.value = { deliver: vi.fn(async () => Promise.reject(new Error("x"))) }; + await expect( + sendCoreAlert({ title: "t", audience: "admins" }), + ).resolves.toBeUndefined(); + }); +}); diff --git a/src/backend/tests/plugins/security/ctx-guards.test.ts b/src/backend/tests/plugins/security/ctx-guards.test.ts index 2696d80a2..58f951661 100644 --- a/src/backend/tests/plugins/security/ctx-guards.test.ts +++ b/src/backend/tests/plugins/security/ctx-guards.test.ts @@ -44,7 +44,6 @@ const UNGATED: Record = { "events.emit": "namespace check: plugin..* only without events:core", "events.on": "namespace check: plugin.* only without events:core", "sync.registerEntity": "adds the plugin's own table to remote sync", - "sync.recordTombstone": "records a delete of the plugin's own row", "registry.provide": "the plugin's own registry key", "registry.consume": "reads what another plugin chose to publish", "registry.revoke": "removes the plugin's own registry key", diff --git a/src/backend/tests/plugins/sync-registry.test.ts b/src/backend/tests/plugins/sync-registry.test.ts index 7ef5dc396..c339fd6b4 100644 --- a/src/backend/tests/plugins/sync-registry.test.ts +++ b/src/backend/tests/plugins/sync-registry.test.ts @@ -9,8 +9,7 @@ import { resetSyncRegistry, unregisterByOwner, } from "../../plugins/sync-registry.js"; -import { registerCoreSyncEntities } from "../../database/routes/sync-entities.js"; -import { SYNCED_ENTITY_TYPES } from "../../../../electron/remote-sync-entities.cjs"; +import { registerCoreSyncEntities } from "../../sync/entities.js"; beforeEach(() => { resetSyncRegistry(); @@ -91,35 +90,47 @@ describe("sync registry", () => { }); }); +describe("entity aliases", () => { + it("finds an entity by a name core uses for it", () => { + registerEntity("demo", { + type: "snippets", + table: {}, + answersTo: ["commandSnippet"], + }); + + expect(getEntity("commandSnippet")?.type).toBe("snippets"); + expect(getEntity("snippets")?.type).toBe("snippets"); + expect(getEntity("nothing")).toBeUndefined(); + }); + + it("keeps core-only options away from plugins", () => { + registerEntity( + "demo", + { type: "thing", table: {} }, + { readOnly: true, load: async () => [] }, + ); + + expect(getEntity("thing")?.readOnly).toBeUndefined(); + expect(getEntity("thing")?.load).toBeUndefined(); + }); +}); + describe("core sync entities", () => { beforeEach(() => { registerCoreSyncEntities(); }); - // The frozen array also lists networkTopology, snippets/snippetFolders, - // dashboardServiceLinks/homepageItems and vaultProfiles, now registered by - // the network-topology, snippets, homepage and vault plugins rather than core. - const PLUGIN_OWNED_TYPES = new Set([ - "networkTopology", - "snippets", - "snippetFolders", - "dashboardServiceLinks", - "homepageItems", - "vaultProfiles", - ]); - - it("registers every core-owned entity the Electron client knows about", () => { - const coreTypes = new Set(listEntityTypes()); - for (const type of SYNCED_ENTITY_TYPES) { - if (PLUGIN_OWNED_TYPES.has(type)) continue; - expect(coreTypes.has(type), type).toBe(true); - } - }); - - it("keeps the dependency order the frozen Electron array encodes", () => { - expect(listEntityTypes()).toEqual( - [...SYNCED_ENTITY_TYPES].filter((type) => !PLUGIN_OWNED_TYPES.has(type)), - ); + it("registers what core syncs, under the wire names 2.8 used", () => { + expect(listEntityTypes()).toEqual([ + "accountProfile", + "sshCredentials", + "sharedCredentials", + "sshFolders", + "hosts", + "sharedHosts", + "userPreferences", + "pluginUserSettings", + ]); }); it("owns all of them as core", () => { @@ -128,15 +139,18 @@ describe("core sync entities", () => { } }); - it("sorts every reference target before the entity that points at it", () => { + it("sorts every column reference target before the entity that points at it", () => { const order = new Map( listEntities().map((entity) => [entity.type, entity.order]), ); for (const entity of listEntities()) { for (const reference of entity.references ?? []) { - // A self-reference is ordered within the entity by orderSyncRows. + // Rows pointing at themselves are ordered within the entity, and a + // JSON reference that arrives first is written again once the + // target exists. if (reference.entityType === entity.type) continue; + if (reference.field.includes(".")) continue; expect( order.get(reference.entityType), `${entity.type} -> ${reference.entityType}`, @@ -145,21 +159,51 @@ describe("core sync entities", () => { } }); - it("marks the one singleton and nothing else", () => { + it("marks the singletons and nothing else", () => { const singletons = listEntities() .filter((entity) => entity.singleton) .map((entity) => entity.type); - expect(singletons.sort()).toEqual(["userPreferences"]); + expect(singletons.sort()).toEqual(["accountProfile", "userPreferences"]); }); - it("keeps the read-only fields that must survive a sync payload", () => { - expect(getEntity("hosts")?.readOnlyFields).toEqual(["connectionOrigin"]); + it("only sends shared copies and the account one way", () => { + const readOnly = listEntities() + .filter((entity) => entity.readOnly) + .map((entity) => entity.type); + + expect(readOnly.sort()).toEqual([ + "accountProfile", + "sharedCredentials", + "sharedHosts", + ]); + }); + + it("keeps device-only host fields off the wire", () => { + expect(getEntity("hosts")?.readOnlyFields).toEqual( + expect.arrayContaining(["connectionOrigin", "localOnly", "sharedSource"]), + ); expect(getEntity("userPreferences")?.readOnlyFields).toEqual([ "storageMode", ]); }); + it("leaves local-only and shared rows out of what a device sends", () => { + const hosts = getEntity("hosts")!; + expect(hosts.shouldSync?.({ localOnly: true })).toBe(false); + expect(hosts.shouldSync?.({ sharedSource: "{}" })).toBe(false); + expect(hosts.shouldSync?.({ localOnly: false, sharedSource: null })).toBe( + true, + ); + }); + + it("encrypts host and credential secrets", () => { + expect(getEntity("hosts")?.encryptedFields).toContain("password"); + expect(getEntity("sshCredentials")?.encryptedFields).toContain( + "privateKey", + ); + }); + it("is idempotent, because several modules prime it", () => { const before = listEntityTypes(); registerCoreSyncEntities(); diff --git a/src/backend/tests/plugins/upgrade-fixture.test.ts b/src/backend/tests/plugins/upgrade-fixture.test.ts index 91d1bb412..be4cde340 100644 --- a/src/backend/tests/plugins/upgrade-fixture.test.ts +++ b/src/backend/tests/plugins/upgrade-fixture.test.ts @@ -592,8 +592,7 @@ function upgradeChecks(current: () => Booted, bootIndex: number) { it("syncs every entity type 2.8 synced, under the same name", async () => { const { listEntityTypes } = await import("../../plugins/sync-registry.js"); - const { registerCoreSyncEntities } = - await import("../../database/routes/sync-entities.js"); + const { registerCoreSyncEntities } = await import("../../sync/entities.js"); registerCoreSyncEntities(); const known = new Set(listEntityTypes()); for (const type of SYNCED_ENTITY_TYPES_28) { diff --git a/src/backend/tests/sync/client/http.test.ts b/src/backend/tests/sync/client/http.test.ts new file mode 100644 index 000000000..095a73550 --- /dev/null +++ b/src/backend/tests/sync/client/http.test.ts @@ -0,0 +1,122 @@ +import http from "http"; +import type { AddressInfo } from "net"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { + normalizeServerUrl, + RemoteError, + remoteJson, + remoteUrl, + requestHeaders, +} from "../../../sync/client/http.js"; + +describe("server addresses", () => { + it("adds https, drops a trailing slash, query and hash, keeps a base path", () => { + expect(normalizeServerUrl("termix.example.com/")).toBe( + "https://termix.example.com", + ); + expect(normalizeServerUrl(" http://10.0.0.5:8080/termix/?x=1#y ")).toBe( + "http://10.0.0.5:8080/termix", + ); + expect(normalizeServerUrl("")).toBeNull(); + expect(normalizeServerUrl("ftp://nope")).toBeNull(); + }); + + it("joins paths onto a base path", () => { + expect(remoteUrl("https://x.example/termix/", "/sync/v2/info")).toBe( + "https://x.example/termix/sync/v2/info", + ); + }); +}); + +describe("request headers", () => { + it("sends the session as a cookie so basic auth can have Authorization", () => { + const headers = requestHeaders({ + serverUrl: "https://x", + sessionToken: "tok", + basicAuth: { username: "u", password: "p" }, + customHeaders: [ + { name: "CF-Access-Client-Id", value: "id" }, + { name: "", value: "ignored" }, + ], + }); + expect(headers.Cookie).toBe("jwt=tok"); + expect(headers.Authorization).toBe( + `Basic ${Buffer.from("u:p").toString("base64")}`, + ); + expect(headers["CF-Access-Client-Id"]).toBe("id"); + expect(Object.keys(headers)).not.toContain(""); + }); +}); + +describe("classifying answers", () => { + let server: http.Server; + let base: string; + + beforeAll(async () => { + server = http.createServer((req, res) => { + switch (req.url) { + case "/ok": + res.setHeader("Content-Type", "application/json"); + res.end(JSON.stringify({ fine: true, cookie: req.headers.cookie })); + return; + case "/login-page": + res.setHeader("Content-Type", "text/html"); + res.end("sign in"); + return; + case "/basic": + res.statusCode = 401; + res.setHeader("WWW-Authenticate", 'Basic realm="proxy"'); + res.end("no"); + return; + case "/signed-out": + res.statusCode = 401; + res.setHeader("Content-Type", "application/json"); + res.end(JSON.stringify({ error: "Session not found" })); + return; + default: + res.statusCode = 500; + res.setHeader("Content-Type", "application/json"); + res.end(JSON.stringify({ error: "boom" })); + } + }); + await new Promise((resolve) => + server.listen(0, "127.0.0.1", resolve), + ); + base = `http://127.0.0.1:${(server.address() as AddressInfo).port}`; + }); + + afterAll(async () => { + await new Promise((resolve) => server.close(() => resolve())); + }); + + const kindOf = async (path: string) => { + try { + await remoteJson({ serverUrl: base, sessionToken: "t" }, path); + return "none"; + } catch (error) { + return (error as RemoteError).kind; + } + }; + + it("returns JSON from Termix with the session attached", async () => { + const body = await remoteJson<{ fine: boolean; cookie: string }>( + { serverUrl: base, sessionToken: "t" }, + "/ok", + ); + expect(body).toEqual({ fine: true, cookie: "jwt=t" }); + }); + + it("tells a proxy page, basic auth, sign out and server errors apart", async () => { + expect(await kindOf("/login-page")).toBe("proxy"); + expect(await kindOf("/basic")).toBe("basic_auth"); + expect(await kindOf("/signed-out")).toBe("signed_out"); + expect(await kindOf("/boom")).toBe("server"); + }); + + it("calls an unreachable server offline", async () => { + const error = await remoteJson({ serverUrl: "http://127.0.0.1:1" }, "/x", { + timeoutMs: 5000, + }).catch((e) => e); + expect((error as RemoteError).kind).toBe("offline"); + }); +}); diff --git a/src/backend/tests/sync/client/link-store.test.ts b/src/backend/tests/sync/client/link-store.test.ts new file mode 100644 index 000000000..bdbbe0b00 --- /dev/null +++ b/src/backend/tests/sync/client/link-store.test.ts @@ -0,0 +1,98 @@ +import { sql } from "drizzle-orm"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const state = vi.hoisted(() => ({ drizzle: null as unknown })); + +vi.mock("../../../database/db/index.js", () => ({ + getDb: () => state.drizzle, + getSqlite: () => null, +})); +vi.mock("../../../utils/database-save-trigger.js", () => ({ + DatabaseSaveTrigger: { + triggerSave: vi.fn(), + forceSave: vi.fn(async () => {}), + }, +})); +vi.mock("../../../utils/system-crypto.js", () => ({ + SystemCrypto: { + getInstance: () => ({ getEncryptionKey: async () => Buffer.alloc(32, 5) }), + }, +})); + +const { TestSqliteDatabase } = + await import("../../database/repositories/test-support.js"); +const { createLink, deleteLink, getLink, updateLink } = + await import("../../../sync/client/link-store.js"); + +let db: InstanceType; + +beforeEach(async () => { + db = new TestSqliteDatabase("sqlite"); + state.drizzle = (await db.connect()).drizzle; + await db.run( + sql`INSERT INTO users (id, username, password_hash) VALUES ('local', 'local', '')`, + ); +}); + +afterEach(async () => { + await db.close(); +}); + +describe("the desktop's link", () => { + it("stores secrets encrypted and reads them back", async () => { + await createLink({ + userId: "local", + serverUrl: "https://termix.example", + sessionToken: "session-token", + customHeaders: [{ name: "CF-Access-Client-Secret", value: "shh" }], + basicAuth: { username: "gate", password: "pw" }, + }); + + const [raw] = await db.query>( + sql`SELECT session_token, custom_headers, basic_auth FROM sync_link`, + ); + for (const value of Object.values(raw)) { + expect(value).toMatch(/^sysenc:v1:/); + expect(value).not.toContain("shh"); + } + + const link = await getLink(); + expect(link).toMatchObject({ + sessionToken: "session-token", + customHeaders: [{ name: "CF-Access-Client-Secret", value: "shh" }], + basicAuth: { username: "gate", password: "pw" }, + cursor: 0, + knownTypes: [], + disabledTypes: [], + status: "idle", + }); + }); + + it("keeps one link at a time and updates it in place", async () => { + await createLink({ + userId: "local", + serverUrl: "https://a", + sessionToken: "a", + }); + await createLink({ + userId: "local", + serverUrl: "https://b", + sessionToken: "b", + }); + await updateLink({ + cursor: 42, + disabledTypes: ["hosts"], + status: "offline", + }); + + const link = await getLink(); + expect(link?.serverUrl).toBe("https://b"); + expect(link?.cursor).toBe(42); + expect(link?.disabledTypes).toEqual(["hosts"]); + expect(link?.status).toBe("offline"); + + await deleteLink(); + expect(await getLink()).toBeNull(); + expect(await updateLink({ cursor: 1 })).toBeNull(); + }); +}); diff --git a/src/backend/tests/database/routes/sync-host-plugin-settings.test.ts b/src/backend/tests/sync/host-plugin-settings.test.ts similarity index 90% rename from src/backend/tests/database/routes/sync-host-plugin-settings.test.ts rename to src/backend/tests/sync/host-plugin-settings.test.ts index 73d1a82ed..bb2a887d5 100644 --- a/src/backend/tests/database/routes/sync-host-plugin-settings.test.ts +++ b/src/backend/tests/sync/host-plugin-settings.test.ts @@ -13,16 +13,16 @@ const h = vi.hoisted(() => ({ manifests: [] as PluginManifest[], })); -vi.mock("../../../database/routes/host-plugin-settings.js", () => ({ +vi.mock("../../database/routes/host-plugin-settings.js", () => ({ hostSettingsPlugins: () => h.manifests, })); -vi.mock("../../../plugins/registry.js", () => ({ +vi.mock("../../plugins/registry.js", () => ({ consume: (key: string) => h.hooks.get(key), })); -vi.mock("../../../utils/logger.js", () => ({ +vi.mock("../../utils/logger.js", () => ({ databaseLogger: { warn: vi.fn(), info: vi.fn(), error: vi.fn() }, })); -vi.mock("../../../plugins/settings.js", () => ({ +vi.mock("../../plugins/settings.js", () => ({ declaredFields: (manifest: PluginManifest) => manifest.contributes!.settings!.host!.fields, getAllSettings: async ( @@ -49,7 +49,7 @@ vi.mock("../../../plugins/settings.js", () => ({ })); const { exportHostPluginSettings, importHostPluginSettings } = - await import("../../../database/routes/sync-host-plugin-settings.js"); + await import("../../sync/host-plugin-settings.js"); const VAULT = { id: "vault", diff --git a/src/backend/tests/sync/server/push.test.ts b/src/backend/tests/sync/server/push.test.ts new file mode 100644 index 000000000..746057729 --- /dev/null +++ b/src/backend/tests/sync/server/push.test.ts @@ -0,0 +1,78 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { + registerEntity, + resetSyncRegistry, +} from "../../../plugins/sync-registry.js"; +import { + MAX_PUSH_OPS, + orderOps, + parsePushOps, + type PushOp, +} from "../../../sync/server/push.js"; + +beforeEach(() => { + resetSyncRegistry(); + registerEntity("core", { type: "creds", table: {}, order: 10 }); + registerEntity("core", { + type: "hosts", + table: {}, + order: 50, + references: [ + { field: "parentId", syncField: "parentSyncId", entityType: "hosts" }, + ], + }); +}); + +const op = ( + entityType: string, + syncId: string, + kind: "upsert" | "delete" = "upsert", + row: Record = {}, +): PushOp => ({ + entityType, + syncId, + baseRevision: 0, + op: kind, + ...(kind === "upsert" ? { row } : {}), +}); + +describe("parsing pushed ops", () => { + it("accepts well-formed ops", () => { + const ops = [op("hosts", "a"), op("hosts", "b", "delete")]; + expect(parsePushOps({ ops })).toEqual(ops); + }); + + it("refuses malformed or oversized batches", () => { + expect(parsePushOps({})).toBeNull(); + expect(parsePushOps({ ops: [{ entityType: "hosts" }] })).toBeNull(); + expect( + parsePushOps({ ops: [{ ...op("hosts", "a"), row: undefined }] }), + ).toBeNull(); + expect( + parsePushOps({ + ops: Array.from({ length: MAX_PUSH_OPS + 1 }, (_, i) => + op("hosts", String(i)), + ), + }), + ).toBeNull(); + }); +}); + +describe("ordering pushed ops", () => { + it("writes targets first and parents before children, and deletes last in reverse", () => { + const ordered = orderOps([ + op("hosts", "old", "delete"), + op("hosts", "child", "upsert", { parentSyncId: "parent" }), + op("creds", "gone", "delete"), + op("hosts", "parent"), + op("creds", "key"), + ]); + expect(ordered.map((o) => `${o.op}:${o.syncId}`)).toEqual([ + "upsert:key", + "upsert:parent", + "upsert:child", + "delete:old", + "delete:gone", + ]); + }); +}); diff --git a/src/backend/tests/sync/two-way.test.ts b/src/backend/tests/sync/two-way.test.ts new file mode 100644 index 000000000..2aa19d0ac --- /dev/null +++ b/src/backend/tests/sync/two-way.test.ts @@ -0,0 +1,524 @@ +/** + * A server and linked desktops, each with its own database, syncing through + * the real engine and the real /sync/v2 routes. Only the network, auth and + * the crypto keys are faked. + */ + +import express from "express"; +import request from "supertest"; +import { and, eq, sql } from "drizzle-orm"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { DatabaseContext } from "../../database/repositories/database-context.js"; + +const state = vi.hoisted(() => ({ + current: null as null | { drizzle: unknown }, + server: null as null | { drizzle: unknown }, + allow: true, +})); + +const SERVER_USER = "srv-user"; + +vi.mock("../../database/db/index.js", () => ({ + getDb: () => state.current!.drizzle, + getSqlite: () => null, +})); +vi.mock("../../utils/database-save-trigger.js", () => ({ + DatabaseSaveTrigger: { + triggerSave: vi.fn(), + forceSave: vi.fn(async () => {}), + }, +})); +vi.mock("../../utils/system-crypto.js", () => ({ + SystemCrypto: { + getInstance: () => ({ + getEncryptionKey: async () => Buffer.alloc(32, 7), + getJWTSecret: async () => "secret", + }), + }, +})); +vi.mock("../../utils/data-crypto.js", () => ({ + DataCrypto: { getUserDataKey: () => Buffer.alloc(32, 3) }, +})); +vi.mock("../../utils/permission-manager.js", () => ({ + PermissionManager: { + getInstance: () => ({ + hasPermission: async () => state.allow, + canAccessHost: async () => ({ hasAccess: false }), + getUserPermissions: async () => [], + }), + }, +})); +vi.mock("../../utils/shared-host-secrets-manager.js", () => ({ + SharedHostSecretsManager: { + getInstance: () => ({ + resyncHost: async () => {}, + resyncHostsForCredential: async () => {}, + }), + }, +})); +vi.mock("../../utils/shared-credential-secrets-manager.js", () => ({ + SharedCredentialSecretsManager: { + getInstance: () => ({ resyncCredential: async () => {} }), + }, +})); +vi.mock("../../hosts/delete-host.js", async () => { + const { hosts } = await import("../../database/db/schema.js"); + const { eq: equals } = await import("drizzle-orm"); + return { + deleteOwnedHost: async (_userId: string, id: number) => { + await (state.current!.drizzle as DatabaseContext["drizzle"]) + .delete(hosts) + .where(equals(hosts.id, id)); + return { id, name: "" }; + }, + }; +}); +vi.mock("../../hosts/delete-credential.js", async () => { + const { sshCredentials } = await import("../../database/db/schema.js"); + const { eq: equals } = await import("drizzle-orm"); + return { + deleteOwnedCredential: async (_userId: string, id: number) => { + await (state.current!.drizzle as DatabaseContext["drizzle"]) + .delete(sshCredentials) + .where(equals(sshCredentials.id, id)); + return { name: null }; + }, + }; +}); +vi.mock("../../utils/auth-manager.js", () => ({ + AuthManager: { + getInstance: () => ({ + createAuthMiddleware: + () => (req: { userId?: string }, _res: unknown, next: () => void) => { + req.userId = SERVER_USER; + next(); + }, + generateJWTToken: async () => "desktop-session", + revokeSession: async () => true, + }), + }, +})); +vi.mock("../../notify/core-notify.js", () => ({ sendCoreAlert: vi.fn() })); +vi.mock("../../sync/client/plugins.js", () => ({ + mirrorPlugins: async () => {}, + lastRemotePlugins: () => [], + isServerManaged: () => false, +})); +vi.mock("../../sync/client/http.js", async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + // The event stream is not under test. + remoteFetch: () => new Promise(() => {}), + remoteJson: async ( + _target: unknown, + path: string, + init: { method?: string; body?: unknown } = {}, + ) => { + const previous = state.current; + state.current = state.server; + try { + const agent = request(app); + const method = (init.method ?? "GET").toLowerCase() as "get" | "post"; + const response = await agent[method](path).send( + init.body as object | undefined, + ); + if (response.status >= 400) { + throw new actual.RemoteError( + "server", + response.body?.error ?? "error", + response.status, + ); + } + return response.body; + } finally { + state.current = previous; + } + }, + }; +}); + +const { TestSqliteDatabase } = + await import("../database/repositories/test-support.js"); +const schema = await import("../../database/db/schema.js"); +const { default: syncRouter } = await import("../../sync/server/routes.js"); +const { SyncEngine } = await import("../../sync/client/engine.js"); +const { createLink, getLink, updateLink } = + await import("../../sync/client/link-store.js"); +const { markChanged, resetFeed } = await import("../../sync/server/feed.js"); +const { resetSeqAllocator, listConflicts, getConflict, deleteConflict } = + await import("../../sync/records.js"); +const { createResolvers, writeWireRow } = await import("../../sync/store.js"); +const { getEntity } = await import("../../plugins/sync-registry.js"); + +const app = express(); +app.use(express.json({ limit: "5mb" })); +app.use("/sync", syncRouter); + +type Db = InstanceType; +interface Side { + db: Db; + ctx: DatabaseContext; + userId: string; +} + +let server: Side; +let desktops: Side[] = []; + +async function side(userId: string): Promise { + const db = new TestSqliteDatabase("sqlite"); + const ctx = await db.connect(); + await db.run( + sql`INSERT INTO users (id, username, password_hash, is_admin) VALUES (${userId}, ${userId}, '', 1)`, + ); + return { db, ctx, userId }; +} + +async function on(where: Side, fn: () => Promise): Promise { + const previous = state.current; + state.current = where.ctx; + try { + return await fn(); + } finally { + state.current = previous; + } +} + +function db(where: Side) { + return where.ctx.drizzle; +} + +async function addHost( + where: Side, + values: Partial & { name: string }, +) { + await db(where) + .insert(schema.hosts) + .values({ + userId: where.userId, + ip: "10.0.0.1", + port: 22, + username: "root", + authType: "password", + syncId: `host-${values.name}`, + ...values, + }); + // Stands in for the change watcher a write request would trip. + if (where === server) markChanged(); +} + +async function hostsOf(where: Side) { + return db(where) + .select() + .from(schema.hosts) + .where(eq(schema.hosts.userId, where.userId)); +} + +async function linkDesktop( + desktop: Side, +): Promise> { + await on(desktop, () => + createLink({ + userId: desktop.userId, + serverUrl: "https://termix.example", + sessionToken: "desktop-session", + }), + ); + return new SyncEngine(); +} + +async function pass(desktop: Side, engine: InstanceType) { + await on(desktop, () => engine.syncNow()); + const link = await on(desktop, () => getLink()); + expect(link?.lastError ?? null).toBeNull(); +} + +beforeEach(async () => { + resetFeed(); + resetSeqAllocator(); + state.allow = true; + server = await side(SERVER_USER); + state.server = server.ctx; + desktops = [await side("desk-a"), await side("desk-b")]; +}); + +afterEach(async () => { + for (const s of [server, ...desktops]) await s.db.close(); +}); + +describe("linking a desktop", () => { + it("merges both sides, translating references and keeping secrets", async () => { + const [desk] = desktops; + await on(desk, async () => { + await db(desk).insert(schema.sshCredentials).values({ + userId: desk.userId, + name: "deploy key", + authType: "password", + username: "deploy", + syncId: "cred-1", + }); + const [cred] = await db(desk).select().from(schema.sshCredentials); + await writeWireRow( + getEntity("hosts")!, + desk.userId, + { + syncId: "host-desk", + name: "desk", + ip: "10.0.0.2", + port: 22, + username: "root", + authType: "credential", + password: "s3cret", + credentialSyncId: cred.syncId, + }, + createResolvers(desk.userId), + ); + }); + await on(server, () => addHost(server, { name: "srv" })); + + const engine = await linkDesktop(desk); + await pass(desk, engine); + + const serverHosts = await on(server, () => hostsOf(server)); + expect(serverHosts.map((h) => h.name).sort()).toEqual(["desk", "srv"]); + const pushed = serverHosts.find((h) => h.name === "desk")!; + const [serverCred] = await db(server).select().from(schema.sshCredentials); + expect(serverCred.syncId).toBe("cred-1"); + expect(pushed.credentialId).toBe(serverCred.id); + // Stored encrypted, never as plaintext. + expect(pushed.password).not.toBe("s3cret"); + expect(pushed.password).toContain('"iv"'); + + const deskHosts = await on(desk, () => hostsOf(desk)); + expect(deskHosts.map((h) => h.name).sort()).toEqual(["desk", "srv"]); + }); + + it("adopts the server's version of rows both sides already have", async () => { + const [desk] = desktops; + await on(desk, () => addHost(desk, { name: "same", ip: "old" })); + await on(server, () => addHost(server, { name: "same", ip: "new" })); + + const engine = await linkDesktop(desk); + await pass(desk, engine); + + const [host] = await on(desk, () => hostsOf(desk)); + expect(host.ip).toBe("new"); + expect(await on(desk, () => listConflicts(desk.userId))).toEqual([]); + }); +}); + +describe("after linking", () => { + it("sends edits and deletes both ways", async () => { + const [desk] = desktops; + await on(server, () => addHost(server, { name: "a" })); + await on(server, () => addHost(server, { name: "b" })); + const engine = await linkDesktop(desk); + await pass(desk, engine); + + await on(desk, async () => { + await db(desk) + .update(schema.hosts) + .set({ ip: "10.9.9.9" }) + .where(eq(schema.hosts.syncId, "host-a")); + }); + await on(server, async () => { + await db(server) + .delete(schema.hosts) + .where(eq(schema.hosts.syncId, "host-b")); + markChanged(); + }); + await pass(desk, engine); + + const serverHosts = await on(server, () => hostsOf(server)); + expect(serverHosts.map((h) => [h.name, h.ip])).toEqual([["a", "10.9.9.9"]]); + const deskHosts = await on(desk, () => hostsOf(desk)); + expect(deskHosts.map((h) => h.name)).toEqual(["a"]); + + await on(desk, async () => { + await db(desk) + .delete(schema.hosts) + .where(eq(schema.hosts.syncId, "host-a")); + }); + await pass(desk, engine); + expect(await on(server, () => hostsOf(server))).toEqual([]); + }); + + it("never sends a host kept on this device only", async () => { + const [desk] = desktops; + const engine = await linkDesktop(desk); + await pass(desk, engine); + await on(desk, () => addHost(desk, { name: "mine", localOnly: true })); + await pass(desk, engine); + + expect(await on(server, () => hostsOf(server))).toEqual([]); + expect((await on(desk, () => hostsOf(desk))).map((h) => h.name)).toEqual([ + "mine", + ]); + }); + + it("keeps a local edit that lost to a newer server edit, and can restore it", async () => { + const [desk] = desktops; + await on(server, () => addHost(server, { name: "a" })); + const engine = await linkDesktop(desk); + await pass(desk, engine); + + await on(desk, () => + db(desk) + .update(schema.hosts) + .set({ ip: "desk-edit" }) + .where(eq(schema.hosts.syncId, "host-a")), + ); + await on(server, async () => { + await db(server) + .update(schema.hosts) + .set({ ip: "server-edit" }) + .where(eq(schema.hosts.syncId, "host-a")); + markChanged(); + }); + await pass(desk, engine); + + const [local] = await on(desk, () => hostsOf(desk)); + expect(local.ip).toBe("server-edit"); + const conflicts = await on(desk, () => listConflicts(desk.userId)); + expect(conflicts).toHaveLength(1); + expect(JSON.parse(conflicts[0].localRow).ip).toBe("desk-edit"); + + // Keep mine: put it back, and the next pass sends it. + await on(desk, async () => { + const conflict = (await getConflict(desk.userId, conflicts[0].id))!; + await writeWireRow( + getEntity("hosts")!, + desk.userId, + { ...JSON.parse(conflict.localRow), syncId: conflict.syncId }, + createResolvers(desk.userId), + ); + await deleteConflict(desk.userId, conflict.id); + }); + await pass(desk, engine); + const [serverHost] = await on(server, () => hostsOf(server)); + expect(serverHost.ip).toBe("desk-edit"); + }); + + it("does not let one refused change hold up the rest", async () => { + const [desk] = desktops; + const engine = await linkDesktop(desk); + await pass(desk, engine); + + await on(desk, async () => { + await addHost(desk, { name: "one" }); + await db(desk).insert(schema.sshCredentials).values({ + userId: desk.userId, + name: "cred", + authType: "password", + syncId: "cred-x", + }); + }); + // The server refuses host changes only. + const hostPermissions = getEntity("hosts")!.permissions; + const credentialPermissions = getEntity("sshCredentials")!.permissions; + getEntity("sshCredentials")!.permissions = undefined; + state.allow = false; + try { + await pass(desk, engine); + } finally { + getEntity("hosts")!.permissions = hostPermissions; + getEntity("sshCredentials")!.permissions = credentialPermissions; + } + + expect(await on(server, () => hostsOf(server))).toEqual([]); + const creds = await db(server).select().from(schema.sshCredentials); + expect(creds.map((c) => c.name)).toEqual(["cred"]); + const [record] = await on(desk, () => + db(desk) + .select() + .from(schema.syncRecords) + .where( + and( + eq(schema.syncRecords.entityType, "hosts"), + eq(schema.syncRecords.syncId, "host-one"), + ), + ), + ); + expect(record.error).toBe("permission"); + }); + + it("leaves a switched-off kind of data alone, and catches up when it is back on", async () => { + const [desk] = desktops; + const engine = await linkDesktop(desk); + await pass(desk, engine); + + const link = (await on(desk, () => getLink()))!; + await on(desk, () => + updateLink({ + disabledTypes: ["hosts"], + knownTypes: link.knownTypes.filter((type) => type !== "hosts"), + }), + ); + await on(server, () => addHost(server, { name: "later" })); + await on(desk, () => addHost(desk, { name: "private" })); + await pass(desk, engine); + + expect((await on(desk, () => hostsOf(desk))).map((h) => h.name)).toEqual([ + "private", + ]); + expect( + (await on(server, () => hostsOf(server))).map((h) => h.name), + ).toEqual(["later"]); + + await on(desk, () => updateLink({ disabledTypes: [] })); + await pass(desk, engine); + await pass(desk, engine); + const names = (await on(desk, () => hostsOf(desk))) + .map((h) => h.name) + .sort(); + expect(names).toEqual(["later", "private"]); + expect( + (await on(server, () => hostsOf(server))).map((h) => h.name).sort(), + ).toEqual(["later", "private"]); + }); +}); + +describe("two desktops on one account", () => { + it("converge on edits, creates and deletes made offline on each", async () => { + const [a, b] = desktops; + await on(server, () => addHost(server, { name: "shared-one" })); + await on(server, () => addHost(server, { name: "shared-two" })); + const engineA = await linkDesktop(a); + const engineB = await linkDesktop(b); + await pass(a, engineA); + await pass(b, engineB); + + // Offline on both. + await on(a, () => addHost(a, { name: "from-a" })); + await on(a, () => + db(a) + .update(schema.hosts) + .set({ ip: "10.1.1.1" }) + .where(eq(schema.hosts.syncId, "host-shared-one")), + ); + await on(b, () => + db(b) + .delete(schema.hosts) + .where(eq(schema.hosts.syncId, "host-shared-two")), + ); + await on(b, () => addHost(b, { name: "from-b" })); + + await pass(a, engineA); + await pass(b, engineB); + await pass(a, engineA); + + const view = async (where: Side) => + (await on(where, () => hostsOf(where))) + .map((h) => `${h.name}:${h.ip}`) + .sort(); + const expected = [ + "from-a:10.0.0.1", + "from-b:10.0.0.1", + "shared-one:10.1.1.1", + ]; + expect(await view(server)).toEqual(expected); + expect(await view(a)).toEqual(expected); + expect(await view(b)).toEqual(expected); + }); +}); diff --git a/src/backend/tests/sync/wire.test.ts b/src/backend/tests/sync/wire.test.ts new file mode 100644 index 000000000..fcf735748 --- /dev/null +++ b/src/backend/tests/sync/wire.test.ts @@ -0,0 +1,254 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { + registerEntity, + resetSyncRegistry, + type RegisteredSyncEntity, +} from "../../plugins/sync-registry.js"; +import { getEntity } from "../../plugins/sync-registry.js"; +import { + decryptFields, + deserializeReferences, + encryptFields, + hashWire, + orderSelfReferences, + parseReferencePath, + serializeReferences, + toWire, + toWritable, +} from "../../sync/wire.js"; +import { FieldCrypto } from "../../utils/field-crypto.js"; + +const KEY = Buffer.alloc(32, 9); +const OTHER_KEY = Buffer.alloc(32, 4); + +function entity(type: string): RegisteredSyncEntity { + return getEntity(type)!; +} + +beforeEach(() => { + resetSyncRegistry(); + registerEntity("core", { + type: "things", + table: {}, + encryptedFields: ["secret"], + readOnlyFields: ["deviceOnly"], + references: [ + { field: "parentId", syncField: "parentSyncId", entityType: "things" }, + { field: "hops[].thingId", entityType: "things" }, + { + field: "bindings[].action.snippetId", + entityType: "things", + idType: "string", + }, + ], + }); +}); + +const toSync = async (_type: string, id: number) => + id === 404 ? null : `sync-${id}`; +const toId = async (_type: string, syncId: string) => + syncId === "sync-missing" ? null : Number(syncId.replace("sync-", "")); + +describe("reference paths", () => { + it("parses columns, arrays and nested objects", () => { + expect(parseReferencePath("credentialId")).toBeNull(); + expect(parseReferencePath("jumpHosts[].hostId")).toEqual({ + column: "jumpHosts", + steps: [ + { key: "", each: true }, + { key: "hostId", each: false }, + ], + }); + expect(parseReferencePath("layout.panes[].hostId")).toEqual({ + column: "layout", + steps: [ + { key: "panes", each: true }, + { key: "hostId", each: false }, + ], + }); + }); +}); + +describe("serializing references", () => { + it("turns column and JSON ids into sync ids, keeping the storage form", async () => { + const wire = await serializeReferences( + entity("things"), + { + id: 1, + parentId: 7, + hops: JSON.stringify([{ thingId: 3 }, { thingId: 404 }]), + bindings: [{ action: { type: "run", snippetId: "12" } }], + }, + toSync, + ); + + expect(wire.parentId).toBeUndefined(); + expect(wire.parentSyncId).toBe("sync-7"); + expect(JSON.parse(wire.hops as string)).toEqual([ + { thingId: "sync-3" }, + { thingId: null }, + ]); + expect(wire.bindings).toEqual([ + { action: { type: "run", snippetId: "sync-12" } }, + ]); + }); + + it("round-trips back to local ids, including string ids", async () => { + const back = await deserializeReferences( + entity("things"), + { + parentSyncId: "sync-7", + hops: JSON.stringify([{ thingId: "sync-3" }]), + bindings: [{ action: { snippetId: "sync-12" } }], + }, + toId, + null, + ); + + expect(back.parentId).toBe(7); + expect(back.parentSyncId).toBeUndefined(); + expect(JSON.parse(back.hops as string)).toEqual([{ thingId: 3 }]); + expect(back.bindings).toEqual([{ action: { snippetId: "12" } }]); + }); + + it("keeps the current value when a referenced row is not here", async () => { + const back = await deserializeReferences( + entity("things"), + { parentSyncId: "sync-missing" }, + toId, + { parentId: 5 }, + ); + expect(back.parentId).toBe(5); + + const fresh = await deserializeReferences( + entity("things"), + { parentSyncId: "sync-missing" }, + toId, + null, + ); + expect(fresh.parentId).toBeNull(); + }); + + it("runs a registration's own serialize after the references", async () => { + resetSyncRegistry(); + registerEntity("demo", { + type: "custom", + table: {}, + references: [ + { field: "otherId", syncField: "otherSyncId", entityType: "custom" }, + ], + serialize: async (row) => ({ ...row, extra: row.otherSyncId }), + }); + const wire = await serializeReferences( + entity("custom"), + { otherId: 2 }, + toSync, + ); + expect(wire.extra).toBe("sync-2"); + }); +}); + +describe("secret fields", () => { + it("decrypts to plaintext and encrypts again", () => { + const stored = encryptFields( + entity("things"), + { id: 1, secret: "hunter2" }, + KEY, + "1", + ); + expect(FieldCrypto.isEncrypted(stored.secret as string)).toBe(true); + + const plain = decryptFields(entity("things"), stored, KEY); + expect(plain.secret).toBe("hunter2"); + }); + + it("refuses to decrypt with the wrong key instead of sending an empty secret", () => { + const stored = encryptFields( + entity("things"), + { id: 1, secret: "hunter2" }, + KEY, + "1", + ); + expect(() => decryptFields(entity("things"), stored, OTHER_KEY)).toThrow(); + }); + + it("leaves plaintext and already encrypted values alone when encrypting", () => { + const once = encryptFields(entity("things"), { secret: "a" }, KEY, "1"); + const twice = encryptFields(entity("things"), once, KEY, "1"); + expect(twice.secret).toBe(once.secret); + expect( + decryptFields(entity("things"), { id: 1, secret: "plain" }, KEY).secret, + ).toBe("plain"); + }); +}); + +describe("wire shape", () => { + it("drops local ids, the owner and device-only fields", () => { + const wire = toWire( + entity("things"), + { id: 3, userId: "u", name: "a", deviceOnly: true }, + "s-1", + ); + expect(wire).toEqual({ name: "a", syncId: "s-1" }); + }); + + it("never lets an inbound row set ids, the owner or device-only fields", () => { + const writable = toWritable(entity("things"), { + id: 99, + syncId: "s", + userId: "attacker", + deviceOnly: true, + createdAt: "x", + updatedAt: "y", + name: "a", + }); + expect(writable).toEqual({ name: "a" }); + }); +}); + +describe("content hash", () => { + const hash = (row: Record) => + hashWire(entity("things"), row, KEY); + + it("ignores where a row lives and device-only fields", () => { + expect(hash({ syncId: "a", name: "x", updatedAt: "1" })).toBe( + hash({ syncId: "b", name: "x", updatedAt: "2", deviceOnly: true }), + ); + }); + + it("treats the same content the same whatever the driver returned", () => { + expect(hash({ on: true, empty: null, json: '{"b":1,"a":2}' })).toBe( + hash({ on: 1, empty: "", json: { a: 2, b: 1 } }), + ); + }); + + it("changes when a secret changes and depends on the key", () => { + expect(hash({ secret: "a" })).not.toBe(hash({ secret: "b" })); + expect(hashWire(entity("things"), { secret: "a" }, OTHER_KEY)).not.toBe( + hash({ secret: "a" }), + ); + }); +}); + +describe("self references", () => { + it("puts parents before their children", () => { + const rows = orderSelfReferences(entity("things"), [ + { syncId: "child", parentSyncId: "parent" }, + { syncId: "grandchild", parentSyncId: "child" }, + { syncId: "parent", parentSyncId: null }, + ]); + expect(rows.map((row) => row.syncId)).toEqual([ + "parent", + "child", + "grandchild", + ]); + }); + + it("does not loop on a cycle", () => { + const rows = orderSelfReferences(entity("things"), [ + { syncId: "a", parentSyncId: "b" }, + { syncId: "b", parentSyncId: "a" }, + ]); + expect(rows).toHaveLength(2); + }); +}); diff --git a/src/backend/tests/updates/desktop-update-check.test.ts b/src/backend/tests/updates/desktop-update-check.test.ts new file mode 100644 index 000000000..517cb3419 --- /dev/null +++ b/src/backend/tests/updates/desktop-update-check.test.ts @@ -0,0 +1,63 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const sendCoreAlert = vi.hoisted(() => vi.fn(async () => {})); +const latest = vi.hoisted(() => ({ + value: null as null | { version: string; url: string }, +})); +const version = vi.hoisted(() => ({ value: "2.9.0" as string | null })); +const disabled = vi.hoisted(() => ({ value: false })); + +vi.mock("../../notify/core-notify.js", () => ({ sendCoreAlert })); +vi.mock("../../utils/app-version.js", () => ({ + getLocalVersion: () => version.value, +})); +vi.mock("../../utils/latest-release.js", async (importOriginal) => ({ + ...(await importOriginal()), + fetchLatestRelease: async () => latest.value, +})); +vi.mock("../../database/repositories/factory.js", () => ({ + createCurrentUserRepository: () => ({ + listAll: async () => [{ id: "local" }], + }), + createCurrentUserPreferenceRepository: () => ({ + findByUserId: async () => ({ disableUpdateCheck: disabled.value }), + }), +})); + +const { checkForDesktopUpdate } = + await import("../../updates/desktop-update-check.js"); + +beforeEach(() => { + sendCoreAlert.mockClear(); + version.value = "2.9.0"; + disabled.value = false; + latest.value = { + version: "2.9.1", + url: "https://github.com/Termix-SSH/Termix/releases/tag/v2.9.1", + }; +}); + +describe("desktop update check", () => { + it("sends one alert per newer release, linking to it", async () => { + await checkForDesktopUpdate(); + expect(sendCoreAlert).toHaveBeenCalledWith( + expect.objectContaining({ + category: "termix.update", + dedupeKey: "update:2.9.1", + link: { url: latest.value!.url }, + audience: "admins", + }), + ); + }); + + it("stays quiet when up to date, ahead, or turned off", async () => { + version.value = "2.9.1"; + await checkForDesktopUpdate(); + version.value = "3.0.0"; + await checkForDesktopUpdate(); + version.value = "2.9.0"; + disabled.value = true; + await checkForDesktopUpdate(); + expect(sendCoreAlert).not.toHaveBeenCalled(); + }); +}); diff --git a/src/backend/updates/desktop-update-check.ts b/src/backend/updates/desktop-update-check.ts new file mode 100644 index 000000000..3a6ecf6a7 --- /dev/null +++ b/src/backend/updates/desktop-update-check.ts @@ -0,0 +1,65 @@ +/** + * Tells desktop users a newer release is out, through the alerts inbox + * rather than a dialog that blocks the app from opening. + */ + +import { sendCoreAlert } from "../notify/core-notify.js"; +import { getLocalVersion } from "../utils/app-version.js"; +import { compareSemver, fetchLatestRelease } from "../utils/latest-release.js"; +import { systemLogger } from "../utils/logger.js"; +import { + createCurrentUserPreferenceRepository, + createCurrentUserRepository, +} from "../database/repositories/factory.js"; + +const CHECK_EVERY_MS = 12 * 60 * 60 * 1000; +const FIRST_CHECK_MS = 60 * 1000; + +let timer: NodeJS.Timeout | null = null; + +/** Whether every local user turned the update check off. */ +async function checkDisabled(): Promise { + const users = await createCurrentUserRepository().listAll(); + if (users.length === 0) return false; + const preferences = createCurrentUserPreferenceRepository(); + for (const user of users) { + const row = await preferences.findByUserId(user.id); + if (!row?.disableUpdateCheck) return false; + } + return true; +} + +export async function checkForDesktopUpdate(): Promise { + const current = getLocalVersion(); + if (!current || (await checkDisabled())) return; + + const latest = await fetchLatestRelease(); + if (!latest) return; + const comparison = compareSemver(current, latest.version); + if (comparison === null || comparison >= 0) return; + + await sendCoreAlert({ + title: `Termix ${latest.version} is available`, + body: `You are on ${current}. Download the new version to update.`, + severity: "info", + category: "termix.update", + dedupeKey: `update:${latest.version}`, + audience: "admins", + link: { url: latest.url }, + context: { value: latest.version }, + }); +} + +export function startDesktopUpdateCheck(): void { + if (timer) return; + const run = () => + void checkForDesktopUpdate().catch((error) => { + systemLogger.warn("Update check failed", { + operation: "desktop_update_check", + error: error instanceof Error ? error.message : String(error), + }); + }); + setTimeout(run, FIRST_CHECK_MS).unref?.(); + timer = setInterval(run, CHECK_EVERY_MS); + timer.unref?.(); +} diff --git a/src/backend/utils/app-version.ts b/src/backend/utils/app-version.ts new file mode 100644 index 000000000..a05030e72 --- /dev/null +++ b/src/backend/utils/app-version.ts @@ -0,0 +1,31 @@ +import fs from "fs"; +import path from "path"; +import { fileURLToPath } from "url"; + +const here = path.dirname(fileURLToPath(import.meta.url)); + +let cached: string | null | undefined; + +function readVersion(file: string): string | null { + try { + const version = JSON.parse(fs.readFileSync(file, "utf8")).version; + return typeof version === "string" && version !== "unknown" + ? version + : null; + } catch { + return null; + } +} + +/** This install's version: VERSION, else the nearest package.json. */ +export function getLocalVersion(): string | null { + if (cached !== undefined) return cached; + cached = + process.env.VERSION || + readVersion(path.resolve(process.cwd(), "package.json")) || + readVersion(path.resolve("/app", "package.json")) || + readVersion(path.resolve(here, "../../../package.json")) || + readVersion(path.resolve(here, "../../../../package.json")) || + null; + return cached; +} diff --git a/src/backend/utils/field-crypto.ts b/src/backend/utils/field-crypto.ts index 1e418d5d0..fea0a06dc 100644 --- a/src/backend/utils/field-crypto.ts +++ b/src/backend/utils/field-crypto.ts @@ -130,6 +130,13 @@ class FieldCrypto { return decrypted; } + /** The encrypted fields of a table, as property names. */ + static fieldsFor(tableName: string): string[] { + const fields = + this.ENCRYPTED_FIELDS[tableName as keyof typeof this.ENCRYPTED_FIELDS]; + return fields ? [...fields] : []; + } + static shouldEncryptField(tableName: string, fieldName: string): boolean { const fields = this.ENCRYPTED_FIELDS[tableName as keyof typeof this.ENCRYPTED_FIELDS]; diff --git a/src/backend/utils/latest-release.ts b/src/backend/utils/latest-release.ts new file mode 100644 index 000000000..d5107c68f --- /dev/null +++ b/src/backend/utils/latest-release.ts @@ -0,0 +1,143 @@ +import { databaseLogger } from "./logger.js"; +import { getProxyAgent } from "./proxy-agent.js"; +import type { + CacheEntry, + GitHubAPIResponse, + GitHubRelease, +} from "../../types/index.js"; + +class GitHubCache { + private cache: Map = new Map(); + private readonly CACHE_DURATION = 30 * 60 * 1000; + + set(key: string, data: T): void { + const now = Date.now(); + this.cache.set(key, { + data, + timestamp: now, + expiresAt: now + this.CACHE_DURATION, + }); + } + + get(key: string): T | null { + const entry = this.cache.get(key); + if (!entry) { + return null; + } + + if (Date.now() > entry.expiresAt) { + this.cache.delete(key); + return null; + } + + return entry.data as T; + } +} + +const githubCache = new GitHubCache(); + +function parseSemver( + version: string | undefined, +): [number, number, number] | null { + const match = String(version || "").match(/(\d+)\.(\d+)(?:\.(\d+))?/); + if (!match) return null; + + return [Number(match[1]), Number(match[2]), Number(match[3] || 0)]; +} + +export function compareSemver( + a: string | undefined, + b: string | undefined, +): number | null { + const parsedA = parseSemver(a); + const parsedB = parseSemver(b); + if (!parsedA || !parsedB) return null; + + for (let i = 0; i < 3; i += 1) { + if (parsedA[i] > parsedB[i]) return 1; + if (parsedA[i] < parsedB[i]) return -1; + } + + return 0; +} + +const GITHUB_API_BASE = "https://api.github.com"; +export const REPO_OWNER = "Termix-SSH"; +export const REPO_NAME = "Termix"; + +export async function fetchGitHubAPI( + endpoint: string, + cacheKey: string, +): Promise> { + const cachedEntry = githubCache.get>(cacheKey); + if (cachedEntry) { + return { + data: cachedEntry.data, + cached: true, + cache_age: Date.now() - cachedEntry.timestamp, + }; + } + + try { + const url = `${GITHUB_API_BASE}${endpoint}`; + const response = await fetch(url, { + headers: { + Accept: "application/vnd.github+json", + "User-Agent": "TermixUpdateChecker/1.0", + "X-GitHub-Api-Version": "2022-11-28", + }, + dispatcher: getProxyAgent(url), + }); + + if (!response.ok) { + throw new Error( + `GitHub API error: ${response.status} ${response.statusText}`, + ); + } + + const data = (await response.json()) as T; + const cacheData: CacheEntry = { + data, + timestamp: Date.now(), + expiresAt: Date.now() + 30 * 60 * 1000, + }; + githubCache.set(cacheKey, cacheData); + + return { + data: data, + cached: false, + }; + } catch (error) { + databaseLogger.error(`Failed to fetch from GitHub API`, error, { + operation: "github_api", + endpoint, + }); + throw error; + } +} + +export interface LatestRelease { + version: string; + tagName: string; + name: string; + url: string; + publishedAt: string; +} + +/** The newest Termix release on GitHub, cached for 30 minutes. */ +export async function fetchLatestRelease(): Promise { + const release = await fetchGitHubAPI( + `/repos/${REPO_OWNER}/${REPO_NAME}/releases/latest`, + "latest_release", + ); + const rawTag = release.data.tag_name || release.data.name || ""; + const version = rawTag.match(/(\d+\.\d+(\.\d+)?)/)?.[1]; + if (!version) return null; + return { + version, + tagName: release.data.tag_name, + name: release.data.name, + url: release.data.html_url, + publishedAt: release.data.published_at, + }; +} diff --git a/src/backend/utils/logger.ts b/src/backend/utils/logger.ts index f0087f9e4..3edc2c703 100644 --- a/src/backend/utils/logger.ts +++ b/src/backend/utils/logger.ts @@ -306,5 +306,6 @@ export const systemLogger = new Logger("SYSTEM", "🚀", "#14b8a6"); export const versionLogger = new Logger("VERSION", "📦", "#8b5cf6"); export const dashboardLogger = new Logger("DASHBOARD", "📊", "#ec4899"); export const pluginLogger = new Logger("PLUGIN", "🧩", "#84cc16"); +export const syncLogger = new Logger("SYNC", "🔄", "#06b6d4"); export const logger = systemLogger; diff --git a/src/main.tsx b/src/main.tsx index eaef68621..04f8fa2e3 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -40,12 +40,6 @@ const AppShell = lazy(() => import("@/AppShell").then((m) => ({ default: m.AppShell })), ); -const ElectronVersionCheck = lazy(() => - import("@/user/ElectronVersionCheck").then((module) => ({ - default: module.ElectronVersionCheck, - })), -); - type Phase = | "verifying" | "idle-auth" @@ -176,18 +170,7 @@ function App() { if (isElectron()) { try { const token = await getCurrentToken(); - if (token) { - localStorage.setItem("jwt", token); - // Remote Sync's engine (main process) needs this local JWT to - // authenticate against the embedded backend during sync, same - // as a fresh login provides via handleLogin below -- a session - // restore (the common case on every normal launch) must hand - // it over too, or sync silently never runs after the first - // app restart. - window.electronAPI - ?.invoke?.("notify-local-login", token) - .catch(() => {}); - } + if (token) localStorage.setItem("jwt", token); } catch { // Non-fatal: WebSocket connections will fall back to cookie auth } @@ -273,12 +256,6 @@ function App() { })(); if (isElectron()) { window.electronAPI?.startC2SAutoStartTunnels?.().catch(() => {}); - const localJwt = localStorage.getItem("jwt"); - if (localJwt) { - window.electronAPI - ?.invoke?.("notify-local-login", localJwt) - .catch(() => {}); - } } } @@ -438,8 +415,6 @@ function ViewRouter({ view }: { view: string }) { } function RootApp() { - const [showVersionCheck, setShowVersionCheck] = useState(true); - useServiceWorker(); const searchParams = new URLSearchParams(window.location.search); @@ -453,14 +428,6 @@ function RootApp() { ); } - if (isElectron() && showVersionCheck) { - return ( - - setShowVersionCheck(false)} /> - - ); - } - return ; } diff --git a/src/types/electron.d.ts b/src/types/electron.d.ts index 4b5fb7ae0..4b5a5e20c 100644 --- a/src/types/electron.d.ts +++ b/src/types/electron.d.ts @@ -49,6 +49,7 @@ export type LocalPathMutationResult = export interface ElectronAPI { getAppVersion: () => Promise; + setZoomFactor?: (factor: number) => void; getPlatform: () => Promise; getSetting?: (key: string) => Promise; setSetting?: (key: string, value: string) => Promise; @@ -100,15 +101,6 @@ export interface ElectronAPI { started: number; errors: string[]; }>; - onRemoteSyncStatusChanged?: ( - callback: (status: { - connected: boolean; - syncing: boolean; - lastSyncedAt: string | null; - lastError: string | null; - needsReauth: boolean; - }) => void, - ) => () => void; onCloseActiveTab?: (callback: () => void) => () => void; clearSessionCookies: () => Promise; getSessionCookie: ( diff --git a/src/types/index.ts b/src/types/index.ts index 4c910fbef..8780f8d69 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -161,6 +161,10 @@ export type Host = { permissionLevel?: "connect" | "view" | "edit" | "manage"; sharedExpiresAt?: string; ownerUsername?: string; + /** A read-only copy of a host shared with the linked account. */ + sharedCopy?: boolean; + /** Desktop only: kept on this device, never synced to the server. */ + localOnly?: boolean; /** Enabled plugins' host-scope settings, keyed by plugin id. Secrets redacted. */ pluginSettings?: Record>; @@ -250,6 +254,8 @@ export interface HostData { rdpAuthType?: "direct" | "credential" | "none" | null; vncAuthType?: "direct" | "credential" | null; telnetAuthType?: "direct" | "credential" | null; + /** Desktop only: kept on this device, never synced to the server. */ + localOnly?: boolean; } export type SSHHost = Host; @@ -263,6 +269,8 @@ export interface SSHFolder { icon?: string; credentialId?: number | null; sortOrder?: number | null; + /** Desktop only: the folder and its hosts stay on this device. */ + localOnly?: boolean; createdAt: string; updatedAt: string; } diff --git a/src/types/ui-types.ts b/src/types/ui-types.ts index 0eb85f626..933216d90 100644 --- a/src/types/ui-types.ts +++ b/src/types/ui-types.ts @@ -104,6 +104,13 @@ export type Host = { permissionLevel?: SharePermissionLevel; sharedExpiresAt?: string; ownerUsername?: string; + /** + * A read-only copy of a host shared with the account this desktop is + * linked to. It arrives through sync and is managed on the server. + */ + sharedCopy?: boolean; + /** Desktop only: kept on this device, never synced to the server. */ + localOnly?: boolean; }; export type SharePermissionLevel = "connect" | "view" | "edit" | "manage"; @@ -137,6 +144,7 @@ export type HostFolder = { icon?: string; credentialId?: number | null; sortOrder?: number | null; + localOnly?: boolean; }; /** Core's own tab types. Plugins register theirs at runtime. */ diff --git a/src/ui/AppShell.tsx b/src/ui/AppShell.tsx index 1954d0caa..df8f6e5fb 100644 --- a/src/ui/AppShell.tsx +++ b/src/ui/AppShell.tsx @@ -86,6 +86,11 @@ const UserProfilePanel = lazy(() => default: m.UserProfilePanel, })), ); +const SyncPanel = lazy(() => + import("@/settings/sync/SyncPanel").then((m) => ({ + default: m.SyncPanel, + })), +); const AdminSettingsPanel = lazy(() => import("@/sidebar/AdminSettingsPanel").then((m) => ({ default: m.AdminSettingsPanel, @@ -136,7 +141,6 @@ import { getUserPreferences, saveUserPreferences, dismissDonationModal, - isElectron, type UserPreferences, type OpenTabRecord, } from "@/main-axios"; @@ -147,8 +151,8 @@ import { snapshotData, } from "@/shell/shell-layout"; import { DonationReminderModal } from "@/user/DonationReminderModal.tsx"; -import { RemoteSyncBanner } from "@/components/RemoteSyncBanner.tsx"; -import { MigrationNoticeDialog } from "@/components/MigrationNoticeDialog.tsx"; +import { useSyncStatus } from "@/hooks/use-sync-status"; +import { rem, remScale } from "@/lib/rem"; import { dbHealthMonitor } from "@/lib/db-health-monitor"; import { ServerStatusProvider } from "@/lib/ServerStatusContext"; import { sshHostToHost } from "@/sidebar/HostManagerData"; @@ -298,11 +302,6 @@ export function AppShell({ const [sidebarOpen, setSidebarOpen] = useState(true); const [railView, setRailView] = useState("hosts"); - const [remoteSyncInitialServerUrl, setRemoteSyncInitialServerUrl] = useState< - string | undefined - >(undefined); - const [remoteSyncReconnectRequested, setRemoteSyncReconnectRequested] = - useState(false); const [sidebarWidth, setSidebarWidth] = useState(() => { const saved = localStorage.getItem("termix_sidebarWidth"); return saved ? parseInt(saved, 10) : 291; @@ -1094,6 +1093,7 @@ export function AppShell({ icon?: string; credentialId?: number | null; sortOrder?: number | null; + localOnly?: boolean; } >(); for (const f of folders) { @@ -1102,6 +1102,7 @@ export function AppShell({ icon: f.icon ?? undefined, credentialId: f.credentialId ?? null, sortOrder: f.sortOrder ?? null, + localOnly: !!f.localOnly, }); } setRealHostTree(buildHostTree(raw, folderMeta)); @@ -1131,27 +1132,9 @@ export function AppShell({ }; }, [loadHosts]); - // The Electron main process runs remote sync (pull/push hosts and - // credentials with a connected Termix server) on its own timer, entirely - // outside any renderer-initiated action, so nothing normally dispatches - // the termix:hosts-changed / termix:credentials-changed events that - // panels rely on to refetch. Without this, newly-synced hosts/credentials - // only show up after a manual refresh or app restart. - useEffect(() => { - if (!isElectron()) return; - let wasSyncing = false; - const unsubscribe = window.electronAPI?.onRemoteSyncStatusChanged?.( - (status: { syncing: boolean; lastError: string | null }) => { - const justFinished = wasSyncing && !status.syncing && !status.lastError; - wasSyncing = status.syncing; - if (justFinished) { - window.dispatchEvent(new CustomEvent("termix:hosts-changed")); - window.dispatchEvent(new CustomEvent("termix:credentials-changed")); - } - }, - ); - return () => unsubscribe?.(); - }, []); + // Keeps the desktop's sync status polled while the app is open; a pass + // that changed data tells the panels to reload. + useSyncStatus(); // Sync tab host data when allHosts updates (e.g. after editing terminal theme in host settings) useEffect(() => { @@ -2067,9 +2050,11 @@ export function AppShell({ setSidebarDragging(true); const startX = e.clientX; const startW = sidebarWidth; + // Widths are kept in Normal-size pixels, so a drag is scaled back. + const scale = remScale(); function onMove(ev: MouseEvent) { setSidebarWidth( - Math.max(160, Math.min(480, startW + ev.clientX - startX)), + Math.max(160, Math.min(480, startW + (ev.clientX - startX) / scale)), ); } function onUp() { @@ -2090,9 +2075,10 @@ export function AppShell({ setRightSidebarDragging(true); const startX = e.clientX; const startW = rightSidebarWidth; + const scale = remScale(); function onMove(ev: MouseEvent) { setRightSidebarWidth( - Math.max(160, Math.min(480, startW - (ev.clientX - startX))), + Math.max(160, Math.min(480, startW - (ev.clientX - startX) / scale)), ); } function onUp() { @@ -2496,15 +2482,16 @@ export function AppShell({ onPrefsChange={(updates) => setUserPrefs((current) => ({ ...current, ...updates })) } - remoteSyncInitialServerUrl={remoteSyncInitialServerUrl} - remoteSyncReconnectRequested={remoteSyncReconnectRequested} - onRemoteSyncReconnectHandled={() => - setRemoteSyncReconnectRequested(false) - } /> )} + {railView === "sync" && ( +
+ +
+ )} + {railView === "admin-settings" && showAdminUI && (
- {isElectron() && ( - <> - { - setRailView("user-profile"); - if (!sidebarOpen) setSidebarOpen(true); - setRemoteSyncReconnectRequested(true); - }} - /> - { - setRemoteSyncInitialServerUrl(url); - setRailView("user-profile"); - if (!sidebarOpen) setSidebarOpen(true); - }} - /> - - )}
{/* Skinny icon rail — desktop only, hidden on mobile */} {!settingsFullscreen && ( @@ -2673,9 +2642,7 @@ export function AppShell({ width: settingsFullscreen ? "100vw" : sidebarOpen - ? sidebarEditing - ? 560 - : sidebarWidth + ? rem(sidebarEditing ? 560 : sidebarWidth) : 0, transition: sidebarDragging ? "none" : "width 0.2s", }} @@ -2836,7 +2803,7 @@ export function AppShell({
diff --git a/src/ui/api/credentials-api.ts b/src/ui/api/credentials-api.ts index b0ce7517a..e5cfdb237 100644 --- a/src/ui/api/credentials-api.ts +++ b/src/ui/api/credentials-api.ts @@ -232,6 +232,7 @@ export async function updateFolderMetadata( color?: string, icon?: string, credentialId?: number | null, + localOnly?: boolean, ): Promise { try { sshLogger.info("Updating folder metadata", { @@ -247,6 +248,7 @@ export async function updateFolderMetadata( color, icon, credentialId, + localOnly, }); invalidateSSHFoldersCache(); diff --git a/src/ui/api/host-status-api.ts b/src/ui/api/host-status-api.ts index 38c0acf32..1f67b61ed 100644 --- a/src/ui/api/host-status-api.ts +++ b/src/ui/api/host-status-api.ts @@ -8,21 +8,11 @@ import { import type { ServerStatus } from "@/main-axios"; import { getCachedServerStatuses } from "@/lib/hosts-request-cache"; import { resolveConnectionOrigin } from "@/lib/connection-origin"; +import { getLinkedSession } from "@/lib/linked-server"; import type { SSHHost } from "@/types/index"; -// Core's host status checks (/host/status). On the desktop app the aggregate -// read is merged across the local backend and the connected remote server. -async function isRemoteSyncConnected(): Promise { - if (!isElectron()) return false; - try { - const config = (await window.electronAPI?.invoke?.( - "get-remote-sync-config", - )) as { serverUrl?: string } | null; - return !!config?.serverUrl; - } catch { - return false; - } -} +// Core's host status checks (/host/status). On a linked desktop, hosts that +// connect through the server take their status from it, matched by sync id. // HOST STATUS // ============================================================================ @@ -78,6 +68,7 @@ export async function getAllServerStatuses(): Promise< let lastError: unknown = null; let localStatuses: Record = {}; let localHostIds: number[] | null = null; + let remoteHostsBySyncId: Map | null = null; if (isElectron()) { try { @@ -85,11 +76,21 @@ export async function getAllServerStatuses(): Promise< const defaultOrigin = await resolveConnectionOrigin({ connectionOrigin: null, }); - localHostIds = (response.data || []) + const hosts = response.data || []; + localHostIds = hosts .filter( (host) => (host.connectionOrigin ?? defaultOrigin) === "local", ) .map((host) => host.id); + remoteHostsBySyncId = new Map( + hosts + .filter( + (host) => + (host.connectionOrigin ?? defaultOrigin) === "remote" && + !!host.syncId, + ) + .map((host) => [host.syncId as string, host.id]), + ); } catch { // A host-list failure must not start local probes for hosts whose // configured origin may be remote. @@ -132,15 +133,25 @@ export async function getAllServerStatuses(): Promise< return {}; } - if (await isRemoteSyncConnected()) { + if (remoteHostsBySyncId?.size && (await getLinkedSession())) { try { - const remoteResult = await getRemoteCoreApi().get("/host/status", { - timeout: 8000, - __silentRetry: true, - } as AxiosRequestConfig & { __silentRetry?: boolean }); - return { ...localStatuses, ...(remoteResult.data || {}) }; + const remoteResult = await getRemoteCoreApi().get( + "/sync/v2/host-status", + { + timeout: 8000, + __silentRetry: true, + } as AxiosRequestConfig & { __silentRetry?: boolean }, + ); + const merged = { ...localStatuses }; + for (const [syncId, status] of Object.entries( + (remoteResult.data || {}) as Record, + )) { + const localId = remoteHostsBySyncId.get(syncId); + if (localId !== undefined) merged[localId] = status; + } + return merged; } catch { - // remote unreachable this tick -- fall back to local-only statuses + // The server is unreachable this tick; local statuses still stand. } } diff --git a/src/ui/api/rbac-api.ts b/src/ui/api/rbac-api.ts index 1de0286b2..37460c1cc 100644 --- a/src/ui/api/rbac-api.ts +++ b/src/ui/api/rbac-api.ts @@ -21,6 +21,17 @@ async function getSharingTarget(hostId: number, syncId?: string | null) { return { api, hostId: remoteHostId }; } +/** A shared host's copy on a linked desktop is managed on the server. */ +async function getSharedCopyTarget(hostId: number, syncId?: string | null) { + if (!syncId) return { api: rbacApi, hostId }; + const api = await getConnectedRemoteApi(); + const remoteHostId = api ? await resolveRemoteHostId(syncId) : null; + if (!api || remoteHostId === null) { + throw new Error("The linked server is not reachable"); + } + return { api, hostId: remoteHostId }; +} + export async function getRoles(): Promise<{ roles: Role[] }> { try { const api = (await getConnectedRemoteApi()) ?? rbacApi; @@ -358,14 +369,12 @@ export async function revokeHostAccess( export async function getHostAuthOverride( hostId: number, protocol: AuthOverrideProtocol, - remoteShared = false, + sharedCopySyncId?: string | null, ): Promise<{ protocol: AuthOverrideProtocol; credentialId: number | null }> { try { - const api = remoteShared ? await getConnectedRemoteApi() : rbacApi; - if (!api) throw new Error("Remote server is not connected"); - const targetHostId = remoteShared ? Math.abs(hostId) : hostId; - const response = await api.get( - `/rbac/host-access/${targetHostId}/auth/${protocol}`, + const target = await getSharedCopyTarget(hostId, sharedCopySyncId); + const response = await target.api.get( + `/rbac/host-access/${target.hostId}/auth/${protocol}`, ); return response.data; } catch (error) { @@ -377,18 +386,16 @@ export async function setHostAuthOverride( hostId: number, protocol: AuthOverrideProtocol, credentialId: number | null, - remoteShared = false, + sharedCopySyncId?: string | null, ): Promise<{ success: boolean; protocol: AuthOverrideProtocol; credentialId: number | null; }> { try { - const api = remoteShared ? await getConnectedRemoteApi() : rbacApi; - if (!api) throw new Error("Remote server is not connected"); - const targetHostId = remoteShared ? Math.abs(hostId) : hostId; - const response = await api.put( - `/rbac/host-access/${targetHostId}/auth/${protocol}`, + const target = await getSharedCopyTarget(hostId, sharedCopySyncId); + const response = await target.api.put( + `/rbac/host-access/${target.hostId}/auth/${protocol}`, { credentialId }, ); return response.data; diff --git a/src/ui/api/ssh-host-management-api.ts b/src/ui/api/ssh-host-management-api.ts index 1dda71587..2509852bd 100644 --- a/src/ui/api/ssh-host-management-api.ts +++ b/src/ui/api/ssh-host-management-api.ts @@ -6,11 +6,6 @@ import { getCachedSSHHosts, invalidateHostsAndStatusCaches, } from "@/lib/hosts-request-cache"; -import { requestRemoteSync } from "@/lib/remote-sync-trigger"; -import { - getConnectedRemoteApi, - markRemoteSharedHosts, -} from "@/lib/remote-server-api"; // SSH HOST MANAGEMENT // ============================================================================ @@ -22,22 +17,7 @@ export type GetSSHHostsOptions = { async function loadSSHHostsFromApi(): Promise { const hostsResponse = await sshHostApi.get("/db/host"); - const localHosts = Array.isArray(hostsResponse.data) - ? hostsResponse.data - : []; - const remoteApi = await getConnectedRemoteApi(); - if (!remoteApi) return localHosts; - - try { - const remoteResponse = await remoteApi.get("/host/db/host"); - const remoteSharedHosts = Array.isArray(remoteResponse.data) - ? markRemoteSharedHosts(remoteResponse.data) - : []; - return [...localHosts, ...remoteSharedHosts]; - } catch { - // Keep the last locally synced host set usable while the server is offline. - return localHosts; - } + return Array.isArray(hostsResponse.data) ? hostsResponse.data : []; } export async function getSSHHosts( @@ -82,12 +62,10 @@ export async function createSSHHost(hostData: SSHHostData): Promise { headers: { "Content-Type": "multipart/form-data" }, }); invalidateHostsAndStatusCaches(); - void requestRemoteSync(); return response.data; } const response = await sshHostApi.post("/db/host", hostData); invalidateHostsAndStatusCaches(); - void requestRemoteSync(); return response.data; } catch (error) { throw handleApiError(error, "create SSH host"); @@ -108,12 +86,10 @@ export async function updateSSHHost( headers: { "Content-Type": "multipart/form-data" }, }); invalidateHostsAndStatusCaches(); - void requestRemoteSync(); return response.data; } const response = await sshHostApi.put(`/db/host/${hostId}`, hostData); invalidateHostsAndStatusCaches(); - void requestRemoteSync(); return response.data; } catch (error) { throw handleApiError(error, "update SSH host"); @@ -202,7 +178,6 @@ export async function deleteSSHHost( try { const response = await sshHostApi.delete(`/db/host/${hostId}`); invalidateHostsAndStatusCaches(); - void requestRemoteSync(); return response.data; } catch (error) { handleApiError(error, "delete SSH host"); diff --git a/src/ui/api/sync-api.ts b/src/ui/api/sync-api.ts new file mode 100644 index 000000000..66f331433 --- /dev/null +++ b/src/ui/api/sync-api.ts @@ -0,0 +1,155 @@ +import { authApi } from "@/main-axios"; + +export type SyncState = "idle" | "syncing" | "offline" | "error" | "signed_out"; + +export interface SyncProxyHeader { + name: string; + value: string; + /** A saved header whose value is not sent back. */ + set?: boolean; +} + +export interface SyncBasicAuth { + username: string; + password: string; + set?: boolean; +} + +export interface SyncEntityInfo { + type: string; + owner: string; + ownerName: string | null; + readOnly: boolean; + onServer: boolean; + onDevice: boolean; + enabled: boolean; +} + +export interface SyncStatus { + linked: boolean; + serverUrl?: string; + serverName?: string | null; + serverVersion?: string | null; + account?: { + username?: string; + isAdmin?: boolean; + roles?: string[]; + permissions?: string[]; + }; + status?: SyncState; + lastError?: string | null; + linkedAt?: string; + lastSyncAt?: string | null; + pending?: number; + conflicts?: number; + errors?: number; + firstSyncDone?: boolean; + allowInvalidCertificate?: boolean; + customHeaders?: SyncProxyHeader[]; + basicAuth?: SyncBasicAuth | null; + entities: SyncEntityInfo[]; +} + +export type ProbeProblem = + | "invalid_url" + | "unreachable" + | "tls_untrusted" + | "basic_auth_required" + | "proxy_login" + | "not_termix" + | "version_mismatch" + | "different_account"; + +export interface ProbeResult { + ok: boolean; + serverUrl?: string; + name?: string; + version?: string | null; + problem?: ProbeProblem; + message?: string; +} + +export interface ServerTarget { + serverUrl: string; + customHeaders?: SyncProxyHeader[]; + basicAuth?: SyncBasicAuth | null; + allowInvalidCertificate?: boolean; +} + +export interface SyncConflictItem { + id: number; + entityType: string; + syncId: string; + name: string; + createdAt: string; +} + +export interface SyncErrorItem { + entityType: string; + syncId: string; + reason: string | null; +} + +export async function getSyncStatus(): Promise { + return (await authApi.get("/sync/link/status")).data; +} + +export async function probeServer(target: ServerTarget): Promise { + return (await authApi.post("/sync/link/probe", target)).data; +} + +export async function getLinkPreview(): Promise> { + return (await authApi.get("/sync/link/preview")).data?.counts ?? {}; +} + +export async function completeLink( + input: ServerTarget & { + token: string; + mode: "merge" | "replace"; + serverName?: string; + }, +): Promise { + return (await authApi.post("/sync/link/complete", input)).data; +} + +export async function reloginLink(token: string): Promise { + return (await authApi.post("/sync/link/relogin", { token })).data; +} + +export async function unlinkServer(keepData: boolean): Promise { + return (await authApi.post("/sync/link/unlink", { keepData })).data; +} + +export async function syncNow(): Promise { + return (await authApi.post("/sync/link/sync", {}, { timeout: 300_000 })).data; +} + +export async function updateSyncSettings(settings: { + disabledTypes?: string[]; + customHeaders?: SyncProxyHeader[]; + basicAuth?: SyncBasicAuth | null; + allowInvalidCertificate?: boolean; +}): Promise { + return (await authApi.put("/sync/link/settings", settings)).data; +} + +export async function getSyncConflicts(): Promise { + return (await authApi.get("/sync/link/conflicts")).data?.conflicts ?? []; +} + +export async function settleSyncConflict( + id: number, + keep: "mine" | "server", +): Promise { + await authApi.post(`/sync/link/conflicts/${id}`, { keep }); +} + +export async function getSyncErrors(): Promise { + return (await authApi.get("/sync/link/errors")).data?.errors ?? []; +} + +export async function retrySyncErrors(): Promise { + return ( + await authApi.post("/sync/link/errors/retry", {}, { timeout: 300_000 }) + ).data; +} diff --git a/src/ui/auth/Auth.tsx b/src/ui/auth/Auth.tsx index 764059f32..6624c604f 100644 --- a/src/ui/auth/Auth.tsx +++ b/src/ui/auth/Auth.tsx @@ -59,6 +59,7 @@ import { shouldTriggerSilentSignin, } from "./silent-signin"; import { Select2 } from "@/components/select2"; +import { cn } from "@/lib/utils"; const LANGUAGES = [ { code: "en", label: "English" }, @@ -98,6 +99,38 @@ const LANGUAGES = [ { code: "vi", label: "Tiếng Việt" }, ]; +function LanguageRow({ + label, + value, + onChange, + className, +}: { + label: string; + value: string; + onChange: (code: string) => void; + className?: string; +}) { + return ( +
+ {label} + onChange(e.target.value)} + aria-label={label} + align="end" + contentClassName="w-56" + className="h-8 w-40 shrink-0 px-2.5 text-xs" + > + {LANGUAGES.map((lang) => ( + + ))} + +
+ ); +} + const STORAGE_KEY = "termix_auth"; /** An empty list means "whichever factors this screen has UI for". */ @@ -1118,22 +1151,12 @@ export function Auth({ onLogin }: AuthProps) {

{detail}

-
- - {t("common.language")} - - -
+
); @@ -1176,22 +1199,12 @@ export function Auth({ onLogin }: AuthProps) { -
- - {t("common.language")} - - handleLanguageChange(e.target.value)} - className="px-2.5 py-1.5 text-xs bg-background border border-border text-foreground outline-none focus:ring-1 focus:ring-ring" - > - {LANGUAGES.map((lang) => ( - - ))} - -
+ ); @@ -1253,22 +1266,12 @@ export function Auth({ onLogin }: AuthProps) { : "Retry local desktop session"} -
- - {t("common.language")} - - handleLanguageChange(e.target.value)} - className="px-2.5 py-1.5 text-xs bg-background border border-border text-foreground outline-none focus:ring-1 focus:ring-ring" - > - {LANGUAGES.map((lang) => ( - - ))} - -
+ @@ -1746,22 +1749,12 @@ export function Auth({ onLogin }: AuthProps) { ) : null}

-
- - {t("common.language")} - - handleLanguageChange(e.target.value)} - className="px-2.5 py-1.5 text-xs bg-background border border-border text-foreground outline-none focus:ring-1 focus:ring-ring" - > - {LANGUAGES.map((lang) => ( - - ))} - -
+ )} diff --git a/src/ui/auth/ElectronLoginForm.tsx b/src/ui/auth/ElectronLoginForm.tsx index 085cf890d..46adaf229 100644 --- a/src/ui/auth/ElectronLoginForm.tsx +++ b/src/ui/auth/ElectronLoginForm.tsx @@ -8,22 +8,11 @@ interface ElectronLoginFormProps { serverUrl: string; onAuthSuccess: (token: string | null) => void | Promise; onChangeServer: () => void; - // "local" (default): the app's own login, JWT goes to localStorage like - // every other client. "remoteSync": this iframe is authenticating a - // Settings-triggered connection to a remote Termix server for the sync - // engine -- the JWT is handed to the Electron main process's encrypted - // store instead, never exposed to the renderer's localStorage. - targetPurpose?: "local" | "remoteSync"; -} - -interface SaveRemoteSyncJwtResult { - success: boolean; - reason?: string; - error?: string; - status?: { - needsReauth?: boolean; - lastError?: string | null; - } | null; + // "local" (default): the app's own login, the token goes to localStorage + // like every other client. "link": signing in to a server to link this + // desktop to it; the token is only handed to onAuthSuccess, which trades + // it for the desktop's own session. + targetPurpose?: "local" | "link"; } const AUTH_MESSAGE_SOURCES = new Set([ @@ -61,31 +50,10 @@ export function ElectronLoginForm({ setIsAuthenticating(true); try { - if (targetPurpose === "remoteSync") { + if (targetPurpose === "link") { if (!token) { throw new Error(t("errors.authTokenMissing")); } - // The main process refuses to persist the JWT when it has no OS - // keyring to encrypt it with, and reports that by resolving with - // success: false. Dropping the result signs the user in against a - // store that kept nothing, so the next sync tick calls a session - // that was never saved expired. - const result = (await window.electronAPI?.invoke?.( - "save-remote-sync-jwt", - token, - )) as SaveRemoteSyncJwtResult | undefined; - if (!result?.success) { - throw new Error( - result?.reason === "encryption_unavailable" - ? t("errors.keyringUnavailable") - : result?.error || t("errors.authTokenSaveFailed"), - ); - } - if (result.status?.needsReauth || result.status?.lastError) { - throw new Error( - result.status.lastError || t("errors.authTokenRejected"), - ); - } } else if (token) { localStorage.setItem("jwt", token); } @@ -264,7 +232,6 @@ export function ElectronLoginForm({ }; const displayUrl = currentUrl.replace(/^https?:\/\//, ""); - const isEmbeddedServer = serverUrl.includes("localhost:30001"); return (
@@ -282,12 +249,12 @@ export function ElectronLoginForm({ > - {t("serverConfig.changeServer")} + {t("sync.wizard.back")}
- {isEmbeddedServer ? t("serverConfig.localServer") : displayUrl} + {displayUrl}
- - - - - ); -} diff --git a/src/ui/components/RemoteSyncBanner.tsx b/src/ui/components/RemoteSyncBanner.tsx deleted file mode 100644 index f554ac472..000000000 --- a/src/ui/components/RemoteSyncBanner.tsx +++ /dev/null @@ -1,50 +0,0 @@ -import { useEffect, useState } from "react"; -import { useTranslation } from "react-i18next"; -import { AlertTriangle } from "lucide-react"; -import { isElectron } from "@/lib/electron"; - -interface RemoteSyncStatus { - connected: boolean; - syncing: boolean; - lastSyncedAt: string | null; - lastError: string | null; - needsReauth: boolean; -} - -// Non-blocking banner shown when a connected remote sync server needs -// re-authentication. Never gates or hides any other UI -- the local app -// keeps working fully regardless of remote sync state. -export function RemoteSyncBanner({ onReconnect }: { onReconnect: () => void }) { - const { t } = useTranslation(); - const [status, setStatus] = useState(null); - - useEffect(() => { - if (!isElectron()) return; - window.electronAPI - ?.invoke?.("get-remote-sync-status") - .then((s) => setStatus((s as RemoteSyncStatus) ?? null)) - .catch(() => {}); - const unsubscribe = window.electronAPI?.onRemoteSyncStatusChanged?.( - (nextStatus: RemoteSyncStatus) => setStatus(nextStatus), - ); - return () => unsubscribe?.(); - }, []); - - if (!status?.connected || !status.needsReauth) return null; - - return ( -
-
- - {t("remoteSync.bannerMessage")} -
- -
- ); -} diff --git a/src/ui/components/card-grid/CardGridCanvas.tsx b/src/ui/components/card-grid/CardGridCanvas.tsx index 8d7690b3c..f2ca4450f 100644 --- a/src/ui/components/card-grid/CardGridCanvas.tsx +++ b/src/ui/components/card-grid/CardGridCanvas.tsx @@ -1,3 +1,4 @@ +import { rem, remScale } from "@/lib/rem"; import { useCallback, useEffect, @@ -212,7 +213,8 @@ function MasonryTile({ const el = innerRef.current; if (!el) return; const report = () => { - const h = fixedHeight ?? el.getBoundingClientRect().height; + // Row units are Normal-size pixels; a measured height is scaled back. + const h = fixedHeight ?? el.getBoundingClientRect().height / remScale(); onReportRows(heightToRowSpan(h, ROW_UNIT, ROW_GAP)); }; report(); @@ -247,7 +249,7 @@ function MasonryTile({ "min-h-0", fixedHeight != null && "overflow-hidden [&>*]:h-full", )} - style={fixedHeight != null ? { height: fixedHeight } : undefined} + style={fixedHeight != null ? { height: rem(fixedHeight) } : undefined} > {children}
@@ -358,10 +360,16 @@ export function CardGridCanvas({ e.preventDefault(); e.stopPropagation(); const startY = e.clientY; + // Heights are stored in Normal-size pixels, whatever the interface size. + const scale = remScale(); const startH = - tileRefs.current.get(id)?.getBoundingClientRect().height ?? 120; + (tileRefs.current.get(id)?.getBoundingClientRect().height ?? + 120 * scale) / scale; const onMove = (ev: globalThis.MouseEvent) => { - onChange(setHeight(slots, id, startH + (ev.clientY - startY)), columns); + onChange( + setHeight(slots, id, startH + (ev.clientY - startY) / scale), + columns, + ); }; const onUp = () => { window.removeEventListener("mousemove", onMove); @@ -396,10 +404,10 @@ export function CardGridCanvas({ className="grid items-start" style={{ gridTemplateColumns: `repeat(${effectiveColumns}, minmax(0, 1fr))`, - gridAutoRows: `${ROW_UNIT}px`, + gridAutoRows: rem(ROW_UNIT), gridAutoFlow: "row dense", - columnGap: `${ROW_GAP}px`, - rowGap: `${ROW_GAP}px`, + columnGap: rem(ROW_GAP), + rowGap: rem(ROW_GAP), }} > {sorted.map((slot) => { diff --git a/src/ui/components/select2.tsx b/src/ui/components/select2.tsx index 43d7976db..bec71a142 100644 --- a/src/ui/components/select2.tsx +++ b/src/ui/components/select2.tsx @@ -21,6 +21,8 @@ type Select2Props = Omit< "value" | "defaultValue" | "onChange" | "required" > & { placeholder?: string; + align?: "start" | "center" | "end"; + contentClassName?: string; }; function getText(children: React.ReactNode): string { @@ -85,6 +87,8 @@ function Select2({ onChange, disabled, placeholder, + align = "start", + contentClassName, name, id, required, @@ -171,8 +175,11 @@ function Select2({
diff --git a/src/ui/components/version-alert.tsx b/src/ui/components/version-alert.tsx deleted file mode 100644 index b4df3c326..000000000 --- a/src/ui/components/version-alert.tsx +++ /dev/null @@ -1,124 +0,0 @@ -import React from "react"; -import { Alert, AlertTitle, AlertDescription } from "@/components/alert.tsx"; -import { Button } from "@/components/button.tsx"; -import { ExternalLink, Download, AlertTriangle, Info } from "lucide-react"; -import { useTranslation } from "react-i18next"; - -interface VersionAlertProps { - updateInfo: { - success: boolean; - status?: "up_to_date" | "requires_update" | "beta"; - localVersion?: string; - remoteVersion?: string; - latest_release?: { - tag_name: string; - name: string; - published_at: string; - html_url: string; - body: string; - }; - cached?: boolean; - cache_age?: number; - error?: string; - }; - onDownload?: () => void; -} - -export function VersionAlert({ updateInfo, onDownload }: VersionAlertProps) { - const { t } = useTranslation(); - - if (!updateInfo.success) { - return ( - - - {t("versionCheck.error")} - - {updateInfo.error || t("versionCheck.checkFailed")} - - - ); - } - - if (updateInfo.status === "up_to_date") { - return ( - - - {t("versionCheck.upToDate")} - - {t("versionCheck.currentVersion", { - version: updateInfo.localVersion, - })} - - - ); - } - - if (updateInfo.status === "beta") { - return ( - - - {t("versionCheck.betaVersion")} - - {t("versionCheck.betaVersionDesc", { - current: updateInfo.localVersion, - latest: updateInfo.remoteVersion, - })} - - - ); - } - - if (updateInfo.status === "requires_update") { - return ( - - - {t("versionCheck.updateAvailable")} - -
- {t("versionCheck.newVersionAvailable", { - current: updateInfo.localVersion, - latest: updateInfo.remoteVersion, - })} -
- - {updateInfo.latest_release && ( -
-
- {updateInfo.latest_release.name} -
-
- {t("versionCheck.releasedOn", { - date: new Date( - updateInfo.latest_release.published_at, - ).toLocaleDateString(), - })} -
-
- )} - -
- {updateInfo.latest_release?.html_url && ( - - )} -
-
-
- ); - } - - return null; -} diff --git a/src/ui/dashboard/DashboardTab.tsx b/src/ui/dashboard/DashboardTab.tsx index e1133897b..380e04be3 100644 --- a/src/ui/dashboard/DashboardTab.tsx +++ b/src/ui/dashboard/DashboardTab.tsx @@ -1129,11 +1129,9 @@ export function DashboardTab({ getUserInfo() .then((info) => { - // Remote sync is not yet configurable (added in a later phase), so - // a standalone desktop install never shows admin/user-management - // UI -- it has exactly one implicit user and nothing to administer. - const isRemoteSyncConnected = false; - setIsAdmin(!!info.is_admin && (!isElectron() || isRemoteSyncConnected)); + // A desktop on its own has one implicit user and nothing to + // administer; linked, it is an admin when the server account is. + setIsAdmin(isElectron() ? !!info.linked?.isAdmin : !!info.is_admin); }) .catch(() => {}); getUptime() diff --git a/src/ui/hooks/use-sync-status.ts b/src/ui/hooks/use-sync-status.ts new file mode 100644 index 000000000..ffff67ea7 --- /dev/null +++ b/src/ui/hooks/use-sync-status.ts @@ -0,0 +1,22 @@ +import { useSyncExternalStore } from "react"; +import { getSyncStatusSnapshot, subscribeSyncStatus } from "@/lib/sync-status"; + +/** The desktop's sync status, kept current while the component is mounted. */ +export function useSyncStatus() { + return useSyncExternalStore( + subscribeSyncStatus, + getSyncStatusSnapshot, + () => null, + ); +} + +/** What needs the user's attention in Sync, for the rail badge. */ +export function useSyncAttentionCount(): number | null { + const status = useSyncStatus(); + if (!status?.linked) return null; + const count = + (status.conflicts ?? 0) + + (status.errors ?? 0) + + (status.status === "signed_out" ? 1 : 0); + return count || null; +} diff --git a/src/ui/index.css b/src/ui/index.css index c57542f2b..c22015aa3 100644 --- a/src/ui/index.css +++ b/src/ui/index.css @@ -484,6 +484,132 @@ } } +/* Icons sized with the size prop carry width/height attributes in px, which + the interface size setting cannot reach. These restate them in CSS, where + they become rem like every other length. A size class on the icon wins. */ +@layer base { + svg.lucide[width="8"] { + width: 8px; + height: 8px; + } + svg.lucide[width="9"] { + width: 9px; + height: 9px; + } + svg.lucide[width="10"] { + width: 10px; + height: 10px; + } + svg.lucide[width="11"] { + width: 11px; + height: 11px; + } + svg.lucide[width="12"] { + width: 12px; + height: 12px; + } + svg.lucide[width="13"] { + width: 13px; + height: 13px; + } + svg.lucide[width="14"] { + width: 14px; + height: 14px; + } + svg.lucide[width="15"] { + width: 15px; + height: 15px; + } + svg.lucide[width="16"] { + width: 16px; + height: 16px; + } + svg.lucide[width="17"] { + width: 17px; + height: 17px; + } + svg.lucide[width="18"] { + width: 18px; + height: 18px; + } + svg.lucide[width="19"] { + width: 19px; + height: 19px; + } + svg.lucide[width="20"] { + width: 20px; + height: 20px; + } + svg.lucide[width="21"] { + width: 21px; + height: 21px; + } + svg.lucide[width="22"] { + width: 22px; + height: 22px; + } + svg.lucide[width="23"] { + width: 23px; + height: 23px; + } + svg.lucide[width="24"] { + width: 24px; + height: 24px; + } + svg.lucide[width="25"] { + width: 25px; + height: 25px; + } + svg.lucide[width="26"] { + width: 26px; + height: 26px; + } + svg.lucide[width="27"] { + width: 27px; + height: 27px; + } + svg.lucide[width="28"] { + width: 28px; + height: 28px; + } + svg.lucide[width="29"] { + width: 29px; + height: 29px; + } + svg.lucide[width="30"] { + width: 30px; + height: 30px; + } + svg.lucide[width="31"] { + width: 31px; + height: 31px; + } + svg.lucide[width="32"] { + width: 32px; + height: 32px; + } + svg.lucide[width="36"] { + width: 36px; + height: 36px; + } + svg.lucide[width="40"] { + width: 40px; + height: 40px; + } + svg.lucide[width="48"] { + width: 48px; + height: 48px; + } + svg.lucide[width="64"] { + width: 64px; + height: 64px; + } + svg.lucide[width="96"] { + width: 96px; + height: 96px; + } +} + /* Font size scale applied to html element */ html.fs-xs { font-size: 12px; diff --git a/src/ui/lib/connection-origin.ts b/src/ui/lib/connection-origin.ts index aaaabd0c8..86a534530 100644 --- a/src/ui/lib/connection-origin.ts +++ b/src/ui/lib/connection-origin.ts @@ -1,5 +1,6 @@ import { isElectron } from "@/lib/electron"; import { websocketAuthProtocols } from "@/lib/ws-auth"; +import { getLinkedSession } from "@/lib/linked-server"; export type ConnectionOrigin = "local" | "remote"; @@ -10,7 +11,7 @@ interface OriginResolvableHost { /** * Resolves which backend a given host's interactive connection (SSH, Docker * console, RDP/VNC/Telnet) should dial: the desktop app's embedded local - * backend, or a connected remote sync server. A plugin whose connection is + * backend, or the server it is linked to. A plugin whose connection is * always local regardless of this setting (serial: the hardware is * physically attached to this desktop machine) skips this helper entirely * and calls app.wsUrl() without an origin option, which defaults to "local". @@ -55,26 +56,14 @@ export interface RemoteConnectionTarget { } async function getRemoteConnectionTarget(): Promise { - try { - const [config, jwt] = await Promise.all([ - window.electronAPI?.invoke?.("get-remote-sync-config") as Promise<{ - serverUrl?: string; - } | null>, - window.electronAPI?.invoke?.("get-remote-sync-jwt") as Promise< - string | null - >, - ]); - if (!config?.serverUrl) return null; - return { serverUrl: config.serverUrl, jwt: jwt ?? null }; - } catch { - return null; - } + const linked = await getLinkedSession(); + return linked ? { serverUrl: linked.serverUrl, jwt: linked.token } : null; } /** * Builds the base WebSocket URL for an interactive connection protocol, - * given a resolved origin. Returns null when origin is "remote" but no - * remote server is connected -- callers must show a blocking message + * given a resolved origin. Returns null when origin is "remote" but this + * desktop is not linked to a server -- callers must show a blocking message * rather than attempting to connect. */ export interface WebSocketConnectionTarget { diff --git a/src/ui/lib/hosts-request-cache.ts b/src/ui/lib/hosts-request-cache.ts index 5ae87c08a..a720946f9 100644 --- a/src/ui/lib/hosts-request-cache.ts +++ b/src/ui/lib/hosts-request-cache.ts @@ -1,6 +1,6 @@ import type { SSHHost, SSHFolder } from "@/types/index"; import type { ServerStatus } from "@/main-axios"; -import { isElectron } from "./electron"; +import { SYNC_CHANGED_EVENT } from "./sync-events"; import { createTtlRequestCache } from "./ttl-request-cache"; /** Host list changes less often than status; keep a short shared window. */ @@ -16,7 +16,6 @@ const statusCache = const foldersCache = createTtlRequestCache(FOLDERS_TTL_MS); let listenersBound = false; -let lastRemoteSyncCompleteAt: string | null = null; function bindInvalidationListeners(): void { if (listenersBound || typeof window === "undefined") return; @@ -29,32 +28,10 @@ function bindInvalidationListeners(): void { window.addEventListener("ssh-hosts:changed", invalidateHosts); window.addEventListener("hosts:refresh", invalidateHosts); - if (isElectron()) { - window.electronAPI - ?.invoke?.("get-remote-sync-status") - .then((status) => { - const syncedAt = - status && typeof status === "object" - ? (status as { lastSyncedAt?: unknown }).lastSyncedAt - : null; - lastRemoteSyncCompleteAt = - typeof syncedAt === "string" ? syncedAt : null; - }) - .catch(() => {}); - - window.electronAPI?.onRemoteSyncStatusChanged?.((status) => { - if (!status || status.syncing || status.lastError) return; - const syncedAt = - typeof status.lastSyncedAt === "string" ? status.lastSyncedAt : null; - if (!syncedAt || syncedAt === lastRemoteSyncCompleteAt) return; - - lastRemoteSyncCompleteAt = syncedAt; - invalidateHostsAndStatusCaches(); - window.dispatchEvent(new CustomEvent("hosts:refresh")); - window.dispatchEvent(new CustomEvent("termix:hosts-changed")); - window.dispatchEvent(new CustomEvent("termix:credentials-changed")); - }); - } + // A finished sync pass may have changed hosts and their statuses. + window.addEventListener(SYNC_CHANGED_EVENT, () => { + invalidateHostsAndStatusCaches(); + }); } export function getCachedSSHHosts( diff --git a/src/ui/lib/linked-server.ts b/src/ui/lib/linked-server.ts new file mode 100644 index 000000000..6435c8775 --- /dev/null +++ b/src/ui/lib/linked-server.ts @@ -0,0 +1,50 @@ +import { isElectron } from "@/lib/electron"; +import { authApi } from "@/main-axios"; +import { SYNC_CHANGED_EVENT } from "@/lib/sync-events"; + +export { SYNC_CHANGED_EVENT }; + +/** The server this desktop is linked to, and the session it syncs with. */ +export interface LinkedSession { + serverUrl: string; + token: string; +} + +const CACHE_MS = 60_000; +let cached: { at: number; value: LinkedSession | null } | null = null; +let inflight: Promise | null = null; + +if (typeof window !== "undefined") { + window.addEventListener(SYNC_CHANGED_EVENT, () => { + cached = null; + }); +} + +/** Null on the web, or when this desktop is not linked or signed out. */ +export async function getLinkedSession(): Promise { + if (!isElectron()) return null; + if (cached && Date.now() - cached.at < CACHE_MS) return cached.value; + if (!inflight) { + inflight = authApi + .get("/sync/link/session") + .then((response) => { + const { serverUrl, token } = response.data ?? {}; + const value = + typeof serverUrl === "string" && typeof token === "string" + ? { serverUrl: serverUrl.replace(/\/$/, ""), token } + : null; + cached = { at: Date.now(), value }; + return value; + }) + .catch(() => null) + .finally(() => { + inflight = null; + }); + } + return inflight; +} + +export function notifySyncChanged(): void { + cached = null; + window.dispatchEvent(new CustomEvent(SYNC_CHANGED_EVENT)); +} diff --git a/src/ui/lib/rem.ts b/src/ui/lib/rem.ts new file mode 100644 index 000000000..b90e95736 --- /dev/null +++ b/src/ui/lib/rem.ts @@ -0,0 +1,17 @@ +const ROOT_PX = 14; + +/** + * A length in px at the Normal interface size, as rem, so it follows the + * interface size setting like Tailwind classes do. + */ +export function rem(px: number): string { + return `${px / ROOT_PX}rem`; +} + +/** How many real pixels one Normal-size pixel takes right now. */ +export function remScale(): number { + const size = parseFloat( + getComputedStyle(document.documentElement).fontSize || `${ROOT_PX}`, + ); + return Number.isFinite(size) && size > 0 ? size / ROOT_PX : 1; +} diff --git a/src/ui/lib/remote-server-api.ts b/src/ui/lib/remote-server-api.ts index bffbedea6..5e99e63ba 100644 --- a/src/ui/lib/remote-server-api.ts +++ b/src/ui/lib/remote-server-api.ts @@ -1,97 +1,34 @@ import type { AxiosInstance } from "axios"; import { getRemoteCoreApi } from "@/main-axios"; -import { isElectron } from "@/lib/electron"; -import type { SSHHost } from "@/types/index"; +import { getLinkedSession } from "@/lib/linked-server"; -export function markRemoteSharedHosts(hosts: SSHHost[]): SSHHost[] { - return hosts - .filter((host) => host.isShared) - .map((host) => ({ - ...host, - // Local SQLite ids are positive. Negative ids keep remote-only shared - // rows distinct while syncId remains the delegated backend identity. - id: -Math.abs(host.id), - })); -} - -export interface SharedHostConnectionAuth { - username?: string | null; - authType?: string | null; - password?: string | null; - key?: string | null; - keyPassword?: string | null; - keyType?: string | null; -} - -async function getRemoteSharedHostConnectionAuth( - localHostId: number, -): Promise { - if (localHostId >= 0) { - throw new Error("Expected a remote shared host id"); - } - const api = await getConnectedRemoteApi(); - if (!api) throw new Error("Remote server is not connected"); - const response = await api.get( - `/host/db/host/${Math.abs(localHostId)}/local-connection-auth`, - ); - return response.data; -} - -export async function hydrateLocalSharedHostAuth< - T extends { - id?: number; - isShared?: unknown; - syncId?: string | null; - credentialId?: number; - username: string; - authType?: string; - password?: string; - key?: string; - keyPassword?: string; - keyType?: string; - }, ->(host: T): Promise { - if (!host.isShared || typeof host.id !== "number" || host.id >= 0) { - return host; - } - - const auth = await getRemoteSharedHostConnectionAuth(host.id); - return { - ...host, - // This row deliberately does not exist in the embedded database. Avoid - // asking the local backend to resolve its remote sync identity again. - syncId: null, - credentialId: undefined, - username: auth.username || host.username, - authType: auth.authType || host.authType, - password: auth.password || undefined, - key: auth.key || undefined, - keyPassword: auth.keyPassword || undefined, - keyType: auth.keyType || undefined, - }; +/** + * Hosts shared with a linked account arrive through sync as read-only local + * copies that already carry the auth the owner shared, so a connection needs + * nothing extra. Kept for plugins that still call it. + */ +export async function hydrateLocalSharedHostAuth(host: T): Promise { + return host; } +/** The linked server's core API, or null when this desktop is not linked. */ export async function getConnectedRemoteApi(): Promise { - if (!isElectron()) return null; - try { - const config = (await window.electronAPI?.invoke?.( - "get-remote-sync-config", - )) as { serverUrl?: string } | null; - return config?.serverUrl ? getRemoteCoreApi() : null; - } catch { - return null; - } + return (await getLinkedSession()) ? getRemoteCoreApi() : null; } +/** The linked server's own id for a host, found by its sync id. */ export async function resolveRemoteHostId( syncId: string | null | undefined, ): Promise { if (!syncId) return null; const api = await getConnectedRemoteApi(); if (!api) return null; - const response = await api.get("/sync/hosts"); - const host = (response.data?.rows ?? []).find( - (row: { syncId?: string }) => row.syncId === syncId, - ); - return typeof host?.id === "number" ? host.id : null; + try { + const response = await api.get( + `/sync/v2/hosts/${encodeURIComponent(syncId)}`, + ); + return typeof response.data?.id === "number" ? response.data.id : null; + } catch { + return null; + } } diff --git a/src/ui/lib/remote-sync-trigger.ts b/src/ui/lib/remote-sync-trigger.ts deleted file mode 100644 index d5f86700e..000000000 --- a/src/ui/lib/remote-sync-trigger.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { isElectron } from "@/lib/electron"; - -export async function requestRemoteSync(): Promise { - if (!isElectron()) return; - - try { - const config = (await window.electronAPI?.invoke?.( - "get-remote-sync-config", - )) as { serverUrl?: string } | null; - if (config?.serverUrl) { - await window.electronAPI?.invoke?.("remote-sync-now"); - } - } catch { - // The periodic sync remains the fallback when IPC or the server is down. - } -} diff --git a/src/ui/lib/sync-events.ts b/src/ui/lib/sync-events.ts new file mode 100644 index 000000000..734e24c56 --- /dev/null +++ b/src/ui/lib/sync-events.ts @@ -0,0 +1,2 @@ +/** Fired whenever the desktop's link or its synced data changed. */ +export const SYNC_CHANGED_EVENT = "termix:sync-changed"; diff --git a/src/ui/lib/sync-status.ts b/src/ui/lib/sync-status.ts new file mode 100644 index 000000000..593d4c8a8 --- /dev/null +++ b/src/ui/lib/sync-status.ts @@ -0,0 +1,86 @@ +import { isElectron } from "@/lib/electron"; +import { getSyncStatus, type SyncStatus } from "@/api/sync-api"; +import { notifySyncChanged } from "@/lib/linked-server"; + +/** + * The desktop's sync status, polled from the embedded backend while anything + * shows it. When a pass finishes, the views that show synced data are told + * to reload. + */ + +const IDLE_MS = 15_000; +const BUSY_MS = 2_000; + +let snapshot: SyncStatus | null = null; +let timer: ReturnType | null = null; +let lastSyncAt: string | null | undefined; +const listeners = new Set<() => void>(); + +function emit(): void { + for (const listener of listeners) listener(); +} + +function announce(): void { + notifySyncChanged(); + window.dispatchEvent(new CustomEvent("hosts:refresh")); + window.dispatchEvent(new CustomEvent("termix:hosts-changed")); + window.dispatchEvent(new CustomEvent("termix:credentials-changed")); + window.dispatchEvent(new CustomEvent("termix:plugins-changed")); +} + +async function poll(): Promise { + timer = null; + try { + const next = await getSyncStatus(); + const syncedAt = next.lastSyncAt ?? null; + if (lastSyncAt !== undefined && syncedAt !== lastSyncAt) announce(); + if (snapshot && snapshot.linked !== next.linked) notifySyncChanged(); + lastSyncAt = syncedAt; + snapshot = next; + emit(); + } catch { + // The backend may still be starting; the next poll tries again. + } + schedule(); +} + +function schedule(delay?: number): void { + if (listeners.size === 0 || timer) return; + const busy = snapshot?.status === "syncing" || !!snapshot?.pending; + timer = setTimeout(() => void poll(), delay ?? (busy ? BUSY_MS : IDLE_MS)); +} + +export function subscribeSyncStatus(listener: () => void): () => void { + listeners.add(listener); + if (isElectron() && listeners.size === 1 && !timer) void poll(); + return () => { + listeners.delete(listener); + if (listeners.size === 0 && timer) { + clearTimeout(timer); + timer = null; + } + }; +} + +export function getSyncStatusSnapshot(): SyncStatus | null { + return snapshot; +} + +/** Takes a status a request already returned, and checks again soon. */ +export function setSyncStatus(next: SyncStatus): void { + snapshot = next; + if (lastSyncAt !== undefined && (next.lastSyncAt ?? null) !== lastSyncAt) { + announce(); + } + lastSyncAt = next.lastSyncAt ?? null; + emit(); + if (timer) clearTimeout(timer); + timer = null; + schedule(BUSY_MS); +} + +export function refreshSyncStatus(): void { + if (timer) clearTimeout(timer); + timer = null; + void poll(); +} diff --git a/src/ui/lib/theme.ts b/src/ui/lib/theme.ts index c510664ea..37ff1443f 100644 --- a/src/ui/lib/theme.ts +++ b/src/ui/lib/theme.ts @@ -65,11 +65,29 @@ export const FONT_SIZES: { id: FontSizeId; label: string }[] = [ { id: "xl", label: "XL" }, ]; +/** The root font size of each interface size, relative to Normal. */ +export const FONT_SIZE_SCALE: Record = { + xs: 12 / 14, + sm: 13 / 14, + md: 1, + lg: 17 / 14, + xl: 20 / 14, +}; + export function applyFontSize(id: FontSizeId) { const root = document.documentElement; + const size = id in FONT_SIZE_SCALE ? id : "md"; root.classList.remove("fs-xs", "fs-sm", "fs-md", "fs-lg", "fs-xl"); - root.classList.add(`fs-${id}`); - localStorage.setItem("termix-font-size", id); + root.classList.add(`fs-${size}`); + localStorage.setItem("termix-font-size", size); + + // Every length is in rem, so the root size scales the whole interface. An + // earlier desktop build zoomed the window instead; undo that if it did. + ( + window as Window & { + electronAPI?: { setZoomFactor?: (factor: number) => void }; + } + ).electronAPI?.setZoomFactor?.(1); } export const UI_FONTS: { id: UiFontId; label: string; family: string }[] = [ diff --git a/src/ui/locales/en.json b/src/ui/locales/en.json index b757aeff7..9b440a1be 100644 --- a/src/ui/locales/en.json +++ b/src/ui/locales/en.json @@ -112,86 +112,8 @@ "arrangeUnlockedSwitchedToManual": "Switched to manual sort so your order sticks", "arrangeUnlocked": "Unlock drag to rearrange" }, - "serverConfig": { - "title": "Server Configuration", - "description": "Configure the Termix server URL to connect to your backend services", - "serverUrl": "Server URL", - "enterServerUrl": "Please enter a server URL", - "saveFailed": "Failed to save configuration", - "saveError": "Error saving configuration", - "saving": "Saving...", - "saveConfig": "Save Configuration", - "helpText": "Enter the URL where your Termix server is running (e.g., http://localhost:30001 or https://your-server.com)", - "changeServer": "Change Server", - "mustIncludeProtocol": "Server URL must start with http:// or https://", - "allowInvalidCertificate": "Allow invalid certificate", - "allowInvalidCertificateDesc": "Use only for trusted self-hosted servers with self-signed or IP-address certificates.", - "useEmbedded": "Use Local Server", - "embeddedDesc": "Run Termix with the built-in local server (no remote server needed)", - "embeddedConnecting": "Connecting to local server...", - "embeddedNotReady": "Local server is not ready yet. Please wait a moment and try again.", - "localServer": "Local Server", - "savedServers": "Saved Servers", - "noSavedServers": "No saved servers", - "removeServer": "Remove" - }, - "migrationNotice": { - "title": "Termix Desktop now runs standalone", - "body1": "This app used to connect straight to your Termix server. It's been reworked to run fully on its own, storing hosts and credentials locally so it keeps working offline. Two-way sync with a self-hosted Termix server is now optional.", - "body2": "Your existing hosts and credentials are still on {{url}}. Turn on Remote Sync and reconnect to that server to bring them back and keep both in sync going forward.", - "dismiss": "Not now", - "setUpSync": "Set Up Remote Sync" - }, - "remoteSync": { - "title": "Remote Sync", - "description": "Optionally connect this desktop app to a self-hosted Termix server to sync your hosts, credentials, and snippets across devices. The app always works fully offline whether or not you connect.", - "notConnected": "Not connected", - "connected": "Connected", - "connectedTo": "Connected to {{url}}", - "lastSynced": "Last synced {{time}}", - "neverSynced": "Never synced", - "syncError": "Sync error: {{message}}", - "needsReauth": "Sign-in expired", - "connectButton": "Connect to Server", - "disconnectButton": "Disconnect", - "syncNowButton": "Sync Now", - "syncing": "Syncing...", - "syncSuccess": "Remote sync completed", - "syncUnavailable": "Remote sync could not be started", - "serverUrl": "Server URL", - "enterServerUrl": "Please enter a server URL", - "mustIncludeProtocol": "Server URL must start with http:// or https://", - "connectionTestFailed": "Could not reach a Termix server at that URL", - "allowInvalidCertificate": "Allow invalid certificate", - "allowInvalidCertificateDesc": "Use only for trusted self-hosted servers with self-signed or IP-address certificates.", - "savedServers": "Saved Servers", - "removeServer": "Remove", - "continueButton": "Continue", - "cancelButton": "Cancel", - "signInTitle": "Sign in to {{url}}", - "originTitle": "Connection Origin", - "originDescription": "Choose where SSH connections originate from by default. This can be overridden per host.", - "originLocal": "This device (local network)", - "originRemote": "Remote server", - "bannerReconnect": "Reconnect", - "bannerMessage": "Remote sync needs re-authentication" - }, "versionCheck": { - "error": "Version Check Error", - "checkFailed": "Failed to check for updates", - "upToDate": "App is Up to Date", - "currentVersion": "You are running version {{version}}", - "updateAvailable": "Update Available", - "updateLinkLabel": "Update available — open the release notes on GitHub", - "newVersionAvailable": "A new version is available! You are running {{current}}, but {{latest}} is available.", - "betaVersion": "Beta Version", - "betaVersionDesc": "You are running {{current}}, which is newer than the latest stable release {{latest}}.", - "releasedOn": "Released on {{date}}", - "downloadUpdate": "Download Update", - "checking": "Checking for updates...", - "checkUpdates": "Check for Updates", - "checkingUpdates": "Checking for updates...", - "updateRequired": "Update Required" + "updateLinkLabel": "Update available. Open the release notes on GitHub" }, "common": { "close": "Close", @@ -327,7 +249,14 @@ "done_docs_link": "Open the docs", "done_cli": "Command line tool", "done_cli_desc": "Manage your servers from a terminal, and use Termix in your own scripts.", - "done_cli_link": "Open the CLI docs" + "done_cli_link": "Open the CLI docs", + "desktopTitle": "How do you want to use Termix?", + "desktopBody": "You can change this any time from Sync in the sidebar.", + "desktopLocalTitle": "On this device only", + "desktopLocalBody": "Everything is stored here. Nothing to set up.", + "desktopLinkTitle": "With my Termix server", + "desktopLinkBody": "Sign in to your server to use the same hosts and settings on every device.", + "desktopLinked": "Linked to {{server}}" }, "nav": { "home": "Home", @@ -374,7 +303,8 @@ "keepSideMenuOpen": "Keep Menu Open", "collapseSideMenu": "Collapse Menu", "roleAdministrator": "Administrator", - "roleUser": "User" + "roleUser": "User", + "sync": "Sync" }, "hosts": { "hosts": "Hosts", @@ -1072,7 +1002,11 @@ "authFieldsAfterSave": "Save the host first, then fill in these fields.", "enablePluginFeature": "Enable {{name}}", "disablePluginFeature": "Disable {{name}}", - "filterFeaturesGroup": "Features" + "filterFeaturesGroup": "Features", + "localOnly": "Keep on this device only", + "localOnlyDesc": "This host is not sent to the server and does not appear on your other devices.", + "folderLocalOnly": "Keep on this device only", + "folderLocalOnlyDesc": "This folder and every host in it stay on this device and are not sent to the server." }, "commandPalette": { "searchPlaceholder": "Search hosts, commands, or settings...", @@ -1404,8 +1338,6 @@ "resetCodeRateLimitedWithTime": "Rate limited: Too many verification attempts. Please wait {{time}} seconds before trying again.", "authTokenSaveFailed": "Failed to save authentication token", "authTokenMissing": "The server did not return a new authentication token. Please sign in again.", - "authTokenRejected": "The remote server rejected the new authentication token", - "keyringUnavailable": "Signed in, but the session could not be stored: this system has no keyring available to encrypt it with. Start a Secret Service (such as gnome-keyring or KWallet) and sign in again.", "failedToLoadServer": "Failed to load server", "remoteServerRequired": "Remote server required. Connect a remote server in Settings to use this connection type." }, @@ -2085,8 +2017,8 @@ "resetToDefaultsSuccess": "Settings reset to defaults.", "storageModeSwitch": "Preference Storage", "sectionAccount": "Account", - "desktopProfileTitle": "Automatic local desktop profile", - "desktopProfileDescription": "This profile is restricted to the embedded backend and signs in automatically. It has no login password; Remote Sync below uses a separate server account.", + "desktopProfileTitle": "This device only", + "desktopProfileDescription": "This profile lives on this device and signs in by itself. Link it to a Termix server to use the same hosts and settings on every device.", "sectionInterface": "Interface", "interfacePresetDesc": "How much detail Termix shows. Every feature stays available.", "preset_simple": "Simple", @@ -2266,7 +2198,11 @@ "themeSolarized": "Solarized", "themeTokyoNight": "Tokyo Night", "themeOneDark": "One Dark", - "themeGruvbox": "Gruvbox" + "themeGruvbox": "Gruvbox", + "linkedProfileTitle": "Signed in to {{server}}", + "linkedProfileDescription": "This device uses your account on the server and keeps a copy of your data, so it still works offline.", + "manageSync": "Manage sync", + "setUpSync": "Link to a server" } } }, @@ -2501,5 +2437,127 @@ "sudoHint": "Set a sudo password for this host in the host editor to enable privileged actions.", "noData": "No data", "filter": "Filter..." + }, + "sync": { + "server": "Termix server", + "account": "Account", + "status": "Status", + "never": "never", + "justNow": "just now", + "minutesAgo_one": "{{count}} minute ago", + "minutesAgo_other": "{{count}} minutes ago", + "hoursAgo_one": "{{count}} hour ago", + "hoursAgo_other": "{{count}} hours ago", + "actionFailed": "That did not work. Try again.", + "unlinkedTitle": "This device only", + "unlinkedBody": "Termix works on its own on this device. Link it to your Termix server to keep everything in step.", + "unlinkedPointHosts": "Your hosts, credentials, snippets and settings follow you to every device.", + "unlinkedPointOffline": "Everything still works when the server cannot be reached, and catches up later.", + "unlinkedPointAccount": "You sign in with your server account, and hosts people shared with you show up here too.", + "linkButton": "Link to a server", + "state": { + "syncing": "Syncing", + "offline": "Server not reachable", + "error": "Sync stopped", + "signedOut": "Signed out", + "synced": "Synced {{time}}" + }, + "pending_one": "{{count}} change waiting to sync", + "pending_other": "{{count}} changes waiting to sync", + "signInAgain": "Sign in again", + "syncNow": "Sync now", + "unlink": "Unlink", + "unlinked": "This device is no longer linked", + "unlinkTitle": "Unlink this device?", + "unlinkDescription": "It stops syncing and signs out of the server. Hosts shared with you by others are removed from this device either way.", + "unlinkKeepTitle": "Keep my data here", + "unlinkKeepBody": "Your hosts, credentials and settings stay on this device and keep working on their own.", + "unlinkRemoveTitle": "Remove synced data", + "unlinkRemoveBody": "Everything that came from the server is removed from this device. Hosts kept on this device only stay.", + "conflictsTitle": "Conflicts", + "conflictsDescription": "These were changed here and on the server at the same time. The server version is in use. Keep yours to send it to the server instead.", + "keepMine": "Keep mine", + "keepServer": "Keep server", + "errorsTitle": "Not synced", + "errorsDescription": "The server did not accept these changes. They stay on this device.", + "errorPermission": "Your account on the server is not allowed to make this change.", + "retry": "Try again", + "whatSyncs": "What syncs", + "whatSyncsDescription": "Turn off anything you want to keep on this device only.", + "coreGroup": "Termix", + "notOnServer": "The server does not have this", + "notOnDevice": "Not on this device yet", + "localOnlyHint": "You can also keep single hosts or folders on this device from their settings.", + "originTitle": "Where connections start", + "originDescription": "Connect to hosts from this device, or through the server. You can change this per host.", + "originLocal": "This device", + "originRemote": "The server", + "entities": { + "hosts": "Hosts", + "sshCredentials": "Credentials", + "sshFolders": "Folders", + "userPreferences": "Preferences", + "pluginUserSettings": "Feature settings", + "sharedHosts": "Hosts shared with you", + "sharedCredentials": "Credentials shared with you", + "vaultProfiles": "Vault profiles", + "snippetFolders": "Snippet folders", + "snippets": "Snippets", + "dashboardServiceLinks": "Service links", + "homepageItems": "Homepage items", + "networkTopology": "Network map", + "workspaces": "Workspaces" + }, + "proxy": { + "title": "Reverse proxy", + "description": "Only needed when something like Cloudflare Access or a login page sits in front of your server.", + "headersTitle": "Extra headers", + "headersHint": "Sent with every request to the server, for example a Cloudflare Access service token.", + "headerName": "Header name", + "headerValue": "Value", + "savedValue": "Saved, type to change", + "addHeader": "Add header", + "removeHeader": "Remove header", + "basicAuthTitle": "Username and password", + "basicAuthHint": "For a proxy that asks for a username and password before Termix.", + "username": "Username", + "password": "Password", + "allowInvalidCertificate": "Trust a self-signed certificate", + "allowInvalidCertificateHint": "Only for your own server, when its certificate is not from a known authority.", + "saved": "Saved" + }, + "problems": { + "invalidUrl": "That does not look like a web address.", + "unreachable": "Could not reach the server. Check the address and that the server is running.", + "unreachableWithReason": "Could not reach the server: {{reason}}", + "tlsUntrusted": "The server certificate is not trusted. If this is your own server with a self-signed certificate, turn on the option below.", + "basicAuthRequired": "Something in front of the server asks for a username and password. Enter them below.", + "proxyLogin": "A login page or proxy answered instead of Termix. Add the headers it needs below, or check the address.", + "notTermix": "There is no Termix server at that address. If it runs under a path, include it, like https://example.com/termix.", + "versionMismatch": "That server runs an older Termix. Update it to 2.9 or newer to link.", + "differentAccount": "You signed in to a different account. Sign in to the account this device is linked to, or unlink first." + }, + "wizard": { + "title": "Link to a Termix server", + "reloginTitle": "Sign in again", + "serverDescription": "Enter the address you use to open Termix in your browser.", + "signinDescription": "Sign in to {{url}}", + "chooseDescription": "This device already has data. What should happen to it?", + "finishingDescription": "Linking this device.", + "serverUrl": "Server address", + "serverUrlHint": "For example https://termix.example.com", + "showProxySettings": "Reverse proxy settings", + "hideProxySettings": "Hide reverse proxy settings", + "continue": "Continue", + "back": "Back", + "mergeTitle": "Add it to my account", + "mergeBody": "The {{count}} items on this device are added to your account. Items your account already has stay as they are on the server.", + "replaceTitle": "Replace it with my account", + "replaceBody": "The {{count}} items on this device are removed, and this device takes everything from your account. Hosts kept on this device only stay.", + "link": "Link", + "linking": "Linking and running the first sync...", + "linked": "Linked. Your data is syncing now.", + "signedInAgain": "Signed in. Sync is running again." + } } } diff --git a/src/ui/locales/translated/af_ZA.json b/src/ui/locales/translated/af_ZA.json index bfb7cd2bb..bb09ce59d 100644 --- a/src/ui/locales/translated/af_ZA.json +++ b/src/ui/locales/translated/af_ZA.json @@ -232,86 +232,8 @@ "widgetTunnelDesc": "Ingeboude SSH-tonnelbestuurder vir 'n gekonfigureerde gasheer", "widgetNoHostSelected": "Geen gasheer gekonfigureer nie" }, - "serverConfig": { - "title": "Bedienerkonfigurasie", - "description": "Konfigureer die Termix-bediener-URL om aan jou backend-dienste te koppel", - "serverUrl": "Bediener-URL", - "enterServerUrl": "Voer asseblief 'n bediener-URL in", - "saveFailed": "Kon nie konfigurasie stoor nie", - "saveError": "Fout met die stoor van die konfigurasie", - "saving": "Stoor...", - "saveConfig": "Stoor Konfigurasie", - "helpText": "Voer die URL in waar jou Termix-bediener loop (bv. http://localhost:30001 of https://your-server.com)", - "changeServer": "Verander bediener", - "mustIncludeProtocol": "Bediener-URL moet met http:// of https:// begin", - "allowInvalidCertificate": "Laat ongeldige sertifikaat toe", - "allowInvalidCertificateDesc": "Gebruik slegs vir vertroude selfgehoste bedieners met selfgetekende of IP-adres sertifikate.", - "useEmbedded": "Gebruik plaaslike bediener", - "embeddedDesc": "Begin Termix met die ingeboude plaaslike bediener (geen afstandbediener nodig nie)", - "embeddedConnecting": "Verbind met plaaslike bediener...", - "embeddedNotReady": "Die plaaslike bediener is nog nie gereed nie. Wag asseblief 'n oomblik en probeer weer.", - "localServer": "Plaaslike bediener", - "savedServers": "Gestoorde bedieners", - "noSavedServers": "Geen gestoorde bedieners nie", - "removeServer": "Verwyder" - }, - "migrationNotice": { - "title": "Termix Desktop loop nou alleenstaande", - "body1": "Hierdie toepassing het voorheen direk aan jou Termix-bediener gekoppel. Dit is herontwerp om volledig op sy eie te loop, en stoor gashere en geloofsbriewe plaaslik sodat dit vanlyn kan bly werk. Tweerigting-sinchronisasie met 'n self-gehoste Termix-bediener is nou opsioneel.", - "body2": "Jou bestaande gashere en geloofsbriewe is steeds op {{url}}. Skakel Afstandsinchronisasie aan en koppel weer aan daardie bediener om hulle terug te bring en albei vorentoe gesinchroniseer te hou.", - "dismiss": "Nie nou nie", - "setUpSync": "Stel afstandsinchronisasie op" - }, - "remoteSync": { - "title": "Afstandsinchronisasie", - "description": "Koppel opsioneel hierdie lessenaar-app aan 'n self-gehoste Termix-bediener om jou gashere, geloofsbriewe en brokkies oor toestelle te sinkroniseer. Die app werk altyd ten volle vanlyn, of jy nou koppel of nie.", - "notConnected": "Nie gekoppel nie", - "connected": "Verbonde", - "connectedTo": "Gekoppel aan {{url}}", - "lastSynced": "Laas gesinkroniseer {{time}}", - "neverSynced": "Nooit gesinkroniseer nie", - "syncError": "Sinkronisasiefout: {{message}}", - "needsReauth": "Aanmelding het verval", - "connectButton": "Koppel aan bediener", - "disconnectButton": "Ontkoppel", - "syncNowButton": "Sinkroniseer nou", - "syncing": "Sinkroniseer...", - "syncSuccess": "Afstandsinchronisasie voltooi", - "syncUnavailable": "Afstandsinchronisasie kon nie begin word nie", - "serverUrl": "Bediener-URL", - "enterServerUrl": "Voer asseblief 'n bediener-URL in", - "mustIncludeProtocol": "Bediener-URL moet met http:// of https:// begin", - "connectionTestFailed": "Kon nie 'n Termix-bediener by daardie URL bereik nie", - "allowInvalidCertificate": "Laat ongeldige sertifikaat toe", - "allowInvalidCertificateDesc": "Gebruik slegs vir vertroude selfgehoste bedieners met selfgetekende of IP-adres sertifikate.", - "savedServers": "Gestoorde bedieners", - "removeServer": "Verwyder", - "continueButton": "Gaan voort", - "cancelButton": "Kanselleer", - "signInTitle": "Meld aan by {{url}}", - "originTitle": "Verbindingsoorsprong", - "originDescription": "Kies waar SSH-verbindings standaard vandaan kom. Dit kan per gasheer oorskryf word.", - "originLocal": "Hierdie toestel (plaaslike netwerk)", - "originRemote": "Afstandbediener", - "bannerReconnect": "Herkoppel", - "bannerMessage": "Afstandsinchronisasie benodig herverifikasie" - }, "versionCheck": { - "error": "Weergawekontrolefout", - "checkFailed": "Kon nie vir opdaterings kyk nie", - "upToDate": "Toepassing is op datum", - "currentVersion": "Jy gebruik weergawe {{version}}", - "updateAvailable": "Opdatering beskikbaar", - "updateLinkLabel": "Opdatering beskikbaar — maak die vrystellingsnotas op GitHub oop", - "newVersionAvailable": "'n Nuwe weergawe is beskikbaar! Jy gebruik {{current}}, maar {{latest}} is beskikbaar.", - "betaVersion": "Beta-weergawe", - "betaVersionDesc": "Jy gebruik {{current}}, wat nuwer is as die nuutste stabiele weergawe {{latest}}.", - "releasedOn": "Uitgereik op {{date}}", - "downloadUpdate": "Laai opdatering af", - "checking": "Kontroleer tans vir opdaterings...", - "checkUpdates": "Kyk vir opdaterings", - "checkingUpdates": "Kontroleer tans vir opdaterings...", - "updateRequired": "Opdatering Vereis" + "updateLinkLabel": "Opdatering beskikbaar — maak die vrystellingsnotas op GitHub oop" }, "common": { "close": "Maak toe", @@ -1565,8 +1487,6 @@ "resetCodeRateLimitedWithTime": "Tempo beperk: Te veel verifikasiepogings. Wag asseblief {{time}} sekondes voordat u weer probeer.", "authTokenSaveFailed": "Kon nie verifikasietoken stoor nie", "authTokenMissing": "Die bediener het nie 'n nuwe verifikasietoken teruggegee nie. Meld asseblief weer aan.", - "authTokenRejected": "Die afgeleë bediener het die nuwe verifikasieteken verwerp", - "keyringUnavailable": "Aangemeld, maar die sessie kon nie gestoor word nie: hierdie stelsel het geen sleutelring beskikbaar om dit mee te enkripteer nie. Begin 'n Geheime Diens (soos gnome-sleutelring of KWallet) en meld weer aan.", "failedToLoadServer": "Kon nie bediener laai nie", "remoteServerRequired": "Afstandbediener word benodig. Koppel 'n afstandbediener in Instellings om hierdie verbindingstipe te gebruik." }, diff --git a/src/ui/locales/translated/ar_SA.json b/src/ui/locales/translated/ar_SA.json index ecdb2c9f0..7557565e9 100644 --- a/src/ui/locales/translated/ar_SA.json +++ b/src/ui/locales/translated/ar_SA.json @@ -232,86 +232,8 @@ "widgetTunnelDesc": "مدير أنفاق SSH مدمج لمضيف مكوّن", "widgetNoHostSelected": "لم يتم تكوين أي مضيف" }, - "serverConfig": { - "title": "إعدادات الخادم", - "description": "تكوين رابط خادم Termix للاتصال بخدماتك الخلفية", - "serverUrl": "رابط الخادم", - "enterServerUrl": "يرجى إدخال رابط الخادم", - "saveFailed": "فشل حفظ الإعدادات", - "saveError": "خطأ في حفظ الإعدادات", - "saving": "جاري الحفظ...", - "saveConfig": "حفظ الإعدادات", - "helpText": "أدخل الرابط حيث يعمل خادم Termix (مثال: http://localhost:30001 أو https://your-server.com)", - "changeServer": "تغيير الخادم", - "mustIncludeProtocol": "يجب أن يبدأ رابط الخادم بـ http:// أو https://", - "allowInvalidCertificate": "السماح بشهادة غير صالحة", - "allowInvalidCertificateDesc": "استخدم فقط للخوادم الموثوقة ذاتية الاستضافة التي تحمل شهادات موقعة ذاتيًا أو بعنوان IP.", - "useEmbedded": "استخدام الخادم المحلي", - "embeddedDesc": "تشغيل Termix مع الخادم المحلي المدمج (لا حاجة لخادم بعيد)", - "embeddedConnecting": "جاري الاتصال بالخادم المحلي...", - "embeddedNotReady": "الخادم المحلي غير جاهز بعد. يرجى الانتظار قليلاً ثم المحاولة مرة أخرى.", - "localServer": "الخادم المحلي", - "savedServers": "الخوادم المحفوظة", - "noSavedServers": "لا توجد خوادم محفوظة", - "removeServer": "إزالة" - }, - "migrationNotice": { - "title": "يعمل برنامج Termix Desktop الآن بشكل مستقل", - "body1": "كان هذا التطبيق يتصل مباشرةً بخادم Termix الخاص بك. تم تطويره ليعمل بشكل مستقل تمامًا، حيث يخزن بيانات المضيفين وبيانات الاعتماد محليًا، مما يضمن استمرارية عمله دون اتصال بالإنترنت. أصبح التزامن ثنائي الاتجاه مع خادم Termix مُستضاف ذاتيًا اختياريًا الآن.", - "body2": "لا تزال بيانات المضيفين وبيانات الاعتماد الحالية موجودة على {{url}}. قم بتشغيل المزامنة عن بُعد وأعد الاتصال بهذا الخادم لاستعادتها والحفاظ على تزامنها في المستقبل.", - "dismiss": "ليس الآن", - "setUpSync": "إعداد المزامنة عن بُعد" - }, - "remoteSync": { - "title": "المزامنة عن بعد", - "description": "يمكنك ربط تطبيق سطح المكتب هذا بخادم Termix مُستضاف ذاتيًا لمزامنة بيانات المضيفين وبيانات الاعتماد والقصاصات البرمجية عبر الأجهزة. يعمل التطبيق دائمًا بشكل كامل دون اتصال بالإنترنت سواءً كنت متصلاً أم لا.", - "notConnected": "غير متصل", - "connected": "متصل", - "connectedTo": "متصل بـ {{url}}", - "lastSynced": "آخر مزامنة {{time}}", - "neverSynced": "لم تتم المزامنة مطلقاً", - "syncError": "خطأ في المزامنة: {{message}}", - "needsReauth": "انتهت صلاحية تسجيل الدخول", - "connectButton": "الاتصال بالخادم", - "disconnectButton": "قطع الاتصال", - "syncNowButton": "مزامنة الآن", - "syncing": "جارٍ المزامنة...", - "syncSuccess": "اكتملت المزامنة عن بعد", - "syncUnavailable": "تعذر بدء المزامنة عن بعد", - "serverUrl": "عنوان الخادم", - "enterServerUrl": "الرجاء إدخال عنوان URL للخادم", - "mustIncludeProtocol": "يجب أن يبدأ عنوان URL للخادم بـ http:// أو https://", - "connectionTestFailed": "تعذر الوصول إلى خادم Termix على عنوان URL هذا", - "allowInvalidCertificate": "السماح بشهادة غير صالحة", - "allowInvalidCertificateDesc": "استخدمه فقط للخوادم الموثوقة ذاتية الاستضافة التي تحتوي على شهادات موقعة ذاتيًا أو شهادات عناوين IP.", - "savedServers": "الخوادم المحفوظة", - "removeServer": "يزيل", - "continueButton": "يكمل", - "cancelButton": "يلغي", - "signInTitle": "سجّل الدخول إلى {{url}}", - "originTitle": "أصل الاتصالات", - "originDescription": "اختر مصدر اتصالات SSH الافتراضي. يمكن تغيير هذا الإعداد لكل مضيف على حدة.", - "originLocal": "هذا الجهاز (الشبكة المحلية)", - "originRemote": "خادم بعيد", - "bannerReconnect": "إعادة الاتصال", - "bannerMessage": "يتطلب المزامنة عن بُعد إعادة المصادقة" - }, "versionCheck": { - "error": "خطأ في التحقق من الإصدار", - "checkFailed": "فشل التحقق من وجود تحديثات", - "upToDate": "التطبيق محدث", - "currentVersion": "أنت تعمل على الإصدار {{version}}", - "updateAvailable": "تحديث متاح", - "updateLinkLabel": "يتوفر تحديث — افتح ملاحظات الإصدار على GitHub", - "newVersionAvailable": "إصدار جديد متاح! أنت تعمل على {{current}}، لكن {{latest}} متاح.", - "betaVersion": "إصدار تجريبي", - "betaVersionDesc": "أنت تعمل على {{current}}، وهو أحدث من آخر إصدار مستقر {{latest}}.", - "releasedOn": "صدر في {{date}}", - "downloadUpdate": "تنزيل التحديث", - "checking": "جارٍ التحقق من التحديثات...", - "checkUpdates": "التحقق من التحديثات", - "checkingUpdates": "جارٍ التحقق من التحديثات...", - "updateRequired": "التحديث مطلوب" + "updateLinkLabel": "يتوفر تحديث — افتح ملاحظات الإصدار على GitHub" }, "common": { "close": "إغلاق", @@ -1565,8 +1487,6 @@ "resetCodeRateLimitedWithTime": "محدودية المعدل: عدد كبير جداً من محاولات التحقق. يرجى الانتظار {{time}} ثانية قبل المحاولة مرة أخرى.", "authTokenSaveFailed": "فشل في حفظ رمز المصادقة", "authTokenMissing": "لم يُرسل الخادم رمز مصادقة جديدًا. يُرجى تسجيل الدخول مرة أخرى.", - "authTokenRejected": "رفض الخادم البعيد رمز المصادقة الجديد", - "keyringUnavailable": "تم تسجيل الدخول، ولكن تعذر حفظ الجلسة: لا يتوفر لدى هذا النظام سلسلة مفاتيح لتشفيرها. شغّل خدمة سرية (مثل gnome-keyring أو KWallet) وسجّل الدخول مرة أخرى.", "failedToLoadServer": "فشل في تحميل الخادم", "remoteServerRequired": "يلزم وجود خادم بعيد. قم بتوصيل خادم بعيد في الإعدادات لاستخدام نوع الاتصال هذا." }, diff --git a/src/ui/locales/translated/bg_BG.json b/src/ui/locales/translated/bg_BG.json index eed0800ed..da886d4c0 100644 --- a/src/ui/locales/translated/bg_BG.json +++ b/src/ui/locales/translated/bg_BG.json @@ -232,86 +232,8 @@ "widgetTunnelDesc": "Вграден SSH тунелен мениджър за конфигуриран хост", "widgetNoHostSelected": "Няма конфигуриран хост" }, - "serverConfig": { - "title": "Конфигурация на сървъра", - "description": "Конфигурирайте URL адреса на Termix сървъра, за да се свържете с вашите backend услуги", - "serverUrl": "URL адрес на сървъра", - "enterServerUrl": "Моля, въведете URL адрес на сървъра", - "saveFailed": "Запазването на конфигурацията не бе успешно", - "saveError": "Грешка при запазване на конфигурацията", - "saving": "Запазване...", - "saveConfig": "Запазване на конфигурацията", - "helpText": "Въведете URL адреса, където работи вашият Termix сървър (напр. http://localhost:30001 или https://your-server.com)", - "changeServer": "Промяна на сървъра", - "mustIncludeProtocol": "URL адресът на сървъра трябва да започва с http:// или https://", - "allowInvalidCertificate": "Разрешаване на невалиден сертификат", - "allowInvalidCertificateDesc": "Използвайте само за надеждни самостоятелно хоствани сървъри със самоподписани или IP-адресни сертификати.", - "useEmbedded": "Използване на локален сървър", - "embeddedDesc": "Стартирайте Termix с вградения локален сървър (не е необходим отдалечен сървър)", - "embeddedConnecting": "Свързване с локален сървър...", - "embeddedNotReady": "Локалният сървър все още не е готов. Моля, изчакайте малко и опитайте отново.", - "localServer": "Локален сървър", - "savedServers": "Запазени сървъри", - "noSavedServers": "Няма запазени сървъри", - "removeServer": "Премахване" - }, - "migrationNotice": { - "title": "Termix Desktop вече работи самостоятелно", - "body1": "Това приложение се свързваше директно с вашия Termix сървър. То е преработено, за да работи напълно самостоятелно, съхранявайки хостове и идентификационни данни локално, така че да продължи да работи офлайн. Двупосочната синхронизация със самостоятелно хостван Termix сървър вече е по избор.", - "body2": "Вашите съществуващи хостове и идентификационни данни все още са на {{url}}. Включете отдалечената синхронизация и се свържете отново с този сървър, за да ги върнете и да поддържате синхронизирани занапред.", - "dismiss": "Не сега", - "setUpSync": "Настройване на отдалечена синхронизация" - }, - "remoteSync": { - "title": "Дистанционна синхронизация", - "description": "По желание свържете това настолно приложение към самостоятелно хостван Termix сървър, за да синхронизирате вашите хостове, идентификационни данни и фрагменти на различни устройства. Приложението винаги работи напълно офлайн, независимо дали се свързвате или не.", - "notConnected": "Няма връзка", - "connected": "Свързан", - "connectedTo": "Свързано с {{url}}", - "lastSynced": "Последна синхронизация {{time}}", - "neverSynced": "Никога не е синхронизирано", - "syncError": "Грешка при синхронизиране: {{message}}", - "needsReauth": "Входът е изтекъл", - "connectButton": "Свързване със сървъра", - "disconnectButton": "Изключване", - "syncNowButton": "Синхронизирай сега", - "syncing": "Синхронизиране...", - "syncSuccess": "Дистанционната синхронизация е завършена", - "syncUnavailable": "Дистанционното синхронизиране не можа да се стартира", - "serverUrl": "URL адрес на сървъра", - "enterServerUrl": "Моля, въведете URL адрес на сървъра", - "mustIncludeProtocol": "URL адресът на сървъра трябва да започва с http:// или https://", - "connectionTestFailed": "Не можа да се свърже със сървър на Termix на този URL адрес", - "allowInvalidCertificate": "Разрешаване на невалиден сертификат", - "allowInvalidCertificateDesc": "Използвайте само за надеждни самостоятелно хоствани сървъри със самоподписани или IP-адресни сертификати.", - "savedServers": "Запазени сървъри", - "removeServer": "Премахване", - "continueButton": "Продължи", - "cancelButton": "Отказ", - "signInTitle": "Влезте в {{url}}", - "originTitle": "Произход на връзката", - "originDescription": "Изберете откъде произлизат SSH връзките по подразбиране. Това може да се промени за всеки хост.", - "originLocal": "Това устройство (локална мрежа)", - "originRemote": "Отдалечен сървър", - "bannerReconnect": "Свържете се отново", - "bannerMessage": "Дистанционната синхронизация изисква повторно удостоверяване" - }, "versionCheck": { - "error": "Грешка при проверка на версията", - "checkFailed": "Проверката за актуализации не бе успешна", - "upToDate": "Приложението е актуално", - "currentVersion": "Използвате версия {{version}}", - "updateAvailable": "Налична е актуализация", - "updateLinkLabel": "Налична е актуализация — отворете бележките за изданието в GitHub", - "newVersionAvailable": "Налична е нова версия! Използвате {{current}}, но {{latest}} е налична.", - "betaVersion": "Бета версия", - "betaVersionDesc": "Изпълнявате {{current}}, която е по-нова от последната стабилна версия {{latest}}.", - "releasedOn": "Публикувано на {{date}}", - "downloadUpdate": "Изтегляне на актуализация", - "checking": "Проверка за актуализации...", - "checkUpdates": "Проверка за актуализации", - "checkingUpdates": "Проверка за актуализации...", - "updateRequired": "Необходима е актуализация" + "updateLinkLabel": "Налична е актуализация — отворете бележките за изданието в GitHub" }, "common": { "close": "Затвори", @@ -1565,8 +1487,6 @@ "resetCodeRateLimitedWithTime": "Ограничена скорост: Твърде много опити за потвърждение. Моля, изчакайте {{time}} секунди, преди да опитате отново.", "authTokenSaveFailed": "Запазването на токена за удостоверяване не бе успешно", "authTokenMissing": "Сървърът не върна нов токен за удостоверяване. Моля, влезте отново.", - "authTokenRejected": "Отдалеченият сървър отхвърли новия токен за удостоверяване", - "keyringUnavailable": "Влезли сте, но сесията не можа да бъде съхранена: тази система няма наличен ключодържател, с който да я криптира. Стартирайте секретна услуга (като gnome-keyring или KWallet) и влезте отново.", "failedToLoadServer": "Зареждането на сървъра не бе успешно", "remoteServerRequired": "Изисква се отдалечен сървър. Свържете се с отдалечен сървър в „Настройки“, за да използвате този тип връзка." }, diff --git a/src/ui/locales/translated/bn_BD.json b/src/ui/locales/translated/bn_BD.json index 4554f69e9..e37d079aa 100644 --- a/src/ui/locales/translated/bn_BD.json +++ b/src/ui/locales/translated/bn_BD.json @@ -232,86 +232,8 @@ "widgetTunnelDesc": "একটি কনফিগার করা হোস্টের জন্য এমবেডেড SSH টানেল ম্যানেজার", "widgetNoHostSelected": "কোন হোস্ট কনফিগার করা নেই" }, - "serverConfig": { - "title": "সার্ভার কনফিগারেশন", - "description": "আপনার ব্যাকএন্ড পরিষেবাগুলির সাথে সংযোগ করতে Termix সার্ভার URL কনফিগার করুন।", - "serverUrl": "সার্ভার ইউআরএল", - "enterServerUrl": "অনুগ্রহ করে একটি সার্ভার URL লিখুন", - "saveFailed": "কনফিগারেশন সংরক্ষণ করতে ব্যর্থ হয়েছে", - "saveError": "কনফিগারেশন সংরক্ষণ করতে ত্রুটি হয়েছে", - "saving": "সঞ্চয়...", - "saveConfig": "কনফিগারেশন সংরক্ষণ করুন", - "helpText": "আপনার Termix সার্ভারটি যেখানে চলছে সেই URL-টি লিখুন (যেমন, http://localhost:30001 অথবা https://your-server.com)", - "changeServer": "সার্ভার পরিবর্তন করুন", - "mustIncludeProtocol": "সার্ভার URL অবশ্যই http:// অথবা https:// দিয়ে শুরু হতে হবে।", - "allowInvalidCertificate": "অবৈধ সার্টিফিকেট অনুমোদন করুন", - "allowInvalidCertificateDesc": "শুধুমাত্র সেলফ-সাইন্ড বা আইপি-অ্যাড্রেস সার্টিফিকেটযুক্ত বিশ্বস্ত সেলফ-হোস্টেড সার্ভারের জন্য ব্যবহার করুন।", - "useEmbedded": "স্থানীয় সার্ভার ব্যবহার করুন", - "embeddedDesc": "অন্তর্নির্মিত স্থানীয় সার্ভার দিয়ে টারমিক্স চালান (কোন রিমোট সার্ভারের প্রয়োজন নেই)।", - "embeddedConnecting": "স্থানীয় সার্ভারের সাথে সংযোগ স্থাপন করা হচ্ছে...", - "embeddedNotReady": "স্থানীয় সার্ভার এখনও প্রস্তুত নয়। অনুগ্রহ করে কিছুক্ষণ অপেক্ষা করে আবার চেষ্টা করুন।", - "localServer": "স্থানীয় সার্ভার", - "savedServers": "সংরক্ষিত সার্ভার", - "noSavedServers": "কোনো সংরক্ষিত সার্ভার নেই", - "removeServer": "অপসারণ করুন" - }, - "migrationNotice": { - "title": "টার্মিক্স ডেস্কটপ এখন স্বতন্ত্রভাবে চলে।", - "body1": "এই অ্যাপটি আগে সরাসরি আপনার টারমিক্স সার্ভারের সাথে সংযুক্ত হতো। এটিকে এখন সম্পূর্ণ স্বাধীনভাবে চলার জন্য নতুন করে তৈরি করা হয়েছে, যা হোস্ট এবং ক্রেডেনশিয়াল স্থানীয়ভাবে সংরক্ষণ করে, ফলে এটি অফলাইনেও কাজ করতে থাকে। সেলফ-হোস্টেড টারমিক্স সার্ভারের সাথে দ্বিমুখী সিঙ্ক এখন ঐচ্ছিক।", - "body2": "আপনার বর্তমান হোস্ট এবং ক্রেডেনশিয়াল এখনও {{url}}-এ রয়েছে। সেগুলোকে ফিরিয়ে আনতে এবং ভবিষ্যতে উভয়কে সিঙ্কে রাখতে রিমোট সিঙ্ক চালু করুন এবং সেই সার্ভারে পুনরায় সংযোগ করুন।", - "dismiss": "এখন নয়", - "setUpSync": "রিমোট সিঙ্ক সেট আপ করুন" - }, - "remoteSync": { - "title": "রিমোট সিঙ্ক", - "description": "ঐচ্ছিকভাবে, আপনার হোস্ট, ক্রেডেনশিয়াল এবং স্নিপেটগুলো বিভিন্ন ডিভাইসে সিঙ্ক করার জন্য এই ডেস্কটপ অ্যাপটিকে একটি সেলফ-হোস্টেড টারমিক্স সার্ভারের সাথে সংযুক্ত করুন। আপনি সংযুক্ত করুন বা না করুন, অ্যাপটি সর্বদা সম্পূর্ণ অফলাইনে কাজ করে।", - "notConnected": "সংযুক্ত নয়", - "connected": "সংযুক্ত", - "connectedTo": "{{url}} এর সাথে সংযুক্ত", - "lastSynced": "সর্বশেষ সিঙ্ক করা হয়েছে {{time}}", - "neverSynced": "কখনো সিঙ্ক করা হয়নি", - "syncError": "সিঙ্ক ত্রুটি: {{message}}", - "needsReauth": "সাইন-ইন এর মেয়াদ শেষ হয়ে গেছে", - "connectButton": "সার্ভারে সংযোগ করুন", - "disconnectButton": "সংযোগ বিচ্ছিন্ন করুন", - "syncNowButton": "এখন সিঙ্ক করুন", - "syncing": "সিঙ্ক হচ্ছে...", - "syncSuccess": "রিমোট সিঙ্ক সম্পন্ন হয়েছে", - "syncUnavailable": "রিমোট সিঙ্ক শুরু করা যায়নি", - "serverUrl": "সার্ভার ইউআরএল", - "enterServerUrl": "অনুগ্রহ করে একটি সার্ভার URL লিখুন", - "mustIncludeProtocol": "সার্ভার URL অবশ্যই http:// অথবা https:// দিয়ে শুরু হতে হবে।", - "connectionTestFailed": "ঐ URL-এ কোনো Termix সার্ভারে পৌঁছানো যায়নি।", - "allowInvalidCertificate": "অবৈধ সার্টিফিকেট অনুমোদন করুন", - "allowInvalidCertificateDesc": "শুধুমাত্র সেলফ-সাইন্ড বা আইপি-অ্যাড্রেস সার্টিফিকেটযুক্ত বিশ্বস্ত সেলফ-হোস্টেড সার্ভারের জন্য ব্যবহার করুন।", - "savedServers": "সংরক্ষিত সার্ভার", - "removeServer": "অপসারণ করুন", - "continueButton": "চালিয়ে যান", - "cancelButton": "বাতিল করুন", - "signInTitle": "{{url}} এ সাইন ইন করুন", - "originTitle": "সংযোগের উৎস", - "originDescription": "ডিফল্টরূপে SSH সংযোগগুলি কোথা থেকে শুরু হবে তা বেছে নিন। এটি প্রতিটি হোস্টের জন্য আলাদাভাবে পরিবর্তন করা যেতে পারে।", - "originLocal": "এই ডিভাইস (স্থানীয় নেটওয়ার্ক)", - "originRemote": "রিমোট সার্ভার", - "bannerReconnect": "পুনরায় সংযোগ করুন", - "bannerMessage": "রিমোট সিঙ্কের জন্য পুনরায় প্রমাণীকরণ প্রয়োজন।" - }, "versionCheck": { - "error": "সংস্করণ যাচাই ত্রুটি", - "checkFailed": "আপডেট পরীক্ষা করতে ব্যর্থ হয়েছে", - "upToDate": "অ্যাপটি হালনাগাদ করা হয়েছে", - "currentVersion": "আপনি {{version}} সংস্করণটি চালাচ্ছেন।", - "updateAvailable": "আপডেট উপলব্ধ", - "updateLinkLabel": "আপডেট উপলব্ধ — গিটহাবে রিলিজ নোট খুলুন।", - "newVersionAvailable": "একটি নতুন সংস্করণ উপলব্ধ! আপনি {{current}}ব্যবহার করছেন, কিন্তু {{latest}} উপলব্ধ আছে।", - "betaVersion": "বিটা সংস্করণ", - "betaVersionDesc": "আপনি {{current}}ব্যবহার করছেন, যা সর্বশেষ স্থিতিশীল রিলিজ {{latest}} এর চেয়ে নতুন।", - "releasedOn": "{{date}} তারিখে প্রকাশিত।", - "downloadUpdate": "আপডেট ডাউনলোড করুন", - "checking": "আপডেট যাচাই করা হচ্ছে...", - "checkUpdates": "আপডেটগুলির জন্য পরীক্ষা করুন", - "checkingUpdates": "আপডেট যাচাই করা হচ্ছে...", - "updateRequired": "আপডেট প্রয়োজন" + "updateLinkLabel": "আপডেট উপলব্ধ — গিটহাবে রিলিজ নোট খুলুন।" }, "common": { "close": "বন্ধ করুন", @@ -1565,8 +1487,6 @@ "resetCodeRateLimitedWithTime": "রেট সীমিত: অনেক বেশি যাচাইকরণ প্রচেষ্টা করা হয়েছে। অনুগ্রহ করে আবার চেষ্টা করার আগে {{time}} সেকেন্ড অপেক্ষা করুন।", "authTokenSaveFailed": "প্রমাণীকরণ টোকেন সংরক্ষণ করতে ব্যর্থ হয়েছে", "authTokenMissing": "সার্ভারটি নতুন কোনো প্রমাণীকরণ টোকেন ফেরত দেয়নি। অনুগ্রহ করে আবার সাইন ইন করুন।", - "authTokenRejected": "দূরবর্তী সার্ভার নতুন প্রমাণীকরণ টোকেনটি প্রত্যাখ্যান করেছে।", - "keyringUnavailable": "সাইন ইন করা হয়েছে, কিন্তু সেশনটি সংরক্ষণ করা যায়নি: এটিকে এনক্রিপ্ট করার জন্য এই সিস্টেমে কোনো কী-রিং উপলব্ধ নেই। একটি সিক্রেট সার্ভিস (যেমন gnome-keyring বা KWallet) চালু করুন এবং আবার সাইন ইন করুন।", "failedToLoadServer": "সার্ভার লোড করতে ব্যর্থ হয়েছে", "remoteServerRequired": "রিমোট সার্ভার প্রয়োজন। এই সংযোগের ধরণটি ব্যবহার করতে সেটিংস-এ একটি রিমোট সার্ভার সংযুক্ত করুন।" }, diff --git a/src/ui/locales/translated/ca_ES.json b/src/ui/locales/translated/ca_ES.json index f6e36bafd..681368164 100644 --- a/src/ui/locales/translated/ca_ES.json +++ b/src/ui/locales/translated/ca_ES.json @@ -232,86 +232,8 @@ "widgetTunnelDesc": "Gestor de túnels SSH integrat per a un host configurat", "widgetNoHostSelected": "No hi ha cap amfitrió configurat" }, - "serverConfig": { - "title": "Configuració del servidor", - "description": "Configureu l'URL del servidor Termix per connectar-vos als vostres serveis de backend", - "serverUrl": "URL del servidor", - "enterServerUrl": "Si us plau, introduïu una URL de servidor", - "saveFailed": "No s'ha pogut desar la configuració", - "saveError": "S'ha produït un error en desar la configuració", - "saving": "Desant...", - "saveConfig": "Desa la configuració", - "helpText": "Introduïu l'URL on s'executa el vostre servidor Termix (per exemple, http://localhost:30001 o https://el-vostre-servidor.com)", - "changeServer": "Canviar servidor", - "mustIncludeProtocol": "L'URL del servidor ha de començar per http:// o https://", - "allowInvalidCertificate": "Permetre certificat no vàlid", - "allowInvalidCertificateDesc": "Utilitzeu-ho només per a servidors autoallotjats de confiança amb certificats autosignats o d'adreça IP.", - "useEmbedded": "Utilitza el servidor local", - "embeddedDesc": "Executeu Termix amb el servidor local integrat (no cal cap servidor remot)", - "embeddedConnecting": "Connectant al servidor local...", - "embeddedNotReady": "El servidor local encara no està a punt. Espereu un moment i torneu-ho a intentar.", - "localServer": "Servidor local", - "savedServers": "Servidors desats", - "noSavedServers": "Cap servidor desat", - "removeServer": "Elimina" - }, - "migrationNotice": { - "title": "Termix Desktop ara funciona de manera autònoma", - "body1": "Aquesta aplicació abans es connectava directament al vostre servidor Termix. S'ha reelaborat per funcionar completament per si sola, emmagatzemant els hosts i les credencials localment per tal que continuï funcionant fora de línia. La sincronització bidireccional amb un servidor Termix autoallotjat ara és opcional.", - "body2": "Els vostres amfitrions i credencials existents encara són a {{url}}. Activeu la sincronització remota i torneu a connectar-vos a aquest servidor per tornar-los a sincronitzar i mantenir-los tots dos sincronitzats en el futur.", - "dismiss": "Ara no", - "setUpSync": "Configura la sincronització remota" - }, - "remoteSync": { - "title": "Sincronització remota", - "description": "Opcionalment, podeu connectar aquesta aplicació d'escriptori a un servidor Termix autoallotjat per sincronitzar els vostres hosts, credencials i fragments entre dispositius. L'aplicació sempre funciona completament fora de línia, tant si us connecteu com si no.", - "notConnected": "No connectat", - "connected": "Connectat", - "connectedTo": "Connectat a {{url}}", - "lastSynced": "Última sincronització {{time}}", - "neverSynced": "Mai sincronitzat", - "syncError": "Error de sincronització: {{message}}", - "needsReauth": "L'inici de sessió ha caducat", - "connectButton": "Connecta't al servidor", - "disconnectButton": "Desconnecta", - "syncNowButton": "Sincronitza ara", - "syncing": "Sincronitzant...", - "syncSuccess": "Sincronització remota completada", - "syncUnavailable": "No s'ha pogut iniciar la sincronització remota", - "serverUrl": "URL del servidor", - "enterServerUrl": "Si us plau, introduïu una URL de servidor", - "mustIncludeProtocol": "L'URL del servidor ha de començar per http:// o https://", - "connectionTestFailed": "No s'ha pogut connectar amb un servidor Termix en aquesta URL.", - "allowInvalidCertificate": "Permetre certificat no vàlid", - "allowInvalidCertificateDesc": "Utilitzeu-ho només per a servidors autoallotjats de confiança amb certificats autosignats o d'adreça IP.", - "savedServers": "Servidors desats", - "removeServer": "Elimina", - "continueButton": "Continua", - "cancelButton": "Cancel·la", - "signInTitle": "Inicia la sessió a {{url}}", - "originTitle": "Origen de la connexió", - "originDescription": "Trieu l'origen de les connexions SSH per defecte. Això es pot anul·lar per cada host.", - "originLocal": "Aquest dispositiu (xarxa local)", - "originRemote": "Servidor remot", - "bannerReconnect": "Reconnecta", - "bannerMessage": "La sincronització remota necessita una reautenticació" - }, "versionCheck": { - "error": "Error de comprovació de versió", - "checkFailed": "No s'ha pogut comprovar si hi ha actualitzacions", - "upToDate": "L'aplicació està actualitzada", - "currentVersion": "Esteu executant la versió {{version}}", - "updateAvailable": "Actualització disponible", - "updateLinkLabel": "Actualització disponible: obriu les notes de la versió al GitHub", - "newVersionAvailable": "Hi ha una nova versió disponible! Esteu executant {{current}}, però {{latest}} està disponible.", - "betaVersion": "Versió beta", - "betaVersionDesc": "Esteu executant {{current}}, que és més recent que l'última versió estable {{latest}}.", - "releasedOn": "Publicat el {{date}}", - "downloadUpdate": "Descarrega l'actualització", - "checking": "S'estan buscant actualitzacions...", - "checkUpdates": "Comprova si hi ha actualitzacions", - "checkingUpdates": "S'estan buscant actualitzacions...", - "updateRequired": "Actualització necessària" + "updateLinkLabel": "Actualització disponible: obriu les notes de la versió al GitHub" }, "common": { "close": "Tanca", @@ -1565,8 +1487,6 @@ "resetCodeRateLimitedWithTime": "Taxa limitada: massa intents de verificació. Espereu {{time}} segons abans de tornar-ho a intentar.", "authTokenSaveFailed": "No s'ha pogut desar el testimoni d'autenticació", "authTokenMissing": "El servidor no ha retornat un testimoni d'autenticació nou. Si us plau, torneu a iniciar la sessió.", - "authTokenRejected": "El servidor remot ha rebutjat el nou testimoni d'autenticació.", - "keyringUnavailable": "S'ha iniciat la sessió, però no s'ha pogut emmagatzemar la sessió: aquest sistema no té cap anell de claus disponible per xifrar-lo. Inicieu un servei secret (com ara gnome-keyring o KWallet) i torneu a iniciar la sessió.", "failedToLoadServer": "No s'ha pogut carregar el servidor", "remoteServerRequired": "Cal un servidor remot. Connecteu un servidor remot a Configuració per utilitzar aquest tipus de connexió." }, diff --git a/src/ui/locales/translated/cs_CZ.json b/src/ui/locales/translated/cs_CZ.json index 1d9d1cd40..006840baf 100644 --- a/src/ui/locales/translated/cs_CZ.json +++ b/src/ui/locales/translated/cs_CZ.json @@ -232,86 +232,8 @@ "widgetTunnelDesc": "Vestavěný správce tunelů SSH pro nakonfigurovaný hostitel", "widgetNoHostSelected": "Žádný hostitel není nakonfigurován" }, - "serverConfig": { - "title": "Konfigurace serveru", - "description": "Nakonfigurujte URL adresu serveru Termix pro připojení k vašim backendovým službám", - "serverUrl": "URL serveru", - "enterServerUrl": "Zadejte prosím URL adresu serveru", - "saveFailed": "Nepodařilo se uložit konfiguraci", - "saveError": "Chyba při ukládání konfigurace", - "saving": "Ukládání...", - "saveConfig": "Uložit konfiguraci", - "helpText": "Zadejte URL adresu, na které běží váš server Termix (např. http://localhost:30001 nebo https://vas-server.com)", - "changeServer": "Změnit server", - "mustIncludeProtocol": "URL serveru musí začínat http:// nebo https://", - "allowInvalidCertificate": "Povolit neplatný certifikát", - "allowInvalidCertificateDesc": "Používejte pouze pro důvěryhodné servery s vlastním hostováním a certifikáty s vlastním podpisem nebo certifikáty IP adresy.", - "useEmbedded": "Použít lokální server", - "embeddedDesc": "Spusťte Termix s vestavěným lokálním serverem (není potřeba vzdálený server)", - "embeddedConnecting": "Připojování k lokálnímu serveru...", - "embeddedNotReady": "Místní server ještě není připraven. Počkejte chvíli a zkuste to znovu.", - "localServer": "Místní server", - "savedServers": "Uložené servery", - "noSavedServers": "Žádné uložené servery", - "removeServer": "Odstranit" - }, - "migrationNotice": { - "title": "Termix Desktop nyní běží samostatně", - "body1": "Tato aplikace se dříve připojovala přímo k vašemu serveru Termix. Byla přepracována tak, aby běžela plně samostatně a lokálně ukládala hostitele a přihlašovací údaje, takže fungovala i offline. Obousměrná synchronizace se samostatně hostovaným serverem Termix je nyní volitelná.", - "body2": "Vaši stávající hostitelé a přihlašovací údaje jsou stále na {{url}}. Zapněte vzdálenou synchronizaci a znovu se připojte k danému serveru, abyste je obnovili a udrželi obojí synchronizované i v budoucnu.", - "dismiss": "Teď ne", - "setUpSync": "Nastavení vzdálené synchronizace" - }, - "remoteSync": { - "title": "Vzdálená synchronizace", - "description": "Volitelně můžete tuto desktopovou aplikaci připojit k serveru Termix hostovanému na vlastním serveru a synchronizovat hostitele, přihlašovací údaje a úryvky kódu napříč zařízeními. Aplikace vždy funguje plně offline, ať už se připojíte, nebo ne.", - "notConnected": "Nepřipojeno", - "connected": "Připojeno", - "connectedTo": "Připojeno k {{url}}", - "lastSynced": "Naposledy synchronizováno {{time}}", - "neverSynced": "Nikdy nesynchronizováno", - "syncError": "Chyba synchronizace: {{message}}", - "needsReauth": "Platnost přihlášení vypršela", - "connectButton": "Připojení k serveru", - "disconnectButton": "Odpojit", - "syncNowButton": "Synchronizovat nyní", - "syncing": "Synchronizace...", - "syncSuccess": "Vzdálená synchronizace dokončena", - "syncUnavailable": "Vzdálenou synchronizaci se nepodařilo spustit", - "serverUrl": "URL serveru", - "enterServerUrl": "Zadejte prosím URL adresu serveru", - "mustIncludeProtocol": "URL serveru musí začínat http:// nebo https://", - "connectionTestFailed": "Na dané URL adrese se nepodařilo spojit se se serverem Termix.", - "allowInvalidCertificate": "Povolit neplatný certifikát", - "allowInvalidCertificateDesc": "Používejte pouze pro důvěryhodné servery s vlastním hostováním a certifikáty s vlastním podpisem nebo certifikáty IP adresy.", - "savedServers": "Uložené servery", - "removeServer": "Odstranit", - "continueButton": "Pokračovat", - "cancelButton": "Zrušit", - "signInTitle": "Přihlaste se do {{url}}", - "originTitle": "Původ připojení", - "originDescription": "Ve výchozím nastavení vyberte, odkud SSH připojení pocházejí. Toto nastavení lze u jednotlivých hostitelů přepsat.", - "originLocal": "Toto zařízení (lokální síť)", - "originRemote": "Vzdálený server", - "bannerReconnect": "Znovu se připojte", - "bannerMessage": "Vzdálená synchronizace vyžaduje opětovné ověření" - }, "versionCheck": { - "error": "Chyba kontroly verze", - "checkFailed": "Nepodařilo se zkontrolovat aktualizace", - "upToDate": "Aplikace je aktuální", - "currentVersion": "Používáte verzi {{version}}", - "updateAvailable": "Aktualizace k dispozici", - "updateLinkLabel": "Aktualizace k dispozici – otevřete si poznámky k verzi na GitHubu", - "newVersionAvailable": "Je k dispozici nová verze! Používáte {{current}}, ale je k dispozici {{latest}}.", - "betaVersion": "Beta verze", - "betaVersionDesc": "Používáte verzi {{current}}, která je novější než nejnovější stabilní verze {{latest}}.", - "releasedOn": "Vydáno dne {{date}}", - "downloadUpdate": "Stáhnout aktualizaci", - "checking": "Kontrola aktualizací...", - "checkUpdates": "Zkontrolovat aktualizace", - "checkingUpdates": "Kontrola aktualizací...", - "updateRequired": "Vyžadována aktualizace" + "updateLinkLabel": "Aktualizace k dispozici – otevřete si poznámky k verzi na GitHubu" }, "common": { "close": "Blízko", @@ -1565,8 +1487,6 @@ "resetCodeRateLimitedWithTime": "Omezená rychlost: Příliš mnoho pokusů o ověření. Před dalším pokusem prosím počkejte {{time}} sekund.", "authTokenSaveFailed": "Uložení ověřovacího tokenu se nezdařilo", "authTokenMissing": "Server nevrátil nový ověřovací token. Přihlaste se prosím znovu.", - "authTokenRejected": "Vzdálený server odmítl nový ověřovací token.", - "keyringUnavailable": "Přihlášen/a jsem, ale relaci se nepodařilo uložit: tento systém nemá k dispozici žádný svazek klíčů pro její zašifrování. Spusťte tajnou službu (například gnome-keyring nebo KWallet) a znovu se přihlaste.", "failedToLoadServer": "Nepodařilo se načíst server", "remoteServerRequired": "Je vyžadován vzdálený server. Pro použití tohoto typu připojení se připojte ke vzdálenému serveru v Nastavení." }, diff --git a/src/ui/locales/translated/da_DK.json b/src/ui/locales/translated/da_DK.json index 0905a2e71..b2342ae20 100644 --- a/src/ui/locales/translated/da_DK.json +++ b/src/ui/locales/translated/da_DK.json @@ -232,86 +232,8 @@ "widgetTunnelDesc": "Indlejret SSH-tunnelmanager til en konfigureret vært", "widgetNoHostSelected": "Ingen vært konfigureret" }, - "serverConfig": { - "title": "Serverkonfiguration", - "description": "Konfigurer Termix-serverens URL for at oprette forbindelse til dine backend-tjenester", - "serverUrl": "Server-URL", - "enterServerUrl": "Indtast venligst en server-URL", - "saveFailed": "Konfigurationen kunne ikke gemmes", - "saveError": "Fejl ved lagring af konfiguration", - "saving": "Gemmer...", - "saveConfig": "Gem konfiguration", - "helpText": "Indtast URL'en, hvor din Termix-server kører (f.eks. http://localhost:30001 eller https://your-server.com)", - "changeServer": "Skift server", - "mustIncludeProtocol": "Server-URL skal starte med http:// eller https://", - "allowInvalidCertificate": "Tillad ugyldigt certifikat", - "allowInvalidCertificateDesc": "Brug kun til betroede, selvhostede servere med selvsignerede certifikater eller IP-adressecertifikater.", - "useEmbedded": "Brug lokal server", - "embeddedDesc": "Kør Termix med den indbyggede lokale server (ingen fjernserver nødvendig)", - "embeddedConnecting": "Forbinder til lokal server...", - "embeddedNotReady": "Den lokale server er ikke klar endnu. Vent et øjeblik, og prøv igen.", - "localServer": "Lokal server", - "savedServers": "Gemte servere", - "noSavedServers": "Ingen gemte servere", - "removeServer": "Fjerne" - }, - "migrationNotice": { - "title": "Termix Desktop kører nu selvstændigt", - "body1": "Denne app plejede at oprette forbindelse direkte til din Termix-server. Den er blevet omarbejdet til at køre fuldt ud alene og gemmer værter og legitimationsoplysninger lokalt, så den fortsætter med at fungere offline. Tovejssynkronisering med en selvhostet Termix-server er nu valgfri.", - "body2": "Dine eksisterende værter og loginoplysninger er stadig på {{url}}. Aktivér fjernsynkronisering, og opret forbindelse til serveren igen for at få dem tilbage og holde begge synkroniseret fremadrettet.", - "dismiss": "Ikke nu", - "setUpSync": "Opsæt fjernsynkronisering" - }, - "remoteSync": { - "title": "Fjernsynkronisering", - "description": "Du kan eventuelt tilslutte denne desktop-app til en selvhostet Termix-server for at synkronisere dine værter, legitimationsoplysninger og kodestykker på tværs af enheder. Appen fungerer altid fuldt offline, uanset om du opretter forbindelse eller ej.", - "notConnected": "Ikke forbundet", - "connected": "Forbundet", - "connectedTo": "Forbundet til {{url}}", - "lastSynced": "Sidst synkroniseret {{time}}", - "neverSynced": "Aldrig synkroniseret", - "syncError": "Synkroniseringsfejl: {{message}}", - "needsReauth": "Login udløbet", - "connectButton": "Opret forbindelse til serveren", - "disconnectButton": "Afbryde", - "syncNowButton": "Synkroniser nu", - "syncing": "Synkroniserer...", - "syncSuccess": "Fjernsynkronisering fuldført", - "syncUnavailable": "Fjernsynkronisering kunne ikke startes", - "serverUrl": "Server-URL", - "enterServerUrl": "Indtast venligst en server-URL", - "mustIncludeProtocol": "Server-URL skal starte med http:// eller https://", - "connectionTestFailed": "Kunne ikke nå en Termix-server på den URL", - "allowInvalidCertificate": "Tillad ugyldigt certifikat", - "allowInvalidCertificateDesc": "Brug kun til betroede, selvhostede servere med selvsignerede certifikater eller IP-adressecertifikater.", - "savedServers": "Gemte servere", - "removeServer": "Fjerne", - "continueButton": "Fortsætte", - "cancelButton": "Ophæve", - "signInTitle": "Log ind på {{url}}", - "originTitle": "Forbindelsens oprindelse", - "originDescription": "Vælg som standard, hvor SSH-forbindelser stammer fra. Dette kan tilsidesættes pr. vært.", - "originLocal": "Denne enhed (lokalt netværk)", - "originRemote": "Fjernserver", - "bannerReconnect": "Genopret forbindelse", - "bannerMessage": "Fjernsynkronisering skal godkendes igen" - }, "versionCheck": { - "error": "Versionskontrolfejl", - "checkFailed": "Kunne ikke søge efter opdateringer", - "upToDate": "Appen er opdateret", - "currentVersion": "Du kører version {{version}}", - "updateAvailable": "Opdatering tilgængelig", - "updateLinkLabel": "Opdatering tilgængelig — åbn udgivelsesnoterne på GitHub", - "newVersionAvailable": "En ny version er tilgængelig! Du kører {{current}}, men {{latest}} er tilgængelig.", - "betaVersion": "Betaversion", - "betaVersionDesc": "Du kører {{current}}, som er nyere end den seneste stabile udgivelse {{latest}}.", - "releasedOn": "Udgivet den {{date}}", - "downloadUpdate": "Download opdatering", - "checking": "Søger efter opdateringer...", - "checkUpdates": "Tjek for opdateringer", - "checkingUpdates": "Søger efter opdateringer...", - "updateRequired": "Opdatering påkrævet" + "updateLinkLabel": "Opdatering tilgængelig — åbn udgivelsesnoterne på GitHub" }, "common": { "close": "Tæt", @@ -1565,8 +1487,6 @@ "resetCodeRateLimitedWithTime": "Begrænset hastighed: For mange bekræftelsesforsøg. Vent {{time}} sekunder, før du prøver igen.", "authTokenSaveFailed": "Kunne ikke gemme godkendelsestoken", "authTokenMissing": "Serveren returnerede ikke et nyt godkendelsestoken. Log venligst ind igen.", - "authTokenRejected": "Fjernserveren afviste det nye godkendelsestoken", - "keyringUnavailable": "Logget ind, men sessionen kunne ikke gemmes: dette system har ingen nøglering tilgængelig til at kryptere den med. Start en hemmelig tjeneste (såsom gnome-keyring eller KWallet) og log ind igen.", "failedToLoadServer": "Kunne ikke indlæse serveren", "remoteServerRequired": "Fjernserver kræves. Opret forbindelse til en fjernserver i Indstillinger for at bruge denne forbindelsestype." }, diff --git a/src/ui/locales/translated/de_DE.json b/src/ui/locales/translated/de_DE.json index 0b3fbd185..684c64eb0 100644 --- a/src/ui/locales/translated/de_DE.json +++ b/src/ui/locales/translated/de_DE.json @@ -232,86 +232,8 @@ "widgetTunnelDesc": "Eingebetteter SSH-Tunnel-Manager für einen konfigurierten Host", "widgetNoHostSelected": "Kein Host konfiguriert" }, - "serverConfig": { - "title": "Serverkonfiguration", - "description": "Konfigurieren Sie die Termix-Server-URL, um eine Verbindung zu Ihren Backend-Diensten herzustellen", - "serverUrl": "Server-URL", - "enterServerUrl": "Bitte geben Sie eine Server-URL ein", - "saveFailed": "Fehler beim Speichern der Konfiguration", - "saveError": "Fehler beim Speichern der Konfiguration", - "saving": "Speichern...", - "saveConfig": "Konfiguration speichern", - "helpText": "Geben Sie die URL ein, unter der Ihr Termix-Server läuft (z. B. http://localhost:30001 oder https://your-server.com)", - "changeServer": "Server wechseln", - "mustIncludeProtocol": "Die Server-URL muss mit http:// oder https:// beginnen", - "allowInvalidCertificate": "Ungültiges Zertifikat zulassen", - "allowInvalidCertificateDesc": "Nur für vertrauenswürdige selbst gehostete Server mit selbstsignierten oder IP-Adress-Zertifikaten verwenden.", - "useEmbedded": "Lokalen Server verwenden", - "embeddedDesc": "Termix mit dem integrierten lokalen Server ausführen (kein Remote-Server erforderlich)", - "embeddedConnecting": "Verbinde mit lokalem Server...", - "embeddedNotReady": "Der lokale Server ist noch nicht bereit. Bitte warten Sie einen Moment und versuchen Sie es erneut.", - "localServer": "Lokaler Server", - "savedServers": "Gespeicherte Server", - "noSavedServers": "Keine gespeicherten Server", - "removeServer": "Entfernen" - }, - "migrationNotice": { - "title": "Termix Desktop läuft jetzt eigenständig", - "body1": "Diese App verband sich früher direkt mit Ihrem Termix-Server. Sie wurde überarbeitet und läuft nun vollständig eigenständig. Hosts und Zugangsdaten werden lokal gespeichert, sodass die App auch offline funktioniert. Die bidirektionale Synchronisierung mit einem selbst gehosteten Termix-Server ist jetzt optional.", - "body2": "Ihre bestehenden Hosts und Anmeldeinformationen befinden sich noch auf {{url}}. Aktivieren Sie die Remote-Synchronisierung und stellen Sie die Verbindung zu diesem Server wieder her, um sie wiederherzustellen und zukünftig synchron zu halten.", - "dismiss": "Nicht jetzt.", - "setUpSync": "Fernsynchronisierung einrichten" - }, - "remoteSync": { - "title": "Fernsynchronisierung", - "description": "Sie können diese Desktop-App optional mit einem selbst gehosteten Termix-Server verbinden, um Ihre Hosts, Anmeldeinformationen und Code-Snippets geräteübergreifend zu synchronisieren. Die App funktioniert aber auch offline einwandfrei, unabhängig davon, ob eine Verbindung besteht oder nicht.", - "notConnected": "Nicht verbunden", - "connected": "Verbunden", - "connectedTo": "Verbunden mit {{url}}", - "lastSynced": "Zuletzt synchronisiert {{time}}", - "neverSynced": "Nie synchronisiert", - "syncError": "Synchronisierungsfehler: {{message}}", - "needsReauth": "Anmeldung abgelaufen", - "connectButton": "Verbindung zum Server herstellen", - "disconnectButton": "Trennen", - "syncNowButton": "Jetzt synchronisieren", - "syncing": "Synchronisierung läuft...", - "syncSuccess": "Remote-Synchronisierung abgeschlossen", - "syncUnavailable": "Die Remote-Synchronisierung konnte nicht gestartet werden.", - "serverUrl": "Server-URL", - "enterServerUrl": "Bitte geben Sie eine Server-URL ein.", - "mustIncludeProtocol": "Die Server-URL muss mit http:// oder https:// beginnen.", - "connectionTestFailed": "Unter dieser URL konnte kein Termix-Server erreicht werden.", - "allowInvalidCertificate": "Ungültiges Zertifikat zulassen", - "allowInvalidCertificateDesc": "Nur für vertrauenswürdige, selbstgehostete Server mit selbstsignierten oder IP-Adresszertifikaten verwenden.", - "savedServers": "Gespeicherte Server", - "removeServer": "Entfernen", - "continueButton": "Weitermachen", - "cancelButton": "Stornieren", - "signInTitle": "Melden Sie sich an bei {{url}}", - "originTitle": "Verbindungsursprung", - "originDescription": "Wählen Sie den Standardort für SSH-Verbindungen aus. Diese Einstellung kann für jeden Host individuell angepasst werden.", - "originLocal": "Dieses Gerät (lokales Netzwerk)", - "originRemote": "Remote-Server", - "bannerReconnect": "Wiederverbinden", - "bannerMessage": "Für die Remote-Synchronisierung ist eine erneute Authentifizierung erforderlich." - }, "versionCheck": { - "error": "Fehler bei der Versionsprüfung", - "checkFailed": "Fehler bei der Update-Prüfung", - "upToDate": "App ist auf dem neuesten Stand", - "currentVersion": "Sie verwenden Version {{version}}", - "updateAvailable": "Update verfügbar", - "updateLinkLabel": "Update verfügbar – die Versionshinweise finden Sie auf GitHub.", - "newVersionAvailable": "Eine neue Version ist verfügbar! Sie verwenden {{current}}, aber {{latest}} steht zur Verfügung.", - "betaVersion": "Beta-Version", - "betaVersionDesc": "Sie verwenden {{current}}, das neuer ist als die letzte stabile Version {{latest}}.", - "releasedOn": "Veröffentlicht am {{date}}", - "downloadUpdate": "Update herunterladen", - "checking": "Suche nach Updates...", - "checkUpdates": "Auf Updates prüfen", - "checkingUpdates": "Suche nach Updates...", - "updateRequired": "Update erforderlich" + "updateLinkLabel": "Update verfügbar – die Versionshinweise finden Sie auf GitHub." }, "common": { "close": "Schließen", @@ -1565,8 +1487,6 @@ "resetCodeRateLimitedWithTime": "Ratenbegrenzung: Zu viele Verifizierungsversuche. Bitte warten Sie {{time}} Sekunden, bevor Sie es erneut versuchen.", "authTokenSaveFailed": "Fehler beim Speichern des Authentifizierungstokens", "authTokenMissing": "Der Server hat kein neues Authentifizierungstoken zurückgegeben. Bitte melden Sie sich erneut an.", - "authTokenRejected": "Der Remote-Server hat das neue Authentifizierungstoken abgelehnt.", - "keyringUnavailable": "Sie sind angemeldet, aber die Sitzung konnte nicht gespeichert werden: Für dieses System ist kein Schlüsselbund zur Verschlüsselung verfügbar. Starten Sie einen geheimen Dienst (z. B. gnome-keyring oder KWallet) und melden Sie sich erneut an.", "failedToLoadServer": "Server konnte nicht geladen werden", "remoteServerRequired": "Remote-Server erforderlich. Um diesen Verbindungstyp zu verwenden, verbinden Sie einen Remote-Server in den Einstellungen." }, diff --git a/src/ui/locales/translated/el_GR.json b/src/ui/locales/translated/el_GR.json index d6cc4b424..2bd6a8939 100644 --- a/src/ui/locales/translated/el_GR.json +++ b/src/ui/locales/translated/el_GR.json @@ -232,86 +232,8 @@ "widgetTunnelDesc": "Ενσωματωμένος διαχειριστής σήραγγας SSH για έναν διαμορφωμένο κεντρικό υπολογιστή", "widgetNoHostSelected": "Δεν έχει ρυθμιστεί κεντρικός υπολογιστής" }, - "serverConfig": { - "title": "Ρύθμιση παραμέτρων διακομιστή", - "description": "Ρυθμίστε τη διεύθυνση URL του διακομιστή Termix για να συνδεθείτε στις υπηρεσίες backend σας", - "serverUrl": "URL διακομιστή", - "enterServerUrl": "Παρακαλώ εισάγετε μια διεύθυνση URL διακομιστή", - "saveFailed": "Η αποθήκευση της διαμόρφωσης απέτυχε", - "saveError": "Σφάλμα κατά την αποθήκευση της διαμόρφωσης", - "saving": "Οικονομία...", - "saveConfig": "Αποθήκευση διαμόρφωσης", - "helpText": "Εισαγάγετε τη διεύθυνση URL όπου εκτελείται ο διακομιστής Termix (π.χ. http://localhost:30001 ή https://your-server.com)", - "changeServer": "Αλλαγή διακομιστή", - "mustIncludeProtocol": "Η διεύθυνση URL του διακομιστή πρέπει να ξεκινά με http:// ή https://", - "allowInvalidCertificate": "Να επιτρέπεται το μη έγκυρο πιστοποιητικό", - "allowInvalidCertificateDesc": "Χρησιμοποιήστε μόνο για αξιόπιστους αυτο-φιλοξενούμενους διακομιστές με αυτο-υπογεγραμμένα πιστοποιητικά ή πιστοποιητικά διεύθυνσης IP.", - "useEmbedded": "Χρήση τοπικού διακομιστή", - "embeddedDesc": "Εκτελέστε το Termix με τον ενσωματωμένο τοπικό διακομιστή (δεν απαιτείται απομακρυσμένος διακομιστής)", - "embeddedConnecting": "Σύνδεση με τοπικό διακομιστή...", - "embeddedNotReady": "Ο τοπικός διακομιστής δεν είναι ακόμη έτοιμος. Περιμένετε λίγο και δοκιμάστε ξανά.", - "localServer": "Τοπικός διακομιστής", - "savedServers": "Αποθηκευμένοι διακομιστές", - "noSavedServers": "Δεν υπάρχουν αποθηκευμένοι διακομιστές", - "removeServer": "Αφαιρώ" - }, - "migrationNotice": { - "title": "Το Termix Desktop λειτουργεί πλέον αυτόνομα.", - "body1": "Αυτή η εφαρμογή συνδεόταν απευθείας με τον διακομιστή Termix. Έχει αναδιαμορφωθεί ώστε να λειτουργεί πλήρως αυτόνομα, αποθηκεύοντας τοπικά τους κεντρικούς υπολογιστές και τα διαπιστευτήρια, ώστε να συνεχίζει να λειτουργεί εκτός σύνδεσης. Ο αμφίδρομος συγχρονισμός με έναν αυτο-φιλοξενούμενο διακομιστή Termix είναι πλέον προαιρετικός.", - "body2": "Οι υπάρχοντες κεντρικοί υπολογιστές και τα διαπιστευτήριά σας εξακολουθούν να βρίσκονται στη θέση {{url}}. Ενεργοποιήστε τον Απομακρυσμένο συγχρονισμό και συνδεθείτε ξανά σε αυτόν τον διακομιστή για να τα επαναφέρετε και να διατηρήσετε και τα δύο συγχρονισμένα στο μέλλον.", - "dismiss": "Όχι τώρα", - "setUpSync": "Ρύθμιση απομακρυσμένου συγχρονισμού" - }, - "remoteSync": { - "title": "Απομακρυσμένος συγχρονισμός", - "description": "Προαιρετικά, συνδέστε αυτήν την εφαρμογή για υπολογιστές σε έναν αυτο-φιλοξενούμενο διακομιστή Termix για να συγχρονίσετε τους κεντρικούς υπολογιστές, τα διαπιστευτήρια και τα αποσπάσματα σε όλες τις συσκευές. Η εφαρμογή λειτουργεί πάντα πλήρως εκτός σύνδεσης, ανεξάρτητα από το αν συνδεθείτε ή όχι.", - "notConnected": "Δεν είναι συνδεδεμένο", - "connected": "Συνδεδεμένος", - "connectedTo": "Συνδεδεμένο με {{url}}", - "lastSynced": "Τελευταίος συγχρονισμός {{time}}", - "neverSynced": "Δεν συγχρονίστηκε ποτέ", - "syncError": "Σφάλμα συγχρονισμού: {{message}}", - "needsReauth": "Η σύνδεση έληξε", - "connectButton": "Σύνδεση με διακομιστή", - "disconnectButton": "Αποσυνδέω", - "syncNowButton": "Συγχρονισμός τώρα", - "syncing": "Συγχρονισμός...", - "syncSuccess": "Ο απομακρυσμένος συγχρονισμός ολοκληρώθηκε", - "syncUnavailable": "Δεν ήταν δυνατή η έναρξη του απομακρυσμένου συγχρονισμού", - "serverUrl": "URL διακομιστή", - "enterServerUrl": "Παρακαλώ εισάγετε μια διεύθυνση URL διακομιστή", - "mustIncludeProtocol": "Η διεύθυνση URL του διακομιστή πρέπει να ξεκινά με http:// ή https://", - "connectionTestFailed": "Δεν ήταν δυνατή η επικοινωνία με έναν διακομιστή Termix σε αυτήν τη διεύθυνση URL", - "allowInvalidCertificate": "Να επιτρέπεται το μη έγκυρο πιστοποιητικό", - "allowInvalidCertificateDesc": "Χρησιμοποιήστε μόνο για αξιόπιστους αυτο-φιλοξενούμενους διακομιστές με αυτο-υπογεγραμμένα πιστοποιητικά ή πιστοποιητικά διεύθυνσης IP.", - "savedServers": "Αποθηκευμένοι διακομιστές", - "removeServer": "Αφαιρώ", - "continueButton": "Συνεχίζω", - "cancelButton": "Ματαίωση", - "signInTitle": "Συνδεθείτε στο {{url}}", - "originTitle": "Προέλευση σύνδεσης", - "originDescription": "Επιλέξτε από προεπιλογή την προέλευση των συνδέσεων SSH. Αυτό μπορεί να παρακαμφθεί ανά κεντρικό υπολογιστή.", - "originLocal": "Αυτή η συσκευή (τοπικό δίκτυο)", - "originRemote": "Απομακρυσμένος διακομιστής", - "bannerReconnect": "Επανασύνδεση", - "bannerMessage": "Ο απομακρυσμένος συγχρονισμός απαιτεί εκ νέου έλεγχο ταυτότητας" - }, "versionCheck": { - "error": "Σφάλμα ελέγχου έκδοσης", - "checkFailed": "Αποτυχία ελέγχου για ενημερώσεις", - "upToDate": "Η εφαρμογή είναι ενημερωμένη", - "currentVersion": "Χρησιμοποιείτε την έκδοση {{version}}", - "updateAvailable": "Διαθέσιμη ενημέρωση", - "updateLinkLabel": "Διαθέσιμη ενημέρωση — ανοίξτε τις σημειώσεις έκδοσης στο GitHub", - "newVersionAvailable": "Μια νέα έκδοση είναι διαθέσιμη! Χρησιμοποιείτε την έκδοση {{current}}, αλλά η έκδοση {{latest}} είναι διαθέσιμη.", - "betaVersion": "Έκδοση Beta", - "betaVersionDesc": "Εκτελείτε την έκδοση {{current}}, η οποία είναι νεότερη από την τελευταία σταθερή έκδοση {{latest}}.", - "releasedOn": "Κυκλοφόρησε στις {{date}}", - "downloadUpdate": "Λήψη ενημέρωσης", - "checking": "Έλεγχος για ενημερώσεις...", - "checkUpdates": "Έλεγχος για ενημερώσεις", - "checkingUpdates": "Έλεγχος για ενημερώσεις...", - "updateRequired": "Απαιτείται ενημέρωση" + "updateLinkLabel": "Διαθέσιμη ενημέρωση — ανοίξτε τις σημειώσεις έκδοσης στο GitHub" }, "common": { "close": "Κοντά", @@ -1565,8 +1487,6 @@ "resetCodeRateLimitedWithTime": "Περιορισμένος ρυθμός: Πάρα πολλές προσπάθειες επαλήθευσης. Παρακαλώ περιμένετε {{time}} δευτερόλεπτα πριν προσπαθήσετε ξανά.", "authTokenSaveFailed": "Αποτυχία αποθήκευσης διακριτικού ελέγχου ταυτότητας", "authTokenMissing": "Ο διακομιστής δεν επέστρεψε νέο διακριτικό ελέγχου ταυτότητας. Συνδεθείτε ξανά.", - "authTokenRejected": "Ο απομακρυσμένος διακομιστής απέρριψε το νέο διακριτικό ελέγχου ταυτότητας", - "keyringUnavailable": "Συνδέθηκα, αλλά η συνεδρία δεν ήταν δυνατή: αυτό το σύστημα δεν έχει διαθέσιμο keyring για κρυπτογράφηση. Ξεκινήστε μια Μυστική Υπηρεσία (όπως το gnome-keyring ή το KWallet) και συνδεθείτε ξανά.", "failedToLoadServer": "Αποτυχία φόρτωσης διακομιστή", "remoteServerRequired": "Απαιτείται απομακρυσμένος διακομιστής. Συνδέστε έναν απομακρυσμένο διακομιστή στις Ρυθμίσεις για να χρησιμοποιήσετε αυτόν τον τύπο σύνδεσης." }, diff --git a/src/ui/locales/translated/es_ES.json b/src/ui/locales/translated/es_ES.json index 5c5f9500b..bcf62db26 100644 --- a/src/ui/locales/translated/es_ES.json +++ b/src/ui/locales/translated/es_ES.json @@ -232,86 +232,8 @@ "widgetTunnelDesc": "Gestor de túneles SSH integrado para un host configurado", "widgetNoHostSelected": "Sin host configurado" }, - "serverConfig": { - "title": "Configuración del servidor", - "description": "Configure la URL del servidor Termix para conectarse a sus servicios backend", - "serverUrl": "URL del servidor", - "enterServerUrl": "Introduzca una URL del servidor", - "saveFailed": "No se pudo guardar la configuración", - "saveError": "Error al guardar la configuración", - "saving": "Guardando...", - "saveConfig": "Guardar configuración", - "helpText": "Introduzca la URL donde se ejecuta su servidor Termix (por ejemplo, http://localhost:30001 o https://your-server.com)", - "changeServer": "Cambiar servidor", - "mustIncludeProtocol": "La URL del servidor debe comenzar con http:// o https://", - "allowInvalidCertificate": "Permitir certificado no válido", - "allowInvalidCertificateDesc": "Usar solo para servidores autoalojados de confianza con certificados autofirmados o de dirección IP.", - "useEmbedded": "Usar servidor local", - "embeddedDesc": "Ejecutar Termix con el servidor local integrado (no se necesita servidor remoto)", - "embeddedConnecting": "Conectando al servidor local...", - "embeddedNotReady": "El servidor local aún no está listo. Espere un momento e inténtelo de nuevo.", - "localServer": "Servidor local", - "savedServers": "Servidores guardados", - "noSavedServers": "No hay servidores guardados", - "removeServer": "Eliminar" - }, - "migrationNotice": { - "title": "Termix Desktop ahora se ejecuta de forma independiente.", - "body1": "Esta aplicación solía conectarse directamente a tu servidor Termix. Se ha rediseñado para funcionar de forma totalmente independiente, almacenando los hosts y las credenciales localmente para que siga funcionando sin conexión. La sincronización bidireccional con un servidor Termix autohospedado ahora es opcional.", - "body2": "Tus hosts y credenciales actuales siguen en {{url}}. Activa la sincronización remota y vuelve a conectarte a ese servidor para recuperarlos y mantenerlos sincronizados en adelante.", - "dismiss": "Ahora no", - "setUpSync": "Configurar la sincronización remota" - }, - "remoteSync": { - "title": "Sincronización remota", - "description": "Opcionalmente, conecta esta aplicación de escritorio a un servidor Termix autogestionado para sincronizar tus hosts, credenciales y fragmentos de código en todos tus dispositivos. La aplicación siempre funciona sin conexión, independientemente de si te conectas o no.", - "notConnected": "No conectado", - "connected": "Conectado", - "connectedTo": "Conectado a {{url}}", - "lastSynced": "Última sincronización {{time}}", - "neverSynced": "Nunca sincronizado", - "syncError": "Error de sincronización: {{message}}", - "needsReauth": "La sesión ha caducado.", - "connectButton": "Conéctese al servidor", - "disconnectButton": "Desconectar", - "syncNowButton": "Sincronizar ahora", - "syncing": "Sincronizando...", - "syncSuccess": "Sincronización remota completada", - "syncUnavailable": "No se pudo iniciar la sincronización remota.", - "serverUrl": "URL del servidor", - "enterServerUrl": "Por favor, introduzca una URL del servidor.", - "mustIncludeProtocol": "La URL del servidor debe comenzar con http:// o https://", - "connectionTestFailed": "No se pudo conectar con un servidor Termix en esa URL.", - "allowInvalidCertificate": "Permitir certificado no válido", - "allowInvalidCertificateDesc": "Utilizar únicamente en servidores autogestionados de confianza con certificados autofirmados o basados en direcciones IP.", - "savedServers": "Servidores guardados", - "removeServer": "Eliminar", - "continueButton": "Continuar", - "cancelButton": "Cancelar", - "signInTitle": "Iniciar sesión en {{url}}", - "originTitle": "Origen de la conexión", - "originDescription": "Se elige de forma predeterminada el origen de las conexiones SSH. Esta configuración se puede modificar para cada host.", - "originLocal": "Este dispositivo (red local)", - "originRemote": "Servidor remoto", - "bannerReconnect": "Reconectar", - "bannerMessage": "La sincronización remota requiere una nueva autenticación." - }, "versionCheck": { - "error": "Error de verificación de versión", - "checkFailed": "Fallo al comprobar actualizaciones", - "upToDate": "La aplicación está actualizada", - "currentVersion": "Estás usando la versión {{version}}", - "updateAvailable": "Actualización disponible", - "updateLinkLabel": "Actualización disponible: consulta las notas de la versión en GitHub.", - "newVersionAvailable": "¡Hay una nueva versión disponible! Estás usando la {{current}}, pero la {{latest}} está disponible.", - "betaVersion": "Versión Beta", - "betaVersionDesc": "Estás usando {{current}}, que es más reciente que la última versión estable {{latest}}.", - "releasedOn": "Publicado el {{date}}", - "downloadUpdate": "Descargar actualización", - "checking": "Comprobando actualizaciones...", - "checkUpdates": "Buscar actualizaciones", - "checkingUpdates": "Comprobando actualizaciones...", - "updateRequired": "Actualización necesaria" + "updateLinkLabel": "Actualización disponible: consulta las notas de la versión en GitHub." }, "common": { "close": "Cerrar", @@ -1565,8 +1487,6 @@ "resetCodeRateLimitedWithTime": "Límite de intentos: Demasiados intentos de verificación. Espere {{time}} segundos antes de volver a intentarlo.", "authTokenSaveFailed": "Error al guardar el token de autenticación", "authTokenMissing": "El servidor no devolvió un nuevo token de autenticación. Inicie sesión de nuevo.", - "authTokenRejected": "El servidor remoto rechazó el nuevo token de autenticación.", - "keyringUnavailable": "Se ha iniciado sesión, pero no se ha podido guardar la sesión: este sistema no dispone de un llavero de claves para cifrarla. Inicie un servicio de cifrado (como gnome-keyring o KWallet) e inicie sesión de nuevo.", "failedToLoadServer": "Error al cargar el servidor", "remoteServerRequired": "Se requiere un servidor remoto. Conecte un servidor remoto en Configuración para usar este tipo de conexión." }, diff --git a/src/ui/locales/translated/fi_FI.json b/src/ui/locales/translated/fi_FI.json index 1b1d3a1db..ed4aa2c4c 100644 --- a/src/ui/locales/translated/fi_FI.json +++ b/src/ui/locales/translated/fi_FI.json @@ -232,86 +232,8 @@ "widgetTunnelDesc": "Sulautettu SSH-tunnelinhallinta konfiguroidulle isännälle", "widgetNoHostSelected": "Ei määritettyä isäntää" }, - "serverConfig": { - "title": "Palvelimen kokoonpano", - "description": "Määritä Termix-palvelimen URL-osoite yhteyden muodostamiseksi taustapalveluihin", - "serverUrl": "Palvelimen URL-osoite", - "enterServerUrl": "Anna palvelimen URL-osoite", - "saveFailed": "Määritysten tallennus epäonnistui", - "saveError": "Virhe tallennettaessa määritystä", - "saving": "Tallennetaan...", - "saveConfig": "Tallenna kokoonpano", - "helpText": "Anna URL-osoite, jossa Termix-palvelimesi toimii (esim. http://localhost:30001 tai https://your-server.com)", - "changeServer": "Vaihda palvelinta", - "mustIncludeProtocol": "Palvelimen URL-osoitteen on alettava http:// tai https://", - "allowInvalidCertificate": "Salli virheellinen varmenne", - "allowInvalidCertificateDesc": "Käytä vain luotettaville itse isännöidyille palvelimille, joissa on itse allekirjoitetut tai IP-osoitevarmenteet.", - "useEmbedded": "Käytä paikallista palvelinta", - "embeddedDesc": "Suorita Termix sisäänrakennetulla paikallisella palvelimella (etäpalvelinta ei tarvita)", - "embeddedConnecting": "Yhdistetään paikalliseen palvelimeen...", - "embeddedNotReady": "Paikallinen palvelin ei ole vielä valmis. Odota hetki ja yritä uudelleen.", - "localServer": "Paikallinen palvelin", - "savedServers": "Tallennetut palvelimet", - "noSavedServers": "Ei tallennettuja palvelimia", - "removeServer": "Poistaa" - }, - "migrationNotice": { - "title": "Termix Desktop toimii nyt itsenäisesti", - "body1": "Tämä sovellus muodosti aiemmin yhteyden suoraan Termix-palvelimeesi. Se on uudistettu toimimaan täysin itsenäisesti tallentaen isännät ja tunnistetiedot paikallisesti, joten se toimii edelleen offline-tilassa. Kaksisuuntainen synkronointi itse isännöidyn Termix-palvelimen kanssa on nyt valinnainen.", - "body2": "Nykyiset isäntäsi ja tunnistetietosi ovat edelleen {{url}}. Ota etäsynkronointi käyttöön ja muodosta yhteys kyseiseen palvelimeen uudelleen palauttaaksesi ne ja pitääksesi molemmat synkronoituina jatkossa.", - "dismiss": "Ei nyt", - "setUpSync": "Etäsynkronoinnin määrittäminen" - }, - "remoteSync": { - "title": "Etäsynkronointi", - "description": "Voit halutessasi yhdistää tämän työpöytäsovelluksen itse isännöityyn Termix-palvelimeen synkronoidaksesi isännöitsijäsi, tunnistetietosi ja koodinpätkäsi eri laitteiden välillä. Sovellus toimii aina täysin offline-tilassa riippumatta siitä, oletko yhteydessä palvelimeen vai et.", - "notConnected": "Ei yhdistetty", - "connected": "Yhdistetty", - "connectedTo": "Yhdistetty kohteeseen {{url}}", - "lastSynced": "Viimeksi synkronoitu {{time}}", - "neverSynced": "Ei koskaan synkronoitu", - "syncError": "Synkronointivirhe: {{message}}", - "needsReauth": "Kirjautuminen vanhentunut", - "connectButton": "Yhdistä palvelimeen", - "disconnectButton": "Katkaise yhteys", - "syncNowButton": "Synkronoi nyt", - "syncing": "Synkronoidaan...", - "syncSuccess": "Etäsynkronointi valmis", - "syncUnavailable": "Etäsynkronointia ei voitu aloittaa", - "serverUrl": "Palvelimen URL-osoite", - "enterServerUrl": "Anna palvelimen URL-osoite", - "mustIncludeProtocol": "Palvelimen URL-osoitteen on alettava http:// tai https://", - "connectionTestFailed": "Termix-palvelimeen ei saatu yhteyttä kyseisessä URL-osoitteessa", - "allowInvalidCertificate": "Salli virheellinen varmenne", - "allowInvalidCertificateDesc": "Käytä vain luotettaville itse isännöidyille palvelimille, joissa on itse allekirjoitetut tai IP-osoitevarmenteet.", - "savedServers": "Tallennetut palvelimet", - "removeServer": "Poistaa", - "continueButton": "Jatkaa", - "cancelButton": "Peruuttaa", - "signInTitle": "Kirjaudu sisään palveluun {{url}}", - "originTitle": "Yhteyden alkuperä", - "originDescription": "Valitse oletusarvoisesti SSH-yhteyksien lähtöpaikka. Tämä voidaan ohittaa isäntäkohtaisesti.", - "originLocal": "Tämä laite (lähiverkko)", - "originRemote": "Etäpalvelin", - "bannerReconnect": "Yhdistä uudelleen", - "bannerMessage": "Etäsynkronointi vaatii uudelleentunnistuksen" - }, "versionCheck": { - "error": "Versiotarkistusvirhe", - "checkFailed": "Päivitysten tarkistaminen epäonnistui", - "upToDate": "Sovellus on ajan tasalla", - "currentVersion": "Käytät versiota {{version}}", - "updateAvailable": "Päivitys saatavilla", - "updateLinkLabel": "Päivitys saatavilla — avaa julkaisutiedot GitHubissa", - "newVersionAvailable": "Uusi versio on saatavilla! Käytät {{current}}, mutta {{latest}} on saatavilla.", - "betaVersion": "Beta-versio", - "betaVersionDesc": "Käytät {{current}}, joka on uudempi kuin viimeisin vakaa julkaisu {{latest}}.", - "releasedOn": "Julkaistu {{date}}", - "downloadUpdate": "Lataa päivitys", - "checking": "Tarkistetaan päivityksiä...", - "checkUpdates": "Tarkista päivitykset", - "checkingUpdates": "Tarkistetaan päivityksiä...", - "updateRequired": "Päivitys vaaditaan" + "updateLinkLabel": "Päivitys saatavilla — avaa julkaisutiedot GitHubissa" }, "common": { "close": "Lähellä", @@ -1565,8 +1487,6 @@ "resetCodeRateLimitedWithTime": "Nopeus rajoitettu: Liian monta vahvistusyritystä. Odota {{time}} sekuntia ennen kuin yrität uudelleen.", "authTokenSaveFailed": "Todennustunnuksen tallennus epäonnistui", "authTokenMissing": "Palvelin ei palauttanut uutta todennustunnusta. Kirjaudu uudelleen sisään.", - "authTokenRejected": "Etäpalvelin hylkäsi uuden todennustunnuksen", - "keyringUnavailable": "Kirjauduttu sisään, mutta istuntoa ei voitu tallentaa: järjestelmässä ei ole avainnippua sen salaamiseen. Käynnistä salainen palvelu (kuten gnome-keyring tai KWallet) ja kirjaudu uudelleen sisään.", "failedToLoadServer": "Palvelimen lataus epäonnistui", "remoteServerRequired": "Etäpalvelin vaaditaan. Yhdistä etäpalvelin asetuksissa käyttääksesi tätä yhteystyyppiä." }, diff --git a/src/ui/locales/translated/fr_FR.json b/src/ui/locales/translated/fr_FR.json index 2f5fd0a85..cc4adce83 100644 --- a/src/ui/locales/translated/fr_FR.json +++ b/src/ui/locales/translated/fr_FR.json @@ -232,86 +232,8 @@ "widgetTunnelDesc": "Gestionnaire de tunnels SSH intégré pour un hôte configuré", "widgetNoHostSelected": "Aucun hôte configuré" }, - "serverConfig": { - "title": "Configuration du serveur", - "description": "Configurer l'URL du serveur Termix pour se connecter à vos services backend", - "serverUrl": "URL du serveur", - "enterServerUrl": "Veuillez saisir l'URL du serveur", - "saveFailed": "Échec de l'enregistrement de la configuration", - "saveError": "Erreur lors de l'enregistrement de la configuration", - "saving": "Enregistrement...", - "saveConfig": "Enregistrer la configuration", - "helpText": "Entrez l'URL où votre serveur Termix est en cours d'exécution (par ex., http://localhost:30001 ou https://votre-serveur.com)", - "changeServer": "Changer de serveur", - "mustIncludeProtocol": "L'URL du serveur doit commencer par http:// ou https://", - "allowInvalidCertificate": "Autoriser les certificats invalides", - "allowInvalidCertificateDesc": "À utiliser uniquement pour les serveurs auto-hébergés de confiance avec des certificats auto-signés ou d'adresse IP.", - "useEmbedded": "Utiliser le serveur local", - "embeddedDesc": "Exécuter Termix avec le serveur local intégré (aucun serveur distant nécessaire)", - "embeddedConnecting": "Connexion au serveur local...", - "embeddedNotReady": "Le serveur local n'est pas encore prêt. Veuillez patienter un instant et réessayer.", - "localServer": "Serveur local", - "savedServers": "Serveurs enregistrés", - "noSavedServers": "Aucun serveur enregistré", - "removeServer": "Supprimer" - }, - "migrationNotice": { - "title": "Termix Desktop fonctionne désormais de manière autonome.", - "body1": "Cette application se connectait auparavant directement à votre serveur Termix. Elle a été repensée pour fonctionner de manière totalement autonome, en stockant localement les hôtes et les identifiants afin de rester opérationnelle hors ligne. La synchronisation bidirectionnelle avec un serveur Termix auto-hébergé est désormais optionnelle.", - "body2": "Vos hôtes et identifiants actuels sont toujours sur {{url}}. Activez la synchronisation à distance et reconnectez-vous à ce serveur pour les restaurer et les maintenir synchronisés.", - "dismiss": "Pas maintenant", - "setUpSync": "Configurer la synchronisation à distance" - }, - "remoteSync": { - "title": "Synchronisation à distance", - "description": "Vous pouvez connecter cette application de bureau à un serveur Termix auto-hébergé pour synchroniser vos hôtes, identifiants et extraits de code sur tous vos appareils. L'application fonctionne toujours entièrement hors ligne, que vous soyez connecté ou non.", - "notConnected": "Non connecté", - "connected": "Connecté", - "connectedTo": "Connecté à {{url}}", - "lastSynced": "Dernière synchronisation {{time}}", - "neverSynced": "Jamais synchronisé", - "syncError": "Erreur de synchronisation : {{message}}", - "needsReauth": "Connexion expirée", - "connectButton": "Se connecter au serveur", - "disconnectButton": "Déconnecter", - "syncNowButton": "Synchroniser maintenant", - "syncing": "Synchronisation en cours...", - "syncSuccess": "Synchronisation à distance terminée", - "syncUnavailable": "La synchronisation à distance n'a pas pu démarrer.", - "serverUrl": "URL du serveur", - "enterServerUrl": "Veuillez saisir une URL de serveur", - "mustIncludeProtocol": "L'URL du serveur doit commencer par http:// ou https://", - "connectionTestFailed": "Impossible de joindre un serveur Termix à cette URL.", - "allowInvalidCertificate": "Autoriser un certificat invalide", - "allowInvalidCertificateDesc": "À utiliser uniquement pour des serveurs auto-hébergés de confiance avec des certificats auto-signés ou des certificats d'adresse IP.", - "savedServers": "Serveurs sauvegardés", - "removeServer": "Retirer", - "continueButton": "Continuer", - "cancelButton": "Annuler", - "signInTitle": "Connectez-vous à {{url}}", - "originTitle": "Origine de la connexion", - "originDescription": "Choisissez l'origine par défaut des connexions SSH. Ce paramètre peut être modifié pour chaque hôte.", - "originLocal": "Cet appareil (réseau local)", - "originRemote": "Serveur distant", - "bannerReconnect": "Reconnect", - "bannerMessage": "La synchronisation à distance nécessite une réauthentification." - }, "versionCheck": { - "error": "Erreur de vérification de version", - "checkFailed": "Échec de la vérification des mises à jour", - "upToDate": "L'application est à jour", - "currentVersion": "Vous utilisez la version {{version}}", - "updateAvailable": "Mise à jour disponible", - "updateLinkLabel": "Mise à jour disponible — consultez les notes de version sur GitHub", - "newVersionAvailable": "Une nouvelle version est disponible ! Vous utilisez {{current}}, mais la version {{latest}} est disponible.", - "betaVersion": "Version bêta", - "betaVersionDesc": "Vous utilisez {{current}}, qui est plus récente que la dernière version stable {{latest}}.", - "releasedOn": "Publiée le {{date}}", - "downloadUpdate": "Télécharger la mise à jour", - "checking": "Recherche des mises à jour...", - "checkUpdates": "Rechercher des mises à jour", - "checkingUpdates": "Recherche des mises à jour...", - "updateRequired": "Mise à jour requise" + "updateLinkLabel": "Mise à jour disponible — consultez les notes de version sur GitHub" }, "common": { "close": "Fermer", @@ -1565,8 +1487,6 @@ "resetCodeRateLimitedWithTime": "Limitation de débit : trop de tentatives de vérification. Veuillez patienter {{time}} secondes avant de réessayer.", "authTokenSaveFailed": "Échec de l'enregistrement du jeton d'authentification", "authTokenMissing": "Le serveur n'a pas renvoyé de nouveau jeton d'authentification. Veuillez vous reconnecter.", - "authTokenRejected": "Le serveur distant a rejeté le nouveau jeton d'authentification.", - "keyringUnavailable": "Connexion impossible, la session n'a pas pu être enregistrée : ce système ne dispose d'aucun trousseau de clés pour la chiffrer. Veuillez démarrer un service de chiffrement (tel que gnome-keyring ou KWallet) et vous reconnecter.", "failedToLoadServer": "Échec du chargement du serveur", "remoteServerRequired": "Serveur distant requis. Connectez un serveur distant dans les paramètres pour utiliser ce type de connexion." }, diff --git a/src/ui/locales/translated/he_IL.json b/src/ui/locales/translated/he_IL.json index 8266df124..8574c8d03 100644 --- a/src/ui/locales/translated/he_IL.json +++ b/src/ui/locales/translated/he_IL.json @@ -232,86 +232,8 @@ "widgetTunnelDesc": "מנהל מנהרות SSH מוטמע עבור מארח מוגדר", "widgetNoHostSelected": "לא הוגדר מארח" }, - "serverConfig": { - "title": "תצורת שרת", - "description": "הגדר את כתובת ה-URL של שרת Termix כדי להתחבר לשירותי ה-backend שלך", - "serverUrl": "כתובת URL של השרת", - "enterServerUrl": "אנא הזן כתובת URL של שרת", - "saveFailed": "שמירת התצורה נכשלה", - "saveError": "שגיאה בשמירת התצורה", - "saving": "חִסָכוֹן...", - "saveConfig": "שמור תצורה", - "helpText": "הזן את כתובת ה-URL שבה פועל שרת ה-Termix שלך (לדוגמה, http://localhost:30001 או https://your-server.com)", - "changeServer": "שנה שרת", - "mustIncludeProtocol": "כתובת השרת חייבת להתחיל ב-http:// או https://", - "allowInvalidCertificate": "אפשר אישור לא חוקי", - "allowInvalidCertificateDesc": "השתמש רק עבור שרתים מהימנים המתארחים בעצמם עם אישורי חתימה עצמית או אישורי כתובת IP.", - "useEmbedded": "השתמש בשרת מקומי", - "embeddedDesc": "הפעל את Termix עם השרת המקומי המובנה (אין צורך בשרת מרוחק)", - "embeddedConnecting": "מתחבר לשרת מקומי...", - "embeddedNotReady": "השרת המקומי עדיין לא מוכן. אנא המתן רגע ונסה שוב.", - "localServer": "שרת מקומי", - "savedServers": "שרתים שמורים", - "noSavedServers": "אין שרתים שמורים", - "removeServer": "לְהַסִיר" - }, - "migrationNotice": { - "title": "שולחן העבודה של Termix פועל כעת באופן עצמאי", - "body1": "אפליקציה זו הייתה מתחברת ישירות לשרת Termix שלך. היא עובדה מחדש כדי לפעול באופן עצמאי לחלוטין, תוך אחסון מארחים ופרטי גישה מקומיים כך שהיא תמשיך לעבוד במצב לא מקוון. סנכרון דו-כיווני עם שרת Termix המתארח באופן עצמאי הוא כעת אופציונלי.", - "body2": "המארחים והאישורים הקיימים שלך עדיין נמצאים ב- {{url}}. הפעל את הסנכרון מרחוק והתחבר מחדש לשרת זה כדי להחזיר אותם ולשמור על שניהם מסונכרנים מעתה והלאה.", - "dismiss": "לא עכשיו", - "setUpSync": "הגדר סנכרון מרחוק" - }, - "remoteSync": { - "title": "סנכרון מרחוק", - "description": "ניתן לחבר אפליקציית שולחן עבודה זו לשרת Termix המתארח באופן עצמאי כדי לסנכרן את המארחים, האישורים והקטעים שלך בין מכשירים. האפליקציה תמיד פועלת באופן מלא במצב לא מקוון, בין אם אתה מתחבר ובין אם לא.", - "notConnected": "לא מחובר", - "connected": "מְחוּבָּר", - "connectedTo": "מחובר אל {{url}}", - "lastSynced": "סנכרון אחרון {{time}}", - "neverSynced": "מעולם לא סנכרן", - "syncError": "שגיאת סינכרון: {{message}}", - "needsReauth": "פג תוקף הכניסה", - "connectButton": "התחברות לשרת", - "disconnectButton": "לְנַתֵק", - "syncNowButton": "סנכרן עכשיו", - "syncing": "מסנכרן...", - "syncSuccess": "סנכרון מרחוק הושלם", - "syncUnavailable": "לא ניתן היה להתחיל את הסנכרון מרחוק", - "serverUrl": "כתובת URL של השרת", - "enterServerUrl": "אנא הזן כתובת URL של שרת", - "mustIncludeProtocol": "כתובת השרת חייבת להתחיל ב-http:// או https://", - "connectionTestFailed": "לא ניתן היה להגיע לשרת Termix בכתובת URL זו", - "allowInvalidCertificate": "אפשר אישור לא חוקי", - "allowInvalidCertificateDesc": "השתמש רק עבור שרתים מהימנים המתארחים בעצמם עם אישורי חתימה עצמית או אישורי כתובת IP.", - "savedServers": "שרתים שמורים", - "removeServer": "לְהַסִיר", - "continueButton": "לְהַמשִׁיך", - "cancelButton": "לְבַטֵל", - "signInTitle": "היכנס אל {{url}}", - "originTitle": "מקור החיבור", - "originDescription": "בחר כברירת מחדל מהיכן חיבורי SSH מגיעים. ניתן לעקוף זאת לפי מארח.", - "originLocal": "מכשיר זה (רשת מקומית)", - "originRemote": "שרת מרוחק", - "bannerReconnect": "התחבר מחדש", - "bannerMessage": "סנכרון מרחוק דורש אימות מחדש" - }, "versionCheck": { - "error": "שגיאת בדיקת גרסה", - "checkFailed": "נכשל בבדיקת עדכונים", - "upToDate": "האפליקציה מעודכנת", - "currentVersion": "אתה משתמש בגרסה {{version}}", - "updateAvailable": "עדכון זמין", - "updateLinkLabel": "עדכון זמין - פתח את הערות הגרסה ב-GitHub", - "newVersionAvailable": "גרסה חדשה זמינה! אתה מפעיל את {{current}}, אבל {{latest}} זמין.", - "betaVersion": "גרסת בטא", - "betaVersionDesc": "אתה מפעיל את {{current}}, שהוא חדש יותר מהגרסה היציבה האחרונה {{latest}}.", - "releasedOn": "יצא לאור בתאריך {{date}}", - "downloadUpdate": "הורד עדכון", - "checking": "בודק עדכונים...", - "checkUpdates": "בדוק אם יש עדכונים", - "checkingUpdates": "בודק עדכונים...", - "updateRequired": "נדרש עדכון" + "updateLinkLabel": "עדכון זמין - פתח את הערות הגרסה ב-GitHub" }, "common": { "close": "לִסְגוֹר", @@ -1565,8 +1487,6 @@ "resetCodeRateLimitedWithTime": "קצב מוגבל: יותר מדי ניסיונות אימות. אנא המתן {{time}} שניות לפני שתנסה שוב.", "authTokenSaveFailed": "נכשלה שמירת אסימון האימות", "authTokenMissing": "השרת לא החזיר אסימון אימות חדש. אנא היכנס שוב.", - "authTokenRejected": "השרת המרוחק דחה את אסימון האימות החדש", - "keyringUnavailable": "התחברתי, אך לא ניתן היה לאחסן את הסשן: למערכת זו אין טבעת מפתח זמינה להצפנתה. הפעל שירות חשאי (כגון gnome-keyring או KWallet) והתחבר שוב.", "failedToLoadServer": "טעינת השרת נכשלה", "remoteServerRequired": "נדרש שרת מרוחק. יש להתחבר לשרת מרוחק בהגדרות כדי להשתמש בסוג חיבור זה." }, diff --git a/src/ui/locales/translated/hi_IN.json b/src/ui/locales/translated/hi_IN.json index bb156951d..52669e45d 100644 --- a/src/ui/locales/translated/hi_IN.json +++ b/src/ui/locales/translated/hi_IN.json @@ -232,86 +232,8 @@ "widgetTunnelDesc": "कॉन्फ़िगर किए गए होस्ट के लिए एम्बेडेड SSH टनल प्रबंधक", "widgetNoHostSelected": "कोई होस्ट कॉन्फ़िगर नहीं किया गया" }, - "serverConfig": { - "title": "सर्वर कॉन्फ़िगरेशन", - "description": "अपनी बैकएंड सेवाओं से कनेक्ट करने के लिए Termix सर्वर URL कॉन्फ़िगर करें", - "serverUrl": "सर्वर URL", - "enterServerUrl": "कृपया एक सर्वर URL दर्ज करें", - "saveFailed": "कॉन्फ़िगरेशन सहेजने में विफल", - "saveError": "कॉन्फ़िगरेशन सहेजने में त्रुटि", - "saving": "सहेज रहा है...", - "saveConfig": "कॉन्फ़िगरेशन सहेजें", - "helpText": "URL दर्ज करें जहाँ आपका Termix सर्वर चल रहा है (उदा., http://localhost:30001 या https://your-server.com)", - "changeServer": "सर्वर बदलें", - "mustIncludeProtocol": "सर्वर URL http:// या https:// से शुरू होना चाहिए", - "allowInvalidCertificate": "अमान्य प्रमाणपत्र की अनुमति दें", - "allowInvalidCertificateDesc": "केवल स्व-हस्ताक्षरित या IP-पता प्रमाणपत्र वाले विश्वसनीय स्व-होस्टेड सर्वर के लिए उपयोग करें।", - "useEmbedded": "स्थानीय सर्वर का उपयोग करें", - "embeddedDesc": "Termix को अंतर्निहित स्थानीय सर्वर के साथ चलाएँ (कोई दूरस्थ सर्वर आवश्यक नहीं)", - "embeddedConnecting": "स्थानीय सर्वर से कनेक्ट हो रहा है...", - "embeddedNotReady": "स्थानीय सर्वर अभी तक तैयार नहीं है। कृपया कुछ क्षण प्रतीक्षा करें और पुनः प्रयास करें।", - "localServer": "स्थानीय सर्वर", - "savedServers": "सहेजे गए सर्वर", - "noSavedServers": "कोई सहेजे गए सर्वर नहीं", - "removeServer": "हटाएँ" - }, - "migrationNotice": { - "title": "टर्मिक्स डेस्कटॉप अब स्टैंडअलोन मोड में चलता है।", - "body1": "यह ऐप पहले सीधे आपके टर्मिक्स सर्वर से कनेक्ट होता था। अब इसे पूरी तरह से स्वतंत्र रूप से चलने के लिए संशोधित किया गया है, जिसमें होस्ट और क्रेडेंशियल स्थानीय रूप से संग्रहीत होते हैं ताकि यह ऑफ़लाइन भी काम करता रहे। स्व-होस्टेड टर्मिक्स सर्वर के साथ दो-तरफ़ा सिंक अब वैकल्पिक है।", - "body2": "आपके मौजूदा होस्ट और क्रेडेंशियल अभी भी {{url}}पर हैं। रिमोट सिंक चालू करें और उन्हें वापस लाने और भविष्य में दोनों को सिंक में रखने के लिए उस सर्वर से पुनः कनेक्ट करें।", - "dismiss": "अभी नहीं", - "setUpSync": "रिमोट सिंक सेट अप करें" - }, - "remoteSync": { - "title": "रिमोट सिंक", - "description": "आप चाहें तो इस डेस्कटॉप ऐप को अपने होस्ट, क्रेडेंशियल और स्निपेट्स को सभी डिवाइसों में सिंक करने के लिए सेल्फ-होस्टेड टर्मिक्स सर्वर से कनेक्ट कर सकते हैं। ऐप हमेशा पूरी तरह से ऑफलाइन काम करता है, चाहे आप कनेक्ट करें या न करें।", - "notConnected": "जुड़े नहीं हैं", - "connected": "जुड़े हुए", - "connectedTo": "{{url}} से जुड़ा हुआ", - "lastSynced": "अंतिम बार सिंक्रनाइज़ किया गया {{time}}", - "neverSynced": "कभी सिंक्रनाइज़ नहीं हुआ", - "syncError": "सिंक त्रुटि: {{message}}", - "needsReauth": "लॉग-इन की समय सीमा समाप्त हो गई है", - "connectButton": "सर्वर से कनेक्ट करें", - "disconnectButton": "डिस्कनेक्ट", - "syncNowButton": "अभी सिंक करें", - "syncing": "सिंक्रनाइज़ हो रहा है...", - "syncSuccess": "रिमोट सिंक पूरा हुआ", - "syncUnavailable": "रिमोट सिंक शुरू नहीं हो सका", - "serverUrl": "सर्वर यूआरएल", - "enterServerUrl": "कृपया सर्वर URL दर्ज करें", - "mustIncludeProtocol": "सर्वर URL की शुरुआत http:// या https:// से होनी चाहिए।", - "connectionTestFailed": "उस यूआरएल पर टर्मिक्स सर्वर तक नहीं पहुंचा जा सका।", - "allowInvalidCertificate": "अमान्य प्रमाणपत्र की अनुमति दें", - "allowInvalidCertificateDesc": "इसका उपयोग केवल विश्वसनीय सेल्फ-होस्टेड सर्वरों के लिए करें जिनमें सेल्फ-साइन किए गए या आईपी-एड्रेस प्रमाणपत्र हों।", - "savedServers": "सहेजे गए सर्वर", - "removeServer": "निकालना", - "continueButton": "जारी रखना", - "cancelButton": "रद्द करना", - "signInTitle": "{{url}} में साइन इन करें", - "originTitle": "कनेक्शन का स्रोत", - "originDescription": "डिफ़ॉल्ट रूप से SSH कनेक्शन कहाँ से शुरू होंगे, यह चुनें। इसे प्रत्येक होस्ट के लिए बदला जा सकता है।", - "originLocal": "यह डिवाइस (स्थानीय नेटवर्क)", - "originRemote": "रिमोट सर्वर", - "bannerReconnect": "रिकनेक्ट", - "bannerMessage": "रिमोट सिंक के लिए पुनः प्रमाणीकरण आवश्यक है।" - }, "versionCheck": { - "error": "संस्करण जाँच त्रुटि", - "checkFailed": "अपडेट की जाँच करने में विफल", - "upToDate": "ऐप अद्यतित है", - "currentVersion": "आप संस्करण {{version}} चला रहे हैं", - "updateAvailable": "अपडेट उपलब्ध है", - "updateLinkLabel": "अपडेट उपलब्ध है — GitHub पर रिलीज़ नोट्स देखें", - "newVersionAvailable": "एक नया संस्करण उपलब्ध है! आप {{current}} चला रहे हैं, लेकिन {{latest}} उपलब्ध है।", - "betaVersion": "बीटा संस्करण", - "betaVersionDesc": "आप {{current}} चला रहे हैं, जो नवीनतम स्थिर रिलीज़ {{latest}} से नया है।", - "releasedOn": "{{date}} को जारी किया गया", - "downloadUpdate": "अपडेट डाउनलोड करें", - "checking": "अपडेट की जाँच हो रही है...", - "checkUpdates": "अपडेट की जाँच करें", - "checkingUpdates": "अपडेट की जाँच हो रही है...", - "updateRequired": "अपडेट आवश्यक है" + "updateLinkLabel": "अपडेट उपलब्ध है — GitHub पर रिलीज़ नोट्स देखें" }, "common": { "close": "बंद करें", @@ -1565,8 +1487,6 @@ "resetCodeRateLimitedWithTime": "दर सीमित: बहुत अधिक सत्यापन प्रयास। कृपया पुनः प्रयास करने से पहले {{time}} सेकंड प्रतीक्षा करें।", "authTokenSaveFailed": "प्रमाणीकरण टोकन सहेजने में विफल", "authTokenMissing": "सर्वर ने नया प्रमाणीकरण टोकन नहीं भेजा। कृपया दोबारा साइन इन करें।", - "authTokenRejected": "रिमोट सर्वर ने नए प्रमाणीकरण टोकन को अस्वीकार कर दिया।", - "keyringUnavailable": "लॉग इन तो हो गया, लेकिन सेशन सेव नहीं हो सका: इस सिस्टम में एन्क्रिप्शन के लिए कोई कीरिंग उपलब्ध नहीं है। कोई सीक्रेट सर्विस (जैसे gnome-keyring या KWallet) शुरू करें और दोबारा लॉग इन करें।", "failedToLoadServer": "सर्वर लोड करने में विफल", "remoteServerRequired": "रिमोट सर्वर आवश्यक है। इस कनेक्शन प्रकार का उपयोग करने के लिए सेटिंग्स में जाकर रिमोट सर्वर कनेक्ट करें।" }, diff --git a/src/ui/locales/translated/hu_HU.json b/src/ui/locales/translated/hu_HU.json index f96d9b688..579b337e2 100644 --- a/src/ui/locales/translated/hu_HU.json +++ b/src/ui/locales/translated/hu_HU.json @@ -232,86 +232,8 @@ "widgetTunnelDesc": "Beágyazott SSH alagútkezelő egy konfigurált gazdagéphez", "widgetNoHostSelected": "Nincs konfigurálva gazdagép" }, - "serverConfig": { - "title": "Szerver konfiguráció", - "description": "Konfigurálja a Termix szerver URL-címét a háttérszolgáltatásokhoz való csatlakozáshoz", - "serverUrl": "Szerver URL-címe", - "enterServerUrl": "Kérjük, adjon meg egy szerver URL-címét", - "saveFailed": "Nem sikerült menteni a konfigurációt", - "saveError": "Hiba a konfiguráció mentése során", - "saving": "Megtakarítás...", - "saveConfig": "Konfiguráció mentése", - "helpText": "Add meg az URL-címet, ahol a Termix szervered fut (pl. http://localhost:30001 vagy https://your-server.com)", - "changeServer": "Szerverváltás", - "mustIncludeProtocol": "A szerver URL-címének http:// vagy https:// előtaggal kell kezdődnie.", - "allowInvalidCertificate": "Érvénytelen tanúsítvány engedélyezése", - "allowInvalidCertificateDesc": "Csak megbízható, saját üzemeltetésű szerverekhez használja, amelyek önaláírt vagy IP-cím alapú tanúsítvánnyal rendelkeznek.", - "useEmbedded": "Helyi szerver használata", - "embeddedDesc": "Futtassa a Termixet a beépített helyi szerverrel (nincs szükség távoli szerverre)", - "embeddedConnecting": "Kapcsolódás a helyi szerverhez...", - "embeddedNotReady": "A helyi szerver még nem áll készen. Kérjük, várjon egy pillanatot, és próbálja újra.", - "localServer": "Helyi szerver", - "savedServers": "Mentett szerverek", - "noSavedServers": "Nincsenek mentett szerverek", - "removeServer": "Eltávolítás" - }, - "migrationNotice": { - "title": "A Termix Desktop mostantól önállóan is fut", - "body1": "Ez az alkalmazás korábban közvetlenül a Termix szerveredhez csatlakozott. Átdolgoztuk, hogy teljesen önállóan fusson, helyben tárolja a hosztokat és a hitelesítő adatokat, így offline is működik. A kétirányú szinkronizálás egy saját üzemeltetésű Termix szerverrel mostantól opcionális.", - "body2": "A meglévő hosztok és hitelesítő adatok továbbra is a {{url}}címen találhatók. Kapcsold be a távoli szinkronizálást, és csatlakozz újra ehhez a szerverhez, hogy visszahozd őket, és a jövőben is szinkronban maradjanak.", - "dismiss": "Most nem", - "setUpSync": "Távoli szinkronizálás beállítása" - }, - "remoteSync": { - "title": "Távoli szinkronizálás", - "description": "Opcionálisan csatlakoztathatod ezt az asztali alkalmazást egy saját üzemeltetésű Termix szerverhez, hogy szinkronizálhasd a hosztjaidat, hitelesítő adataidat és kódrészleteidet az eszközök között. Az alkalmazás mindig teljesen offline működik, függetlenül attól, hogy csatlakozol-e vagy sem.", - "notConnected": "Nincs csatlakoztatva", - "connected": "Csatlakoztatva", - "connectedTo": "Csatlakoztatva ehhez: {{url}}", - "lastSynced": "Utolsó szinkronizálás {{time}}", - "neverSynced": "Soha nem szinkronizálva", - "syncError": "Szinkronizálási hiba: {{message}}", - "needsReauth": "Bejelentkezés lejárt", - "connectButton": "Kapcsolódás a szerverhez", - "disconnectButton": "Leválasztás", - "syncNowButton": "Szinkronizálás most", - "syncing": "Szinkronizálás...", - "syncSuccess": "Távoli szinkronizálás befejeződött", - "syncUnavailable": "A távoli szinkronizálás nem indítható el.", - "serverUrl": "Szerver URL-címe", - "enterServerUrl": "Kérjük, adjon meg egy szerver URL-címét", - "mustIncludeProtocol": "A szerver URL-címének http:// vagy https:// előtaggal kell kezdődnie.", - "connectionTestFailed": "Nem sikerült elérni a Termix szervert ezen az URL-címen.", - "allowInvalidCertificate": "Érvénytelen tanúsítvány engedélyezése", - "allowInvalidCertificateDesc": "Csak megbízható, saját üzemeltetésű szerverekhez használja, amelyek önaláírt vagy IP-cím alapú tanúsítvánnyal rendelkeznek.", - "savedServers": "Mentett szerverek", - "removeServer": "Eltávolítás", - "continueButton": "Folytatás", - "cancelButton": "Mégsem", - "signInTitle": "Jelentkezzen be ide: {{url}}", - "originTitle": "Kapcsolat eredete", - "originDescription": "Alapértelmezés szerint válassza ki az SSH-kapcsolatok forrását. Ez gazdagépenként felülbírálható.", - "originLocal": "Ez az eszköz (helyi hálózat)", - "originRemote": "Távoli szerver", - "bannerReconnect": "Újracsatlakozás", - "bannerMessage": "A távoli szinkronizáláshoz újra kell hitelesíteni" - }, "versionCheck": { - "error": "Verzióellenőrzési hiba", - "checkFailed": "Nem sikerült ellenőrizni a frissítéseket", - "upToDate": "Az alkalmazás naprakész", - "currentVersion": "A(z) {{version}} verziót futtatja", - "updateAvailable": "Frissítés elérhető", - "updateLinkLabel": "Frissítés elérhető — nyissa meg a kiadási megjegyzéseket a GitHubon", - "newVersionAvailable": "Új verzió érhető el! A(z) {{current}}verziót futtatod, de a(z) {{latest}} verzió már elérhető.", - "betaVersion": "Béta verzió", - "betaVersionDesc": "A(z) {{current}}verziót futtatod, ami újabb, mint a legújabb stabil kiadás {{latest}}.", - "releasedOn": "Megjelent: {{date}}", - "downloadUpdate": "Frissítés letöltése", - "checking": "Frissítések keresése...", - "checkUpdates": "Frissítések keresése", - "checkingUpdates": "Frissítések keresése...", - "updateRequired": "Frissítés szükséges" + "updateLinkLabel": "Frissítés elérhető — nyissa meg a kiadási megjegyzéseket a GitHubon" }, "common": { "close": "Közeli", @@ -1565,8 +1487,6 @@ "resetCodeRateLimitedWithTime": "Korlátozott sebesség: Túl sok ellenőrzési kísérlet. Kérjük, várjon {{time}} másodpercet, mielőtt újra próbálkozik.", "authTokenSaveFailed": "Nem sikerült menteni a hitelesítési tokent", "authTokenMissing": "A szerver nem adott vissza új hitelesítési tokent. Kérjük, jelentkezzen be újra.", - "authTokenRejected": "A távoli szerver elutasította az új hitelesítési tokent", - "keyringUnavailable": "Bejelentkezve, de a munkamenetet nem sikerült tárolni: ennek a rendszernek nincs kulcstartója a titkosításhoz. Indítson el egy titkos szolgáltatást (például a gnome-keyringet vagy a KWalletet), és jelentkezzen be újra.", "failedToLoadServer": "Nem sikerült betölteni a szervert", "remoteServerRequired": "Távoli szerver szükséges. Csatlakoztasson egy távoli szervert a Beállításokban a kapcsolattípus használatához." }, diff --git a/src/ui/locales/translated/id_ID.json b/src/ui/locales/translated/id_ID.json index 33aef99dd..efbd0e5c0 100644 --- a/src/ui/locales/translated/id_ID.json +++ b/src/ui/locales/translated/id_ID.json @@ -232,86 +232,8 @@ "widgetTunnelDesc": "Pengelola terowongan SSH terintegrasi untuk host yang telah dikonfigurasi.", "widgetNoHostSelected": "Tidak ada host yang dikonfigurasi." }, - "serverConfig": { - "title": "Konfigurasi Server", - "description": "Konfigurasikan URL server Termix untuk terhubung ke layanan backend Anda.", - "serverUrl": "URL Server", - "enterServerUrl": "Silakan masukkan URL server", - "saveFailed": "Gagal menyimpan konfigurasi", - "saveError": "Terjadi kesalahan saat menyimpan konfigurasi.", - "saving": "Penghematan...", - "saveConfig": "Simpan Konfigurasi", - "helpText": "Masukkan URL tempat server Termix Anda berjalan (misalnya, http://localhost:30001 atau https://your-server.com)", - "changeServer": "Ubah Server", - "mustIncludeProtocol": "URL server harus diawali dengan http:// atau https://", - "allowInvalidCertificate": "Izinkan sertifikat tidak valid", - "allowInvalidCertificateDesc": "Gunakan hanya untuk server mandiri tepercaya dengan sertifikat yang ditandatangani sendiri atau sertifikat alamat IP.", - "useEmbedded": "Gunakan Server Lokal", - "embeddedDesc": "Jalankan Termix dengan server lokal bawaan (tidak memerlukan server jarak jauh)", - "embeddedConnecting": "Menghubungkan ke server lokal...", - "embeddedNotReady": "Server lokal belum siap. Mohon tunggu sebentar dan coba lagi.", - "localServer": "Server Lokal", - "savedServers": "Server Tersimpan", - "noSavedServers": "Tidak ada server yang tersimpan.", - "removeServer": "Menghapus" - }, - "migrationNotice": { - "title": "Termix Desktop sekarang berjalan secara mandiri.", - "body1": "Aplikasi ini sebelumnya terhubung langsung ke server Termix Anda. Aplikasi ini telah dirancang ulang agar dapat berjalan sepenuhnya secara mandiri, menyimpan host dan kredensial secara lokal sehingga tetap berfungsi saat offline. Sinkronisasi dua arah dengan server Termix yang dihosting sendiri sekarang bersifat opsional.", - "body2": "Host dan kredensial Anda yang ada masih berada di {{url}}. Aktifkan Sinkronisasi Jarak Jauh dan sambungkan kembali ke server tersebut untuk mengembalikannya dan menjaga agar keduanya tetap sinkron ke depannya.", - "dismiss": "Tidak sekarang", - "setUpSync": "Siapkan Sinkronisasi Jarak Jauh" - }, - "remoteSync": { - "title": "Sinkronisasi Jarak Jauh", - "description": "Secara opsional, hubungkan aplikasi desktop ini ke server Termix yang dihosting sendiri untuk menyinkronkan host, kredensial, dan cuplikan Anda di berbagai perangkat. Aplikasi ini selalu berfungsi sepenuhnya secara offline, baik Anda terhubung atau tidak.", - "notConnected": "Tidak terhubung", - "connected": "Terhubung", - "connectedTo": "Terhubung ke {{url}}", - "lastSynced": "Sinkronisasi terakhir {{time}}", - "neverSynced": "Tidak pernah disinkronkan", - "syncError": "Kesalahan sinkronisasi: {{message}}", - "needsReauth": "Login telah kedaluwarsa.", - "connectButton": "Terhubung ke Server", - "disconnectButton": "Memutuskan", - "syncNowButton": "Sinkronkan Sekarang", - "syncing": "Sinkronisasi...", - "syncSuccess": "Sinkronisasi jarak jauh selesai", - "syncUnavailable": "Sinkronisasi jarak jauh tidak dapat dimulai.", - "serverUrl": "URL Server", - "enterServerUrl": "Silakan masukkan URL server", - "mustIncludeProtocol": "URL server harus diawali dengan http:// atau https://", - "connectionTestFailed": "Tidak dapat terhubung ke server Termix di URL tersebut.", - "allowInvalidCertificate": "Izinkan sertifikat tidak valid", - "allowInvalidCertificateDesc": "Gunakan hanya untuk server mandiri tepercaya dengan sertifikat yang ditandatangani sendiri atau sertifikat alamat IP.", - "savedServers": "Server Tersimpan", - "removeServer": "Menghapus", - "continueButton": "Melanjutkan", - "cancelButton": "Membatalkan", - "signInTitle": "Masuk ke {{url}}", - "originTitle": "Asal Koneksi", - "originDescription": "Pilih dari mana koneksi SSH berasal secara default. Pengaturan ini dapat diubah per host.", - "originLocal": "Perangkat ini (jaringan lokal)", - "originRemote": "Server jarak jauh", - "bannerReconnect": "Terhubung kembali", - "bannerMessage": "Sinkronisasi jarak jauh memerlukan autentikasi ulang." - }, "versionCheck": { - "error": "Kesalahan Pemeriksaan Versi", - "checkFailed": "Gagal memeriksa pembaruan.", - "upToDate": "Aplikasi sudah diperbarui.", - "currentVersion": "Anda menjalankan versi {{version}}", - "updateAvailable": "Pembaruan Tersedia", - "updateLinkLabel": "Pembaruan tersedia — buka catatan rilis di GitHub", - "newVersionAvailable": "Versi baru tersedia! Anda menjalankan {{current}}, tetapi {{latest}} tersedia.", - "betaVersion": "Versi Beta", - "betaVersionDesc": "Anda menjalankan {{current}}, yang lebih baru daripada rilis stabil terbaru {{latest}}.", - "releasedOn": "Dirilis pada {{date}}", - "downloadUpdate": "Unduh Pembaruan", - "checking": "Memeriksa pembaruan...", - "checkUpdates": "Periksa Pembaruan", - "checkingUpdates": "Memeriksa pembaruan...", - "updateRequired": "Pembaruan Diperlukan" + "updateLinkLabel": "Pembaruan tersedia — buka catatan rilis di GitHub" }, "common": { "close": "Menutup", @@ -1565,8 +1487,6 @@ "resetCodeRateLimitedWithTime": "Pembatasan laju: Terlalu banyak upaya verifikasi. Harap tunggu {{time}} detik sebelum mencoba lagi.", "authTokenSaveFailed": "Gagal menyimpan token otentikasi.", "authTokenMissing": "Server tidak mengembalikan token otentikasi baru. Silakan masuk kembali.", - "authTokenRejected": "Server jarak jauh menolak token otentikasi baru tersebut.", - "keyringUnavailable": "Berhasil masuk, tetapi sesi tidak dapat disimpan: sistem ini tidak memiliki keyring yang tersedia untuk mengenkripsinya. Mulai Secret Service (seperti gnome-keyring atau KWallet) dan masuk lagi.", "failedToLoadServer": "Server gagal dimuat.", "remoteServerRequired": "Diperlukan server jarak jauh. Hubungkan server jarak jauh di Pengaturan untuk menggunakan jenis koneksi ini." }, diff --git a/src/ui/locales/translated/it_IT.json b/src/ui/locales/translated/it_IT.json index 50aaac6ca..d810130dd 100644 --- a/src/ui/locales/translated/it_IT.json +++ b/src/ui/locales/translated/it_IT.json @@ -232,86 +232,8 @@ "widgetTunnelDesc": "Gestore di tunnel SSH integrato per un host configurato", "widgetNoHostSelected": "Nessun host configurato" }, - "serverConfig": { - "title": "Configurazione del Server", - "description": "Configura l'URL del server Termix per connetterti ai tuoi servizi backend", - "serverUrl": "URL del server", - "enterServerUrl": "Inserisci un URL del server", - "saveFailed": "Salvataggio della configurazione non riuscito", - "saveError": "Errore durante il salvataggio della configurazione", - "saving": "Salvataggio in corso...", - "saveConfig": "Salva configurazione", - "helpText": "Inserisci l'URL del server Termix (es. http://localhost:30001 o https://your-server.com)", - "changeServer": "Cambia server", - "mustIncludeProtocol": "L'URL del server deve iniziare con http:// o https://", - "allowInvalidCertificate": "Consenti certificato non valido", - "allowInvalidCertificateDesc": "Utilizzare solo per server self-hosted attendibili con certificati autofirmati o basati su indirizzo IP.", - "useEmbedded": "Usa server locale", - "embeddedDesc": "Esegui Termix con il server locale integrato (nessun server remoto richiesto)", - "embeddedConnecting": "Connessione al server locale in corso...", - "embeddedNotReady": "Il server locale non è ancora pronto. Attendere qualche istante e riprovare.", - "localServer": "Server locale", - "savedServers": "Server salvati", - "noSavedServers": "Nessun server salvato", - "removeServer": "Rimuovi" - }, - "migrationNotice": { - "title": "Termix Desktop ora funziona in modalità standalone.", - "body1": "Questa app in precedenza si connetteva direttamente al server Termix. È stata rielaborata per funzionare in modo completamente autonomo, memorizzando host e credenziali localmente in modo da continuare a funzionare offline. La sincronizzazione bidirezionale con un server Termix self-hosted è ora opzionale.", - "body2": "I tuoi host e le tue credenziali attuali sono ancora su {{url}}. Attiva la sincronizzazione remota e riconnettiti a quel server per ripristinarli e mantenerli sincronizzati in futuro.", - "dismiss": "Non adesso", - "setUpSync": "Configura la sincronizzazione remota" - }, - "remoteSync": { - "title": "Sincronizzazione remota", - "description": "Facoltativamente, è possibile collegare questa applicazione desktop a un server Termix autogestito per sincronizzare host, credenziali e snippet tra i dispositivi. L'applicazione funziona sempre completamente offline, indipendentemente dal fatto che sia connessa o meno.", - "notConnected": "Non connesso", - "connected": "Collegato", - "connectedTo": "Connesso a {{url}}", - "lastSynced": "Ultima sincronizzazione {{time}}", - "neverSynced": "Mai sincronizzato", - "syncError": "Errore di sincronizzazione: {{message}}", - "needsReauth": "Accesso scaduto", - "connectButton": "Connessione al server", - "disconnectButton": "Disconnetti", - "syncNowButton": "Sincronizza ora", - "syncing": "Sincronizzazione in corso...", - "syncSuccess": "Sincronizzazione remota completata", - "syncUnavailable": "Impossibile avviare la sincronizzazione remota", - "serverUrl": "URL del server", - "enterServerUrl": "Inserisci l'URL del server", - "mustIncludeProtocol": "L'URL del server deve iniziare con http:// o https://", - "connectionTestFailed": "Impossibile raggiungere un server Termix a quell'URL", - "allowInvalidCertificate": "Consenti certificato non valido", - "allowInvalidCertificateDesc": "Da utilizzare esclusivamente per server self-hosted affidabili con certificati autofirmati o certificati IP.", - "savedServers": "Server salvati", - "removeServer": "Rimuovere", - "continueButton": "Continuare", - "cancelButton": "Cancellare", - "signInTitle": "Accedi a {{url}}", - "originTitle": "Origine della connessione", - "originDescription": "Scegli da dove originare le connessioni SSH per impostazione predefinita. Questa impostazione può essere modificata per ogni singolo host.", - "originLocal": "Questo dispositivo (rete locale)", - "originRemote": "Server remoto", - "bannerReconnect": "Ricollega", - "bannerMessage": "La sincronizzazione remota richiede una nuova autenticazione." - }, "versionCheck": { - "error": "Errore controllo versione", - "checkFailed": "Controllo aggiornamenti non riuscito", - "upToDate": "App aggiornata", - "currentVersion": "Stai utilizzando la versione {{version}}", - "updateAvailable": "Aggiornamento disponibile", - "updateLinkLabel": "Aggiornamento disponibile: consulta le note di rilascio su GitHub.", - "newVersionAvailable": "È disponibile una nuova versione! Stai utilizzando {{current}}, ma è disponibile {{latest}}.", - "betaVersion": "Versione beta", - "betaVersionDesc": "Stai utilizzando {{current}}, che è più recente dell'ultima versione stabile {{latest}}.", - "releasedOn": "Rilasciato il {{date}}", - "downloadUpdate": "Scarica aggiornamento", - "checking": "Controllo aggiornamenti in corso...", - "checkUpdates": "Controlla aggiornamenti", - "checkingUpdates": "Controllo aggiornamenti in corso...", - "updateRequired": "Aggiornamento richiesto" + "updateLinkLabel": "Aggiornamento disponibile: consulta le note di rilascio su GitHub." }, "common": { "close": "Chiudi", @@ -1565,8 +1487,6 @@ "resetCodeRateLimitedWithTime": "Limite di tentativi raggiunto: troppi tentativi di verifica. Attendere {{time}} secondi prima di riprovare.", "authTokenSaveFailed": "Salvataggio del token di autenticazione non riuscito", "authTokenMissing": "Il server non ha restituito un nuovo token di autenticazione. Effettua nuovamente l'accesso.", - "authTokenRejected": "Il server remoto ha rifiutato il nuovo token di autenticazione", - "keyringUnavailable": "Accesso effettuato, ma la sessione non è stata salvata: questo sistema non dispone di un portachiavi per crittografarla. Avvia un Servizio Segreto (come gnome-keyring o KWallet) e accedi di nuovo.", "failedToLoadServer": "Caricamento del server non riuscito", "remoteServerRequired": "È necessario un server remoto. Per utilizzare questo tipo di connessione, collega un server remoto nelle Impostazioni." }, diff --git a/src/ui/locales/translated/ja_JP.json b/src/ui/locales/translated/ja_JP.json index e04c5b497..fda0f1cf4 100644 --- a/src/ui/locales/translated/ja_JP.json +++ b/src/ui/locales/translated/ja_JP.json @@ -232,86 +232,8 @@ "widgetTunnelDesc": "設定済みホスト用の組み込みSSHトンネルマネージャー", "widgetNoHostSelected": "ホストが設定されていません" }, - "serverConfig": { - "title": "サーバー設定", - "description": "TermixサーバーのURLを設定してバックエンドサービスに接続します", - "serverUrl": "サーバーURL", - "enterServerUrl": "サーバーURLを入力してください", - "saveFailed": "設定の保存に失敗しました", - "saveError": "設定の保存中にエラーが発生しました", - "saving": "保存中...", - "saveConfig": "設定を保存", - "helpText": "Termixサーバーが実行されているURLを入力してください (例: http://localhost:30001 または https://your-server.com)", - "changeServer": "サーバー変更", - "mustIncludeProtocol": "サーバーURLはhttp://またはhttps://で始まる必要があります", - "allowInvalidCertificate": "無効な証明書を許可", - "allowInvalidCertificateDesc": "自己署名証明書やIPアドレス証明書を使った信頼できる自己ホストサーバーにのみ使用してください。", - "useEmbedded": "ローカルサーバーを使用", - "embeddedDesc": "組み込みのローカルサーバーでTermixを実行します(リモートサーバー不要)", - "embeddedConnecting": "ローカルサーバーに接続中...", - "embeddedNotReady": "ローカルサーバーの準備ができていません。しばらく待ってからやり直してください。", - "localServer": "ローカルサーバー", - "savedServers": "保存済みサーバー", - "noSavedServers": "保存されたサーバーはありません", - "removeServer": "削除" - }, - "migrationNotice": { - "title": "Termix Desktopがスタンドアロンで動作するようになりました", - "body1": "このアプリは以前はTermixサーバーに直接接続していましたが、完全に独立して動作するように改良され、ホスト名と認証情報をローカルに保存することでオフラインでも動作し続けます。セルフホスト型のTermixサーバーとの双方向同期はオプションになりました。", - "body2": "既存のホストと認証情報はまだ {{url}}にあります。リモート同期を有効にして、そのサーバーに再接続すると、それらが復元され、今後両方のサーバーが同期された状態になります。", - "dismiss": "今じゃない", - "setUpSync": "リモート同期の設定" - }, - "remoteSync": { - "title": "リモート同期", - "description": "オプションとして、このデスクトップアプリをセルフホスト型のTermixサーバーに接続することで、ホスト、認証情報、およびスニペットをデバイス間で同期できます。アプリは接続の有無に関わらず、常に完全にオフラインで動作します。", - "notConnected": "接続されていません", - "connected": "接続済み", - "connectedTo": "{{url}}に接続しました", - "lastSynced": "最終同期日時 {{time}}", - "neverSynced": "同期されたことはありません", - "syncError": "同期エラー: {{message}}", - "needsReauth": "ログイン期限が切れました", - "connectButton": "サーバーに接続", - "disconnectButton": "切断", - "syncNowButton": "今すぐ同期", - "syncing": "同期中...", - "syncSuccess": "リモート同期が完了しました", - "syncUnavailable": "リモート同期を開始できませんでした", - "serverUrl": "サーバーURL", - "enterServerUrl": "サーバーのURLを入力してください", - "mustIncludeProtocol": "サーバーURLはhttp://またはhttps://で始まる必要があります。", - "connectionTestFailed": "そのURLのTermixサーバーに接続できませんでした", - "allowInvalidCertificate": "無効な証明書を許可する", - "allowInvalidCertificateDesc": "自己署名証明書またはIPアドレス証明書を使用する、信頼できる自己ホスト型サーバーでのみ使用してください。", - "savedServers": "保存済みサーバー", - "removeServer": "取り除く", - "continueButton": "続く", - "cancelButton": "キャンセル", - "signInTitle": "{{url}}にサインインしてください。", - "originTitle": "接続元", - "originDescription": "SSH接続のデフォルトの発信元を選択します。この設定はホストごとに変更できます。", - "originLocal": "このデバイス(ローカルネットワーク)", - "originRemote": "リモートサーバー", - "bannerReconnect": "再接続", - "bannerMessage": "リモート同期には再認証が必要です" - }, "versionCheck": { - "error": "バージョン確認エラー", - "checkFailed": "更新の確認に失敗しました", - "upToDate": "アプリは最新です", - "currentVersion": "バージョン {{version}} を実行中です", - "updateAvailable": "アップデートがあります", - "updateLinkLabel": "アップデートが利用可能です — GitHubのリリースノートをご覧ください", - "newVersionAvailable": "新しいバージョンが利用可能です。現在 {{current}} を使用中ですが、{{latest}} が利用可能です。", - "betaVersion": "ベータ版", - "betaVersionDesc": "{{current}} を実行中です。これは最新安定版 {{latest}} よりも新しいバージョンです。", - "releasedOn": "リリース日: {{date}}", - "downloadUpdate": "アップデートをダウンロード", - "checking": "アップデートを確認中...", - "checkUpdates": "アップデートを確認", - "checkingUpdates": "アップデートを確認中...", - "updateRequired": "アップデートが必要です" + "updateLinkLabel": "アップデートが利用可能です — GitHubのリリースノートをご覧ください" }, "common": { "close": "閉じる", @@ -1565,8 +1487,6 @@ "resetCodeRateLimitedWithTime": "レート制限: 認証試行が多すぎます。{{time}}秒待ってから再試行してください。", "authTokenSaveFailed": "認証トークンの保存に失敗しました", "authTokenMissing": "サーバーから新しい認証トークンが返されませんでした。再度ログインしてください。", - "authTokenRejected": "リモートサーバーが新しい認証トークンを拒否しました", - "keyringUnavailable": "ログインしましたが、セッションを保存できませんでした。このシステムには、セッションを暗号化するためのキーリングがありません。シークレットサービス(gnome-keyringやKWalletなど)を起動して、再度ログインしてください。", "failedToLoadServer": "サーバーの読み込みに失敗しました", "remoteServerRequired": "リモートサーバーが必要です。この接続タイプを使用するには、設定でリモートサーバーを接続してください。" }, diff --git a/src/ui/locales/translated/ko_KR.json b/src/ui/locales/translated/ko_KR.json index 7f7135e86..66784a71c 100644 --- a/src/ui/locales/translated/ko_KR.json +++ b/src/ui/locales/translated/ko_KR.json @@ -232,86 +232,8 @@ "widgetTunnelDesc": "설정된 호스트를 위한 내장 SSH 터널 관리자", "widgetNoHostSelected": "구성된 호스트 없음" }, - "serverConfig": { - "title": "서버 설정", - "description": "백엔드 서비스에 연결할 Termix 서버 URL을 구성합니다", - "serverUrl": "서버 URL", - "enterServerUrl": "서버 URL을 입력하세요", - "saveFailed": "설정 저장 실패", - "saveError": "설정 저장 오류", - "saving": "저장 중...", - "saveConfig": "설정 저장", - "helpText": "Termix 서버가 실행 중인 URL을 입력하세요 (예: http://localhost:30001 또는 https://your-server.com)", - "changeServer": "서버 변경", - "mustIncludeProtocol": "서버 URL은 http:// 또는 https://로 시작해야 합니다", - "allowInvalidCertificate": "유효하지 않은 인증서 허용", - "allowInvalidCertificateDesc": "자체 서명 또는 IP 주소 인증서를 사용하는 신뢰할 수 있는 자체 호스팅 서버에만 사용하세요.", - "useEmbedded": "로컬 서버 사용", - "embeddedDesc": "내장 로컬 서버로 Termix 실행 (원격 서버 불필요)", - "embeddedConnecting": "로컬 서버에 연결 중...", - "embeddedNotReady": "로컬 서버가 아직 준비되지 않았습니다. 잠시 후 다시 시도해 주세요.", - "localServer": "로컬 서버", - "savedServers": "저장된 서버", - "noSavedServers": "저장된 서버 없음", - "removeServer": "제거" - }, - "migrationNotice": { - "title": "Termix Desktop이 이제 독립 실행형으로 실행됩니다.", - "body1": "이 앱은 이전에는 Termix 서버에 직접 연결되었지만, 이제 완전히 독립적으로 실행되도록 재설계되었습니다. 호스트와 자격 증명을 로컬에 저장하여 오프라인 상태에서도 작동합니다. 자체 호스팅 Termix 서버와의 양방향 동기화는 이제 선택 사항입니다.", - "body2": "기존 호스트 및 자격 증명은 여전히 {{url}}에 있습니다. 원격 동기화를 켜고 해당 서버에 다시 연결하여 정보를 복원하고 향후 두 상태를 동기화 상태로 유지하세요.", - "dismiss": "지금은 안 돼", - "setUpSync": "원격 동기화 설정" - }, - "remoteSync": { - "title": "원격 동기화", - "description": "선택적으로 이 데스크톱 앱을 자체 호스팅 Termix 서버에 연결하여 호스트, 자격 증명 및 코드 조각을 여러 장치에서 동기화할 수 있습니다. 연결 여부와 관계없이 앱은 항상 완전히 오프라인에서 작동합니다.", - "notConnected": "연결되지 않음", - "connected": "연결됨", - "connectedTo": "{{url}}에 연결됨", - "lastSynced": "마지막 동기화 시간: {{time}}", - "neverSynced": "동기화되지 않음", - "syncError": "동기화 오류: {{message}}", - "needsReauth": "로그인 시간이 만료되었습니다", - "connectButton": "서버에 연결", - "disconnectButton": "연결을 끊으세요", - "syncNowButton": "지금 동기화하세요", - "syncing": "동기화 중...", - "syncSuccess": "원격 동기화가 완료되었습니다.", - "syncUnavailable": "원격 동기화를 시작할 수 없습니다.", - "serverUrl": "서버 URL", - "enterServerUrl": "서버 URL을 입력해 주세요.", - "mustIncludeProtocol": "서버 URL은 http:// 또는 https://로 시작해야 합니다.", - "connectionTestFailed": "해당 URL에서 Termix 서버에 연결할 수 없습니다.", - "allowInvalidCertificate": "유효하지 않은 인증서를 허용합니다", - "allowInvalidCertificateDesc": "자체 서명 인증서 또는 IP 주소 인증서를 사용하는 신뢰할 수 있는 자체 호스팅 서버에서만 사용하십시오.", - "savedServers": "저장된 서버", - "removeServer": "제거하다", - "continueButton": "계속하다", - "cancelButton": "취소", - "signInTitle": "{{url}}에 로그인하세요", - "originTitle": "연결 출처", - "originDescription": "SSH 연결의 기본 시작 위치를 선택합니다. 이 설정은 호스트별로 재정의할 수 있습니다.", - "originLocal": "이 장치(로컬 네트워크)", - "originRemote": "원격 서버", - "bannerReconnect": "다시 연결", - "bannerMessage": "원격 동기화에는 재인증이 필요합니다." - }, "versionCheck": { - "error": "버전 확인 오류", - "checkFailed": "업데이트 확인 실패", - "upToDate": "앱이 최신 상태입니다", - "currentVersion": "현재 버전 {{version}}을(를) 사용 중입니다.", - "updateAvailable": "업데이트 가능", - "updateLinkLabel": "업데이트가 있습니다. GitHub에서 릴리스 노트를 확인하세요.", - "newVersionAvailable": "새 버전이 출시되었습니다! 현재 {{current}}을(를) 사용 중이며, {{latest}} 버전이 제공됩니다.", - "betaVersion": "베타 버전", - "betaVersionDesc": "현재 {{current}}을(를) 사용 중이며, 이는 최신 안정 릴리스 {{latest}}보다 최신 버전입니다.", - "releasedOn": "{{date}}에 출시됨", - "downloadUpdate": "업데이트 다운로드", - "checking": "업데이트 확인 중...", - "checkUpdates": "업데이트 확인", - "checkingUpdates": "업데이트 확인 중...", - "updateRequired": "업데이트 필요" + "updateLinkLabel": "업데이트가 있습니다. GitHub에서 릴리스 노트를 확인하세요." }, "common": { "close": "닫기", @@ -1565,8 +1487,6 @@ "resetCodeRateLimitedWithTime": "속도 제한: 너무 많은 인증 시도가 있었습니다. 다시 시도하기 전에 {{time}}초 동안 기다려 주세요.", "authTokenSaveFailed": "인증 토큰 저장에 실패했습니다.", "authTokenMissing": "서버에서 새 인증 토큰을 반환하지 않았습니다. 다시 로그인해 주세요.", - "authTokenRejected": "원격 서버에서 새 인증 토큰을 거부했습니다.", - "keyringUnavailable": "로그인은 되었지만 세션을 저장할 수 없습니다. 이 시스템에는 세션을 암호화하는 데 사용할 수 있는 키링이 없습니다. gnome-keyring 또는 KWallet과 같은 비밀 서비스(Secret Service)를 시작하고 다시 로그인하세요.", "failedToLoadServer": "서버를 불러오지 못했습니다.", "remoteServerRequired": "원격 서버가 필요합니다. 이 연결 유형을 사용하려면 설정에서 원격 서버를 연결하세요." }, diff --git a/src/ui/locales/translated/nl_NL.json b/src/ui/locales/translated/nl_NL.json index 8723b3add..c88b9c50d 100644 --- a/src/ui/locales/translated/nl_NL.json +++ b/src/ui/locales/translated/nl_NL.json @@ -232,86 +232,8 @@ "widgetTunnelDesc": "Ingebouwde SSH-tunnelmanager voor een geconfigureerde host", "widgetNoHostSelected": "Geen host geconfigureerd" }, - "serverConfig": { - "title": "Serverconfiguratie", - "description": "Configureer de Termix-server-URL om verbinding te maken met uw backendservices.", - "serverUrl": "Server-URL", - "enterServerUrl": "Voer een server-URL in.", - "saveFailed": "Configuratie opslaan mislukt", - "saveError": "Fout bij het opslaan van de configuratie", - "saving": "Besparing...", - "saveConfig": "Configuratie opslaan", - "helpText": "Voer de URL in waar uw Termix-server draait (bijvoorbeeld http://localhost:30001 of https://uw-server.com)", - "changeServer": "Server wijzigen", - "mustIncludeProtocol": "De server-URL moet beginnen met http:// of https://", - "allowInvalidCertificate": "Ongeldig certificaat toestaan", - "allowInvalidCertificateDesc": "Uitsluitend te gebruiken voor vertrouwde, zelfgehoste servers met zelfondertekende certificaten of IP-adrescertificaten.", - "useEmbedded": "Lokale server gebruiken", - "embeddedDesc": "Start Termix met de ingebouwde lokale server (geen externe server nodig).", - "embeddedConnecting": "Verbinding maken met de lokale server...", - "embeddedNotReady": "De lokale server is nog niet gereed. Wacht even en probeer het later opnieuw.", - "localServer": "Lokale server", - "savedServers": "Opgeslagen servers", - "noSavedServers": "Geen opgeslagen servers", - "removeServer": "Verwijderen" - }, - "migrationNotice": { - "title": "Termix Desktop draait nu zelfstandig.", - "body1": "Deze app maakte voorheen rechtstreeks verbinding met uw Termix-server. Hij is herwerkt om volledig zelfstandig te werken, waarbij hosts en inloggegevens lokaal worden opgeslagen, zodat hij ook offline blijft functioneren. Tweewegsynchronisatie met een zelfgehoste Termix-server is nu optioneel.", - "body2": "Uw bestaande hosts en inloggegevens bevinden zich nog steeds op {{url}}. Schakel externe synchronisatie in en maak opnieuw verbinding met die server om ze te herstellen en beide in de toekomst gesynchroniseerd te houden.", - "dismiss": "Nu niet", - "setUpSync": "Externe synchronisatie instellen" - }, - "remoteSync": { - "title": "Synchronisatie op afstand", - "description": "U kunt deze desktop-app optioneel verbinden met een zelfgehoste Termix-server om uw hosts, inloggegevens en codefragmenten op verschillende apparaten te synchroniseren. De app werkt altijd volledig offline, ongeacht of u verbinding maakt of niet.", - "notConnected": "Niet verbonden", - "connected": "Aangesloten", - "connectedTo": "Verbonden met {{url}}", - "lastSynced": "Laatst gesynchroniseerd {{time}}", - "neverSynced": "Nooit gesynchroniseerd", - "syncError": "Synchronisatiefout: {{message}}", - "needsReauth": "Aanmelden verlopen", - "connectButton": "Verbinding maken met de server", - "disconnectButton": "Verbinding verbreken", - "syncNowButton": "Nu synchroniseren", - "syncing": "Synchroniseren...", - "syncSuccess": "Externe synchronisatie voltooid", - "syncUnavailable": "Externe synchronisatie kon niet worden gestart.", - "serverUrl": "Server-URL", - "enterServerUrl": "Voer een server-URL in.", - "mustIncludeProtocol": "De server-URL moet beginnen met http:// of https://", - "connectionTestFailed": "Er kon geen Termix-server worden bereikt op die URL.", - "allowInvalidCertificate": "Ongeldig certificaat toestaan", - "allowInvalidCertificateDesc": "Uitsluitend te gebruiken voor vertrouwde, zelfgehoste servers met zelfondertekende certificaten of IP-adrescertificaten.", - "savedServers": "Opgeslagen servers", - "removeServer": "Verwijderen", - "continueButton": "Doorgaan", - "cancelButton": "Annuleren", - "signInTitle": "Meld je aan bij {{url}}", - "originTitle": "Verbindingsoorsprong", - "originDescription": "Kies de standaardlocatie voor SSH-verbindingen. Deze instelling kan per host worden gewijzigd.", - "originLocal": "Dit apparaat (lokaal netwerk)", - "originRemote": "Externe server", - "bannerReconnect": "Opnieuw verbinding maken", - "bannerMessage": "Externe synchronisatie vereist herverificatie." - }, "versionCheck": { - "error": "Versiecontrolefout", - "checkFailed": "Controleren op updates is mislukt.", - "upToDate": "De app is up-to-date.", - "currentVersion": "Je gebruikt versie {{version}}", - "updateAvailable": "Update beschikbaar", - "updateLinkLabel": "Update beschikbaar — bekijk de release-opmerkingen op GitHub.", - "newVersionAvailable": "Er is een nieuwe versie beschikbaar! Je gebruikt momenteel {{current}}, maar {{latest}} is beschikbaar.", - "betaVersion": "Bètaversie", - "betaVersionDesc": "Je gebruikt versie {{current}}, die nieuwer is dan de laatste stabiele versie {{latest}}.", - "releasedOn": "Uitgebracht op {{date}}", - "downloadUpdate": "Download update", - "checking": "Controleren op updates...", - "checkUpdates": "Controleer op updates", - "checkingUpdates": "Controleren op updates...", - "updateRequired": "Update vereist" + "updateLinkLabel": "Update beschikbaar — bekijk de release-opmerkingen op GitHub." }, "common": { "close": "Dichtbij", @@ -1565,8 +1487,6 @@ "resetCodeRateLimitedWithTime": "Snelheid beperkt: Te veel verificatiepogingen. Wacht {{time}} seconden voordat u het opnieuw probeert.", "authTokenSaveFailed": "Het opslaan van het authenticatietoken is mislukt.", "authTokenMissing": "De server heeft geen nieuw authenticatietoken teruggestuurd. Meld u alstublieft opnieuw aan.", - "authTokenRejected": "De externe server heeft het nieuwe authenticatietoken geweigerd.", - "keyringUnavailable": "U bent ingelogd, maar de sessie kon niet worden opgeslagen: dit systeem heeft geen sleutelbos beschikbaar om deze mee te versleutelen. Start een geheime service (zoals gnome-keyring of KWallet) en log opnieuw in.", "failedToLoadServer": "Server laden mislukt", "remoteServerRequired": "Externe server vereist. Verbind een externe server in de instellingen om dit verbindingstype te gebruiken." }, diff --git a/src/ui/locales/translated/no_NO.json b/src/ui/locales/translated/no_NO.json index eb807e659..204a2cd9e 100644 --- a/src/ui/locales/translated/no_NO.json +++ b/src/ui/locales/translated/no_NO.json @@ -232,86 +232,8 @@ "widgetTunnelDesc": "Innebygd SSH-tunnelbehandler for en konfigurert vert", "widgetNoHostSelected": "Ingen vert konfigurert" }, - "serverConfig": { - "title": "Serverkonfigurasjon", - "description": "Konfigurer URL-adressen til Termix-serveren for å koble til backend-tjenestene dine", - "serverUrl": "Server-URL", - "enterServerUrl": "Vennligst skriv inn en server-URL", - "saveFailed": "Kunne ikke lagre konfigurasjonen", - "saveError": "Feil ved lagring av konfigurasjonen", - "saving": "Lagrer...", - "saveConfig": "Lagre konfigurasjon", - "helpText": "Skriv inn URL-en der Termix-serveren din kjører (f.eks. http://localhost:30001 eller https://your-server.com)", - "changeServer": "Endre server", - "mustIncludeProtocol": "Server-URL-en må starte med http:// eller https://", - "allowInvalidCertificate": "Tillat ugyldig sertifikat", - "allowInvalidCertificateDesc": "Bruk kun for klarerte, selvhostede servere med selvsignerte sertifikater eller IP-adressesertifikater.", - "useEmbedded": "Bruk lokal server", - "embeddedDesc": "Kjør Termix med den innebygde lokale serveren (ingen ekstern server nødvendig)", - "embeddedConnecting": "Kobler til lokal server...", - "embeddedNotReady": "Den lokale serveren er ikke klar ennå. Vent litt og prøv på nytt.", - "localServer": "Lokal server", - "savedServers": "Lagrede servere", - "noSavedServers": "Ingen lagrede servere", - "removeServer": "Fjerne" - }, - "migrationNotice": { - "title": "Termix Desktop kjører nå frittstående", - "body1": "Denne appen pleide å koble seg direkte til Termix-serveren din. Den har blitt omarbeidet til å kjøre helt alene, og lagrer verter og påloggingsinformasjon lokalt, slik at den fortsetter å fungere offline. Toveis synkronisering med en selvhostet Termix-server er nå valgfritt.", - "body2": "Dine eksisterende verter og påloggingsinformasjon er fortsatt på {{url}}. Slå på ekstern synkronisering og koble til den serveren på nytt for å få dem tilbake og holde begge synkroniserte fremover.", - "dismiss": "Ikke nå", - "setUpSync": "Konfigurer ekstern synkronisering" - }, - "remoteSync": { - "title": "Fjernsynkronisering", - "description": "Du kan eventuelt koble denne skrivebordsappen til en selvhostet Termix-server for å synkronisere verter, påloggingsinformasjon og kodebiter på tvers av enheter. Appen fungerer alltid helt offline, enten du kobler til eller ikke.", - "notConnected": "Ikke tilkoblet", - "connected": "Tilkoblet", - "connectedTo": "Koblet til {{url}}", - "lastSynced": "Sist synkronisert {{time}}", - "neverSynced": "Aldri synkronisert", - "syncError": "Synkroniseringsfeil: {{message}}", - "needsReauth": "Påloggingen er utløpt", - "connectButton": "Koble til server", - "disconnectButton": "Frakople", - "syncNowButton": "Synkroniser nå", - "syncing": "Synkroniserer...", - "syncSuccess": "Ekstern synkronisering fullført", - "syncUnavailable": "Fjernsynkronisering kunne ikke startes", - "serverUrl": "Server-URL", - "enterServerUrl": "Vennligst skriv inn en server-URL", - "mustIncludeProtocol": "Server-URL-en må starte med http:// eller https://", - "connectionTestFailed": "Kunne ikke nå en Termix-server på den URL-adressen", - "allowInvalidCertificate": "Tillat ugyldig sertifikat", - "allowInvalidCertificateDesc": "Bruk kun for klarerte, selvhostede servere med selvsignerte sertifikater eller IP-adressesertifikater.", - "savedServers": "Lagrede servere", - "removeServer": "Fjerne", - "continueButton": "Fortsette", - "cancelButton": "Kansellere", - "signInTitle": "Logg inn på {{url}}", - "originTitle": "Tilkoblingens opprinnelse", - "originDescription": "Velg hvor SSH-tilkoblinger kommer fra som standard. Dette kan overstyres per vert.", - "originLocal": "Denne enheten (lokalt nettverk)", - "originRemote": "Ekstern server", - "bannerReconnect": "Koble til igjen", - "bannerMessage": "Ekstern synkronisering trenger ny autentisering" - }, "versionCheck": { - "error": "Versjonssjekkfeil", - "checkFailed": "Kunne ikke sjekke etter oppdateringer", - "upToDate": "Appen er oppdatert", - "currentVersion": "Du kjører versjon {{version}}", - "updateAvailable": "Oppdatering tilgjengelig", - "updateLinkLabel": "Oppdatering tilgjengelig – åpne utgivelsesnotatene på GitHub", - "newVersionAvailable": "En ny versjon er tilgjengelig! Du kjører {{current}}, men {{latest}} er tilgjengelig.", - "betaVersion": "Betaversjon", - "betaVersionDesc": "Du kjører {{current}}, som er nyere enn den siste stabile utgivelsen {{latest}}.", - "releasedOn": "Utgitt {{date}}", - "downloadUpdate": "Last ned oppdatering", - "checking": "Sjekker etter oppdateringer ...", - "checkUpdates": "Se etter oppdateringer", - "checkingUpdates": "Sjekker etter oppdateringer ...", - "updateRequired": "Oppdatering kreves" + "updateLinkLabel": "Oppdatering tilgjengelig – åpne utgivelsesnotatene på GitHub" }, "common": { "close": "Lukke", @@ -1565,8 +1487,6 @@ "resetCodeRateLimitedWithTime": "Frekvens begrenset: For mange bekreftelsesforsøk. Vent {{time}} sekunder før du prøver på nytt.", "authTokenSaveFailed": "Kunne ikke lagre autentiseringstokenet", "authTokenMissing": "Serveren returnerte ikke et nytt autentiseringstoken. Vennligst logg inn på nytt.", - "authTokenRejected": "Den eksterne serveren avviste det nye autentiseringstokenet", - "keyringUnavailable": "Logget inn, men økten kunne ikke lagres: dette systemet har ingen nøkkelring tilgjengelig for å kryptere den med. Start en hemmelig tjeneste (som gnome-keyring eller KWallet) og logg inn på nytt.", "failedToLoadServer": "Kunne ikke laste inn serveren", "remoteServerRequired": "Ekstern server kreves. Koble til en ekstern server i Innstillinger for å bruke denne tilkoblingstypen." }, diff --git a/src/ui/locales/translated/pl_PL.json b/src/ui/locales/translated/pl_PL.json index b634fd397..3be9f4ee7 100644 --- a/src/ui/locales/translated/pl_PL.json +++ b/src/ui/locales/translated/pl_PL.json @@ -232,86 +232,8 @@ "widgetTunnelDesc": "Wbudowany menedżer tuneli SSH dla skonfigurowanego hosta", "widgetNoHostSelected": "Brak skonfigurowanego hosta" }, - "serverConfig": { - "title": "Konfiguracja serwera", - "description": "Skonfiguruj adres URL serwera Termix, aby połączyć się z jego usługami", - "serverUrl": "Adres URL serwera", - "enterServerUrl": "Podaj adres URL serwera", - "saveFailed": "Nie udało się zapisać konfiguracji", - "saveError": "Błąd podczas zapisywania konfiguracji", - "saving": "Zapisywanie...", - "saveConfig": "Zapisz konfigurację", - "helpText": "Wprowadź adres URL, pod którym działa serwer Termix (np. http://localhost:30001 lub https://your-server.com)", - "changeServer": "Zmień serwer", - "mustIncludeProtocol": "Adres URL serwera musi zaczynać się od http:// lub https://", - "allowInvalidCertificate": "Zezwól na nieprawidłowy certyfikat", - "allowInvalidCertificateDesc": "Używaj tylko dla zaufanych serwerów własnych, z certyfikatami samopodpisanymi lub wystawionymi na adres IP.", - "useEmbedded": "Użyj serwera lokalnego", - "embeddedDesc": "Uruchom Termix za pomocą wbudowanego serwera lokalnego (serwer zdalny nie jest potrzebny)", - "embeddedConnecting": "Łączenie z serwerem lokalnym...", - "embeddedNotReady": "Serwer lokalny nie jest jeszcze gotowy. Poczekaj chwilę i spróbuj ponownie.", - "localServer": "Serwer lokalny", - "savedServers": "Zapisane serwery", - "noSavedServers": "Brak zapisanych serwerów", - "removeServer": "Usuń" - }, - "migrationNotice": { - "title": "Termix Desktop działa teraz autonomicznie", - "body1": "Ta aplikacja łączyła się bezpośrednio z serwerem Termix. Została przeprojektowana tak, aby działać w pełni samodzielnie: przechowuje hosty i poświadczenia lokalnie, dzięki czemu działa też bez połączenia. Dwukierunkowa synchronizacja z własnym serwerem Termix jest teraz opcjonalna.", - "body2": "Dotychczasowe hosty i poświadczenia są nadal na {{url}}. Włącz synchronizację zdalną i połącz się ponownie z tym serwerem, aby je przywrócić i utrzymywać oba miejsca w synchronizacji.", - "dismiss": "Nie teraz", - "setUpSync": "Skonfiguruj synchronizację zdalną" - }, - "remoteSync": { - "title": "Zdalna synchronizacja", - "description": "Opcjonalnie połącz tę aplikację desktopową z własnym serwerem Termix, aby synchronizować hosty, poświadczenia i snippety między urządzeniami. Aplikacja zawsze działa w pełni bez połączenia, niezależnie od tego, czy połączysz się z serwerem.", - "notConnected": "Nie połączono", - "connected": "Połączono", - "connectedTo": "Połączono z {{url}}", - "lastSynced": "Ostatnia synchronizacja {{time}}", - "neverSynced": "Nigdy nie synchronizowano", - "syncError": "Błąd synchronizacji: {{message}}", - "needsReauth": "Logowanie wygasło", - "connectButton": "Połącz się z serwerem", - "disconnectButton": "Odłącz", - "syncNowButton": "Synchronizuj teraz", - "syncing": "Synchronizacja...", - "syncSuccess": "Synchronizacja zdalna zakończona", - "syncUnavailable": "Nie można rozpocząć synchronizacji zdalnej", - "serverUrl": "Adres URL serwera", - "enterServerUrl": "Podaj adres URL serwera", - "mustIncludeProtocol": "Adres URL serwera musi zaczynać się od http:// lub https://", - "connectionTestFailed": "Nie można nawiązać połączenia z serwerem Termix pod tym adresem URL", - "allowInvalidCertificate": "Zezwól na nieprawidłowy certyfikat", - "allowInvalidCertificateDesc": "Używaj tylko dla zaufanych serwerów własnych, z certyfikatami samopodpisanymi lub wystawionymi na adres IP.", - "savedServers": "Zapisane serwery", - "removeServer": "Usuń", - "continueButton": "Kontynuuj", - "cancelButton": "Anuluj", - "signInTitle": "Zaloguj się do {{url}}", - "originTitle": "Źródło połączenia", - "originDescription": "Wybierz domyślne źródło połączeń SSH. Można to zmienić dla każdego hosta.", - "originLocal": "To urządzenie (sieć lokalna)", - "originRemote": "Serwer zdalny", - "bannerReconnect": "Połącz ponownie", - "bannerMessage": "Zdalna synchronizacja wymaga ponownego uwierzytelnienia" - }, "versionCheck": { - "error": "Błąd sprawdzania wersji", - "checkFailed": "Nie udało się sprawdzić dostępności aktualizacji", - "upToDate": "Aplikacja jest aktualna", - "currentVersion": "Używasz wersji {{version}}", - "updateAvailable": "Dostępna aktualizacja", - "updateLinkLabel": "Dostępna aktualizacja: otwórz informacje o wydaniu na GitHubie", - "newVersionAvailable": "Dostępna jest nowa wersja! Używasz wersji {{current}}, ale dostępna jest wersja {{latest}}.", - "betaVersion": "Wersja beta", - "betaVersionDesc": "Używasz wersji {{current}}, która jest nowsza od najnowszej stabilnej wersji {{latest}}.", - "releasedOn": "Wydano {{date}}", - "downloadUpdate": "Pobierz aktualizację", - "checking": "Sprawdzanie aktualizacji...", - "checkUpdates": "Sprawdź aktualizacje", - "checkingUpdates": "Sprawdzanie aktualizacji...", - "updateRequired": "Wymagana aktualizacja" + "updateLinkLabel": "Dostępna aktualizacja: otwórz informacje o wydaniu na GitHubie" }, "common": { "close": "Zamknij", @@ -1565,8 +1487,6 @@ "resetCodeRateLimitedWithTime": "Przekroczono limit prób: zbyt wiele weryfikacji. Odczekaj {{time}} sekund przed kolejną próbą.", "authTokenSaveFailed": "Nie udało się zapisać tokenu uwierzytelniającego", "authTokenMissing": "Serwer nie zwrócił nowego tokenu uwierzytelniającego. Zaloguj się ponownie.", - "authTokenRejected": "Zdalny serwer odrzucił nowy token uwierzytelniający", - "keyringUnavailable": "Zalogowano, ale nie udało się zapisać sesji: ten system nie ma dostępnego pęku kluczy do jej zaszyfrowania. Uruchom usługę Secret Service (np. gnome-keyring lub KWallet) i zaloguj się ponownie.", "failedToLoadServer": "Nie udało się załadować serwera", "remoteServerRequired": "Wymagany jest serwer zdalny. Połącz się z serwerem zdalnym w Ustawieniach, aby korzystać z tego typu połączenia." }, diff --git a/src/ui/locales/translated/pt_BR.json b/src/ui/locales/translated/pt_BR.json index 67e8a41d2..e14bdbb3a 100644 --- a/src/ui/locales/translated/pt_BR.json +++ b/src/ui/locales/translated/pt_BR.json @@ -232,86 +232,8 @@ "widgetTunnelDesc": "Gerenciador de túnel SSH integrado para um host configurado", "widgetNoHostSelected": "Nenhum host configurado" }, - "serverConfig": { - "title": "Configuração do servidor", - "description": "Configure o URL do servidor Termix para conectar-se aos seus serviços de backend.", - "serverUrl": "URL do servidor", - "enterServerUrl": "Por favor, insira um URL de servidor.", - "saveFailed": "Falha ao salvar a configuração", - "saveError": "Erro ao salvar a configuração", - "saving": "Salvando...", - "saveConfig": "Salvar configuração", - "helpText": "Insira o URL onde seu servidor Termix está em execução (por exemplo, http://localhost:30001 ou https://seu-servidor.com)", - "changeServer": "Alterar servidor", - "mustIncludeProtocol": "O URL do servidor deve começar com http:// ou https://", - "allowInvalidCertificate": "Permitir certificado inválido", - "allowInvalidCertificateDesc": "Utilize somente em servidores autohospedados confiáveis com certificados autoassinados ou de endereço IP.", - "useEmbedded": "Usar servidor local", - "embeddedDesc": "Execute o Termix com o servidor local integrado (não é necessário um servidor remoto).", - "embeddedConnecting": "Conectando ao servidor local...", - "embeddedNotReady": "O servidor local ainda não está pronto. Aguarde um momento e tente novamente.", - "localServer": "Servidor local", - "savedServers": "Servidores salvos", - "noSavedServers": "Nenhum servidor salvo", - "removeServer": "Remover" - }, - "migrationNotice": { - "title": "O Termix Desktop agora funciona de forma independente.", - "body1": "Este aplicativo costumava se conectar diretamente ao seu servidor Termix. Ele foi reformulado para funcionar de forma totalmente independente, armazenando hosts e credenciais localmente para que continue funcionando offline. A sincronização bidirecional com um servidor Termix auto-hospedado agora é opcional.", - "body2": "Seus hosts e credenciais atuais ainda estão em {{url}}. Ative a Sincronização Remota e reconecte-se a esse servidor para recuperá-los e mantê-los sincronizados daqui para frente.", - "dismiss": "Agora não", - "setUpSync": "Configurar sincronização remota" - }, - "remoteSync": { - "title": "Sincronização remota", - "description": "Opcionalmente, conecte este aplicativo de desktop a um servidor Termix auto-hospedado para sincronizar seus hosts, credenciais e trechos de código entre dispositivos. O aplicativo funciona sempre totalmente offline, independentemente de estar conectado ou não.", - "notConnected": "Não conectado", - "connected": "Conectado", - "connectedTo": "Conectado a {{url}}", - "lastSynced": "Última sincronização {{time}}", - "neverSynced": "Nunca sincronizado", - "syncError": "Erro de sincronização: {{message}}", - "needsReauth": "Login expirou", - "connectButton": "Conectar ao servidor", - "disconnectButton": "Desconectar", - "syncNowButton": "Sincronizar agora", - "syncing": "Sincronizando...", - "syncSuccess": "Sincronização remota concluída", - "syncUnavailable": "Não foi possível iniciar a sincronização remota.", - "serverUrl": "URL do servidor", - "enterServerUrl": "Por favor, insira um URL de servidor.", - "mustIncludeProtocol": "O URL do servidor deve começar com http:// ou https://", - "connectionTestFailed": "Não foi possível acessar um servidor Termix nesse URL.", - "allowInvalidCertificate": "Permitir certificado inválido", - "allowInvalidCertificateDesc": "Utilize somente em servidores autohospedados confiáveis com certificados autoassinados ou de endereço IP.", - "savedServers": "Servidores salvos", - "removeServer": "Remover", - "continueButton": "Continuar", - "cancelButton": "Cancelar", - "signInTitle": "Faça login em {{url}}", - "originTitle": "Origem da conexão", - "originDescription": "Por padrão, escolha de onde as conexões SSH se originam. Essa configuração pode ser alterada para cada host individualmente.", - "originLocal": "Este dispositivo (rede local)", - "originRemote": "Servidor remoto", - "bannerReconnect": "Reconectar", - "bannerMessage": "A sincronização remota requer nova autenticação." - }, "versionCheck": { - "error": "Erro na verificação de versão", - "checkFailed": "Falha ao verificar se há atualizações", - "upToDate": "O aplicativo está atualizado.", - "currentVersion": "Você está executando a versão {{version}}", - "updateAvailable": "Atualização disponível", - "updateLinkLabel": "Atualização disponível — abra as notas de lançamento no GitHub.", - "newVersionAvailable": "Uma nova versão está disponível! Você está executando {{current}}, mas {{latest}} está disponível.", - "betaVersion": "Versão Beta", - "betaVersionDesc": "Você está executando {{current}}, que é mais recente do que a versão estável mais recente {{latest}}.", - "releasedOn": "Lançado em {{date}}", - "downloadUpdate": "Baixar atualização", - "checking": "Verificando atualizações...", - "checkUpdates": "Verifique se há atualizações", - "checkingUpdates": "Verificando atualizações...", - "updateRequired": "Atualização necessária" + "updateLinkLabel": "Atualização disponível — abra as notas de lançamento no GitHub." }, "common": { "close": "Fechar", @@ -1565,8 +1487,6 @@ "resetCodeRateLimitedWithTime": "Taxa de tentativas limitada: Muitas tentativas de verificação. Aguarde {{time}} segundos antes de tentar novamente.", "authTokenSaveFailed": "Falha ao salvar o token de autenticação", "authTokenMissing": "O servidor não retornou um novo token de autenticação. Por favor, faça login novamente.", - "authTokenRejected": "O servidor remoto rejeitou o novo token de autenticação.", - "keyringUnavailable": "A sessão foi iniciada, mas não pôde ser salva: este sistema não possui um chaveiro disponível para criptografá-la. Inicie um Serviço Secreto (como o gnome-keyring ou o KWallet) e faça login novamente.", "failedToLoadServer": "Falha ao carregar o servidor", "remoteServerRequired": "É necessário um servidor remoto. Conecte um servidor remoto nas Configurações para usar este tipo de conexão." }, diff --git a/src/ui/locales/translated/pt_PT.json b/src/ui/locales/translated/pt_PT.json index 6d3a425eb..ecba77e79 100644 --- a/src/ui/locales/translated/pt_PT.json +++ b/src/ui/locales/translated/pt_PT.json @@ -232,86 +232,8 @@ "widgetTunnelDesc": "Gestor de túneis SSH integrado para um host configurado", "widgetNoHostSelected": "Nenhum host configurado" }, - "serverConfig": { - "title": "Configuração do Servidor", - "description": "Configure o URL do servidor Termix para se ligar aos seus serviços de backend", - "serverUrl": "URL do Servidor", - "enterServerUrl": "Por favor, insira um URL do servidor", - "saveFailed": "Falha ao guardar a configuração", - "saveError": "Erro ao guardar a configuração", - "saving": "A guardar...", - "saveConfig": "Guardar Configuração", - "helpText": "Insira o URL onde o seu servidor Termix está a ser executado (ex: http://localhost:30001 ou https://your-server.com)", - "changeServer": "Alterar Servidor", - "mustIncludeProtocol": "O URL do servidor deve começar com http:// ou https://", - "allowInvalidCertificate": "Permitir certificado inválido", - "allowInvalidCertificateDesc": "Usar apenas para servidores auto-hospedados de confiança com certificados auto-assinados ou de endereço IP.", - "useEmbedded": "Usar Servidor Local", - "embeddedDesc": "Executar o Termix com o servidor local incorporado (não é necessário servidor remoto)", - "embeddedConnecting": "A ligar ao servidor local...", - "embeddedNotReady": "O servidor local ainda não está pronto. Aguarde um momento e tente novamente.", - "localServer": "Servidor Local", - "savedServers": "Servidores Guardados", - "noSavedServers": "Nenhum servidor guardado", - "removeServer": "Remover" - }, - "migrationNotice": { - "title": "O Termix Desktop funciona agora de forma independente.", - "body1": "Esta aplicação costumava ligar-se diretamente ao seu servidor Termix. Foi reformulado para funcionar de forma totalmente independente, armazenando hosts e credenciais localmente para que continue a funcionar offline. A sincronização bidirecional com um servidor Termix auto-hospedado é agora opcional.", - "body2": "Os seus hosts e credenciais atuais ainda estão em {{url}}. Ative a Sincronização Remota e volte a ligar-se a esse servidor para os recuperar e mantê-los sincronizados daqui para a frente.", - "dismiss": "Agora não", - "setUpSync": "Configurar sincronização remota" - }, - "remoteSync": { - "title": "Sincronização remota", - "description": "Opcionalmente, ligue esta aplicação de desktop a um servidor Termix auto-hospedado para sincronizar os seus hosts, credenciais e pedaços de código entre dispositivos. A aplicação funciona sempre totalmente offline, independentemente de estar ligado ou não.", - "notConnected": "Não conectado", - "connected": "Conectado", - "connectedTo": "Ligado a {{url}}", - "lastSynced": "Última sincronização {{time}}", - "neverSynced": "Nunca sincronizado", - "syncError": "Erro de sincronização: {{message}}", - "needsReauth": "Login expirou", - "connectButton": "Ligar ao servidor", - "disconnectButton": "Desconectar", - "syncNowButton": "Sincronizar agora", - "syncing": "Sincronizando...", - "syncSuccess": "Sincronização remota concluída", - "syncUnavailable": "Não foi possível iniciar a sincronização remota.", - "serverUrl": "URL do servidor", - "enterServerUrl": "Por favor, introduza um URL de servidor.", - "mustIncludeProtocol": "O URL do servidor deve começar por http:// ou https://", - "connectionTestFailed": "Não foi possível aceder a um servidor Termix nesse URL.", - "allowInvalidCertificate": "Permitir certificado inválido", - "allowInvalidCertificateDesc": "Utilize apenas em servidores auto-hospedados fidedignos com certificados autoassinados ou de endereço IP.", - "savedServers": "Servidores salvos", - "removeServer": "Remover", - "continueButton": "Continuar", - "cancelButton": "Cancelar", - "signInTitle": "Faça login em {{url}}", - "originTitle": "Origem da ligação", - "originDescription": "Por predefinição, escolha de onde se originam as ligações SSH. Esta configuração pode ser alterada para cada host individualmente.", - "originLocal": "Este dispositivo (rede local)", - "originRemote": "Servidor remoto", - "bannerReconnect": "Reconectar", - "bannerMessage": "A sincronização remota requer nova autenticação." - }, "versionCheck": { - "error": "Erro na Verificação de Versão", - "checkFailed": "Falha ao verificar atualizações", - "upToDate": "A aplicação está atualizada", - "currentVersion": "Está a executar a versão {{version}}", - "updateAvailable": "Atualização disponível", - "updateLinkLabel": "Atualização disponível — abra as notas de lançamento no GitHub.", - "newVersionAvailable": "Está disponível uma nova versão! Está a executar {{current}}, mas a {{latest}} está disponível.", - "betaVersion": "Versão Beta", - "betaVersionDesc": "Está a executar a {{current}}, que é mais recente do que a última versão estável {{latest}}.", - "releasedOn": "Lançado a {{date}}", - "downloadUpdate": "Descarregar Atualização", - "checking": "A verificar atualizações...", - "checkUpdates": "Verificar Atualizações", - "checkingUpdates": "A verificar atualizações...", - "updateRequired": "Atualização necessária" + "updateLinkLabel": "Atualização disponível — abra as notas de lançamento no GitHub." }, "common": { "close": "Fechar", @@ -1565,8 +1487,6 @@ "resetCodeRateLimitedWithTime": "Limite de tentativas: demasiadas tentativas de verificação. Por favor, aguarde {{time}} segundos antes de tentar novamente.", "authTokenSaveFailed": "Falha ao guardar o token de autenticação", "authTokenMissing": "O servidor não devolveu um novo token de autenticação. Por favor, inicie sessão novamente.", - "authTokenRejected": "O servidor remoto rejeitou o novo token de autenticação.", - "keyringUnavailable": "A sessão foi iniciada, mas não pôde ser guardada: este sistema não tem um porta-chaves disponível para a encriptar. Inicie um Serviço Secreto (como o gnome-keyring ou o KWallet) e inicie sessão novamente.", "failedToLoadServer": "Falha ao carregar o servidor", "remoteServerRequired": "É necessário um servidor remoto. Ligue um servidor remoto nas Definições para utilizar este tipo de ligação." }, diff --git a/src/ui/locales/translated/ro_RO.json b/src/ui/locales/translated/ro_RO.json index d0747dc8d..3dbdb032b 100644 --- a/src/ui/locales/translated/ro_RO.json +++ b/src/ui/locales/translated/ro_RO.json @@ -232,86 +232,8 @@ "widgetTunnelDesc": "Manager de tunel SSH încorporat pentru o gazdă configurată", "widgetNoHostSelected": "Nicio gazdă configurată" }, - "serverConfig": { - "title": "Configurarea serverului", - "description": "Configurați adresa URL a serverului Termix pentru a vă conecta la serviciile backend", - "serverUrl": "URL-ul serverului", - "enterServerUrl": "Introduceți adresa URL a serverului", - "saveFailed": "Nu s-a putut salva configurația", - "saveError": "Eroare la salvarea configurației", - "saving": "Economisire...", - "saveConfig": "Salvați configurația", - "helpText": "Introduceți adresa URL unde rulează serverul Termix (de exemplu, http://localhost:30001 sau https://serverul-dumneavoastră.com)", - "changeServer": "Schimbare server", - "mustIncludeProtocol": "URL-ul serverului trebuie să înceapă cu http:// sau https://", - "allowInvalidCertificate": "Permiteți certificatul nevalid", - "allowInvalidCertificateDesc": "A se utiliza numai pentru servere auto-găzduite de încredere, cu certificate autosemnate sau cu adresă IP.", - "useEmbedded": "Folosește serverul local", - "embeddedDesc": "Rulați Termix cu serverul local încorporat (nu este nevoie de un server la distanță)", - "embeddedConnecting": "Conectare la serverul local...", - "embeddedNotReady": "Serverul local nu este încă gata. Vă rugăm să așteptați un moment și să încercați din nou.", - "localServer": "Server local", - "savedServers": "Servere salvate", - "noSavedServers": "Niciun server salvat", - "removeServer": "Elimina" - }, - "migrationNotice": { - "title": "Termix Desktop rulează acum independent", - "body1": "Această aplicație se conecta direct la serverul Termix. A fost reelaborată pentru a rula complet independent, stocând gazdele și acreditările local, astfel încât să funcționeze în continuare offline. Sincronizarea bidirecțională cu un server Termix auto-găzduit este acum opțională.", - "body2": "Gazdele și acreditările existente sunt încă pe {{url}}. Activați sincronizarea la distanță și reconectați-vă la serverul respectiv pentru a le reactiva și a le menține sincronizate de acum înainte.", - "dismiss": "Nu acum", - "setUpSync": "Configurați sincronizarea la distanță" - }, - "remoteSync": { - "title": "Sincronizare la distanță", - "description": "Opțional, conectați această aplicație desktop la un server Termix auto-găzduit pentru a sincroniza gazdele, acreditările și fragmentele de date pe toate dispozitivele. Aplicația funcționează întotdeauna complet offline, indiferent dacă vă conectați sau nu.", - "notConnected": "Neconectat", - "connected": "Conectat", - "connectedTo": "Conectat la {{url}}", - "lastSynced": "Ultima sincronizare {{time}}", - "neverSynced": "Nu s-a sincronizat niciodată", - "syncError": "Eroare de sincronizare: {{message}}", - "needsReauth": "Conectarea a expirat", - "connectButton": "Conectare la server", - "disconnectButton": "Deconecta", - "syncNowButton": "Sincronizează acum", - "syncing": "Se sincronizează...", - "syncSuccess": "Sincronizarea la distanță a fost finalizată", - "syncUnavailable": "Sincronizarea la distanță nu a putut fi pornită", - "serverUrl": "URL-ul serverului", - "enterServerUrl": "Introduceți adresa URL a serverului", - "mustIncludeProtocol": "URL-ul serverului trebuie să înceapă cu http:// sau https://", - "connectionTestFailed": "Nu s-a putut accesa un server Termix la adresa URL respectivă.", - "allowInvalidCertificate": "Permiteți certificatul nevalid", - "allowInvalidCertificateDesc": "A se utiliza numai pentru servere auto-găzduite de încredere, cu certificate autosemnate sau cu adresă IP.", - "savedServers": "Servere salvate", - "removeServer": "Elimina", - "continueButton": "Continua", - "cancelButton": "Anula", - "signInTitle": "Conectați-vă la {{url}}", - "originTitle": "Originea conexiunii", - "originDescription": "Alegeți în mod implicit de unde provin conexiunile SSH. Această opțiune poate fi ignorată pentru fiecare gazdă.", - "originLocal": "Acest dispozitiv (rețea locală)", - "originRemote": "Server la distanță", - "bannerReconnect": "Reconectați-vă", - "bannerMessage": "Sincronizarea la distanță necesită reautentificare" - }, "versionCheck": { - "error": "Eroare la verificarea versiunii", - "checkFailed": "Nu s-a putut verifica dacă există actualizări", - "upToDate": "Aplicația este actualizată", - "currentVersion": "Folosești versiunea {{version}}", - "updateAvailable": "Actualizare disponibilă", - "updateLinkLabel": "Actualizare disponibilă — deschideți notele de lansare pe GitHub", - "newVersionAvailable": "O versiune nouă este disponibilă! Utilizați {{current}}, dar {{latest}} este disponibil.", - "betaVersion": "Versiune Beta", - "betaVersionDesc": "Rulezi {{current}}, care este mai nouă decât cea mai recentă versiune stabilă {{latest}}.", - "releasedOn": "Lansat pe {{date}}", - "downloadUpdate": "Descărcați actualizarea", - "checking": "Se verifică actualizările...", - "checkUpdates": "Verificați actualizările", - "checkingUpdates": "Se caută actualizări...", - "updateRequired": "Actualizare necesară" + "updateLinkLabel": "Actualizare disponibilă — deschideți notele de lansare pe GitHub" }, "common": { "close": "Aproape", @@ -1565,8 +1487,6 @@ "resetCodeRateLimitedWithTime": "Rată limitată: Prea multe încercări de verificare. Vă rugăm să așteptați {{time}} secunde înainte de a încerca din nou.", "authTokenSaveFailed": "Nu s-a putut salva jetonul de autentificare", "authTokenMissing": "Serverul nu a returnat un nou token de autentificare. Vă rugăm să vă conectați din nou.", - "authTokenRejected": "Serverul la distanță a respins noul token de autentificare", - "keyringUnavailable": "Conectat, dar sesiunea nu a putut fi stocată: acest sistem nu are un keyring disponibil pentru criptare. Porniți un Serviciu Secret (cum ar fi gnome-keyring sau KWallet) și conectați-vă din nou.", "failedToLoadServer": "Nu s-a putut încărca serverul", "remoteServerRequired": "Este necesar un server la distanță. Conectați un server la distanță în Setări pentru a utiliza acest tip de conexiune." }, diff --git a/src/ui/locales/translated/ru_RU.json b/src/ui/locales/translated/ru_RU.json index 0490c0d9f..3a816d797 100644 --- a/src/ui/locales/translated/ru_RU.json +++ b/src/ui/locales/translated/ru_RU.json @@ -232,86 +232,8 @@ "widgetTunnelDesc": "Встроенный менеджер SSH-туннелей для настроенного хоста", "widgetNoHostSelected": "Хост не настроен" }, - "serverConfig": { - "title": "Конфигурация сервера", - "description": "Настройте URL сервера Termix для подключения к вашим внутренним службам", - "serverUrl": "URL сервера", - "enterServerUrl": "Пожалуйста, введите URL сервера", - "saveFailed": "Не удалось сохранить конфигурацию", - "saveError": "Ошибка сохранения конфигурации", - "saving": "Сохранение...", - "saveConfig": "Сохранить конфигурацию", - "helpText": "Введите URL, по которому работает ваш сервер Termix (например, http://localhost:30001 или https://your-server.com)", - "changeServer": "Сменить сервер", - "mustIncludeProtocol": "URL сервера должен начинаться с http:// или https://", - "allowInvalidCertificate": "Разрешить недействительный сертификат", - "allowInvalidCertificateDesc": "Используйте только для доверенных самостоятельно развёрнутых серверов с самоподписанными сертификатами или сертификатами для IP-адресов.", - "useEmbedded": "Использовать локальный сервер", - "embeddedDesc": "Запускать Termix со встроенным локальным сервером (удалённый сервер не требуется)", - "embeddedConnecting": "Подключение к локальному серверу...", - "embeddedNotReady": "Локальный сервер ещё не готов. Пожалуйста, подождите немного и попробуйте снова.", - "localServer": "Локальный сервер", - "savedServers": "Сохранённые серверы", - "noSavedServers": "Нет сохранённых серверов", - "removeServer": "Удалить" - }, - "migrationNotice": { - "title": "Теперь Termix Desktop работает в автономном режиме.", - "body1": "Раньше это приложение напрямую подключалось к вашему серверу Termix. Теперь оно переработано так, чтобы работать полностью автономно: данные о хостах и учётные данные хранятся локально, поэтому приложение продолжает работать даже без подключения к интернету. Двунаправленная синхронизация с вашим собственным сервером Termix теперь является необязательной.", - "body2": "Ваши существующие хосты и учетные данные по-прежнему находятся на {{url}}. Включите удаленную синхронизацию и переподключитесь к этому серверу, чтобы восстановить их и обеспечить синхронизацию в дальнейшем.", - "dismiss": "Не сейчас", - "setUpSync": "Настроить удалённую синхронизацию" - }, - "remoteSync": { - "title": "Удалённая синхронизация", - "description": "При желании подключите это настольное приложение к собственному серверу Termix, чтобы синхронизировать хосты, учётные данные и фрагменты кода между устройствами. Приложение в любом случае полностью работает в автономном режиме, независимо от того, подключаете вы сервер или нет.", - "notConnected": "Не подключено", - "connected": "Подключено", - "connectedTo": "Подключено к {{url}}", - "lastSynced": "Последняя синхронизация {{time}}", - "neverSynced": "Никогда не синхронизировалось", - "syncError": "Ошибка синхронизации: {{message}}", - "needsReauth": "Срок действия авторизации истек.", - "connectButton": "Подключиться к серверу", - "disconnectButton": "Отключить", - "syncNowButton": "Синхронизировать сейчас", - "syncing": "Синхронизация...", - "syncSuccess": "Удалённая синхронизация завершена.", - "syncUnavailable": "Не удалось запустить удаленную синхронизацию.", - "serverUrl": "URL сервера", - "enterServerUrl": "Пожалуйста, введите URL-адрес сервера.", - "mustIncludeProtocol": "URL-адрес сервера должен начинаться с http:// или https://", - "connectionTestFailed": "Не удалось подключиться к серверу Termix по указанному URL-адресу.", - "allowInvalidCertificate": "Разрешить использование недействительного сертификата", - "allowInvalidCertificateDesc": "Используйте только для доверенных серверов, размещённых самостоятельно, с самоподписанными сертификатами или сертификатами для IP-адреса.", - "savedServers": "Сохраненные серверы", - "removeServer": "Удалить", - "continueButton": "Продолжить", - "cancelButton": "Отмена", - "signInTitle": "Войдите в {{url}}", - "originTitle": "Источник соединения", - "originDescription": "По умолчанию выбирается место, откуда исходят SSH-соединения. Это можно изменить для каждого хоста отдельно.", - "originLocal": "Это устройство (локальная сеть)", - "originRemote": "Удаленный сервер", - "bannerReconnect": "Переподключитесь", - "bannerMessage": "Для удалённой синхронизации требуется повторная аутентификация." - }, "versionCheck": { - "error": "Ошибка проверки версии", - "checkFailed": "Не удалось проверить обновления", - "upToDate": "Приложение актуально", - "currentVersion": "Вы используете версию {{version}}", - "updateAvailable": "Доступно обновление", - "updateLinkLabel": "Доступно обновление — откройте примечания к выпуску на GitHub.", - "newVersionAvailable": "Доступна новая версия! Вы используете {{current}}, но доступна версия {{latest}}.", - "betaVersion": "Бета-версия", - "betaVersionDesc": "Вы используете {{current}}, которая новее последней стабильной версии {{latest}}.", - "releasedOn": "Выпущено {{date}}", - "downloadUpdate": "Скачать обновление", - "checking": "Проверка обновлений...", - "checkUpdates": "Проверить обновления", - "checkingUpdates": "Проверка обновлений...", - "updateRequired": "Требуется обновление" + "updateLinkLabel": "Доступно обновление — откройте примечания к выпуску на GitHub." }, "common": { "close": "Закрыть", @@ -1565,8 +1487,6 @@ "resetCodeRateLimitedWithTime": "Превышена частота запросов: слишком много попыток подтверждения. Подождите {{time}} секунд, прежде чем повторить попытку.", "authTokenSaveFailed": "Не удалось сохранить токен аутентификации", "authTokenMissing": "Сервер не вернул новый токен аутентификации. Пожалуйста, войдите в систему снова.", - "authTokenRejected": "Удаленный сервер отклонил новый токен аутентификации.", - "keyringUnavailable": "Вход выполнен, но сессия не сохранена: в системе отсутствует доступный брелок ключей для шифрования. Запустите службу секретов (например, gnome-keyring или KWallet) и войдите снова.", "failedToLoadServer": "Не удалось загрузить сервер", "remoteServerRequired": "Требуется удаленный сервер. Для использования этого типа подключения подключите удаленный сервер в настройках." }, diff --git a/src/ui/locales/translated/sr_SP.json b/src/ui/locales/translated/sr_SP.json index 10294cb0d..49dba32e2 100644 --- a/src/ui/locales/translated/sr_SP.json +++ b/src/ui/locales/translated/sr_SP.json @@ -232,86 +232,8 @@ "widgetTunnelDesc": "Уграђени SSH менаџер тунела за конфигурисани хост", "widgetNoHostSelected": "Није конфигурисан хост" }, - "serverConfig": { - "title": "Конфигурација сервера", - "description": "Конфигуришите URL адресу Termix сервера да бисте се повезали са вашим бекенд сервисима", - "serverUrl": "URL сервера", - "enterServerUrl": "Молимо вас да унесете URL адресу сервера", - "saveFailed": "Чување конфигурације није успело", - "saveError": "Грешка при чувању конфигурације", - "saving": "Чување...", - "saveConfig": "Сачувај конфигурацију", - "helpText": "Унесите URL адресу на којој ради ваш Termix сервер (нпр. http://localhost:30001 или https://your-server.com)", - "changeServer": "Промени сервер", - "mustIncludeProtocol": "URL сервера мора да почиње са http:// или https://", - "allowInvalidCertificate": "Дозволи неважећи сертификат", - "allowInvalidCertificateDesc": "Користите само за поуздане самохостоване сервере са самопотписаним сертификатима или сертификатима за IP адресу.", - "useEmbedded": "Користи локални сервер", - "embeddedDesc": "Покрените Termix са уграђеним локалним сервером (није потребан удаљени сервер)", - "embeddedConnecting": "Повезивање са локалним сервером...", - "embeddedNotReady": "Локални сервер још није спреман. Молимо сачекајте тренутак и покушајте поново.", - "localServer": "Локални сервер", - "savedServers": "Сачувани сервери", - "noSavedServers": "Нема сачуваних сервера", - "removeServer": "Уклони" - }, - "migrationNotice": { - "title": "Termix Desktop сада ради самостално", - "body1": "Ова апликација се раније директно повезивала са вашим Termix сервером. Прерађена је тако да ради потпуно самостално, чувајући хостове и акредитиве локално како би наставила да ради ван мреже. Двосмерна синхронизација са самостално хостованим Termix сервером је сада опционална.", - "body2": "Ваши постојећи хостови и акредитиви су и даље на {{url}}. Укључите даљинску синхронизацију и поново се повежите са тим сервером да бисте их вратили и одржали синхронизацију у будућности.", - "dismiss": "Не сада", - "setUpSync": "Подешавање даљинске синхронизације" - }, - "remoteSync": { - "title": "Даљинска синхронизација", - "description": "Опционо, повежите ову десктоп апликацију са самостално хостованим Termix сервером да бисте синхронизовали своје хостове, акредитиве и исечке кода на свим уређајима. Апликација увек ради потпуно ван мреже, без обзира да ли се повежете или не.", - "notConnected": "Није повезано", - "connected": "Повезано", - "connectedTo": "Повезано са {{url}}", - "lastSynced": "Последња синхронизација {{time}}", - "neverSynced": "Никада није синхронизовано", - "syncError": "Грешка у синхронизацији: {{message}}", - "needsReauth": "Пријава је истекла", - "connectButton": "Повежи се са сервером", - "disconnectButton": "Прекини везу", - "syncNowButton": "Синхронизуј сада", - "syncing": "Синхронизација...", - "syncSuccess": "Даљинска синхронизација је завршена", - "syncUnavailable": "Даљинска синхронизација није могла да се покрене", - "serverUrl": "URL сервера", - "enterServerUrl": "Молимо вас да унесете URL адресу сервера", - "mustIncludeProtocol": "URL сервера мора да почиње са http:// или https://", - "connectionTestFailed": "Није могуће доћи до Termix сервера на тој URL адреси", - "allowInvalidCertificate": "Дозволи неважећи сертификат", - "allowInvalidCertificateDesc": "Користите само за поуздане самохостоване сервере са самопотписаним сертификатима или сертификатима за IP адресу.", - "savedServers": "Сачувани сервери", - "removeServer": "Уклони", - "continueButton": "Настави", - "cancelButton": "Откажи", - "signInTitle": "Пријавите се на {{url}}", - "originTitle": "Порекло везе", - "originDescription": "Изаберите одакле SSH везе потичу подразумевано. Ово се може заменити за сваки хост.", - "originLocal": "Овај уређај (локална мрежа)", - "originRemote": "Удаљени сервер", - "bannerReconnect": "Поново се повежите", - "bannerMessage": "Даљинска синхронизација захтева поновну аутентификацију" - }, "versionCheck": { - "error": "Грешка при провери верзије", - "checkFailed": "Провера ажурирања није успела", - "upToDate": "Апликација је ажурирана", - "currentVersion": "Користите верзију {{version}}", - "updateAvailable": "Ажурирање је доступно", - "updateLinkLabel": "Доступно ажурирање — отворите белешке о издању на GitHub-у", - "newVersionAvailable": "Нова верзија је доступна! Користите {{current}}, али је доступан {{latest}}.", - "betaVersion": "Бета верзија", - "betaVersionDesc": "Користите {{current}}, што је новије од најновије стабилне верзије {{latest}}.", - "releasedOn": "Објављено {{date}}", - "downloadUpdate": "Преузми ажурирање", - "checking": "Провера ажурирања...", - "checkUpdates": "Провери ажурирања", - "checkingUpdates": "Провера ажурирања...", - "updateRequired": "Потребно је ажурирање" + "updateLinkLabel": "Доступно ажурирање — отворите белешке о издању на GitHub-у" }, "common": { "close": "Затвори", @@ -1565,8 +1487,6 @@ "resetCodeRateLimitedWithTime": "Ограничена брзина: Превише покушаја верификације. Молимо сачекајте {{time}} секунди пре него што покушате поново.", "authTokenSaveFailed": "Чување токена за аутентификацију није успело", "authTokenMissing": "Сервер није вратио нови токен за аутентификацију. Молимо вас да се поново пријавите.", - "authTokenRejected": "Удаљени сервер је одбио нови токен за аутентификацију", - "keyringUnavailable": "Пријављени сте, али сесија није могла бити сачувана: овај систем нема доступан привезак кључева за шифровање. Покрените тајну услугу (као што је gnome-keyring или KWallet) и поново се пријавите.", "failedToLoadServer": "Учитавање сервера није успело", "remoteServerRequired": "Потребан је удаљени сервер. Повежите се са удаљеним сервером у Подешавањима да бисте користили овај тип везе." }, diff --git a/src/ui/locales/translated/sv_SE.json b/src/ui/locales/translated/sv_SE.json index 0653494ee..1ce6e728c 100644 --- a/src/ui/locales/translated/sv_SE.json +++ b/src/ui/locales/translated/sv_SE.json @@ -232,86 +232,8 @@ "widgetTunnelDesc": "Inbäddad SSH-tunnelhanterare för en konfigurerad värd", "widgetNoHostSelected": "Ingen värd konfigurerad" }, - "serverConfig": { - "title": "Serverkonfiguration", - "description": "Konfigurera Termix-serverns URL för att ansluta till dina backend-tjänster", - "serverUrl": "Server-URL", - "enterServerUrl": "Ange en server-URL", - "saveFailed": "Kunde inte spara konfigurationen", - "saveError": "Fel vid sparning av konfigurationen", - "saving": "Sparande...", - "saveConfig": "Spara konfiguration", - "helpText": "Ange URL:en där din Termix-server körs (t.ex. http://localhost:30001 eller https://your-server.com)", - "changeServer": "Ändra server", - "mustIncludeProtocol": "Serverns URL måste börja med http:// eller https://", - "allowInvalidCertificate": "Tillåt ogiltigt certifikat", - "allowInvalidCertificateDesc": "Använd endast för betrodda, självhostade servrar med självsignerade certifikat eller IP-adresscertifikat.", - "useEmbedded": "Använd lokal server", - "embeddedDesc": "Kör Termix med den inbyggda lokala servern (ingen fjärrserver behövs)", - "embeddedConnecting": "Ansluter till lokal server...", - "embeddedNotReady": "Den lokala servern är inte redo än. Vänta en stund och försök igen.", - "localServer": "Lokal server", - "savedServers": "Sparade servrar", - "noSavedServers": "Inga sparade servrar", - "removeServer": "Ta bort" - }, - "migrationNotice": { - "title": "Termix Desktop körs nu fristående", - "body1": "Den här appen brukade ansluta direkt till din Termix-server. Den har omarbetats för att köras helt självständigt och lagrar värdar och inloggningsuppgifter lokalt så att den fortsätter att fungera offline. Tvåvägssynkronisering med en egen Termix-server är nu valfritt.", - "body2": "Dina befintliga värdar och inloggningsuppgifter finns fortfarande på {{url}}. Aktivera fjärrsynkronisering och återanslut till servern för att få tillbaka dem och hålla båda synkroniserade framöver.", - "dismiss": "Inte nu", - "setUpSync": "Konfigurera fjärrsynkronisering" - }, - "remoteSync": { - "title": "Fjärrsynkronisering", - "description": "Anslut alternativt den här skrivbordsappen till en självhostad Termix-server för att synkronisera dina värdar, inloggningsuppgifter och kodavsnitt mellan enheter. Appen fungerar alltid helt offline oavsett om du ansluter eller inte.", - "notConnected": "Inte ansluten", - "connected": "Ansluten", - "connectedTo": "Ansluten till {{url}}", - "lastSynced": "Senast synkroniserad {{time}}", - "neverSynced": "Aldrig synkroniserad", - "syncError": "Synkroniseringsfel: {{message}}", - "needsReauth": "Inloggningen har upphört", - "connectButton": "Anslut till servern", - "disconnectButton": "Koppla från", - "syncNowButton": "Synkronisera nu", - "syncing": "Synkroniserar...", - "syncSuccess": "Fjärrsynkronisering slutförd", - "syncUnavailable": "Fjärrsynkronisering kunde inte startas", - "serverUrl": "Server-URL", - "enterServerUrl": "Ange en server-URL", - "mustIncludeProtocol": "Serverns URL måste börja med http:// eller https://", - "connectionTestFailed": "Kunde inte nå en Termix-server på den URL:en", - "allowInvalidCertificate": "Tillåt ogiltigt certifikat", - "allowInvalidCertificateDesc": "Använd endast för betrodda, självhostade servrar med självsignerade certifikat eller IP-adresscertifikat.", - "savedServers": "Sparade servrar", - "removeServer": "Ta bort", - "continueButton": "Fortsätta", - "cancelButton": "Avboka", - "signInTitle": "Logga in på {{url}}", - "originTitle": "Anslutningens ursprung", - "originDescription": "Välj var SSH-anslutningar kommer ifrån som standard. Detta kan åsidosättas per värd.", - "originLocal": "Den här enheten (lokalt nätverk)", - "originRemote": "Fjärrserver", - "bannerReconnect": "Återanslut", - "bannerMessage": "Fjärrsynkronisering behöver autentiseras på nytt" - }, "versionCheck": { - "error": "Versionskontrollfel", - "checkFailed": "Misslyckades med att söka efter uppdateringar", - "upToDate": "Appen är uppdaterad", - "currentVersion": "Du kör version {{version}}", - "updateAvailable": "Uppdatering tillgänglig", - "updateLinkLabel": "Uppdatering tillgänglig — öppna versionsinformationen på GitHub", - "newVersionAvailable": "En ny version är tillgänglig! Du kör {{current}}, men {{latest}} är tillgänglig.", - "betaVersion": "Betaversion", - "betaVersionDesc": "Du kör {{current}}, vilket är nyare än den senaste stabila versionen {{latest}}.", - "releasedOn": "Släpptes den {{date}}", - "downloadUpdate": "Ladda ner uppdatering", - "checking": "Söker efter uppdateringar...", - "checkUpdates": "Kontrollera efter uppdateringar", - "checkingUpdates": "Söker efter uppdateringar...", - "updateRequired": "Uppdatering krävs" + "updateLinkLabel": "Uppdatering tillgänglig — öppna versionsinformationen på GitHub" }, "common": { "close": "Nära", @@ -1565,8 +1487,6 @@ "resetCodeRateLimitedWithTime": "Begränsad hastighet: För många verifieringsförsök. Vänta {{time}} sekunder innan du försöker igen.", "authTokenSaveFailed": "Misslyckades med att spara autentiseringstoken", "authTokenMissing": "Servern returnerade inte en ny autentiseringstoken. Vänligen logga in igen.", - "authTokenRejected": "Fjärrservern avvisade den nya autentiseringstoken", - "keyringUnavailable": "Inloggad, men sessionen kunde inte lagras: det här systemet har ingen nyckelring tillgänglig att kryptera den med. Starta en hemlig tjänst (som gnome-keyring eller KWallet) och logga in igen.", "failedToLoadServer": "Misslyckades med att ladda servern", "remoteServerRequired": "Fjärrserver krävs. Anslut en fjärrserver i Inställningar för att använda den här anslutningstypen." }, diff --git a/src/ui/locales/translated/th_TH.json b/src/ui/locales/translated/th_TH.json index ad6024bb7..0304d44a0 100644 --- a/src/ui/locales/translated/th_TH.json +++ b/src/ui/locales/translated/th_TH.json @@ -232,86 +232,8 @@ "widgetTunnelDesc": "ตัวจัดการอุโมงค์ SSH แบบฝังตัวสำหรับโฮสต์ที่กำหนดค่าไว้", "widgetNoHostSelected": "ไม่มีการกำหนดค่าโฮสต์" }, - "serverConfig": { - "title": "การกำหนดค่าเซิร์ฟเวอร์", - "description": "กำหนดค่า URL ของเซิร์ฟเวอร์ Termix เพื่อเชื่อมต่อกับบริการแบ็กเอนด์ของคุณ", - "serverUrl": "URL ของเซิร์ฟเวอร์", - "enterServerUrl": "โปรดป้อน URL ของเซิร์ฟเวอร์", - "saveFailed": "ไม่สามารถบันทึกการตั้งค่าได้", - "saveError": "เกิดข้อผิดพลาดในการบันทึกการตั้งค่า", - "saving": "ประหยัด...", - "saveConfig": "บันทึกการตั้งค่า", - "helpText": "ป้อน URL ที่เซิร์ฟเวอร์ Termix ของคุณกำลังทำงานอยู่ (เช่น http://localhost:30001 หรือ https://your-server.com)", - "changeServer": "เปลี่ยนเซิร์ฟเวอร์", - "mustIncludeProtocol": "URL ของเซิร์ฟเวอร์ต้องขึ้นต้นด้วย http:// หรือ https://", - "allowInvalidCertificate": "อนุญาตให้ใช้ใบรับรองที่ไม่ถูกต้อง", - "allowInvalidCertificateDesc": "ใช้ได้เฉพาะกับเซิร์ฟเวอร์ที่ดูแลเองที่เชื่อถือได้ ซึ่งมีใบรับรองแบบลงนามเองหรือใบรับรองตามที่อยู่ IP เท่านั้น", - "useEmbedded": "ใช้เซิร์ฟเวอร์ภายในเครื่อง", - "embeddedDesc": "เรียกใช้งาน Termix ด้วยเซิร์ฟเวอร์ภายในเครื่อง (ไม่จำเป็นต้องใช้เซิร์ฟเวอร์ระยะไกล)", - "embeddedConnecting": "กำลังเชื่อมต่อกับเซิร์ฟเวอร์ภายใน...", - "embeddedNotReady": "เซิร์ฟเวอร์ในพื้นที่ยังไม่พร้อมใช้งาน โปรดรอสักครู่แล้วลองใหม่อีกครั้ง", - "localServer": "เซิร์ฟเวอร์ภายใน", - "savedServers": "เซิร์ฟเวอร์ที่บันทึกไว้", - "noSavedServers": "ไม่มีเซิร์ฟเวอร์ที่บันทึกไว้", - "removeServer": "ลบ" - }, - "migrationNotice": { - "title": "ตอนนี้ Termix Desktop สามารถใช้งานแบบสแตนด์อโลนได้แล้ว", - "body1": "แอปนี้เดิมทีเชื่อมต่อโดยตรงกับเซิร์ฟเวอร์ Termix ของคุณ แต่ตอนนี้ได้รับการปรับปรุงใหม่ให้ทำงานได้ด้วยตัวเองอย่างสมบูรณ์ โดยจัดเก็บโฮสต์และข้อมูลรับรองไว้ในเครื่อง ทำให้ยังคงทำงานได้แม้ในขณะออฟไลน์ การซิงค์ข้อมูลแบบสองทางกับเซิร์ฟเวอร์ Termix ที่คุณโฮสต์เองนั้นเป็นตัวเลือกเสริมแล้ว", - "body2": "โฮสต์และข้อมูลรับรองที่มีอยู่ของคุณยังคงอยู่บน {{url}}เปิดใช้งานการซิงค์ระยะไกลและเชื่อมต่อกับเซิร์ฟเวอร์นั้นอีกครั้งเพื่อนำข้อมูลกลับมาและทำให้ทั้งสองซิงค์กันต่อไป", - "dismiss": "ไม่ใช่ตอนนี้", - "setUpSync": "ตั้งค่าการซิงค์ระยะไกล" - }, - "remoteSync": { - "title": "การซิงค์ระยะไกล", - "description": "คุณสามารถเชื่อมต่อแอปพลิเคชันบนเดสก์ท็อปนี้กับเซิร์ฟเวอร์ Termix ที่คุณโฮสต์เองเพื่อซิงค์โฮสต์ ข้อมูลรับรอง และโค้ดตัวอย่างระหว่างอุปกรณ์ต่างๆ ได้ แอปพลิเคชันนี้ทำงานได้อย่างสมบูรณ์ในโหมดออฟไลน์เสมอ ไม่ว่าคุณจะเชื่อมต่อหรือไม่ก็ตาม", - "notConnected": "ไม่ได้เชื่อมต่อ", - "connected": "เชื่อมต่อแล้ว", - "connectedTo": "เชื่อมต่อกับ {{url}}", - "lastSynced": "ซิงค์ครั้งล่าสุด {{time}}", - "neverSynced": "ไม่เคยซิงค์", - "syncError": "ข้อผิดพลาดในการซิงค์: {{message}}", - "needsReauth": "การเข้าสู่ระบบหมดอายุแล้ว", - "connectButton": "เชื่อมต่อกับเซิร์ฟเวอร์", - "disconnectButton": "ตัดการเชื่อมต่อ", - "syncNowButton": "ซิงค์เดี๋ยวนี้", - "syncing": "กำลังซิงค์...", - "syncSuccess": "การซิงค์ระยะไกลเสร็จสมบูรณ์แล้ว", - "syncUnavailable": "ไม่สามารถเริ่มการซิงค์ระยะไกลได้", - "serverUrl": "URL ของเซิร์ฟเวอร์", - "enterServerUrl": "โปรดป้อน URL ของเซิร์ฟเวอร์", - "mustIncludeProtocol": "URL ของเซิร์ฟเวอร์ต้องขึ้นต้นด้วย http:// หรือ https://", - "connectionTestFailed": "ไม่สามารถเชื่อมต่อกับเซิร์ฟเวอร์ Termix ที่ URL นั้นได้", - "allowInvalidCertificate": "อนุญาตให้ใช้ใบรับรองที่ไม่ถูกต้อง", - "allowInvalidCertificateDesc": "ใช้ได้เฉพาะกับเซิร์ฟเวอร์ที่ดูแลเองที่เชื่อถือได้ ซึ่งมีใบรับรองแบบลงนามเองหรือใบรับรองตามที่อยู่ IP เท่านั้น", - "savedServers": "เซิร์ฟเวอร์ที่บันทึกไว้", - "removeServer": "ลบ", - "continueButton": "ดำเนินการต่อ", - "cancelButton": "ยกเลิก", - "signInTitle": "เข้าสู่ระบบที่ {{url}}", - "originTitle": "จุดเริ่มต้นของการเชื่อมต่อ", - "originDescription": "เลือกต้นทางของการเชื่อมต่อ SSH ตามค่าเริ่มต้น สามารถเปลี่ยนแปลงการตั้งค่านี้ได้สำหรับแต่ละโฮสต์", - "originLocal": "อุปกรณ์นี้ (เครือข่ายท้องถิ่น)", - "originRemote": "เซิร์ฟเวอร์ระยะไกล", - "bannerReconnect": "เชื่อมต่อใหม่", - "bannerMessage": "การซิงค์ระยะไกลจำเป็นต้องยืนยันตัวตนอีกครั้ง" - }, "versionCheck": { - "error": "ข้อผิดพลาดในการตรวจสอบเวอร์ชัน", - "checkFailed": "ไม่สามารถตรวจสอบการอัปเดตได้", - "upToDate": "แอปได้รับการอัปเดตแล้ว", - "currentVersion": "คุณกำลังใช้งานเวอร์ชัน {{version}}", - "updateAvailable": "มีการอัปเดตแล้ว", - "updateLinkLabel": "มีการอัปเดตแล้ว — ดูบันทึกการเปลี่ยนแปลงได้ที่ GitHub", - "newVersionAvailable": "มีเวอร์ชันใหม่ให้ใช้งานแล้ว! คุณกำลังใช้งาน {{current}}อยู่ แต่มี {{latest}} ให้ใช้งานแล้ว", - "betaVersion": "เวอร์ชันเบต้า", - "betaVersionDesc": "คุณกำลังใช้งาน {{current}}ซึ่งใหม่กว่าเวอร์ชันเสถียรล่าสุด {{latest}}", - "releasedOn": "เผยแพร่เมื่อ {{date}}", - "downloadUpdate": "ดาวน์โหลดการอัปเดต", - "checking": "กำลังตรวจสอบการอัปเดต...", - "checkUpdates": "ตรวจสอบการอัปเดต", - "checkingUpdates": "กำลังตรวจสอบการอัปเดต...", - "updateRequired": "ต้องอัปเดตข้อมูล" + "updateLinkLabel": "มีการอัปเดตแล้ว — ดูบันทึกการเปลี่ยนแปลงได้ที่ GitHub" }, "common": { "close": "ปิด", @@ -1565,8 +1487,6 @@ "resetCodeRateLimitedWithTime": "จำกัดจำนวนครั้ง: มีการพยายามยืนยันมากเกินไป โปรดรอ {{time}} วินาทีก่อนลองอีกครั้ง", "authTokenSaveFailed": "ไม่สามารถบันทึกโทเค็นการตรวจสอบสิทธิ์ได้", "authTokenMissing": "เซิร์ฟเวอร์ไม่ได้ส่งโทเค็นการตรวจสอบสิทธิ์ใหม่กลับมา โปรดเข้าสู่ระบบอีกครั้ง", - "authTokenRejected": "เซิร์ฟเวอร์ระยะไกลปฏิเสธโทเค็นการตรวจสอบสิทธิ์ใหม่", - "keyringUnavailable": "เข้าสู่ระบบแล้ว แต่ไม่สามารถบันทึกเซสชันได้: ระบบนี้ไม่มีคีย์ริงสำหรับเข้ารหัส โปรดเริ่มใช้งานบริการเข้ารหัสลับ (เช่น gnome-keyring หรือ KWallet) แล้วเข้าสู่ระบบอีกครั้ง", "failedToLoadServer": "ไม่สามารถโหลดเซิร์ฟเวอร์ได้", "remoteServerRequired": "จำเป็นต้องใช้เซิร์ฟเวอร์ระยะไกล เชื่อมต่อเซิร์ฟเวอร์ระยะไกลในการตั้งค่าเพื่อใช้ประเภทการเชื่อมต่อนี้" }, diff --git a/src/ui/locales/translated/tr_TR.json b/src/ui/locales/translated/tr_TR.json index 15282d6ed..e0acd2c21 100644 --- a/src/ui/locales/translated/tr_TR.json +++ b/src/ui/locales/translated/tr_TR.json @@ -232,86 +232,8 @@ "widgetTunnelDesc": "Yapılandırılmış bir sunucu için gömülü SSH tünel yöneticisi", "widgetNoHostSelected": "Hiçbir sunucu yapılandırılmadı" }, - "serverConfig": { - "title": "Sunucu Yapılandırması", - "description": "Arka uç hizmetlerinize bağlanmak için Termix sunucu URL'sini yapılandırın", - "serverUrl": "Sunucu URL'si", - "enterServerUrl": "Lütfen bir sunucu URL'si girin", - "saveFailed": "Yapılandırma kaydedilemedi", - "saveError": "Yapılandırma kaydedilirken hata oluştu", - "saving": "Kaydediliyor...", - "saveConfig": "Yapılandırmayı Kaydet", - "helpText": "Termix sunucunuzun çalıştığı URL'yi girin (ör. http://localhost:30001 veya https://sunucunuz.com)", - "changeServer": "Sunucuyu Değiştir", - "mustIncludeProtocol": "Sunucu URL'si http:// veya https:// ile başlamalıdır", - "allowInvalidCertificate": "Geçersiz sertifikaya izin ver", - "allowInvalidCertificateDesc": "Yalnızca kendinden imzalı veya IP adresi sertifikaları olan güvenilir, kendi barındırdığınız sunucular için kullanın.", - "useEmbedded": "Yerel Sunucu Kullan", - "embeddedDesc": "Termix'i yerleşik yerel sunucu ile çalıştırın (uzak sunucuya gerek yok)", - "embeddedConnecting": "Yerel sunucuya bağlanılıyor...", - "embeddedNotReady": "Yerel sunucu henüz hazır değil. Lütfen biraz bekleyip tekrar deneyin.", - "localServer": "Yerel Sunucu", - "savedServers": "Kayıtlı Sunucular", - "noSavedServers": "Kayıtlı sunucu yok", - "removeServer": "Kaldır" - }, - "migrationNotice": { - "title": "Termix Desktop artık bağımsız olarak çalışıyor.", - "body1": "Bu uygulama eskiden doğrudan Termix sunucunuza bağlanıyordu. Artık tamamen kendi başına çalışacak şekilde yeniden tasarlandı; sunucuları ve kimlik bilgilerini yerel olarak depolayarak çevrimdışı çalışmaya devam ediyor. Kendi sunucunuzda barındırdığınız Termix sunucusuyla çift yönlü senkronizasyon artık isteğe bağlıdır.", - "body2": "Mevcut sunucularınız ve kimlik bilgileriniz hala {{url}}üzerindedir. Uzaktan Senkronizasyonu açın ve bunları geri getirmek ve ileride ikisinin de senkronize kalmasını sağlamak için o sunucuya yeniden bağlanın.", - "dismiss": "Şimdi değil", - "setUpSync": "Uzaktan Senkronizasyonu Ayarla" - }, - "remoteSync": { - "title": "Uzaktan Senkronizasyon", - "description": "İsteğe bağlı olarak, bu masaüstü uygulamasını kendi barındırdığınız bir Termix sunucusuna bağlayarak sunucularınızı, kimlik bilgilerinizi ve kod parçacıklarınızı cihazlar arasında senkronize edebilirsiniz. Uygulama, bağlantı kurup kurmamanızdan bağımsız olarak her zaman tamamen çevrimdışı çalışır.", - "notConnected": "Bağlı değil", - "connected": "Bağlı", - "connectedTo": "{{url}} ile bağlantılı", - "lastSynced": "Son senkronizasyon {{time}}", - "neverSynced": "Hiç senkronize edilmedi", - "syncError": "Senkronizasyon hatası: {{message}}", - "needsReauth": "Oturum açma süresi doldu", - "connectButton": "Sunucuya bağlan", - "disconnectButton": "Bağlantıyı kes", - "syncNowButton": "Şimdi Senkronize Et", - "syncing": "Senkronizasyon...", - "syncSuccess": "Uzaktan senkronizasyon tamamlandı.", - "syncUnavailable": "Uzaktan senkronizasyon başlatılamadı.", - "serverUrl": "Sunucu URL'si", - "enterServerUrl": "Lütfen bir sunucu URL'si girin.", - "mustIncludeProtocol": "Sunucu URL'si http:// veya https:// ile başlamalıdır.", - "connectionTestFailed": "Belirtilen URL'deki Termix sunucusuna ulaşılamadı.", - "allowInvalidCertificate": "Geçersiz sertifikaya izin ver", - "allowInvalidCertificateDesc": "Yalnızca kendinden imzalı veya IP adresi sertifikalarına sahip, güvenilir, kendi sunucunuzda barındırdığınız sunucular için kullanın.", - "savedServers": "Kaydedilen Sunucular", - "removeServer": "Kaldırmak", - "continueButton": "Devam etmek", - "cancelButton": "İptal etmek", - "signInTitle": "{{url}} adresine giriş yapın.", - "originTitle": "Bağlantı Kaynağı", - "originDescription": "SSH bağlantılarının varsayılan olarak nereden kaynaklanacağını seçin. Bu ayar her sunucu için geçersiz kılınabilir.", - "originLocal": "Bu cihaz (yerel ağ)", - "originRemote": "Uzak sunucu", - "bannerReconnect": "Yeniden bağlan", - "bannerMessage": "Uzaktan senkronizasyon için yeniden kimlik doğrulaması gerekiyor." - }, "versionCheck": { - "error": "Sürüm Denetleme Hatası", - "checkFailed": "Güncellemeler denetlenemedi", - "upToDate": "Uygulama güncel", - "currentVersion": "Sürüm {{version}} kullanıyorsunuz", - "updateAvailable": "Güncelleme Mevcut", - "updateLinkLabel": "Güncelleme mevcut — GitHub'daki sürüm notlarını açın.", - "newVersionAvailable": "Yeni bir sürüm mevcut! Şu anda {{current}} sürümünü kullanıyorsunuz, ancak {{latest}} sürümü mevcut.", - "betaVersion": "Beta Sürümü", - "betaVersionDesc": "Şu anda {{current}} sürümünü kullanıyorsunuz, bu sürüm en son kararlı sürüm {{latest}}'den daha yeni.", - "releasedOn": "{{date}} tarihinde yayımlandı", - "downloadUpdate": "Güncellemeyi İndir", - "checking": "Güncellemeler kontrol ediliyor...", - "checkUpdates": "Güncellemeleri Kontrol Et", - "checkingUpdates": "Güncellemeler kontrol ediliyor...", - "updateRequired": "Güncelleme Gerekli" + "updateLinkLabel": "Güncelleme mevcut — GitHub'daki sürüm notlarını açın." }, "common": { "close": "Kapat", @@ -1565,8 +1487,6 @@ "resetCodeRateLimitedWithTime": "Hız sınırlaması: Çok fazla doğrulama denemesi. Lütfen tekrar denemeden önce {{time}} saniye bekleyin.", "authTokenSaveFailed": "Kimlik doğrulama belirteci kaydedilemedi", "authTokenMissing": "Sunucu yeni bir kimlik doğrulama belirteci döndürmedi. Lütfen tekrar giriş yapın.", - "authTokenRejected": "Uzak sunucu yeni kimlik doğrulama belirtecini reddetti.", - "keyringUnavailable": "Oturum açıldı, ancak oturum kaydedilemedi: bu sistemde şifrelemek için kullanılabilecek bir anahtar halkası bulunmuyor. Bir Gizli Servis (örneğin gnome-keyring veya KWallet) başlatın ve tekrar oturum açın.", "failedToLoadServer": "Sunucu yüklenemedi", "remoteServerRequired": "Uzak sunucu gereklidir. Bu bağlantı türünü kullanmak için Ayarlar'dan bir uzak sunucu bağlayın." }, diff --git a/src/ui/locales/translated/uk_UA.json b/src/ui/locales/translated/uk_UA.json index 00b3d7706..d8a0c05e7 100644 --- a/src/ui/locales/translated/uk_UA.json +++ b/src/ui/locales/translated/uk_UA.json @@ -232,86 +232,8 @@ "widgetTunnelDesc": "Вбудований менеджер тунелів SSH для налаштованого хоста", "widgetNoHostSelected": "Хост не налаштовано" }, - "serverConfig": { - "title": "Конфігурація сервера", - "description": "Налаштуйте URL-адресу сервера Termix для підключення до ваших серверних служб", - "serverUrl": "URL-адреса сервера", - "enterServerUrl": "Будь ласка, введіть URL-адресу сервера", - "saveFailed": "Не вдалося зберегти конфігурацію", - "saveError": "Помилка збереження конфігурації", - "saving": "Збереження...", - "saveConfig": "Зберегти конфігурацію", - "helpText": "Введіть URL-адресу, на якій працює ваш сервер Termix (наприклад, http://localhost:30001 або https://your-server.com)", - "changeServer": "Змінити сервер", - "mustIncludeProtocol": "URL-адреса сервера має починатися з http:// або https://", - "allowInvalidCertificate": "Дозволити недійсний сертифікат", - "allowInvalidCertificateDesc": "Використовуйте лише для довірених серверів із власним хостингом та самопідписаними сертифікатами або сертифікатами IP-адреси.", - "useEmbedded": "Використовувати локальний сервер", - "embeddedDesc": "Запустіть Termix із вбудованим локальним сервером (віддалений сервер не потрібен)", - "embeddedConnecting": "Підключення до локального сервера...", - "embeddedNotReady": "Локальний сервер ще не готовий. Зачекайте хвилинку та спробуйте ще раз.", - "localServer": "Локальний сервер", - "savedServers": "Збережені сервери", - "noSavedServers": "Немає збережених серверів", - "removeServer": "Видалити" - }, - "migrationNotice": { - "title": "Termix Desktop тепер працює автономно", - "body1": "Цей додаток раніше підключався безпосередньо до вашого сервера Termix. Його було перероблено, щоб він працював повністю самостійно, зберігаючи хости та облікові дані локально, щоб він міг працювати офлайн. Двостороння синхронізація з власним сервером Termix тепер необов'язкова.", - "body2": "Ваші існуючі хости та облікові дані все ще на {{url}}. Увімкніть віддалену синхронізацію та повторно підключіться до цього сервера, щоб відновити їх та підтримувати синхронізацію надалі.", - "dismiss": "Не зараз", - "setUpSync": "Налаштування віддаленої синхронізації" - }, - "remoteSync": { - "title": "Віддалена синхронізація", - "description": "За бажанням, підключіть цей настільний додаток до окремого сервера Termix, щоб синхронізувати свої хости, облікові дані та фрагменти коду на всіх пристроях. Додаток завжди працює повністю офлайн, незалежно від того, чи підключено ви його до мережі.", - "notConnected": "Не підключено", - "connected": "Підключено", - "connectedTo": "Підключено до {{url}}", - "lastSynced": "Остання синхронізація {{time}}", - "neverSynced": "Ніколи не синхронізувалося", - "syncError": "Помилка синхронізації: {{message}}", - "needsReauth": "Термін дії входу минув", - "connectButton": "Підключення до сервера", - "disconnectButton": "Відключитися", - "syncNowButton": "Синхронізувати зараз", - "syncing": "Синхронізація...", - "syncSuccess": "Віддалену синхронізацію завершено", - "syncUnavailable": "Не вдалося розпочати віддалену синхронізацію", - "serverUrl": "URL-адреса сервера", - "enterServerUrl": "Будь ласка, введіть URL-адресу сервера", - "mustIncludeProtocol": "URL-адреса сервера має починатися з http:// або https://", - "connectionTestFailed": "Не вдалося зв'язатися із сервером Termix за цією URL-адресою", - "allowInvalidCertificate": "Дозволити недійсний сертифікат", - "allowInvalidCertificateDesc": "Використовуйте лише для довірених серверів із власним хостингом та самопідписаними сертифікатами або сертифікатами IP-адреси.", - "savedServers": "Збережені сервери", - "removeServer": "Видалити", - "continueButton": "Продовжити", - "cancelButton": "Скасувати", - "signInTitle": "Увійти до {{url}}", - "originTitle": "Джерело з'єднання", - "originDescription": "Виберіть, звідки за замовчуванням створюються SSH-з’єднання. Це можна змінити для кожного хоста окремо.", - "originLocal": "Цей пристрій (локальна мережа)", - "originRemote": "Віддалений сервер", - "bannerReconnect": "Знову підключитися", - "bannerMessage": "Віддалена синхронізація потребує повторної автентифікації" - }, "versionCheck": { - "error": "Помилка перевірки версії", - "checkFailed": "Не вдалося перевірити наявність оновлень", - "upToDate": "Додаток оновлено", - "currentVersion": "Ви використовуєте версію {{version}}", - "updateAvailable": "Доступне оновлення", - "updateLinkLabel": "Доступне оновлення — відкрийте нотатки до випуску на GitHub", - "newVersionAvailable": "Доступна нова версія! Ви використовуєте {{current}}, але {{latest}} доступний.", - "betaVersion": "Бета-версія", - "betaVersionDesc": "Ви використовуєте {{current}}, яка є новішою за останню стабільну версію {{latest}}.", - "releasedOn": "Випущено {{date}}", - "downloadUpdate": "Завантажити оновлення", - "checking": "Перевірка оновлень...", - "checkUpdates": "Перевірити наявність оновлень", - "checkingUpdates": "Перевірка оновлень...", - "updateRequired": "Потрібне оновлення" + "updateLinkLabel": "Доступне оновлення — відкрийте нотатки до випуску на GitHub" }, "common": { "close": "Закрити", @@ -1565,8 +1487,6 @@ "resetCodeRateLimitedWithTime": "Обмеження швидкості: Забагато спроб перевірки. Будь ласка, зачекайте {{time}} секунд, перш ніж повторити спробу.", "authTokenSaveFailed": "Не вдалося зберегти токен автентифікації", "authTokenMissing": "Сервер не повернув новий токен автентифікації. Будь ласка, увійдіть ще раз.", - "authTokenRejected": "Віддалений сервер відхилив новий токен автентифікації", - "keyringUnavailable": "Ви ввійшли, але сеанс не вдалося зберегти: у цій системі немає доступного зв'язка ключів для його шифрування. Запустіть секретну службу (наприклад, gnome-keyring або KWallet) та знову ввійдіть.", "failedToLoadServer": "Не вдалося завантажити сервер", "remoteServerRequired": "Потрібен віддалений сервер. Підключіться до віддаленого сервера в налаштуваннях, щоб використовувати цей тип підключення." }, diff --git a/src/ui/locales/translated/vi_VN.json b/src/ui/locales/translated/vi_VN.json index f42961cb5..5fc311f06 100644 --- a/src/ui/locales/translated/vi_VN.json +++ b/src/ui/locales/translated/vi_VN.json @@ -232,86 +232,8 @@ "widgetTunnelDesc": "Trình quản lý đường hầm SSH tích hợp cho máy chủ đã cấu hình", "widgetNoHostSelected": "Chưa cấu hình máy chủ" }, - "serverConfig": { - "title": "Cấu hình Máy chủ", - "description": "Cấu hình URL máy chủ Termix để kết nối đến các dịch vụ backend của bạn", - "serverUrl": "URL máy chủ", - "enterServerUrl": "Vui lòng nhập URL máy chủ", - "saveFailed": "Lưu cấu hình thất bại", - "saveError": "Lỗi khi lưu cấu hình", - "saving": "Đang lưu...", - "saveConfig": "Lưu Cấu hình", - "helpText": "Nhập URL nơi máy chủ Termix của bạn đang chạy (ví dụ: http://localhost:30001 hoặc https://your-server.com)", - "changeServer": "Đổi Máy chủ", - "mustIncludeProtocol": "URL máy chủ phải bắt đầu bằng http:// hoặc https://", - "allowInvalidCertificate": "Cho phép chứng chỉ không hợp lệ", - "allowInvalidCertificateDesc": "Chỉ dùng cho các máy chủ tự lưu trữ đáng tin cậy có chứng chỉ tự ký hoặc chứng chỉ địa chỉ IP.", - "useEmbedded": "Dùng Máy chủ Cục bộ", - "embeddedDesc": "Chạy Termix với máy chủ cục bộ tích hợp (không cần máy chủ từ xa)", - "embeddedConnecting": "Đang kết nối đến máy chủ cục bộ...", - "embeddedNotReady": "Máy chủ cục bộ chưa sẵn sàng. Vui lòng đợi một lát và thử lại.", - "localServer": "Máy chủ Cục bộ", - "savedServers": "Máy chủ Đã lưu", - "noSavedServers": "Không có máy chủ nào được lưu", - "removeServer": "Xóa" - }, - "migrationNotice": { - "title": "Termix Desktop hiện đã có thể chạy độc lập.", - "body1": "Ứng dụng này trước đây kết nối trực tiếp với máy chủ Termix của bạn. Giờ đây, nó đã được thiết kế lại để hoạt động hoàn toàn độc lập, lưu trữ máy chủ và thông tin đăng nhập cục bộ để tiếp tục hoạt động ngoại tuyến. Đồng bộ hóa hai chiều với máy chủ Termix tự lưu trữ hiện là tùy chọn.", - "body2": "Máy chủ và thông tin đăng nhập hiện tại của bạn vẫn nằm trên {{url}}. Hãy bật Đồng bộ hóa từ xa và kết nối lại với máy chủ đó để khôi phục chúng và giữ cho cả hai luôn được đồng bộ trong tương lai.", - "dismiss": "Không phải bây giờ", - "setUpSync": "Thiết lập đồng bộ hóa từ xa" - }, - "remoteSync": { - "title": "Đồng bộ từ xa", - "description": "Bạn có thể tùy chọn kết nối ứng dụng máy tính để bàn này với máy chủ Termix tự lưu trữ để đồng bộ hóa máy chủ, thông tin đăng nhập và đoạn mã trên các thiết bị. Ứng dụng luôn hoạt động hoàn toàn ngoại tuyến cho dù bạn có kết nối mạng hay không.", - "notConnected": "Không kết nối", - "connected": "Đã kết nối", - "connectedTo": "Đã kết nối với {{url}}", - "lastSynced": "Lần đồng bộ cuối cùng {{time}}", - "neverSynced": "Chưa bao giờ đồng bộ", - "syncError": "Lỗi đồng bộ: {{message}}", - "needsReauth": "Đăng nhập đã hết hạn", - "connectButton": "Kết nối với máy chủ", - "disconnectButton": "Ngắt kết nối", - "syncNowButton": "Đồng bộ ngay", - "syncing": "Đang đồng bộ hóa...", - "syncSuccess": "Đồng bộ hóa từ xa đã hoàn tất", - "syncUnavailable": "Không thể khởi động đồng bộ hóa từ xa.", - "serverUrl": "URL máy chủ", - "enterServerUrl": "Vui lòng nhập URL máy chủ", - "mustIncludeProtocol": "URL máy chủ phải bắt đầu bằng http:// hoặc https://", - "connectionTestFailed": "Không thể kết nối đến máy chủ Termix tại địa chỉ URL đó.", - "allowInvalidCertificate": "Cho phép chứng chỉ không hợp lệ", - "allowInvalidCertificateDesc": "Chỉ sử dụng cho các máy chủ tự lưu trữ đáng tin cậy có chứng chỉ tự ký hoặc chứng chỉ địa chỉ IP.", - "savedServers": "Máy chủ đã lưu", - "removeServer": "Di dời", - "continueButton": "Tiếp tục", - "cancelButton": "Hủy bỏ", - "signInTitle": "Đăng nhập vào {{url}}", - "originTitle": "Nguồn gốc kết nối", - "originDescription": "Chọn nơi mặc định các kết nối SSH được tạo ra. Có thể thay đổi cài đặt này cho từng máy chủ.", - "originLocal": "Thiết bị này (mạng cục bộ)", - "originRemote": "Máy chủ từ xa", - "bannerReconnect": "Kết nối lại", - "bannerMessage": "Đồng bộ hóa từ xa cần xác thực lại." - }, "versionCheck": { - "error": "Lỗi Kiểm tra Phiên bản", - "checkFailed": "Kiểm tra cập nhật thất bại", - "upToDate": "Ứng dụng đã là phiên bản mới nhất", - "currentVersion": "Bạn đang chạy phiên bản {{version}}", - "updateAvailable": "Có bản cập nhật", - "updateLinkLabel": "Đã có bản cập nhật — xem ghi chú phát hành trên GitHub.", - "newVersionAvailable": "Đã có phiên bản mới! Bạn đang chạy {{current}}, nhưng đã có {{latest}}.", - "betaVersion": "Phiên bản beta", - "betaVersionDesc": "Bạn đang chạy {{current}}, mới hơn bản phát hành ổn định mới nhất {{latest}}.", - "releasedOn": "Phát hành vào {{date}}", - "downloadUpdate": "Tải xuống bản cập nhật", - "checking": "Đang kiểm tra cập nhật...", - "checkUpdates": "Kiểm tra cập nhật", - "checkingUpdates": "Đang kiểm tra cập nhật...", - "updateRequired": "Yêu cầu cập nhật" + "updateLinkLabel": "Đã có bản cập nhật — xem ghi chú phát hành trên GitHub." }, "common": { "close": "Đóng", @@ -1565,8 +1487,6 @@ "resetCodeRateLimitedWithTime": "Hạn chế tốc độ: Quá nhiều lần xác minh. Vui lòng chờ {{time}} giây trước khi thử lại.", "authTokenSaveFailed": "Lưu token xác thực thất bại", "authTokenMissing": "Máy chủ không trả về mã xác thực mới. Vui lòng đăng nhập lại.", - "authTokenRejected": "Máy chủ từ xa đã từ chối mã thông báo xác thực mới.", - "keyringUnavailable": "Đã đăng nhập, nhưng phiên làm việc không thể được lưu lại: hệ thống này không có khóa mã hóa nào để sử dụng. Hãy khởi động một dịch vụ mã hóa bí mật (như gnome-keyring hoặc KWallet) và đăng nhập lại.", "failedToLoadServer": "Tải máy chủ thất bại", "remoteServerRequired": "Cần có máy chủ từ xa. Kết nối máy chủ từ xa trong Cài đặt để sử dụng loại kết nối này." }, diff --git a/src/ui/locales/translated/zh_CN.json b/src/ui/locales/translated/zh_CN.json index abd834e9a..4b46d5535 100644 --- a/src/ui/locales/translated/zh_CN.json +++ b/src/ui/locales/translated/zh_CN.json @@ -232,86 +232,8 @@ "widgetTunnelDesc": "用于已配置主机的嵌入式 SSH 隧道管理器", "widgetNoHostSelected": "未配置主机" }, - "serverConfig": { - "title": "服务器配置", - "description": "配置 Termix 服务器 URL 以连接到后端服务", - "serverUrl": "服务器 URL", - "enterServerUrl": "请输入服务器 URL", - "saveFailed": "保存配置失败", - "saveError": "保存配置出错", - "saving": "保存中...", - "saveConfig": "保存配置", - "helpText": "输入 Termix 服务器运行的 URL(例如 http://localhost:30001 或 https://your-server.com)", - "changeServer": "更改服务器", - "mustIncludeProtocol": "服务器 URL 必须以 http:// 或 https:// 开头", - "allowInvalidCertificate": "允许无效证书", - "allowInvalidCertificateDesc": "仅用于可信的自托管服务器,证书为自签名或 IP 地址证书。", - "useEmbedded": "使用本地服务器", - "embeddedDesc": "使用内置本地服务器运行 Termix(无需远程服务器)", - "embeddedConnecting": "正在连接本地服务器...", - "embeddedNotReady": "本地服务器尚未就绪,请稍候再试。", - "localServer": "本地服务器", - "savedServers": "已保存的服务器", - "noSavedServers": "无已保存的服务器", - "removeServer": "移除" - }, - "migrationNotice": { - "title": "Termix Desktop 现在可以独立运行", - "body1": "此应用以前直接连接到您的 Termix 服务器。现在已重新设计,可完全独立运行,将主机和凭据存储在本地,因此即使离线也能正常工作。与自托管 Termix 服务器的双向同步现在是可选的。", - "body2": "您现有的主机和凭据仍然在 {{url}}上。启用远程同步并重新连接到该服务器,即可恢复它们并保持两者同步。", - "dismiss": "稍后再说", - "setUpSync": "设置远程同步" - }, - "remoteSync": { - "title": "远程同步", - "description": "您可以选择将此桌面应用程序连接到自托管的 Termix 服务器,以便在不同设备间同步主机名、凭据和代码片段。无论是否连接,该应用程序始终可以完全离线运行。", - "notConnected": "未连接", - "connected": "已连接", - "connectedTo": "已连接至 {{url}}", - "lastSynced": "上次同步时间 {{time}}", - "neverSynced": "从未同步", - "syncError": "同步错误: {{message}}", - "needsReauth": "登录已过期", - "connectButton": "连接到服务器", - "disconnectButton": "断开连接", - "syncNowButton": "立即同步", - "syncing": "正在同步...", - "syncSuccess": "远程同步完成", - "syncUnavailable": "远程同步无法启动", - "serverUrl": "服务器 URL", - "enterServerUrl": "请输入服务器 URL", - "mustIncludeProtocol": "服务器 URL 必须以 http:// 或 https:// 开头", - "connectionTestFailed": "无法通过该 URL 连接到 Termix 服务器", - "allowInvalidCertificate": "允许无效证书", - "allowInvalidCertificateDesc": "仅适用于具有自签名证书或 IP 地址证书的受信任的自托管服务器。", - "savedServers": "已保存的服务器", - "removeServer": "删除", - "continueButton": "继续", - "cancelButton": "取消", - "signInTitle": "登录到 {{url}}", - "originTitle": "连接源", - "originDescription": "选择 SSH 连接的默认发起位置。此设置可针对每个主机进行单独设置。", - "originLocal": "此设备(本地网络)", - "originRemote": "远程服务器", - "bannerReconnect": "重新连接", - "bannerMessage": "远程同步需要重新验证" - }, "versionCheck": { - "error": "版本检查错误", - "checkFailed": "检查更新失败", - "upToDate": "应用已是最新", - "currentVersion": "您正在运行版本 {{version}}", - "updateAvailable": "有可用更新", - "updateLinkLabel": "有更新可用——请在 GitHub 上查看发布说明。", - "newVersionAvailable": "新版本可用!您当前版本为 {{current}},最新版本为 {{latest}}。", - "betaVersion": "测试版", - "betaVersionDesc": "您正在运行 {{current}},比最新稳定版 {{latest}} 更新。", - "releasedOn": "发布于 {{date}}", - "downloadUpdate": "下载更新", - "checking": "正在检查更新...", - "checkUpdates": "检查更新", - "checkingUpdates": "正在检查更新...", - "updateRequired": "需要更新" + "updateLinkLabel": "有更新可用——请在 GitHub 上查看发布说明。" }, "common": { "close": "关闭", @@ -1565,8 +1487,6 @@ "resetCodeRateLimitedWithTime": "频率受限:验证尝试次数过多,请等待 {{time}} 秒后再试。", "authTokenSaveFailed": "保存认证令牌失败", "authTokenMissing": "服务器未返回新的身份验证令牌。请重新登录。", - "authTokenRejected": "远程服务器拒绝了新的身份验证令牌。", - "keyringUnavailable": "已登录,但会话无法保存:此系统没有可用于加密的密钥环。请启动密钥服务(例如 gnome-keyring 或 KWallet)并重新登录。", "failedToLoadServer": "加载服务器失败", "remoteServerRequired": "需要远程服务器。请在“设置”中连接远程服务器以使用此连接类型。" }, diff --git a/src/ui/locales/translated/zh_TW.json b/src/ui/locales/translated/zh_TW.json index 6748f5920..62898e368 100644 --- a/src/ui/locales/translated/zh_TW.json +++ b/src/ui/locales/translated/zh_TW.json @@ -232,86 +232,8 @@ "widgetTunnelDesc": "用於已設定主機的內嵌 SSH 通道管理員", "widgetNoHostSelected": "未設定主機" }, - "serverConfig": { - "title": "伺服器設定", - "description": "設定 Termix 伺服器 URL 以連線至您的後端服務", - "serverUrl": "伺服器 URL", - "enterServerUrl": "請輸入伺服器 URL", - "saveFailed": "儲存設定失敗", - "saveError": "儲存設定時發生錯誤", - "saving": "儲存中...", - "saveConfig": "儲存設定", - "helpText": "輸入您的 Termix 伺服器執行所在的 URL(例如 http://localhost:30001 或 https://your-server.com)", - "changeServer": "變更伺服器", - "mustIncludeProtocol": "伺服器 URL 必須以 http:// 或 https:// 開頭", - "allowInvalidCertificate": "允許無效的憑證", - "allowInvalidCertificateDesc": "僅用於使用自我簽署或 IP 位址憑證的受信任自託管伺服器。", - "useEmbedded": "使用本機伺服器", - "embeddedDesc": "以內建的本機伺服器執行 Termix(無需遠端伺服器)", - "embeddedConnecting": "正在連線至本機伺服器...", - "embeddedNotReady": "本機伺服器尚未就緒。請稍候片刻再試一次。", - "localServer": "本機伺服器", - "savedServers": "已儲存的伺服器", - "noSavedServers": "無已儲存的伺服器", - "removeServer": "移除" - }, - "migrationNotice": { - "title": "Termix Desktop 現在可以獨立運行", - "body1": "此應用程式以前直接連接到您的 Termix 伺服器。現在已重新設計,可完全獨立運行,將主機和憑證儲存在本地,因此即使離線也能正常工作。與自託管 Termix 伺服器的雙向同步現在是可選的。", - "body2": "您現有的主機和憑證仍在 {{url}}上。啟用遠端同步並重新連接到該伺服器,即可恢復它們並保持兩者同步。", - "dismiss": "現在不要", - "setUpSync": "設定遠端同步" - }, - "remoteSync": { - "title": "遠端同步", - "description": "您可以選擇將此桌面應用程式連接到自架的 Termix 伺服器,以便在不同裝置間同步主機名稱、憑證和程式碼片段。無論是否連接,該應用程式始終可以完全離線運行。", - "notConnected": "未連接", - "connected": "已連接", - "connectedTo": "已連接至 {{url}}", - "lastSynced": "上次同步時間 {{time}}", - "neverSynced": "從未同步", - "syncError": "同步錯誤: {{message}}", - "needsReauth": "登入已過期", - "connectButton": "連接到伺服器", - "disconnectButton": "斷開", - "syncNowButton": "立即同步", - "syncing": "正在同步...", - "syncSuccess": "遠端同步完成", - "syncUnavailable": "遠端同步無法啟動", - "serverUrl": "伺服器 URL", - "enterServerUrl": "請輸入伺服器網址", - "mustIncludeProtocol": "伺服器 URL 必須以 http:// 或 https:// 開頭。", - "connectionTestFailed": "無法透過該 URL 連線到 Termix 伺服器", - "allowInvalidCertificate": "允許無效證書", - "allowInvalidCertificateDesc": "僅適用於具有自簽名憑證或 IP 位址憑證的受信任的自託管伺服器。", - "savedServers": "已儲存的伺服器", - "removeServer": "消除", - "continueButton": "繼續", - "cancelButton": "取消", - "signInTitle": "登入 {{url}}", - "originTitle": "連接來源", - "originDescription": "選擇 SSH 連線的預設發起位置。此設定可針對每個主機進行單獨設定。", - "originLocal": "此設備(本地網路)", - "originRemote": "遠端伺服器", - "bannerReconnect": "重新連接", - "bannerMessage": "遠端同步需要重新驗證" - }, "versionCheck": { - "error": "版本檢查錯誤", - "checkFailed": "檢查更新失敗", - "upToDate": "應用程式已是最新版本", - "currentVersion": "您正在執行版本 {{version}}", - "updateAvailable": "有可用更新", - "updateLinkLabel": "有更新可用——請在 GitHub 上查看發布說明。", - "newVersionAvailable": "有新版本可用!您正在執行 {{current}},但 {{latest}} 已可用。", - "betaVersion": "Beta 版本", - "betaVersionDesc": "您正在執行 {{current}},此版本比最新的穩定版本 {{latest}} 還新。", - "releasedOn": "發佈於 {{date}}", - "downloadUpdate": "下載更新", - "checking": "正在檢查更新...", - "checkUpdates": "檢查更新", - "checkingUpdates": "正在檢查更新...", - "updateRequired": "需要更新" + "updateLinkLabel": "有更新可用——請在 GitHub 上查看發布說明。" }, "common": { "close": "關閉", @@ -1565,8 +1487,6 @@ "resetCodeRateLimitedWithTime": "速率限制:驗證嘗試次數過多。請等待 {{time}} 秒後再試。", "authTokenSaveFailed": "無法儲存驗證權杖", "authTokenMissing": "伺服器未傳回新的身份驗證令牌。請重新登入。", - "authTokenRejected": "遠端伺服器拒絕了新的身份驗證令牌。", - "keyringUnavailable": "已登錄,但會話無法儲存:此系統沒有可用於加密的金鑰環。請啟動金鑰服務(例如 gnome-keyring 或 KWallet)並重新登入。", "failedToLoadServer": "無法載入伺服器", "remoteServerRequired": "需要遠端伺服器。請在「設定」中連接遠端伺服器以使用此連線類型。" }, diff --git a/src/ui/main-axios.ts b/src/ui/main-axios.ts index 610570b08..7f7e3b7cd 100644 --- a/src/ui/main-axios.ts +++ b/src/ui/main-axios.ts @@ -98,10 +98,18 @@ export interface UserInfo { password_hash?: string; data_unlocked?: boolean; show_donation_modal?: boolean; + /** On a desktop linked to a server, the account it is signed in to there. */ + linked?: LinkedAccountInfo | null; } -export interface RemoteSyncUserInfo extends UserInfo { - roles: UserRole[]; +export interface LinkedAccountInfo { + serverUrl: string; + serverName: string | null; + username: string | null; + isAdmin: boolean; + roles: string[]; + permissions: string[]; + status: string; } interface UserCount { @@ -577,43 +585,6 @@ interface AxiosErrorExtended extends AxiosError { config?: AxiosRequestConfigExtended; } -export interface ElectronUpdateCheckResult { - success: boolean; - status?: "up_to_date" | "requires_update" | "beta"; - localVersion?: string; - remoteVersion?: string; - latest_release?: { - tag_name: string; - name: string; - published_at: string; - html_url: string; - body: string; - }; - cached?: boolean; - cache_age?: number; - error?: string; -} - -export async function checkElectronUpdate(): Promise { - if (!isElectron()) - return { success: false, error: "Not in Electron environment" }; - - try { - const result = (await ( - window as Window & - typeof globalThis & { - IS_ELECTRON?: boolean; - electronAPI?: { invoke?: (channel: string) => Promise }; - } - ).electronAPI?.invoke?.("check-electron-update")) as - ElectronUpdateCheckResult | undefined; - return result ?? { success: false, error: "Update check failed" }; - } catch (error) { - console.error("Failed to check Electron update:", error); - return { success: false, error: "Update check failed" }; - } -} - /** A URL on the main backend, resolved for web, dev proxy and Electron. */ export function getBackendUrl(path: string): string { return getApiUrl(path, 30001); @@ -624,10 +595,8 @@ function getApiUrl(path: string, defaultPort: number): string { const electronMode = isElectron(); if (electronMode) { - // The desktop app always runs its embedded local backend as the - // source of truth. A configured remote sync server is a separate, - // narrow connection used only by the sync engine (see - // remote-sync-axios.ts), not by these shared instances. + // The desktop app always talks to its embedded backend. The server it + // may be linked to is reached through createRemoteOriginApiInstance. return `http://localhost:${defaultPort}${path}`; } else if (devMode) { if (!import.meta.env.VITE_API_HOST) { @@ -651,9 +620,8 @@ function getApiUrl(path: string, defaultPort: number): string { // the backend that holds that host's live session is the connected remote // server instead, so calls for that host must follow it there. // -// These dynamically-baseURL'd instances resolve the remote server's URL and -// JWT fresh on every request (cheap, and correct even if the user -// connects/disconnects remote sync without an app reload). +// These instances look up the linked server and its session on every +// request, so linking or unlinking needs no reload. export function createRemoteOriginApiInstance(path: string): AxiosInstance { const instance = axios.create({ @@ -663,27 +631,19 @@ export function createRemoteOriginApiInstance(path: string): AxiosInstance { instance.interceptors.request.use( async (config: InternalAxiosRequestConfig) => { - const [remoteConfig, remoteJwt] = await Promise.all([ - window.electronAPI?.invoke?.("get-remote-sync-config") as Promise<{ - serverUrl?: string; - } | null>, - window.electronAPI?.invoke?.("get-remote-sync-jwt") as Promise< - string | null - >, - ]); - - const baseUrl = (remoteConfig?.serverUrl || "").replace(/\/$/, ""); - config.baseURL = baseUrl - ? `${baseUrl}${path}` + const { getLinkedSession } = await import("@/lib/linked-server"); + const linked = await getLinkedSession(); + config.baseURL = linked + ? `${linked.serverUrl}${path}` : "http://no-server-configured"; if (config.headers.set) { config.headers.set("X-Electron-App", "true"); - if (remoteJwt) - config.headers.set("Authorization", `Bearer ${remoteJwt}`); + if (linked) + config.headers.set("Authorization", `Bearer ${linked.token}`); } else { config.headers["X-Electron-App"] = "true"; - if (remoteJwt) config.headers["Authorization"] = `Bearer ${remoteJwt}`; + if (linked) config.headers["Authorization"] = `Bearer ${linked.token}`; } return config; @@ -695,11 +655,7 @@ export function createRemoteOriginApiInstance(path: string): AxiosInstance { let remoteCoreApi: AxiosInstance | null = null; -/** - * The remote server's core routes, unprefixed. - * - * For core routes such as /sync/hosts and /host/status. - */ +/** The linked server's core routes, unprefixed. */ export function getRemoteCoreApi(): AxiosInstance { if (!remoteCoreApi) { remoteCoreApi = createRemoteOriginApiInstance(""); @@ -1053,18 +1009,6 @@ export async function getUserInfo(): Promise { } } -export async function getRemoteSyncUserInfo(): Promise { - if (!isElectron()) return null; - try { - // ?? null so a missing preload bridge matches the declared return type - // rather than resolving to undefined. - return ((await window.electronAPI?.invoke?.("get-remote-sync-user-info")) ?? - null) as RemoteSyncUserInfo | null; - } catch { - return null; - } -} - export async function dismissDonationModal(): Promise { try { await authApi.post("/users/me/dismiss-donation-modal"); diff --git a/src/ui/onboarding/onboarding-steps.ts b/src/ui/onboarding/onboarding-steps.ts index f4e65a8dd..1b87476ed 100644 --- a/src/ui/onboarding/onboarding-steps.ts +++ b/src/ui/onboarding/onboarding-steps.ts @@ -6,6 +6,8 @@ import { FeaturesStep } from "./steps/FeaturesStep"; import { WorkflowStep } from "./steps/WorkflowStep"; import { SecurityStep } from "./steps/SecurityStep"; import { DoneStep } from "./steps/DoneStep"; +import { DesktopSyncStep } from "./steps/DesktopSyncStep"; +import { isElectron } from "@/lib/electron"; // eslint-disable-next-line @typescript-eslint/no-empty-object-type export interface OnboardingContext {} @@ -34,6 +36,12 @@ export const ONBOARDING_STEPS: OnboardingStep[] = [ Component: WelcomeStep, }, { id: "preset", titleKey: "onboarding.presetTitle", Component: PresetStep }, + { + id: "desktop-sync", + titleKey: "onboarding.desktopTitle", + Component: DesktopSyncStep, + isRelevant: () => isElectron(), + }, { id: "appearance", titleKey: "onboarding.appearanceTitle", diff --git a/src/ui/onboarding/steps/DesktopSyncStep.tsx b/src/ui/onboarding/steps/DesktopSyncStep.tsx new file mode 100644 index 000000000..687e44854 --- /dev/null +++ b/src/ui/onboarding/steps/DesktopSyncStep.tsx @@ -0,0 +1,79 @@ +import { useState } from "react"; +import { useTranslation } from "react-i18next"; +import { Cloud, Laptop } from "lucide-react"; +import { LinkServerDialog } from "@/settings/sync/LinkServerDialog"; +import { useSyncStatus } from "@/hooks/use-sync-status"; +import { setSyncStatus } from "@/lib/sync-status"; +import { notifySyncChanged } from "@/lib/linked-server"; + +/** + * Desktop only: use Termix on this device alone, or link it to a server. + * Either choice can be changed later from Sync. + */ +export function DesktopSyncStep() { + const { t } = useTranslation(); + const status = useSyncStatus(); + const [linkOpen, setLinkOpen] = useState(false); + const linked = !!status?.linked; + + const options = [ + { + id: "local", + icon: Laptop, + title: t("onboarding.desktopLocalTitle"), + body: t("onboarding.desktopLocalBody"), + selected: !linked, + onClick: () => {}, + }, + { + id: "link", + icon: Cloud, + title: t("onboarding.desktopLinkTitle"), + body: linked + ? t("onboarding.desktopLinked", { + server: status?.serverName || status?.serverUrl, + }) + : t("onboarding.desktopLinkBody"), + selected: linked, + onClick: () => !linked && setLinkOpen(true), + }, + ]; + + return ( +
+

+ {t("onboarding.desktopBody")} +

+
+ {options.map(({ id, icon: Icon, title, body, selected, onClick }) => ( + + ))} +
+ { + setSyncStatus(next); + notifySyncChanged(); + }} + /> +
+ ); +} diff --git a/src/ui/plugin-host/desktop.ts b/src/ui/plugin-host/desktop.ts index 3dab09e51..4755f4955 100644 --- a/src/ui/plugin-host/desktop.ts +++ b/src/ui/plugin-host/desktop.ts @@ -1,24 +1,14 @@ -/** app.desktop: the desktop app's remote sync connection, for plugins. */ +/** app.desktop: the server this desktop is linked to, for plugins. */ -import { isElectron } from "@/lib/electron"; +import { getLinkedSession } from "@/lib/linked-server"; +import { SYNC_CHANGED_EVENT } from "@/lib/sync-events"; export async function remoteServerUrl(): Promise { - if (!isElectron()) return null; - try { - const config = (await window.electronAPI?.invoke?.( - "get-remote-sync-config", - )) as { serverUrl?: string } | null | undefined; - return config?.serverUrl || null; - } catch { - return null; - } + return (await getLinkedSession())?.serverUrl ?? null; } export function onRemoteServerChange(listener: () => void): () => void { - const unsubscribe = window.electronAPI?.onRemoteSyncStatusChanged?.(() => - listener(), - ); - return () => { - if (typeof unsubscribe === "function") unsubscribe(); - }; + const handler = () => listener(); + window.addEventListener(SYNC_CHANGED_EVENT, handler); + return () => window.removeEventListener(SYNC_CHANGED_EVENT, handler); } diff --git a/src/ui/plugin-host/sdk-ui.ts b/src/ui/plugin-host/sdk-ui.ts index 0e200465a..631d375e0 100644 --- a/src/ui/plugin-host/sdk-ui.ts +++ b/src/ui/plugin-host/sdk-ui.ts @@ -133,6 +133,7 @@ export { hydrateLocalSharedHostAuth, resolveRemoteHostId, } from "@/lib/remote-server-api"; +export { remoteServerUrl as linkedServerUrl } from "@/plugin-host/desktop"; export { useConnectionDefaults } from "@/contexts/ConnectionDefaultsContext"; // Dashboard reads. The calls a plugin makes on the user's behalf (recent diff --git a/src/ui/settings/RemoteSyncPanel.tsx b/src/ui/settings/RemoteSyncPanel.tsx deleted file mode 100644 index d653a2780..000000000 --- a/src/ui/settings/RemoteSyncPanel.tsx +++ /dev/null @@ -1,336 +0,0 @@ -import { useCallback, useEffect, useRef, useState } from "react"; -import { useTranslation } from "react-i18next"; -import { Server, RefreshCw, Loader2 } from "lucide-react"; -import { Button } from "@/components/button.tsx"; -import { - Dialog, - DialogContent, - DialogHeader, - DialogTitle, -} from "@/components/dialog.tsx"; -import { RemoteSyncServerPicker } from "./RemoteSyncServerPicker.tsx"; -import { ElectronLoginForm } from "@/auth/ElectronLoginForm.tsx"; -import { invalidateServerStatusCache } from "@/lib/hosts-request-cache.ts"; -import { toast } from "sonner"; - -interface RemoteSyncConfig { - serverUrl: string; - connectedAt: string; - lastSyncedAt?: string | null; - lastSyncStatus?: "ok" | "error" | "never"; - lastSyncError?: string | null; -} - -interface RemoteSyncStatus { - connected: boolean; - syncing: boolean; - lastSyncedAt: string | null; - lastError: string | null; - needsReauth: boolean; -} - -type DesktopSettings = { defaultConnectionOrigin: "local" | "remote" }; - -interface RemoteSyncPanelProps { - initialServerUrl?: string; - reconnectRequested?: boolean; - onReconnectRequestHandled?: () => void; -} - -export function RemoteSyncPanel({ - initialServerUrl, - reconnectRequested = false, - onReconnectRequestHandled, -}: RemoteSyncPanelProps) { - const { t } = useTranslation(); - const [config, setConfig] = useState(null); - const [status, setStatus] = useState(null); - const [desktopSettings, setDesktopSettings] = useState({ - defaultConnectionOrigin: "local", - }); - const [step, setStep] = useState<"idle" | "picker" | "login">("idle"); - const [pendingServerUrl, setPendingServerUrl] = useState(""); - const [syncingNow, setSyncingNow] = useState(false); - const consumedInitialServerUrl = useRef(false); - - const refresh = useCallback(async () => { - const [cfg, st, settings] = await Promise.all([ - window.electronAPI?.invoke?.( - "get-remote-sync-config", - ) as Promise, - window.electronAPI?.invoke?.( - "get-remote-sync-status", - ) as Promise, - window.electronAPI?.invoke?.( - "get-desktop-settings", - ) as Promise, - ]); - setConfig(cfg ?? null); - setStatus(st ?? null); - if (settings) setDesktopSettings(settings); - }, []); - - useEffect(() => { - refresh(); - const unsubscribe = window.electronAPI?.onRemoteSyncStatusChanged?.( - (nextStatus: RemoteSyncStatus) => setStatus(nextStatus), - ); - return () => unsubscribe?.(); - }, [refresh]); - - useEffect(() => { - if (consumedInitialServerUrl.current) return; - if (!initialServerUrl || config?.serverUrl) return; - consumedInitialServerUrl.current = true; - setPendingServerUrl(initialServerUrl); - setStep("picker"); - }, [initialServerUrl, config]); - - useEffect(() => { - if (!reconnectRequested || !config?.serverUrl) return; - setPendingServerUrl(config.serverUrl); - setStep("login"); - onReconnectRequestHandled?.(); - }, [config?.serverUrl, onReconnectRequestHandled, reconnectRequested]); - - const handleConnectClick = () => setStep("picker"); - - const handleReconnectClick = () => { - if (!config?.serverUrl) return; - setPendingServerUrl(config.serverUrl); - setStep("login"); - }; - - const handleServerConfigured = (serverUrl: string) => { - setPendingServerUrl(serverUrl); - setStep("login"); - }; - - const handleAuthSuccess = async () => { - setStep("idle"); - await refresh(); - }; - - const handleDisconnect = async () => { - await window.electronAPI?.invoke?.("clear-remote-sync-config"); - await refresh(); - }; - - const handleSyncNow = async () => { - setSyncingNow(true); - try { - const result = (await window.electronAPI?.invoke?.("remote-sync-now")) as - RemoteSyncStatus | null | undefined; - if (!result || result.needsReauth || result.lastError) { - toast.error(result?.lastError || t("remoteSync.syncUnavailable")); - } else { - toast.success(t("remoteSync.syncSuccess")); - } - } catch (error) { - toast.error( - error instanceof Error && error.message - ? error.message - : t("remoteSync.syncUnavailable"), - ); - } finally { - setSyncingNow(false); - await refresh(); - } - }; - - const handleOriginChange = async (origin: "local" | "remote") => { - const next = { ...desktopSettings, defaultConnectionOrigin: origin }; - setDesktopSettings(next); - await window.electronAPI?.invoke?.("save-desktop-settings", next); - invalidateServerStatusCache(); - window.dispatchEvent(new CustomEvent("hosts:refresh")); - }; - - const isConnected = !!config?.serverUrl; - const connectedToText = config?.serverUrl - ? t("remoteSync.connectedTo", { url: config.serverUrl }) - : ""; - const connectionSubtitleText = status?.needsReauth - ? t("remoteSync.needsReauth") - : status?.lastError - ? t("remoteSync.syncError", { message: status.lastError }) - : status?.lastSyncedAt - ? t("remoteSync.lastSynced", { - time: new Date(status.lastSyncedAt).toLocaleString(), - }) - : t("remoteSync.neverSynced"); - - return ( -
-
-
- -

{t("remoteSync.title")}

-
-

- {t("remoteSync.description")} -

- -
-
- {isConnected ? ( - <> - - {connectedToText} - - - {connectionSubtitleText} - - - ) : ( - - {t("remoteSync.notConnected")} - - )} -
- -
- {isConnected ? ( - <> - {status?.needsReauth && ( - - )} - - - - ) : ( - - )} -
-
-
- -
-

{t("remoteSync.originTitle")}

-

- {t("remoteSync.originDescription")} -

-
- - -
-
- - !open && setStep("idle")} - > - - - - {t("remoteSync.title")} - - - setStep("idle")} - initialServerUrl={initialServerUrl} - /> - - - - !open && setStep("idle")} - > - e.preventDefault()} - onInteractOutside={(e) => e.preventDefault()} - > - - - {t("remoteSync.signInTitle", { url: pendingServerUrl })} - - -
- setStep("picker")} - /> -
-
-
-
- ); -} diff --git a/src/ui/settings/RemoteSyncServerPicker.tsx b/src/ui/settings/RemoteSyncServerPicker.tsx deleted file mode 100644 index cb97c7e34..000000000 --- a/src/ui/settings/RemoteSyncServerPicker.tsx +++ /dev/null @@ -1,278 +0,0 @@ -import React, { useState, useEffect, useRef } from "react"; -import { Button } from "@/components/button.tsx"; -import { Input } from "@/components/input.tsx"; -import { Label } from "@/components/label.tsx"; -import { Alert, AlertTitle, AlertDescription } from "@/components/alert.tsx"; -import { Switch } from "@/components/switch.tsx"; -import { useTranslation } from "react-i18next"; -import { ChevronDown, X } from "lucide-react"; -import { toast } from "sonner"; - -const SAVED_URLS_KEY = "termix_saved_server_urls"; -const MAX_SAVED_URLS = 5; - -function getSavedUrls(): string[] { - try { - const raw = localStorage.getItem(SAVED_URLS_KEY); - if (!raw) return []; - return JSON.parse(raw); - } catch { - return []; - } -} - -function addSavedUrl(url: string) { - const urls = getSavedUrls().filter((u) => u !== url); - urls.unshift(url); - localStorage.setItem( - SAVED_URLS_KEY, - JSON.stringify(urls.slice(0, MAX_SAVED_URLS)), - ); -} - -function removeSavedUrl(url: string) { - const urls = getSavedUrls().filter((u) => u !== url); - localStorage.setItem(SAVED_URLS_KEY, JSON.stringify(urls)); -} - -interface RemoteSyncServerPickerProps { - onServerConfigured: (serverUrl: string) => void; - onCancel: () => void; - initialServerUrl?: string; -} - -export function RemoteSyncServerPicker({ - onServerConfigured, - onCancel, - initialServerUrl, -}: RemoteSyncServerPickerProps) { - const { t } = useTranslation(); - const [serverUrl, setServerUrl] = useState(initialServerUrl ?? ""); - const [allowInvalidCertificate, setAllowInvalidCertificate] = useState(false); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(null); - const [savedUrls, setSavedUrls] = useState([]); - const [dropdownOpen, setDropdownOpen] = useState(false); - const dropdownRef = useRef(null); - - useEffect(() => { - setSavedUrls(getSavedUrls()); - }, []); - - useEffect(() => { - function handleClickOutside(e: MouseEvent) { - if ( - dropdownRef.current && - !dropdownRef.current.contains(e.target as Node) - ) { - setDropdownOpen(false); - } - } - if (dropdownOpen) { - document.addEventListener("mousedown", handleClickOutside); - } - return () => document.removeEventListener("mousedown", handleClickOutside); - }, [dropdownOpen]); - - const handleSaveConfig = async () => { - if (!serverUrl.trim()) { - setError(t("remoteSync.enterServerUrl")); - return; - } - - setLoading(true); - setError(null); - - try { - const normalizedUrl = serverUrl.trim(); - - if ( - !normalizedUrl.startsWith("http://") && - !normalizedUrl.startsWith("https://") - ) { - setError(t("remoteSync.mustIncludeProtocol")); - setLoading(false); - return; - } - - const testResult = (await window.electronAPI?.invoke?.( - "test-server-connection", - normalizedUrl, - normalizedUrl.startsWith("https://") && allowInvalidCertificate, - )) as { success?: boolean; error?: string; warning?: string } | undefined; - - if (!testResult?.success) { - setError(testResult?.error || t("remoteSync.connectionTestFailed")); - setLoading(false); - return; - } - - if (testResult.warning) { - toast.warning(testResult.warning); - } - - const result = await window.electronAPI?.invoke?.( - "save-remote-sync-config", - { - serverUrl: normalizedUrl, - allowInvalidCertificate: - normalizedUrl.startsWith("https://") && allowInvalidCertificate, - connectedAt: new Date().toISOString(), - }, - ); - - if ((result as { success?: boolean })?.success) { - addSavedUrl(normalizedUrl); - setSavedUrls(getSavedUrls()); - onServerConfigured(normalizedUrl); - } else { - setError(t("serverConfig.saveFailed")); - } - } catch { - setError(t("serverConfig.saveError")); - } finally { - setLoading(false); - } - }; - - const handleUrlChange = (value: string) => { - setServerUrl(value); - setError(null); - }; - - return ( -
-
-

{t("remoteSync.title")}

-

- {t("remoteSync.description")} -

-
- -
-
- -
- handleUrlChange(e.target.value)} - disabled={loading} - className={savedUrls.length > 0 ? "pr-9" : ""} - onFocus={() => { - if (savedUrls.length > 0) setDropdownOpen(true); - }} - /> - {savedUrls.length > 0 && ( - - )} - {dropdownOpen && savedUrls.length > 0 && ( -
-

- {t("remoteSync.savedServers")} -

- {savedUrls.map((url) => ( -
- - -
- ))} -
- )} -
-
- - {serverUrl.trim().startsWith("https://") && ( -
-
- -

- {t("remoteSync.allowInvalidCertificateDesc")} -

-
- -
- )} - - {error && ( - - {t("common.error")} - {error} - - )} - -
- - -
-
-
- ); -} diff --git a/src/ui/settings/remote-sync-state.ts b/src/ui/settings/remote-sync-state.ts deleted file mode 100644 index d73d29b1a..000000000 --- a/src/ui/settings/remote-sync-state.ts +++ /dev/null @@ -1,7 +0,0 @@ -export function shouldForceLocalPreferenceStorage( - isDesktop: boolean, - remoteSyncConnected: boolean | null, - storageMode: "local" | "cloud", -): boolean { - return isDesktop && remoteSyncConnected === false && storageMode === "cloud"; -} diff --git a/src/ui/settings/sync/LinkServerDialog.tsx b/src/ui/settings/sync/LinkServerDialog.tsx new file mode 100644 index 000000000..4df6244b3 --- /dev/null +++ b/src/ui/settings/sync/LinkServerDialog.tsx @@ -0,0 +1,419 @@ +import { useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { toast } from "sonner"; +import { AlertCircle, Loader2 } from "lucide-react"; +import { Button } from "@/components/button"; +import { Input } from "@/components/input"; +import { Switch } from "@/components/switch"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/dialog"; +import { ElectronLoginForm } from "@/auth/ElectronLoginForm"; +import { + completeLink, + getLinkPreview, + probeServer, + reloginLink, + type ProbeProblem, + type ServerTarget, + type SyncStatus, +} from "@/api/sync-api"; +import { ProxySettingsFields } from "./ProxySettingsFields"; + +type Step = "server" | "signin" | "choose" | "finishing"; + +interface LinkServerDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + onLinked: (status: SyncStatus) => void; + /** Sign back in to the server this desktop is already linked to. */ + relogin?: { serverUrl: string }; +} + +function problemText( + t: (key: string, options?: Record) => string, + problem: ProbeProblem | undefined, + message: string | undefined, +): string { + switch (problem) { + case "invalid_url": + return t("sync.problems.invalidUrl"); + case "tls_untrusted": + return t("sync.problems.tlsUntrusted"); + case "basic_auth_required": + return t("sync.problems.basicAuthRequired"); + case "proxy_login": + return t("sync.problems.proxyLogin"); + case "not_termix": + return t("sync.problems.notTermix"); + case "version_mismatch": + return t("sync.problems.versionMismatch"); + case "different_account": + return t("sync.problems.differentAccount"); + default: + return message + ? t("sync.problems.unreachableWithReason", { reason: message }) + : t("sync.problems.unreachable"); + } +} + +export function LinkServerDialog({ + open, + onOpenChange, + onLinked, + relogin, +}: LinkServerDialogProps) { + const { t } = useTranslation(); + const [step, setStep] = useState(relogin ? "signin" : "server"); + const [target, setTarget] = useState({ + serverUrl: relogin?.serverUrl ?? "", + customHeaders: [], + basicAuth: null, + allowInvalidCertificate: false, + }); + const [showAdvanced, setShowAdvanced] = useState(false); + const [checking, setChecking] = useState(false); + const [problem, setProblem] = useState(null); + const [problemKind, setProblemKind] = useState(null); + const [serverName, setServerName] = useState(); + const [token, setToken] = useState(null); + const [counts, setCounts] = useState>({}); + const [mode, setMode] = useState<"merge" | "replace">("merge"); + const [finishError, setFinishError] = useState(null); + + useEffect(() => { + if (!open) return; + setStep(relogin ? "signin" : "server"); + setProblem(null); + setProblemKind(null); + setToken(null); + setFinishError(null); + setMode("merge"); + if (relogin) { + setTarget((current) => ({ ...current, serverUrl: relogin.serverUrl })); + return; + } + // An older version of the app may have saved a server address. + window.electronAPI + ?.invoke?.("get-previous-server-url") + .then((result) => { + const url = (result as { serverUrl?: string | null } | null)?.serverUrl; + if (url) { + setTarget((current) => + current.serverUrl ? current : { ...current, serverUrl: url }, + ); + } + }) + .catch(() => {}); + }, [open, relogin]); + + const handleCheck = async () => { + setChecking(true); + setProblem(null); + setProblemKind(null); + try { + const result = await probeServer(target); + if (!result.ok) { + setProblemKind(result.problem ?? null); + setProblem(problemText(t, result.problem, result.message)); + if ( + result.problem === "basic_auth_required" || + result.problem === "proxy_login" + ) { + setShowAdvanced(true); + } + if (result.problem === "basic_auth_required" && !target.basicAuth) { + setTarget((current) => ({ + ...current, + basicAuth: { username: "", password: "" }, + })); + } + return; + } + setTarget((current) => ({ + ...current, + serverUrl: result.serverUrl ?? current.serverUrl, + })); + setServerName(result.name); + setStep("signin"); + } catch { + setProblem(problemText(t, "unreachable", undefined)); + } finally { + setChecking(false); + } + }; + + const finish = async (sessionToken: string, chosen: "merge" | "replace") => { + setStep("finishing"); + setFinishError(null); + try { + const status = await completeLink({ + ...target, + token: sessionToken, + mode: chosen, + serverName, + }); + toast.success(t("sync.wizard.linked")); + onLinked(status); + onOpenChange(false); + } catch (error) { + const data = ( + error as { + response?: { data?: { problem?: ProbeProblem; message?: string } }; + } + )?.response?.data; + setFinishError(problemText(t, data?.problem, data?.message)); + setStep("choose"); + } + }; + + const handleSignedIn = async (sessionToken: string | null) => { + if (!sessionToken) return; + if (relogin) { + try { + const status = await reloginLink(sessionToken); + toast.success(t("sync.wizard.signedInAgain")); + onLinked(status); + onOpenChange(false); + } catch (error) { + const data = ( + error as { + response?: { + data?: { + error?: string; + problem?: ProbeProblem; + message?: string; + }; + }; + } + )?.response?.data; + throw new Error( + data?.error === "different_account" + ? t("sync.problems.differentAccount") + : problemText(t, data?.problem, data?.message), + ); + } + return; + } + setToken(sessionToken); + const preview = await getLinkPreview().catch(() => ({})); + setCounts(preview); + const total = Object.values(preview).reduce((sum, count) => sum + count, 0); + if (total === 0) { + await finish(sessionToken, "merge"); + return; + } + setStep("choose"); + }; + + const localTotal = Object.values(counts).reduce( + (sum, count) => sum + count, + 0, + ); + const wide = step === "signin"; + + return ( + + wide && event.preventDefault()} + onInteractOutside={(event) => wide && event.preventDefault()} + > + + + {relogin ? t("sync.wizard.reloginTitle") : t("sync.wizard.title")} + + + {step === "server" && t("sync.wizard.serverDescription")} + {step === "signin" && + t("sync.wizard.signinDescription", { url: target.serverUrl })} + {step === "choose" && t("sync.wizard.chooseDescription")} + {step === "finishing" && t("sync.wizard.finishingDescription")} + + + + {step === "server" && ( +
{ + event.preventDefault(); + void handleCheck(); + }} + > +
+ + + setTarget({ ...target, serverUrl: event.target.value }) + } + /> +

+ {t("sync.wizard.serverUrlHint")} +

+
+ + {problem && ( +
+ + {problem} +
+ )} + + {(problemKind === "tls_untrusted" || + target.allowInvalidCertificate) && ( +
+
+ + {t("sync.proxy.allowInvalidCertificate")} + + + {t("sync.proxy.allowInvalidCertificateHint")} + +
+ + setTarget({ ...target, allowInvalidCertificate: checked }) + } + /> +
+ )} + + + {showAdvanced && ( + setTarget({ ...target, ...next })} + /> + )} + +
+ + +
+ + )} + + {step === "signin" && ( +
+ + relogin ? onOpenChange(false) : setStep("server") + } + /> +
+ )} + + {step === "choose" && ( +
+ {finishError && ( +
+ + {finishError} +
+ )} + {( + [ + { + value: "merge", + title: t("sync.wizard.mergeTitle"), + body: t("sync.wizard.mergeBody", { count: localTotal }), + }, + { + value: "replace", + title: t("sync.wizard.replaceTitle"), + body: t("sync.wizard.replaceBody", { count: localTotal }), + }, + ] as const + ).map((option) => ( + + ))} +
+ + +
+
+ )} + + {step === "finishing" && ( +
+ + {t("sync.wizard.linking")} +
+ )} +
+
+ ); +} diff --git a/src/ui/settings/sync/ProxySettingsFields.tsx b/src/ui/settings/sync/ProxySettingsFields.tsx new file mode 100644 index 000000000..7b848b6b3 --- /dev/null +++ b/src/ui/settings/sync/ProxySettingsFields.tsx @@ -0,0 +1,147 @@ +import { useTranslation } from "react-i18next"; +import { Plus, Trash2 } from "lucide-react"; +import { Input } from "@/components/input"; +import { PasswordInput } from "@/components/password-input"; +import { Switch } from "@/components/switch"; +import type { SyncBasicAuth, SyncProxyHeader } from "@/api/sync-api"; + +interface ProxySettingsFieldsProps { + customHeaders: SyncProxyHeader[]; + basicAuth: SyncBasicAuth | null; + onChange: (next: { + customHeaders?: SyncProxyHeader[]; + basicAuth?: SyncBasicAuth | null; + }) => void; +} + +/** + * What a reverse proxy in front of the server wants: extra headers (a + * Cloudflare Access service token, a gateway key) and basic auth. + */ +export function ProxySettingsFields({ + customHeaders, + basicAuth, + onChange, +}: ProxySettingsFieldsProps) { + const { t } = useTranslation(); + + const setHeader = (index: number, patch: Partial) => { + onChange({ + customHeaders: customHeaders.map((header, i) => + i === index ? { ...header, ...patch } : header, + ), + }); + }; + + return ( +
+
+
+ + {t("sync.proxy.headersTitle")} + + + {t("sync.proxy.headersHint")} + +
+ {customHeaders.map((header, index) => ( +
+ + setHeader(index, { name: event.target.value }) + } + /> + + setHeader(index, { value: event.target.value }) + } + /> + +
+ ))} + +
+ +
+
+
+ + {t("sync.proxy.basicAuthTitle")} + + + {t("sync.proxy.basicAuthHint")} + +
+ + onChange({ + basicAuth: checked ? { username: "", password: "" } : null, + }) + } + /> +
+ {basicAuth && ( +
+ + onChange({ + basicAuth: { ...basicAuth, username: event.target.value }, + }) + } + /> + + onChange({ + basicAuth: { ...basicAuth, password: event.target.value }, + }) + } + /> +
+ )} +
+
+ ); +} diff --git a/src/ui/settings/sync/SyncPanel.tsx b/src/ui/settings/sync/SyncPanel.tsx new file mode 100644 index 000000000..da3aab5c4 --- /dev/null +++ b/src/ui/settings/sync/SyncPanel.tsx @@ -0,0 +1,635 @@ +import { + useCallback, + useEffect, + useMemo, + useState, + type ReactNode, +} from "react"; +import { useTranslation } from "react-i18next"; +import { toast } from "sonner"; +import { + AlertTriangle, + Cloud, + CloudOff, + Loader2, + RefreshCw, + Unlink, +} from "lucide-react"; +import { Button } from "@/components/button"; +import { Switch } from "@/components/switch"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/dialog"; +import { useSyncStatus } from "@/hooks/use-sync-status"; +import { refreshSyncStatus, setSyncStatus } from "@/lib/sync-status"; +import { notifySyncChanged } from "@/lib/linked-server"; +import { invalidateServerStatusCache } from "@/lib/hosts-request-cache"; +import { + getSyncConflicts, + getSyncErrors, + retrySyncErrors, + settleSyncConflict, + syncNow, + unlinkServer, + updateSyncSettings, + type SyncBasicAuth, + type SyncConflictItem, + type SyncEntityInfo, + type SyncErrorItem, + type SyncProxyHeader, + type SyncStatus, +} from "@/api/sync-api"; +import { LinkServerDialog } from "./LinkServerDialog"; +import { ProxySettingsFields } from "./ProxySettingsFields"; + +type DesktopSettings = { defaultConnectionOrigin: "local" | "remote" }; + +function Section({ + title, + description, + children, +}: { + title: string; + description?: string; + children: ReactNode; +}) { + return ( +
+
+ + {title} + + {description && ( + + {description} + + )} +
+ {children} +
+ ); +} + +function useRelativeTime() { + const { t } = useTranslation(); + return (value: string | null | undefined) => { + if (!value) return t("sync.never"); + const seconds = Math.max( + 0, + Math.round((Date.now() - new Date(value).getTime()) / 1000), + ); + if (seconds < 60) return t("sync.justNow"); + if (seconds < 3600) + return t("sync.minutesAgo", { count: Math.round(seconds / 60) }); + if (seconds < 86400) + return t("sync.hoursAgo", { count: Math.round(seconds / 3600) }); + return new Date(value).toLocaleString(); + }; +} + +function entityLabel( + t: (key: string, options?: Record) => string, + entity: SyncEntityInfo, +): string { + return t(`sync.entities.${entity.type}`, { defaultValue: entity.type }); +} + +export function SyncPanel() { + const { t } = useTranslation(); + const status = useSyncStatus(); + const relativeTime = useRelativeTime(); + const [linkOpen, setLinkOpen] = useState(false); + const [reloginOpen, setReloginOpen] = useState(false); + const [unlinkOpen, setUnlinkOpen] = useState(false); + const [keepData, setKeepData] = useState(true); + const [busy, setBusy] = useState(null); + const [conflicts, setConflicts] = useState([]); + const [errors, setErrors] = useState([]); + const [desktopSettings, setDesktopSettings] = useState({ + defaultConnectionOrigin: "local", + }); + const [proxyDraft, setProxyDraft] = useState<{ + customHeaders: SyncProxyHeader[]; + basicAuth: SyncBasicAuth | null; + allowInvalidCertificate: boolean; + } | null>(null); + + const apply = useCallback((next: SyncStatus) => { + setSyncStatus(next); + notifySyncChanged(); + }, []); + + useEffect(() => { + window.electronAPI + ?.invoke?.("get-desktop-settings") + .then((settings) => { + if (settings) setDesktopSettings(settings as DesktopSettings); + }) + .catch(() => {}); + }, []); + + const conflictCount = status?.conflicts ?? 0; + const errorCount = status?.errors ?? 0; + useEffect(() => { + if (!status?.linked) return; + getSyncConflicts() + .then(setConflicts) + .catch(() => {}); + }, [status?.linked, conflictCount, status?.lastSyncAt]); + useEffect(() => { + if (!status?.linked) return; + getSyncErrors() + .then(setErrors) + .catch(() => {}); + }, [status?.linked, errorCount, status?.lastSyncAt]); + + const run = async (key: string, action: () => Promise) => { + setBusy(key); + try { + await action(); + } catch { + toast.error(t("sync.actionFailed")); + } finally { + setBusy(null); + } + }; + + const groups = useMemo(() => { + const byOwner = new Map(); + for (const entity of status?.entities ?? []) { + const key = entity.owner === "core" ? "core" : entity.owner; + const list = byOwner.get(key) ?? []; + list.push(entity); + byOwner.set(key, list); + } + return [...byOwner.entries()].sort(([a], [b]) => + a === "core" ? -1 : b === "core" ? 1 : a.localeCompare(b), + ); + }, [status?.entities]); + + if (!status) { + return ( +
+ +
+ ); + } + + const linkDialog = ( + + ); + + if (!status.linked) { + return ( +
+
+
+ + {t("sync.unlinkedTitle")} +
+

+ {t("sync.unlinkedBody")} +

+
    +
  • {t("sync.unlinkedPointHosts")}
  • +
  • {t("sync.unlinkedPointOffline")}
  • +
  • {t("sync.unlinkedPointAccount")}
  • +
+ +
+ {linkDialog} +
+ ); + } + + const state = status.status ?? "idle"; + const stateText = + state === "syncing" + ? t("sync.state.syncing") + : state === "offline" + ? t("sync.state.offline") + : state === "error" + ? t("sync.state.error") + : state === "signed_out" + ? t("sync.state.signedOut") + : t("sync.state.synced", { time: relativeTime(status.lastSyncAt) }); + + const toggleType = (type: string, enabled: boolean) => + run(`type:${type}`, async () => { + const disabled = new Set( + (status.entities ?? []) + .filter((entity) => !entity.enabled) + .map((entity) => entity.type), + ); + if (enabled) disabled.delete(type); + else disabled.add(type); + apply(await updateSyncSettings({ disabledTypes: [...disabled] })); + }); + + const handleOriginChange = async (origin: "local" | "remote") => { + const next = { ...desktopSettings, defaultConnectionOrigin: origin }; + setDesktopSettings(next); + await window.electronAPI?.invoke?.("save-desktop-settings", next); + invalidateServerStatusCache(); + window.dispatchEvent(new CustomEvent("hosts:refresh")); + }; + + const draft = proxyDraft ?? { + customHeaders: status.customHeaders ?? [], + basicAuth: status.basicAuth ?? null, + allowInvalidCertificate: !!status.allowInvalidCertificate, + }; + + return ( +
+
+
+ +
+ + {status.serverName || t("sync.server")} + + + {status.serverUrl} + +
+
+
+
+ + {t("sync.account")} + + + {status.account?.username || "—"} + +
+
+ + {t("sync.status")} + + + {state === "syncing" && ( + + )} + {stateText} + +
+
+ {status.lastError && state !== "idle" && state !== "syncing" && ( +

+ {status.lastError} +

+ )} + {!!status.pending && ( +

+ {t("sync.pending", { count: status.pending })} +

+ )} +
+ {state === "signed_out" ? ( + + ) : ( + + )} + +
+
+ + {conflicts.length > 0 && ( +
+ {conflicts.map((conflict) => ( +
+
+ + {conflict.name} + + + {t(`sync.entities.${conflict.entityType}`, { + defaultValue: conflict.entityType, + })} + +
+
+ + +
+
+ ))} +
+ )} + + {errors.length > 0 && ( +
+ {errors.map((error) => ( +
+ +
+ + {t(`sync.entities.${error.entityType}`, { + defaultValue: error.entityType, + })} + + + {error.reason === "permission" + ? t("sync.errorPermission") + : error.reason} + +
+
+ ))} + +
+ )} + +
+ {groups.map(([owner, entities]) => ( +
+ + {owner === "core" + ? t("sync.coreGroup") + : (entities[0]?.ownerName ?? owner)} + + {entities.map((entity) => ( +
+
+ {entityLabel(t, entity)} + {!entity.onServer && ( + + {t("sync.notOnServer")} + + )} + {entity.onServer && !entity.onDevice && ( + + {t("sync.notOnDevice")} + + )} +
+ + void toggleType(entity.type, checked) + } + /> +
+ ))} +
+ ))} +

+ {t("sync.localOnlyHint")} +

+
+ +
+
+ {(["local", "remote"] as const).map((origin) => ( + + ))} +
+
+ +
+ setProxyDraft({ ...draft, ...next })} + /> +
+
+ + {t("sync.proxy.allowInvalidCertificate")} + + + {t("sync.proxy.allowInvalidCertificateHint")} + +
+ + setProxyDraft({ ...draft, allowInvalidCertificate: checked }) + } + /> +
+ {proxyDraft && ( + + )} +
+ + + + + + + {t("sync.unlinkTitle")} + {t("sync.unlinkDescription")} + +
+ {( + [ + { + value: true, + title: t("sync.unlinkKeepTitle"), + body: t("sync.unlinkKeepBody"), + }, + { + value: false, + title: t("sync.unlinkRemoveTitle"), + body: t("sync.unlinkRemoveBody"), + }, + ] as const + ).map((option) => ( + + ))} +
+ + + + +
+
+ {linkDialog} +
+ ); +} diff --git a/src/ui/sidebar/AppRail.tsx b/src/ui/sidebar/AppRail.tsx index 01c264bf9..8e9b1edca 100644 --- a/src/ui/sidebar/AppRail.tsx +++ b/src/ui/sidebar/AppRail.tsx @@ -15,6 +15,7 @@ import { Skeleton } from "@/components/skeleton"; import { readRailPreference, setRailPreference } from "./rail-preferences"; import { useRailItems, type RailItemDef } from "./rail-items"; import { RailBadge } from "./RailBadge"; +import { rem } from "@/lib/rem"; /** Core rail views; plugins add their own ids at runtime. */ export type CoreRailView = @@ -102,7 +103,7 @@ function buildRailButtons( const btnBase = "relative flex items-center h-7 rounded shrink-0 transition-colors gap-2.5"; -const btnStyle = { margin: "0 4px", padding: "0 8px" }; +const btnStyle = { margin: `0 ${rem(4)}`, padding: `0 ${rem(8)}` }; export function AppRail({ railView, @@ -240,7 +241,7 @@ export function AppRail({ return (
setHovered(true)} onMouseLeave={() => setHovered(false)} onContextMenu={(e) => { @@ -273,7 +274,9 @@ export function AppRail({
) : "tabType" in item ? (
- ) : ( + )} + {(!isElectron() || isLinked) && (
@@ -1427,33 +1413,37 @@ export function UserProfilePanel({ ))}
-
- - {t("newUi.sidebar.userProfile.authMethodLabel")} - - - {authMethod || "—"} - -
-
- - {t("newUi.sidebar.userProfile.twoFaLabel")} - - - {accountTotpEnabled ? ( - <> - - - {t("newUi.sidebar.userProfile.twoFaOn")} - - - ) : ( - - {t("newUi.sidebar.userProfile.twoFaOff")} + {!isElectron() && ( + <> +
+ + {t("newUi.sidebar.userProfile.authMethodLabel")} - )} - -
+ + {authMethod || "—"} + +
+
+ + {t("newUi.sidebar.userProfile.twoFaLabel")} + + + {accountTotpEnabled ? ( + <> + + + {t("newUi.sidebar.userProfile.twoFaOn")} + + + ) : ( + + {t("newUi.sidebar.userProfile.twoFaOff")} + + )} + +
+ + )}
)} @@ -1519,16 +1509,6 @@ export function UserProfilePanel({
- {isElectron() && ( -
- -
- )} - {!isElectron() && (
@@ -2011,7 +1991,7 @@ export function UserProfilePanel({ {/* The embedded desktop backend auto-authenticates its machine-local profile, so server login controls would imply protection they do - not provide. Remote Sync owns its separate account UI above. */} + not provide. A linked account's sign-in is managed on its server. */}