From 4758016f416295250a3fd012534068fedb71c5f4 Mon Sep 17 00:00:00 2001 From: LukeGus Date: Tue, 22 Sep 2026 14:11:50 -0500 Subject: [PATCH] feat: collapse plugin runtime to one tier behind a plugin SDK --- drizzle/mysql/0026_empty_black_bird.sql | 3 + drizzle/mysql/meta/0026_snapshot.json | 9769 +++++++++++++++++ drizzle/mysql/meta/_journal.json | 7 + drizzle/postgres/0027_sharp_guardian.sql | 3 + drizzle/postgres/meta/0027_snapshot.json | 9481 ++++++++++++++++ drizzle/postgres/meta/_journal.json | 7 + .../sqlite/0023_talented_shooting_star.sql | 18 + drizzle/sqlite/meta/0023_snapshot.json | 9218 ++++++++++++++++ drizzle/sqlite/meta/_journal.json | 7 + eslint.config.mjs | 81 + package-lock.json | 15 + package.json | 14 +- packages/plugin-sdk/ARCHITECTURE.md | 470 + packages/plugin-sdk/package.json | 41 + .../plugin-sdk/schema/manifest.schema.json | 317 + packages/plugin-sdk/src/backend.ts | 135 + packages/plugin-sdk/src/capabilities.ts | 97 + packages/plugin-sdk/src/frontend.ts | 40 + packages/plugin-sdk/src/manifest.ts | 840 ++ packages/plugin-sdk/src/testing.ts | 139 + packages/plugin-sdk/tsconfig.json | 21 + plugins/ai/manifest.json | 71 +- plugins/automations/manifest.json | 30 +- plugins/docker/backend/console.ts | 87 +- plugins/docker/backend/index.mjs | 7 +- plugins/docker/manifest.json | 27 +- plugins/fleets/manifest.json | 26 +- plugins/host-metrics/manifest.json | 28 +- plugins/network-topology/manifest.json | 23 +- plugins/proxmox/manifest.json | 27 +- plugins/remote-desktop/manifest.json | 39 +- plugins/ssh-terminal/manifest.json | 26 +- plugins/tailscale/backend/routes.ts | 125 +- plugins/tailscale/manifest.json | 26 +- plugins/web-endpoint/manifest.json | 25 +- plugins/workspaces/manifest.json | 20 +- .../manifests/invalid-action-permission.json | 29 +- .../manifests/invalid-bad-id.json | 17 +- .../manifests/invalid-missing-field.json | 15 +- .../invalid-nested-unknown-field.json | 27 + .../invalid-role-default-escalation.json | 25 + .../manifests/invalid-service-permission.json | 35 +- .../manifests/invalid-unknown-capability.json | 16 + .../manifests/invalid-unknown-category.json | 17 +- .../manifests/invalid-unknown-field.json | 17 + .../manifests/invalid-unknown-permission.json | 23 - .../__fixtures__/manifests/valid-actions.json | 46 +- .../manifests/valid-services.json | 40 +- scripts/__fixtures__/manifests/valid.json | 37 +- scripts/plugin-manifest.schema.json | 415 - scripts/validate-plugin-manifest.cjs | 668 +- scripts/validate-plugin-manifest.test.ts | 111 +- src/backend/database/db/index.ts | 72 +- src/backend/database/db/schema.mysql.ts | 16 +- src/backend/database/db/schema.pg.ts | 16 +- src/backend/database/db/schema.ts | 16 +- .../plugin-permission-grant-repository.ts | 5 +- .../repositories/plugin-repository.ts | 5 + src/backend/database/routes/plugins.ts | 198 +- src/backend/plugins/actor.ts | 63 + src/backend/plugins/broker.ts | 787 -- src/backend/plugins/ctx.ts | 471 + src/backend/plugins/disposables.ts | 69 + src/backend/plugins/first-party.ts | 152 - src/backend/plugins/host-ctx.ts | 373 - src/backend/plugins/http-bridge.ts | 108 - src/backend/plugins/index.ts | 466 +- src/backend/plugins/loader.ts | 761 +- src/backend/plugins/manifest.ts | 891 +- src/backend/plugins/paths.ts | 40 +- src/backend/plugins/permissions.ts | 51 +- src/backend/plugins/protocol.ts | 73 - src/backend/plugins/worker-bootstrap.ts | 301 - .../routes/plugin-api-routes-auth.test.ts | 231 +- .../tests/database/routes/plugins.test.ts | 104 +- src/backend/tests/plugins/broker.test.ts | 620 -- .../tests/plugins/bundled-plugin.test.ts | 507 +- .../tests/plugins/capabilities.test.ts | 234 + .../plugins/credential-isolation.test.ts | 365 - .../tests/plugins/disable-isolation.test.ts | 190 +- src/backend/tests/plugins/first-party.test.ts | 279 - src/backend/tests/plugins/fixture-plugin.ts | 24 +- src/backend/tests/plugins/http-bridge.test.ts | 480 - .../tests/plugins/in-process-loader.test.ts | 200 - src/backend/tests/plugins/loader.test.ts | 789 +- src/backend/tests/plugins/manifest.test.ts | 832 +- .../tests/plugins/secret-contract.test.ts | 16 +- .../tests/plugins/service-contract.test.ts | 17 +- src/backend/tests/plugins/shutdown.test.ts | 146 + .../tests/plugins/worker-deactivate.test.ts | 121 - src/ui/api/plugins-api.ts | 26 +- src/ui/locales/en.json | 114 +- src/ui/sidebar/AdminPluginsSection.tsx | 8 +- src/ui/sidebar/PluginPermissionsDialog.tsx | 88 +- src/ui/tests/shell/pluginLoader.test.ts | 6 +- tsconfig.plugins.json | 3 +- 96 files changed, 34631 insertions(+), 8551 deletions(-) create mode 100644 drizzle/mysql/0026_empty_black_bird.sql create mode 100644 drizzle/mysql/meta/0026_snapshot.json create mode 100644 drizzle/postgres/0027_sharp_guardian.sql create mode 100644 drizzle/postgres/meta/0027_snapshot.json create mode 100644 drizzle/sqlite/0023_talented_shooting_star.sql create mode 100644 drizzle/sqlite/meta/0023_snapshot.json create mode 100644 packages/plugin-sdk/ARCHITECTURE.md create mode 100644 packages/plugin-sdk/package.json create mode 100644 packages/plugin-sdk/schema/manifest.schema.json create mode 100644 packages/plugin-sdk/src/backend.ts create mode 100644 packages/plugin-sdk/src/capabilities.ts create mode 100644 packages/plugin-sdk/src/frontend.ts create mode 100644 packages/plugin-sdk/src/manifest.ts create mode 100644 packages/plugin-sdk/src/testing.ts create mode 100644 packages/plugin-sdk/tsconfig.json create mode 100644 scripts/__fixtures__/manifests/invalid-nested-unknown-field.json create mode 100644 scripts/__fixtures__/manifests/invalid-role-default-escalation.json create mode 100644 scripts/__fixtures__/manifests/invalid-unknown-capability.json create mode 100644 scripts/__fixtures__/manifests/invalid-unknown-field.json delete mode 100644 scripts/__fixtures__/manifests/invalid-unknown-permission.json delete mode 100644 scripts/plugin-manifest.schema.json create mode 100644 src/backend/plugins/actor.ts delete mode 100644 src/backend/plugins/broker.ts create mode 100644 src/backend/plugins/ctx.ts create mode 100644 src/backend/plugins/disposables.ts delete mode 100644 src/backend/plugins/first-party.ts delete mode 100644 src/backend/plugins/host-ctx.ts delete mode 100644 src/backend/plugins/http-bridge.ts delete mode 100644 src/backend/plugins/protocol.ts delete mode 100644 src/backend/plugins/worker-bootstrap.ts delete mode 100644 src/backend/tests/plugins/broker.test.ts create mode 100644 src/backend/tests/plugins/capabilities.test.ts delete mode 100644 src/backend/tests/plugins/credential-isolation.test.ts delete mode 100644 src/backend/tests/plugins/first-party.test.ts delete mode 100644 src/backend/tests/plugins/http-bridge.test.ts delete mode 100644 src/backend/tests/plugins/in-process-loader.test.ts create mode 100644 src/backend/tests/plugins/shutdown.test.ts delete mode 100644 src/backend/tests/plugins/worker-deactivate.test.ts diff --git a/drizzle/mysql/0026_empty_black_bird.sql b/drizzle/mysql/0026_empty_black_bird.sql new file mode 100644 index 000000000..7a083c4dc --- /dev/null +++ b/drizzle/mysql/0026_empty_black_bird.sql @@ -0,0 +1,3 @@ +ALTER TABLE `plugin_permission_grants` MODIFY COLUMN `granted_by` varchar(255);--> statement-breakpoint +ALTER TABLE `plugin_permission_grants` ADD `source` text DEFAULT ('admin') NOT NULL;--> statement-breakpoint +ALTER TABLE `plugins` ADD `last_error` text; \ No newline at end of file diff --git a/drizzle/mysql/meta/0026_snapshot.json b/drizzle/mysql/meta/0026_snapshot.json new file mode 100644 index 000000000..fa553b7c0 --- /dev/null +++ b/drizzle/mysql/meta/0026_snapshot.json @@ -0,0 +1,9769 @@ +{ + "version": "5", + "dialect": "mysql", + "id": "54e5b1b4-5a54-4015-9659-478230074540", + "prevId": "1dbcc312-4743-49a9-bb91-f468b21a520c", + "tables": { + "ai_conversations": { + "name": "ai_conversations", + "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 + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "provider_id": { + "name": "provider_id", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "idx_ai_conversations_user": { + "name": "idx_ai_conversations_user", + "columns": [ + "user_id", + "updated_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "ai_conversations_user_id_users_id_fk": { + "name": "ai_conversations_user_id_users_id_fk", + "tableFrom": "ai_conversations", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "ai_conversations_id": { + "name": "ai_conversations_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "ai_messages": { + "name": "ai_messages", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "conversation_id": { + "name": "conversation_id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "('')" + }, + "tool_calls": { + "name": "tool_calls", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "idx_ai_messages_conversation": { + "name": "idx_ai_messages_conversation", + "columns": [ + "conversation_id", + "created_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "ai_messages_conversation_id_ai_conversations_id_fk": { + "name": "ai_messages_conversation_id_ai_conversations_id_fk", + "tableFrom": "ai_messages", + "tableTo": "ai_conversations", + "columnsFrom": [ + "conversation_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "ai_messages_id": { + "name": "ai_messages_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "ai_proposals": { + "name": "ai_proposals", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "conversation_id": { + "name": "conversation_id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "payload": { + "name": "payload", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "('{}')" + }, + "status": { + "name": "status", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "applied_at": { + "name": "applied_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "result_summary": { + "name": "result_summary", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "idx_ai_proposals_user": { + "name": "idx_ai_proposals_user", + "columns": [ + "user_id", + "status" + ], + "isUnique": false + }, + "idx_ai_proposals_conversation": { + "name": "idx_ai_proposals_conversation", + "columns": [ + "conversation_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "ai_proposals_conversation_id_ai_conversations_id_fk": { + "name": "ai_proposals_conversation_id_ai_conversations_id_fk", + "tableFrom": "ai_proposals", + "tableTo": "ai_conversations", + "columnsFrom": [ + "conversation_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "ai_proposals_user_id_users_id_fk": { + "name": "ai_proposals_user_id_users_id_fk", + "tableFrom": "ai_proposals", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "ai_proposals_id": { + "name": "ai_proposals_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "ai_providers": { + "name": "ai_providers", + "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_type": { + "name": "provider_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "label": { + "name": "label", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "base_url": { + "name": "base_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "api_key": { + "name": "api_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "api_key_prefix": { + "name": "api_key_prefix", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "default_model": { + "name": "default_model", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "idx_ai_providers_user_label": { + "name": "idx_ai_providers_user_label", + "columns": [ + "user_id", + "label" + ], + "isUnique": true + } + }, + "foreignKeys": { + "ai_providers_user_id_users_id_fk": { + "name": "ai_providers_user_id_users_id_fk", + "tableFrom": "ai_providers", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "ai_providers_id": { + "name": "ai_providers_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "api_keys": { + "name": "api_keys", + "columns": { + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token_prefix": { + "name": "token_prefix", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "expires_at": { + "name": "expires_at", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_used_at": { + "name": "last_used_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + } + }, + "indexes": { + "idx_api_keys_user_id": { + "name": "idx_api_keys_user_id", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "api_keys_user_id_users_id_fk": { + "name": "api_keys_user_id_users_id_fk", + "tableFrom": "api_keys", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "api_keys_id": { + "name": "api_keys_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "audit_logs": { + "name": "audit_logs", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "action": { + "name": "action", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "resource_type": { + "name": "resource_type", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "resource_id": { + "name": "resource_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "resource_name": { + "name": "resource_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "details": { + "name": "details", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "success": { + "name": "success", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "timestamp": { + "name": "timestamp", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "idx_audit_logs_timestamp": { + "name": "idx_audit_logs_timestamp", + "columns": [ + "timestamp" + ], + "isUnique": false + }, + "idx_audit_logs_user_ts": { + "name": "idx_audit_logs_user_ts", + "columns": [ + "user_id", + "timestamp" + ], + "isUnique": false + }, + "idx_audit_logs_action_ts": { + "name": "idx_audit_logs_action_ts", + "columns": [ + "action", + "timestamp" + ], + "isUnique": false + }, + "idx_audit_logs_resource_ts": { + "name": "idx_audit_logs_resource_ts", + "columns": [ + "resource_type", + "timestamp" + ], + "isUnique": false + } + }, + "foreignKeys": { + "audit_logs_user_id_users_id_fk": { + "name": "audit_logs_user_id_users_id_fk", + "tableFrom": "audit_logs", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "audit_logs_id": { + "name": "audit_logs_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "automation_channels": { + "name": "automation_channels", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "automation_id": { + "name": "automation_id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "channel_id": { + "name": "channel_id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_automation_channels_pair": { + "name": "idx_automation_channels_pair", + "columns": [ + "automation_id", + "channel_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "automation_channels_automation_id_automations_id_fk": { + "name": "automation_channels_automation_id_automations_id_fk", + "tableFrom": "automation_channels", + "tableTo": "automations", + "columnsFrom": [ + "automation_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "automation_channels_channel_id_notification_channels_id_fk": { + "name": "automation_channels_channel_id_notification_channels_id_fk", + "tableFrom": "automation_channels", + "tableTo": "notification_channels", + "columnsFrom": [ + "channel_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "automation_channels_id": { + "name": "automation_channels_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "automation_run_steps": { + "name": "automation_run_steps", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "run_id": { + "name": "run_id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "step_index": { + "name": "step_index", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "step_id": { + "name": "step_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "step_type": { + "name": "step_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "started_at": { + "name": "started_at", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "finished_at": { + "name": "finished_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "output": { + "name": "output", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "truncated": { + "name": "truncated", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + } + }, + "indexes": { + "idx_automation_run_steps_run": { + "name": "idx_automation_run_steps_run", + "columns": [ + "run_id", + "step_index" + ], + "isUnique": false + } + }, + "foreignKeys": { + "automation_run_steps_run_id_automation_runs_id_fk": { + "name": "automation_run_steps_run_id_automation_runs_id_fk", + "tableFrom": "automation_run_steps", + "tableTo": "automation_runs", + "columnsFrom": [ + "run_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "automation_run_steps_id": { + "name": "automation_run_steps_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "automation_runs": { + "name": "automation_runs", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "automation_id": { + "name": "automation_id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "trigger_type": { + "name": "trigger_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "trigger_context": { + "name": "trigger_context", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "started_at": { + "name": "started_at", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "finished_at": { + "name": "finished_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "duration_ms": { + "name": "duration_ms", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "dry_run": { + "name": "dry_run", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "parent_run_id": { + "name": "parent_run_id", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_automation_runs_automation": { + "name": "idx_automation_runs_automation", + "columns": [ + "automation_id", + "started_at" + ], + "isUnique": false + }, + "idx_automation_runs_user": { + "name": "idx_automation_runs_user", + "columns": [ + "user_id", + "started_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "automation_runs_automation_id_automations_id_fk": { + "name": "automation_runs_automation_id_automations_id_fk", + "tableFrom": "automation_runs", + "tableTo": "automations", + "columnsFrom": [ + "automation_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "automation_runs_user_id_users_id_fk": { + "name": "automation_runs_user_id_users_id_fk", + "tableFrom": "automation_runs", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "automation_runs_id": { + "name": "automation_runs_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "automation_schedules": { + "name": "automation_schedules", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "automation_id": { + "name": "automation_id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "cron": { + "name": "cron", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "interval_seconds": { + "name": "interval_seconds", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "timezone": { + "name": "timezone", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "next_due_at": { + "name": "next_due_at", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_tick_at": { + "name": "last_tick_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_automation_schedules_automation": { + "name": "idx_automation_schedules_automation", + "columns": [ + "automation_id" + ], + "isUnique": true + }, + "idx_automation_schedules_due": { + "name": "idx_automation_schedules_due", + "columns": [ + "next_due_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "automation_schedules_automation_id_automations_id_fk": { + "name": "automation_schedules_automation_id_automations_id_fk", + "tableFrom": "automation_schedules", + "tableTo": "automations", + "columnsFrom": [ + "automation_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "automation_schedules_id": { + "name": "automation_schedules_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "automation_trigger_state": { + "name": "automation_trigger_state", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "automation_id": { + "name": "automation_id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "state_key": { + "name": "state_key", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "breach_started_at": { + "name": "breach_started_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_fired_at": { + "name": "last_fired_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_value": { + "name": "last_value", + "type": "double", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_observed_state": { + "name": "last_observed_state", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "idx_automation_trigger_state_key": { + "name": "idx_automation_trigger_state_key", + "columns": [ + "automation_id", + "state_key" + ], + "isUnique": true + } + }, + "foreignKeys": { + "automation_trigger_state_automation_id_automations_id_fk": { + "name": "automation_trigger_state_automation_id_automations_id_fk", + "tableFrom": "automation_trigger_state", + "tableTo": "automations", + "columnsFrom": [ + "automation_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "automation_trigger_state_id": { + "name": "automation_trigger_state_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "automations": { + "name": "automations", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "definition": { + "name": "definition", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "definition_version": { + "name": "definition_version", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "concurrency_policy": { + "name": "concurrency_policy", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "('skip')" + }, + "max_run_seconds": { + "name": "max_run_seconds", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 300 + }, + "dry_run": { + "name": "dry_run", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "last_run_at": { + "name": "last_run_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_run_status": { + "name": "last_run_status", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "idx_automations_user": { + "name": "idx_automations_user", + "columns": [ + "user_id", + "enabled" + ], + "isUnique": false + } + }, + "foreignKeys": { + "automations_user_id_users_id_fk": { + "name": "automations_user_id_users_id_fk", + "tableFrom": "automations", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "automations_id": { + "name": "automations_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "c2s_tunnel_presets": { + "name": "c2s_tunnel_presets", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "config": { + "name": "config", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "platform": { + "name": "platform", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "computer_name": { + "name": "computer_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": {}, + "foreignKeys": { + "c2s_tunnel_presets_user_id_users_id_fk": { + "name": "c2s_tunnel_presets_user_id_users_id_fk", + "tableFrom": "c2s_tunnel_presets", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "c2s_tunnel_presets_id": { + "name": "c2s_tunnel_presets_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "collab_room_members": { + "name": "collab_room_members", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "room_id": { + "name": "room_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "room_role": { + "name": "room_role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "('member')" + }, + "added_by": { + "name": "added_by", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "idx_collab_room_members_room_user": { + "name": "idx_collab_room_members_room_user", + "columns": [ + "room_id", + "user_id" + ], + "isUnique": true + }, + "idx_collab_room_members_user": { + "name": "idx_collab_room_members_user", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "collab_room_members_room_id_collab_rooms_id_fk": { + "name": "collab_room_members_room_id_collab_rooms_id_fk", + "tableFrom": "collab_room_members", + "tableTo": "collab_rooms", + "columnsFrom": [ + "room_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "collab_room_members_user_id_users_id_fk": { + "name": "collab_room_members_user_id_users_id_fk", + "tableFrom": "collab_room_members", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "collab_room_members_added_by_users_id_fk": { + "name": "collab_room_members_added_by_users_id_fk", + "tableFrom": "collab_room_members", + "tableTo": "users", + "columnsFrom": [ + "added_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "collab_room_members_id": { + "name": "collab_room_members_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "collab_rooms": { + "name": "collab_rooms", + "columns": { + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "persistent": { + "name": "persistent", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "presenter_user_id": { + "name": "presenter_user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "stage_protocol": { + "name": "stage_protocol", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "stage_host_id": { + "name": "stage_host_id", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "stage_share_id": { + "name": "stage_share_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "guest_link_token": { + "name": "guest_link_token", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "ended_at": { + "name": "ended_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_collab_rooms_owner": { + "name": "idx_collab_rooms_owner", + "columns": [ + "owner_user_id" + ], + "isUnique": false + }, + "idx_collab_rooms_guest_token": { + "name": "idx_collab_rooms_guest_token", + "columns": [ + "guest_link_token" + ], + "isUnique": true + } + }, + "foreignKeys": { + "collab_rooms_owner_user_id_users_id_fk": { + "name": "collab_rooms_owner_user_id_users_id_fk", + "tableFrom": "collab_rooms", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "collab_rooms_presenter_user_id_users_id_fk": { + "name": "collab_rooms_presenter_user_id_users_id_fk", + "tableFrom": "collab_rooms", + "tableTo": "users", + "columnsFrom": [ + "presenter_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "collab_rooms_stage_host_id_ssh_data_id_fk": { + "name": "collab_rooms_stage_host_id_ssh_data_id_fk", + "tableFrom": "collab_rooms", + "tableTo": "ssh_data", + "columnsFrom": [ + "stage_host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "collab_rooms_stage_share_id_session_shares_id_fk": { + "name": "collab_rooms_stage_share_id_session_shares_id_fk", + "tableFrom": "collab_rooms", + "tableTo": "session_shares", + "columnsFrom": [ + "stage_share_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "collab_rooms_id": { + "name": "collab_rooms_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "command_history": { + "name": "command_history", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "host_id": { + "name": "host_id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "command": { + "name": "command", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "executed_at": { + "name": "executed_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "idx_command_history_user_host": { + "name": "idx_command_history_user_host", + "columns": [ + "user_id", + "host_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "command_history_user_id_users_id_fk": { + "name": "command_history_user_id_users_id_fk", + "tableFrom": "command_history", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "command_history_host_id_ssh_data_id_fk": { + "name": "command_history_host_id_ssh_data_id_fk", + "tableFrom": "command_history", + "tableTo": "ssh_data", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "command_history_id": { + "name": "command_history_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "credential_access": { + "name": "credential_access", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "credential_id": { + "name": "credential_id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "role_id": { + "name": "role_id", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "granted_by": { + "name": "granted_by", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "permission_level": { + "name": "permission_level", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "('use')" + }, + "expires_at": { + "name": "expires_at", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "varchar(255)", + "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": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": {}, + "foreignKeys": { + "credential_sidebar_preferences_user_id_users_id_fk": { + "name": "credential_sidebar_preferences_user_id_users_id_fk", + "tableFrom": "credential_sidebar_preferences", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "credential_sidebar_preferences_user_id": { + "name": "credential_sidebar_preferences_user_id", + "columns": [ + "user_id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "dashboard_service_links": { + "name": "dashboard_service_links", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "label": { + "name": "label", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "order": { + "name": "order", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "sync_id": { + "name": "sync_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": {}, + "foreignKeys": { + "dashboard_service_links_user_id_users_id_fk": { + "name": "dashboard_service_links_user_id_users_id_fk", + "tableFrom": "dashboard_service_links", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "dashboard_service_links_id": { + "name": "dashboard_service_links_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": { + "dashboard_service_links_sync_id_unique": { + "name": "dashboard_service_links_sync_id_unique", + "columns": [ + "sync_id" + ] + } + }, + "checkConstraint": {} + }, + "dismissed_alerts": { + "name": "dismissed_alerts", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "alert_id": { + "name": "alert_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "dismissed_at": { + "name": "dismissed_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "idx_dismissed_alerts_user_id": { + "name": "idx_dismissed_alerts_user_id", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "dismissed_alerts_user_id_users_id_fk": { + "name": "dismissed_alerts_user_id_users_id_fk", + "tableFrom": "dismissed_alerts", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "dismissed_alerts_id": { + "name": "dismissed_alerts_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "file_manager_pinned": { + "name": "file_manager_pinned", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "host_id": { + "name": "host_id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "pinned_at": { + "name": "pinned_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "idx_file_manager_pinned_user": { + "name": "idx_file_manager_pinned_user", + "columns": [ + "user_id", + "host_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "file_manager_pinned_user_id_users_id_fk": { + "name": "file_manager_pinned_user_id_users_id_fk", + "tableFrom": "file_manager_pinned", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "file_manager_pinned_host_id_ssh_data_id_fk": { + "name": "file_manager_pinned_host_id_ssh_data_id_fk", + "tableFrom": "file_manager_pinned", + "tableTo": "ssh_data", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "file_manager_pinned_id": { + "name": "file_manager_pinned_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "file_manager_recent": { + "name": "file_manager_recent", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "host_id": { + "name": "host_id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_opened": { + "name": "last_opened", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "idx_file_manager_recent_user": { + "name": "idx_file_manager_recent_user", + "columns": [ + "user_id", + "host_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "file_manager_recent_user_id_users_id_fk": { + "name": "file_manager_recent_user_id_users_id_fk", + "tableFrom": "file_manager_recent", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "file_manager_recent_host_id_ssh_data_id_fk": { + "name": "file_manager_recent_host_id_ssh_data_id_fk", + "tableFrom": "file_manager_recent", + "tableTo": "ssh_data", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "file_manager_recent_id": { + "name": "file_manager_recent_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "file_manager_shortcuts": { + "name": "file_manager_shortcuts", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "host_id": { + "name": "host_id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "idx_file_manager_shortcuts_user": { + "name": "idx_file_manager_shortcuts_user", + "columns": [ + "user_id", + "host_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "file_manager_shortcuts_user_id_users_id_fk": { + "name": "file_manager_shortcuts_user_id_users_id_fk", + "tableFrom": "file_manager_shortcuts", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "file_manager_shortcuts_host_id_ssh_data_id_fk": { + "name": "file_manager_shortcuts_host_id_ssh_data_id_fk", + "tableFrom": "file_manager_shortcuts", + "tableTo": "ssh_data", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "file_manager_shortcuts_id": { + "name": "file_manager_shortcuts_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "fleet_inventory": { + "name": "fleet_inventory", + "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 + }, + "os_pretty_name": { + "name": "os_pretty_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "kernel": { + "name": "kernel", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "architecture": { + "name": "architecture", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "hostname": { + "name": "hostname", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "uptime_seconds": { + "name": "uptime_seconds", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "ip": { + "name": "ip", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "package_manager": { + "name": "package_manager", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "collected_at": { + "name": "collected_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "idx_fleet_inventory_host": { + "name": "idx_fleet_inventory_host", + "columns": [ + "host_id", + "user_id" + ], + "isUnique": true + }, + "idx_fleet_inventory_user": { + "name": "idx_fleet_inventory_user", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "fleet_inventory_host_id_ssh_data_id_fk": { + "name": "fleet_inventory_host_id_ssh_data_id_fk", + "tableFrom": "fleet_inventory", + "tableTo": "ssh_data", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "fleet_inventory_user_id_users_id_fk": { + "name": "fleet_inventory_user_id_users_id_fk", + "tableFrom": "fleet_inventory", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "fleet_inventory_id": { + "name": "fleet_inventory_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "fleet_members": { + "name": "fleet_members", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "fleet_id": { + "name": "fleet_id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "host_id": { + "name": "host_id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "added_at": { + "name": "added_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "idx_fleet_members_fleet_host": { + "name": "idx_fleet_members_fleet_host", + "columns": [ + "fleet_id", + "host_id" + ], + "isUnique": true + }, + "idx_fleet_members_host": { + "name": "idx_fleet_members_host", + "columns": [ + "host_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "fleet_members_fleet_id_fleets_id_fk": { + "name": "fleet_members_fleet_id_fleets_id_fk", + "tableFrom": "fleet_members", + "tableTo": "fleets", + "columnsFrom": [ + "fleet_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "fleet_members_host_id_ssh_data_id_fk": { + "name": "fleet_members_host_id_ssh_data_id_fk", + "tableFrom": "fleet_members", + "tableTo": "ssh_data", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "fleet_members_id": { + "name": "fleet_members_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "fleets": { + "name": "fleets", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "icon": { + "name": "icon", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "tag_rules": { + "name": "tag_rules", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sync_id": { + "name": "sync_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": {}, + "foreignKeys": { + "fleets_user_id_users_id_fk": { + "name": "fleets_user_id_users_id_fk", + "tableFrom": "fleets", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "fleets_id": { + "name": "fleets_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": { + "fleets_sync_id_unique": { + "name": "fleets_sync_id_unique", + "columns": [ + "sync_id" + ] + } + }, + "checkConstraint": {} + }, + "folder_access": { + "name": "folder_access", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "folder": { + "name": "folder", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "role_id": { + "name": "role_id", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "granted_by": { + "name": "granted_by", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "permission_level": { + "name": "permission_level", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "('connect')" + }, + "expires_at": { + "name": "expires_at", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "idx_folder_access_owner_folder": { + "name": "idx_folder_access_owner_folder", + "columns": [ + "owner_user_id", + "folder" + ], + "isUnique": false + } + }, + "foreignKeys": { + "folder_access_owner_user_id_users_id_fk": { + "name": "folder_access_owner_user_id_users_id_fk", + "tableFrom": "folder_access", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "folder_access_user_id_users_id_fk": { + "name": "folder_access_user_id_users_id_fk", + "tableFrom": "folder_access", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "folder_access_role_id_roles_id_fk": { + "name": "folder_access_role_id_roles_id_fk", + "tableFrom": "folder_access", + "tableTo": "roles", + "columnsFrom": [ + "role_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "folder_access_granted_by_users_id_fk": { + "name": "folder_access_granted_by_users_id_fk", + "tableFrom": "folder_access", + "tableTo": "users", + "columnsFrom": [ + "granted_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "folder_access_id": { + "name": "folder_access_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "homepage_items": { + "name": "homepage_items", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type_id": { + "name": "type_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "config": { + "name": "config", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "('{}')" + }, + "folder_id": { + "name": "folder_id", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sync_id": { + "name": "sync_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "idx_homepage_items_user_id": { + "name": "idx_homepage_items_user_id", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "homepage_items_user_id_users_id_fk": { + "name": "homepage_items_user_id_users_id_fk", + "tableFrom": "homepage_items", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "homepage_items_id": { + "name": "homepage_items_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": { + "homepage_items_sync_id_unique": { + "name": "homepage_items_sync_id_unique", + "columns": [ + "sync_id" + ] + } + }, + "checkConstraint": {} + }, + "homepage_layouts": { + "name": "homepage_layouts", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "layout": { + "name": "layout", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "('{}')" + }, + "updated_at": { + "name": "updated_at", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": {}, + "foreignKeys": { + "homepage_layouts_user_id_users_id_fk": { + "name": "homepage_layouts_user_id_users_id_fk", + "tableFrom": "homepage_layouts", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "homepage_layouts_id": { + "name": "homepage_layouts_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": { + "homepage_layouts_user_id_unique": { + "name": "homepage_layouts_user_id_unique", + "columns": [ + "user_id" + ] + } + }, + "checkConstraint": {} + }, + "host_access": { + "name": "host_access", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "host_id": { + "name": "host_id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "role_id": { + "name": "role_id", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "granted_by": { + "name": "granted_by", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "permission_level": { + "name": "permission_level", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "('connect')" + }, + "expires_at": { + "name": "expires_at", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "varchar(255)", + "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_health_checks": { + "name": "host_health_checks", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "host_id": { + "name": "host_id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "checks": { + "name": "checks", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "interval_seconds": { + "name": "interval_seconds", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 300 + }, + "created_at": { + "name": "created_at", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "idx_host_health_checks_user_host": { + "name": "idx_host_health_checks_user_host", + "columns": [ + "user_id", + "host_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "host_health_checks_user_id_users_id_fk": { + "name": "host_health_checks_user_id_users_id_fk", + "tableFrom": "host_health_checks", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "host_health_checks_host_id_ssh_data_id_fk": { + "name": "host_health_checks_host_id_ssh_data_id_fk", + "tableFrom": "host_health_checks", + "tableTo": "ssh_data", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "host_health_checks_id": { + "name": "host_health_checks_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "host_health_history": { + "name": "host_health_history", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "host_id": { + "name": "host_id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "check_id": { + "name": "check_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ts": { + "name": "ts", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "ok": { + "name": "ok", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "latency_ms": { + "name": "latency_ms", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "detail": { + "name": "detail", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "host_health_history_user_id_users_id_fk": { + "name": "host_health_history_user_id_users_id_fk", + "tableFrom": "host_health_history", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "host_health_history_host_id_ssh_data_id_fk": { + "name": "host_health_history_host_id_ssh_data_id_fk", + "tableFrom": "host_health_history", + "tableTo": "ssh_data", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "host_health_history_id": { + "name": "host_health_history_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "host_metrics_history": { + "name": "host_metrics_history", + "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 + }, + "ts": { + "name": "ts", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "cpu_percent": { + "name": "cpu_percent", + "type": "double", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "mem_percent": { + "name": "mem_percent", + "type": "double", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "disk_percent": { + "name": "disk_percent", + "type": "double", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "net_rx_bytes": { + "name": "net_rx_bytes", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "net_tx_bytes": { + "name": "net_tx_bytes", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "host_metrics_history_host_id_ssh_data_id_fk": { + "name": "host_metrics_history_host_id_ssh_data_id_fk", + "tableFrom": "host_metrics_history", + "tableTo": "ssh_data", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "host_metrics_history_id": { + "name": "host_metrics_history_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "host_metrics_preferences": { + "name": "host_metrics_preferences", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "host_id": { + "name": "host_id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "layout": { + "name": "layout", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "idx_host_metrics_prefs_user_host": { + "name": "idx_host_metrics_prefs_user_host", + "columns": [ + "user_id", + "host_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "host_metrics_preferences_user_id_users_id_fk": { + "name": "host_metrics_preferences_user_id_users_id_fk", + "tableFrom": "host_metrics_preferences", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "host_metrics_preferences_host_id_ssh_data_id_fk": { + "name": "host_metrics_preferences_host_id_ssh_data_id_fk", + "tableFrom": "host_metrics_preferences", + "tableTo": "ssh_data", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "host_metrics_preferences_id": { + "name": "host_metrics_preferences_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": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": {}, + "foreignKeys": { + "host_sidebar_preferences_user_id_users_id_fk": { + "name": "host_sidebar_preferences_user_id_users_id_fk", + "tableFrom": "host_sidebar_preferences", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "host_sidebar_preferences_user_id": { + "name": "host_sidebar_preferences_user_id", + "columns": [ + "user_id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "ssh_data": { + "name": "ssh_data", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "connection_type": { + "name": "connection_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "('ssh')" + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "ip": { + "name": "ip", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "port": { + "name": "port", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "folder": { + "name": "folder", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "parent_host_id": { + "name": "parent_host_id", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "tags": { + "name": "tags", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "pin": { + "name": "pin", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "sort_order": { + "name": "sort_order", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "auth_type": { + "name": "auth_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "use_warpgate": { + "name": "use_warpgate", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "share_ssh_auth": { + "name": "share_ssh_auth", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "force_keyboard_interactive": { + "name": "force_keyboard_interactive", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "key_password": { + "name": "key_password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "key_type": { + "name": "key_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sudo_password": { + "name": "sudo_password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "autostart_password": { + "name": "autostart_password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "autostart_key": { + "name": "autostart_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "autostart_key_password": { + "name": "autostart_key_password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "credential_id": { + "name": "credential_id", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "override_credential_username": { + "name": "override_credential_username", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "vault_profile_id": { + "name": "vault_profile_id", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "enable_terminal": { + "name": "enable_terminal", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "enable_session_logging": { + "name": "enable_session_logging", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "allow_session_sharing": { + "name": "allow_session_sharing", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "enable_command_history": { + "name": "enable_command_history", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "enable_tunnel": { + "name": "enable_tunnel", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "tunnel_connections": { + "name": "tunnel_connections", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "jump_hosts": { + "name": "jump_hosts", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "enable_file_manager": { + "name": "enable_file_manager", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "scp_legacy": { + "name": "scp_legacy", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "enable_docker": { + "name": "enable_docker", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "enable_web_ui": { + "name": "enable_web_ui", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "enable_tmux_monitor": { + "name": "enable_tmux_monitor", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "enable_terminal_toolbar": { + "name": "enable_terminal_toolbar", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "enable_ai_assistant": { + "name": "enable_ai_assistant", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "show_terminal_in_sidebar": { + "name": "show_terminal_in_sidebar", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "show_file_manager_in_sidebar": { + "name": "show_file_manager_in_sidebar", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "show_tunnel_in_sidebar": { + "name": "show_tunnel_in_sidebar", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "show_docker_in_sidebar": { + "name": "show_docker_in_sidebar", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "show_server_stats_in_sidebar": { + "name": "show_server_stats_in_sidebar", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "default_path": { + "name": "default_path", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "stats_config": { + "name": "stats_config", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "docker_config": { + "name": "docker_config", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "web_ui_config": { + "name": "web_ui_config", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "enable_proxmox": { + "name": "enable_proxmox", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "proxmox_config": { + "name": "proxmox_config", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "enable_proxmox_stats": { + "name": "enable_proxmox_stats", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "proxmox_stats_config": { + "name": "proxmox_stats_config", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "terminal_config": { + "name": "terminal_config", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "quick_actions": { + "name": "quick_actions", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "enable_ssh": { + "name": "enable_ssh", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "enable_rdp": { + "name": "enable_rdp", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "enable_vnc": { + "name": "enable_vnc", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "enable_telnet": { + "name": "enable_telnet", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "ssh_port": { + "name": "ssh_port", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 22 + }, + "rdp_port": { + "name": "rdp_port", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 3389 + }, + "vnc_port": { + "name": "vnc_port", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 5900 + }, + "telnet_port": { + "name": "telnet_port", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 23 + }, + "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 + }, + "rdp_security": { + "name": "rdp_security", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "rdp_ignore_cert": { + "name": "rdp_ignore_cert", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 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 + }, + "security": { + "name": "security", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "ignore_cert": { + "name": "ignore_cert", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "guacamole_config": { + "name": "guacamole_config", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "use_socks5": { + "name": "use_socks5", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "socks5_host": { + "name": "socks5_host", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "socks5_port": { + "name": "socks5_port", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "socks5_username": { + "name": "socks5_username", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "socks5_password": { + "name": "socks5_password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "socks5_proxy_chain": { + "name": "socks5_proxy_chain", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "connection_origin": { + "name": "connection_origin", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "mac_address": { + "name": "mac_address", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "wol_broadcast_address": { + "name": "wol_broadcast_address", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "port_knock_sequence": { + "name": "port_knock_sequence", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "host_key_fingerprint": { + "name": "host_key_fingerprint", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "host_key_type": { + "name": "host_key_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "host_key_algorithm": { + "name": "host_key_algorithm", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "('sha256')" + }, + "host_key_first_seen": { + "name": "host_key_first_seen", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "host_key_last_verified": { + "name": "host_key_last_verified", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "host_key_changed_count": { + "name": "host_key_changed_count", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "sync_id": { + "name": "sync_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "idx_ssh_data_user_id": { + "name": "idx_ssh_data_user_id", + "columns": [ + "user_id" + ], + "isUnique": false + }, + "idx_ssh_data_parent_host": { + "name": "idx_ssh_data_parent_host", + "columns": [ + "parent_host_id" + ], + "isUnique": false + }, + "idx_ssh_data_credential": { + "name": "idx_ssh_data_credential", + "columns": [ + "credential_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "ssh_data_user_id_users_id_fk": { + "name": "ssh_data_user_id_users_id_fk", + "tableFrom": "ssh_data", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "ssh_data_parent_host_id_ssh_data_id_fk": { + "name": "ssh_data_parent_host_id_ssh_data_id_fk", + "tableFrom": "ssh_data", + "tableTo": "ssh_data", + "columnsFrom": [ + "parent_host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "ssh_data_credential_id_ssh_credentials_id_fk": { + "name": "ssh_data_credential_id_ssh_credentials_id_fk", + "tableFrom": "ssh_data", + "tableTo": "ssh_credentials", + "columnsFrom": [ + "credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "ssh_data_vault_profile_id_vault_profiles_id_fk": { + "name": "ssh_data_vault_profile_id_vault_profiles_id_fk", + "tableFrom": "ssh_data", + "tableTo": "vault_profiles", + "columnsFrom": [ + "vault_profile_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "ssh_data_rdp_credential_id_ssh_credentials_id_fk": { + "name": "ssh_data_rdp_credential_id_ssh_credentials_id_fk", + "tableFrom": "ssh_data", + "tableTo": "ssh_credentials", + "columnsFrom": [ + "rdp_credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "ssh_data_vnc_credential_id_ssh_credentials_id_fk": { + "name": "ssh_data_vnc_credential_id_ssh_credentials_id_fk", + "tableFrom": "ssh_data", + "tableTo": "ssh_credentials", + "columnsFrom": [ + "vnc_credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "ssh_data_telnet_credential_id_ssh_credentials_id_fk": { + "name": "ssh_data_telnet_credential_id_ssh_credentials_id_fk", + "tableFrom": "ssh_data", + "tableTo": "ssh_credentials", + "columnsFrom": [ + "telnet_credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "ssh_data_id": { + "name": "ssh_data_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": { + "ssh_data_sync_id_unique": { + "name": "ssh_data_sync_id_unique", + "columns": [ + "sync_id" + ] + } + }, + "checkConstraint": {} + }, + "network_topology": { + "name": "network_topology", + "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 + }, + "topology": { + "name": "topology", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": {}, + "foreignKeys": { + "network_topology_user_id_users_id_fk": { + "name": "network_topology_user_id_users_id_fk", + "tableFrom": "network_topology", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "network_topology_id": { + "name": "network_topology_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "notification_channels": { + "name": "notification_channels", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "config": { + "name": "config", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": {}, + "foreignKeys": { + "notification_channels_user_id_users_id_fk": { + "name": "notification_channels_user_id_users_id_fk", + "tableFrom": "notification_channels", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "notification_channels_id": { + "name": "notification_channels_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "opkssh_tokens": { + "name": "opkssh_tokens", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "host_id": { + "name": "host_id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ssh_cert": { + "name": "ssh_cert", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "private_key": { + "name": "private_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sub": { + "name": "sub", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "issuer": { + "name": "issuer", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "audience": { + "name": "audience", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "expires_at": { + "name": "expires_at", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_used": { + "name": "last_used", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_opkssh_tokens_user_host": { + "name": "idx_opkssh_tokens_user_host", + "columns": [ + "user_id", + "host_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "opkssh_tokens_user_id_users_id_fk": { + "name": "opkssh_tokens_user_id_users_id_fk", + "tableFrom": "opkssh_tokens", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "opkssh_tokens_host_id_ssh_data_id_fk": { + "name": "opkssh_tokens_host_id_ssh_data_id_fk", + "tableFrom": "opkssh_tokens", + "tableTo": "ssh_data", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "opkssh_tokens_id": { + "name": "opkssh_tokens_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "plugin_install_counts": { + "name": "plugin_install_counts", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "plugin_id": { + "name": "plugin_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "registry_id": { + "name": "registry_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "count": { + "name": "count", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "('aggregate-telemetry')" + }, + "updated_at": { + "name": "updated_at", + "type": "varchar(255)", + "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_permission_grants": { + "name": "plugin_permission_grants", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "plugin_id": { + "name": "plugin_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "capability": { + "name": "capability", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "granted_at": { + "name": "granted_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "granted_by": { + "name": "granted_by", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "('admin')" + } + }, + "indexes": { + "idx_plugin_permission_grants_plugin_capability": { + "name": "idx_plugin_permission_grants_plugin_capability", + "columns": [ + "plugin_id", + "capability" + ], + "isUnique": true + } + }, + "foreignKeys": { + "plugin_permission_grants_plugin_id_plugins_id_fk": { + "name": "plugin_permission_grants_plugin_id_plugins_id_fk", + "tableFrom": "plugin_permission_grants", + "tableTo": "plugins", + "columnsFrom": [ + "plugin_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "plugin_permission_grants_granted_by_users_id_fk": { + "name": "plugin_permission_grants_granted_by_users_id_fk", + "tableFrom": "plugin_permission_grants", + "tableTo": "users", + "columnsFrom": [ + "granted_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "plugin_permission_grants_id": { + "name": "plugin_permission_grants_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "plugin_registries": { + "name": "plugin_registries", + "columns": { + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "('community')" + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "signing_key": { + "name": "signing_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_checked_at": { + "name": "last_checked_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_index_hash": { + "name": "last_index_hash", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "plugin_registries_id": { + "name": "plugin_registries_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "plugin_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": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "idx_plugin_storage_plugin_key": { + "name": "idx_plugin_storage_plugin_key", + "columns": [ + "plugin_id", + "key" + ], + "isUnique": true + } + }, + "foreignKeys": { + "plugin_storage_plugin_id_plugins_id_fk": { + "name": "plugin_storage_plugin_id_plugins_id_fk", + "tableFrom": "plugin_storage", + "tableTo": "plugins", + "columnsFrom": [ + "plugin_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "plugin_storage_id": { + "name": "plugin_storage_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "plugins": { + "name": "plugins", + "columns": { + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "version": { + "name": "version", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tier": { + "name": "tier", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "('available')" + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "('community')" + }, + "registry_id": { + "name": "registry_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "('disabled')" + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "installed_at": { + "name": "installed_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "varchar(255)", + "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": {} + }, + "proxmox_node_history": { + "name": "proxmox_node_history", + "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 + }, + "ts": { + "name": "ts", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "cpu_percent": { + "name": "cpu_percent", + "type": "double", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "mem_percent": { + "name": "mem_percent", + "type": "double", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "disk_percent": { + "name": "disk_percent", + "type": "double", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "net_rx_bytes": { + "name": "net_rx_bytes", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "net_tx_bytes": { + "name": "net_tx_bytes", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "proxmox_node_history_host_id_ssh_data_id_fk": { + "name": "proxmox_node_history_host_id_ssh_data_id_fk", + "tableFrom": "proxmox_node_history", + "tableTo": "ssh_data", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "proxmox_node_history_id": { + "name": "proxmox_node_history_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "proxmox_stats_preferences": { + "name": "proxmox_stats_preferences", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "host_id": { + "name": "host_id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "layout": { + "name": "layout", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "idx_proxmox_stats_prefs_user_host": { + "name": "idx_proxmox_stats_prefs_user_host", + "columns": [ + "user_id", + "host_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "proxmox_stats_preferences_user_id_users_id_fk": { + "name": "proxmox_stats_preferences_user_id_users_id_fk", + "tableFrom": "proxmox_stats_preferences", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "proxmox_stats_preferences_host_id_ssh_data_id_fk": { + "name": "proxmox_stats_preferences_host_id_ssh_data_id_fk", + "tableFrom": "proxmox_stats_preferences", + "tableTo": "ssh_data", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "proxmox_stats_preferences_id": { + "name": "proxmox_stats_preferences_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": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "roles_id": { + "name": "roles_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": { + "roles_name_unique": { + "name": "roles_name_unique", + "columns": [ + "name" + ] + } + }, + "checkConstraint": {} + }, + "secret_sources": { + "name": "secret_sources", + "columns": { + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "('onepassword-connect')" + }, + "base_url": { + "name": "base_url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "shared": { + "name": "shared", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "idx_secret_sources_user": { + "name": "idx_secret_sources_user", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "secret_sources_user_id_users_id_fk": { + "name": "secret_sources_user_id_users_id_fk", + "tableFrom": "secret_sources", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "secret_sources_id": { + "name": "secret_sources_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "session_recordings": { + "name": "session_recordings", + "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 + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "access_id": { + "name": "access_id", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "started_at": { + "name": "started_at", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "ended_at": { + "name": "ended_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "duration": { + "name": "duration", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "commands": { + "name": "commands", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "dangerous_actions": { + "name": "dangerous_actions", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "recording_path": { + "name": "recording_path", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "protocol": { + "name": "protocol", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'ssh'" + }, + "format": { + "name": "format", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "('text')" + }, + "terminated_by_owner": { + "name": "terminated_by_owner", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "termination_reason": { + "name": "termination_reason", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_session_recordings_user_started": { + "name": "idx_session_recordings_user_started", + "columns": [ + "user_id", + "started_at" + ], + "isUnique": false + }, + "idx_session_recordings_host": { + "name": "idx_session_recordings_host", + "columns": [ + "host_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "session_recordings_host_id_ssh_data_id_fk": { + "name": "session_recordings_host_id_ssh_data_id_fk", + "tableFrom": "session_recordings", + "tableTo": "ssh_data", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "session_recordings_user_id_users_id_fk": { + "name": "session_recordings_user_id_users_id_fk", + "tableFrom": "session_recordings", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "session_recordings_access_id_host_access_id_fk": { + "name": "session_recordings_access_id_host_access_id_fk", + "tableFrom": "session_recordings", + "tableTo": "host_access", + "columnsFrom": [ + "access_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "session_recordings_id": { + "name": "session_recordings_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "session_share_participants": { + "name": "session_share_participants", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "share_id": { + "name": "share_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "guest_label": { + "name": "guest_label", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "joined_at": { + "name": "joined_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "left_at": { + "name": "left_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "session_share_participants_share_id_session_shares_id_fk": { + "name": "session_share_participants_share_id_session_shares_id_fk", + "tableFrom": "session_share_participants", + "tableTo": "session_shares", + "columnsFrom": [ + "share_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "session_share_participants_user_id_users_id_fk": { + "name": "session_share_participants_user_id_users_id_fk", + "tableFrom": "session_share_participants", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "session_share_participants_id": { + "name": "session_share_participants_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "session_shares": { + "name": "session_shares", + "columns": { + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "host_id": { + "name": "host_id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "protocol": { + "name": "protocol", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "session_id": { + "name": "session_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tab_instance_id": { + "name": "tab_instance_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "share_type": { + "name": "share_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "target_user_id": { + "name": "target_user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "link_token": { + "name": "link_token", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "permission_level": { + "name": "permission_level", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "('read-only')" + }, + "created_at": { + "name": "created_at", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "expires_at": { + "name": "expires_at", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_joined_at": { + "name": "last_joined_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "join_count": { + "name": "join_count", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + } + }, + "indexes": { + "idx_session_shares_session_id": { + "name": "idx_session_shares_session_id", + "columns": [ + "session_id" + ], + "isUnique": false + }, + "idx_session_shares_host_id": { + "name": "idx_session_shares_host_id", + "columns": [ + "host_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "session_shares_host_id_ssh_data_id_fk": { + "name": "session_shares_host_id_ssh_data_id_fk", + "tableFrom": "session_shares", + "tableTo": "ssh_data", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "session_shares_owner_user_id_users_id_fk": { + "name": "session_shares_owner_user_id_users_id_fk", + "tableFrom": "session_shares", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "session_shares_target_user_id_users_id_fk": { + "name": "session_shares_target_user_id_users_id_fk", + "tableFrom": "session_shares", + "tableTo": "users", + "columnsFrom": [ + "target_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "session_shares_id": { + "name": "session_shares_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": { + "session_shares_link_token_unique": { + "name": "session_shares_link_token_unique", + "columns": [ + "link_token" + ] + } + }, + "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": "varchar(255)", + "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": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "varchar(255)", + "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": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "varchar(255)", + "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": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "varchar(255)", + "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": {} + }, + "snippet_access": { + "name": "snippet_access", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "snippet_id": { + "name": "snippet_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": "('view')" + }, + "expires_at": { + "name": "expires_at", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "idx_snippet_access_user_id": { + "name": "idx_snippet_access_user_id", + "columns": [ + "user_id" + ], + "isUnique": false + }, + "idx_snippet_access_snippet_id": { + "name": "idx_snippet_access_snippet_id", + "columns": [ + "snippet_id" + ], + "isUnique": false + }, + "idx_snippet_access_role_id": { + "name": "idx_snippet_access_role_id", + "columns": [ + "role_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "snippet_access_snippet_id_snippets_id_fk": { + "name": "snippet_access_snippet_id_snippets_id_fk", + "tableFrom": "snippet_access", + "tableTo": "snippets", + "columnsFrom": [ + "snippet_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "snippet_access_user_id_users_id_fk": { + "name": "snippet_access_user_id_users_id_fk", + "tableFrom": "snippet_access", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "snippet_access_role_id_roles_id_fk": { + "name": "snippet_access_role_id_roles_id_fk", + "tableFrom": "snippet_access", + "tableTo": "roles", + "columnsFrom": [ + "role_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "snippet_access_granted_by_users_id_fk": { + "name": "snippet_access_granted_by_users_id_fk", + "tableFrom": "snippet_access", + "tableTo": "users", + "columnsFrom": [ + "granted_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "snippet_access_id": { + "name": "snippet_access_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "snippet_folders": { + "name": "snippet_folders", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "icon": { + "name": "icon", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sync_id": { + "name": "sync_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": {}, + "foreignKeys": { + "snippet_folders_user_id_users_id_fk": { + "name": "snippet_folders_user_id_users_id_fk", + "tableFrom": "snippet_folders", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "snippet_folders_id": { + "name": "snippet_folders_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": { + "snippet_folders_sync_id_unique": { + "name": "snippet_folders_sync_id_unique", + "columns": [ + "sync_id" + ] + } + }, + "checkConstraint": {} + }, + "snippets": { + "name": "snippets", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "folder": { + "name": "folder", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "order": { + "name": "order", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "sync_id": { + "name": "sync_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "host_filter": { + "name": "host_filter", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_note": { + "name": "is_note", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + } + }, + "indexes": { + "idx_snippets_user_id": { + "name": "idx_snippets_user_id", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "snippets_user_id_users_id_fk": { + "name": "snippets_user_id_users_id_fk", + "tableFrom": "snippets", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "snippets_id": { + "name": "snippets_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": { + "snippets_sync_id_unique": { + "name": "snippets_sync_id_unique", + "columns": [ + "sync_id" + ] + } + }, + "checkConstraint": {} + }, + "ssh_credential_usage": { + "name": "ssh_credential_usage", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "credential_id": { + "name": "credential_id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "host_id": { + "name": "host_id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "used_at": { + "name": "used_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "idx_ssh_credential_usage_credential": { + "name": "idx_ssh_credential_usage_credential", + "columns": [ + "credential_id" + ], + "isUnique": false + }, + "idx_ssh_credential_usage_user": { + "name": "idx_ssh_credential_usage_user", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "ssh_credential_usage_credential_id_ssh_credentials_id_fk": { + "name": "ssh_credential_usage_credential_id_ssh_credentials_id_fk", + "tableFrom": "ssh_credential_usage", + "tableTo": "ssh_credentials", + "columnsFrom": [ + "credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "ssh_credential_usage_host_id_ssh_data_id_fk": { + "name": "ssh_credential_usage_host_id_ssh_data_id_fk", + "tableFrom": "ssh_credential_usage", + "tableTo": "ssh_data", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "ssh_credential_usage_user_id_users_id_fk": { + "name": "ssh_credential_usage_user_id_users_id_fk", + "tableFrom": "ssh_credential_usage", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "ssh_credential_usage_id": { + "name": "ssh_credential_usage_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "ssh_credentials": { + "name": "ssh_credentials", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "folder": { + "name": "folder", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "tags": { + "name": "tags", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "pin": { + "name": "pin", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "sort_order": { + "name": "sort_order", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "auth_type": { + "name": "auth_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "private_key": { + "name": "private_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "public_key": { + "name": "public_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "key_password": { + "name": "key_password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "key_type": { + "name": "key_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "detected_key_type": { + "name": "detected_key_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cert_public_key": { + "name": "cert_public_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "usage_count": { + "name": "usage_count", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "last_used": { + "name": "last_used", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sync_id": { + "name": "sync_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "idx_ssh_credentials_user_id": { + "name": "idx_ssh_credentials_user_id", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "ssh_credentials_user_id_users_id_fk": { + "name": "ssh_credentials_user_id_users_id_fk", + "tableFrom": "ssh_credentials", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "ssh_credentials_id": { + "name": "ssh_credentials_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": { + "ssh_credentials_sync_id_unique": { + "name": "ssh_credentials_sync_id_unique", + "columns": [ + "sync_id" + ] + } + }, + "checkConstraint": {} + }, + "ssh_folders": { + "name": "ssh_folders", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "icon": { + "name": "icon", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "credential_id": { + "name": "credential_id", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sort_order": { + "name": "sort_order", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sync_id": { + "name": "sync_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "idx_ssh_folders_user_id": { + "name": "idx_ssh_folders_user_id", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "ssh_folders_user_id_users_id_fk": { + "name": "ssh_folders_user_id_users_id_fk", + "tableFrom": "ssh_folders", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "ssh_folders_credential_id_ssh_credentials_id_fk": { + "name": "ssh_folders_credential_id_ssh_credentials_id_fk", + "tableFrom": "ssh_folders", + "tableTo": "ssh_credentials", + "columnsFrom": [ + "credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "ssh_folders_id": { + "name": "ssh_folders_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": { + "ssh_folders_sync_id_unique": { + "name": "ssh_folders_sync_id_unique", + "columns": [ + "sync_id" + ] + } + }, + "checkConstraint": {} + }, + "sso_providers": { + "name": "sso_providers", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "display_order": { + "name": "display_order", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "config": { + "name": "config", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "sso_providers_id": { + "name": "sso_providers_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "sync_tombstones": { + "name": "sync_tombstones", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "entity_type": { + "name": "entity_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sync_id": { + "name": "sync_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": {}, + "foreignKeys": { + "sync_tombstones_user_id_users_id_fk": { + "name": "sync_tombstones_user_id_users_id_fk", + "tableFrom": "sync_tombstones", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "sync_tombstones_id": { + "name": "sync_tombstones_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "termix_identities": { + "name": "termix_identities", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "handle": { + "name": "handle", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": {}, + "foreignKeys": { + "termix_identities_user_id_users_id_fk": { + "name": "termix_identities_user_id_users_id_fk", + "tableFrom": "termix_identities", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "termix_identities_id": { + "name": "termix_identities_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": { + "termix_identities_user_id_unique": { + "name": "termix_identities_user_id_unique", + "columns": [ + "user_id" + ] + }, + "termix_identities_handle_unique": { + "name": "termix_identities_handle_unique", + "columns": [ + "handle" + ] + } + }, + "checkConstraint": {} + }, + "termix_identity_ca": { + "name": "termix_identity_ca", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "identity_id": { + "name": "identity_id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "public_key": { + "name": "public_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "private_key": { + "name": "private_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "validity_days": { + "name": "validity_days", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 90 + }, + "created_at": { + "name": "created_at", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": {}, + "foreignKeys": { + "termix_identity_ca_identity_id_termix_identities_id_fk": { + "name": "termix_identity_ca_identity_id_termix_identities_id_fk", + "tableFrom": "termix_identity_ca", + "tableTo": "termix_identities", + "columnsFrom": [ + "identity_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "termix_identity_ca_user_id_users_id_fk": { + "name": "termix_identity_ca_user_id_users_id_fk", + "tableFrom": "termix_identity_ca", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "termix_identity_ca_id": { + "name": "termix_identity_ca_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": { + "termix_identity_ca_identity_id_unique": { + "name": "termix_identity_ca_identity_id_unique", + "columns": [ + "identity_id" + ] + } + }, + "checkConstraint": {} + }, + "termix_identity_keys": { + "name": "termix_identity_keys", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "identity_id": { + "name": "identity_id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "public_key": { + "name": "public_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "key_type": { + "name": "key_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "algorithm": { + "name": "algorithm", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "label": { + "name": "label", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "comment": { + "name": "comment", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "('manual')" + }, + "credential_id": { + "name": "credential_id", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": {}, + "foreignKeys": { + "termix_identity_keys_identity_id_termix_identities_id_fk": { + "name": "termix_identity_keys_identity_id_termix_identities_id_fk", + "tableFrom": "termix_identity_keys", + "tableTo": "termix_identities", + "columnsFrom": [ + "identity_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "termix_identity_keys_user_id_users_id_fk": { + "name": "termix_identity_keys_user_id_users_id_fk", + "tableFrom": "termix_identity_keys", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "termix_identity_keys_credential_id_ssh_credentials_id_fk": { + "name": "termix_identity_keys_credential_id_ssh_credentials_id_fk", + "tableFrom": "termix_identity_keys", + "tableTo": "ssh_credentials", + "columnsFrom": [ + "credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "termix_identity_keys_id": { + "name": "termix_identity_keys_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "tmux_session_tags": { + "name": "tmux_session_tags", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "host_id": { + "name": "host_id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "session_name": { + "name": "session_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tag": { + "name": "tag", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": {}, + "foreignKeys": { + "tmux_session_tags_user_id_users_id_fk": { + "name": "tmux_session_tags_user_id_users_id_fk", + "tableFrom": "tmux_session_tags", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "tmux_session_tags_host_id_ssh_data_id_fk": { + "name": "tmux_session_tags_host_id_ssh_data_id_fk", + "tableFrom": "tmux_session_tags", + "tableTo": "ssh_data", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "tmux_session_tags_id": { + "name": "tmux_session_tags_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "transfer_recent": { + "name": "transfer_recent", + "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 + }, + "source_host_id": { + "name": "source_host_id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "dest_host_id": { + "name": "dest_host_id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "dest_path": { + "name": "dest_path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "dest_path_label": { + "name": "dest_path_label", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_used": { + "name": "last_used", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "idx_transfer_recent_user": { + "name": "idx_transfer_recent_user", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "transfer_recent_user_id_users_id_fk": { + "name": "transfer_recent_user_id_users_id_fk", + "tableFrom": "transfer_recent", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "transfer_recent_source_host_id_ssh_data_id_fk": { + "name": "transfer_recent_source_host_id_ssh_data_id_fk", + "tableFrom": "transfer_recent", + "tableTo": "ssh_data", + "columnsFrom": [ + "source_host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "transfer_recent_dest_host_id_ssh_data_id_fk": { + "name": "transfer_recent_dest_host_id_ssh_data_id_fk", + "tableFrom": "transfer_recent", + "tableTo": "ssh_data", + "columnsFrom": [ + "dest_host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "transfer_recent_id": { + "name": "transfer_recent_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": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "expires_at": { + "name": "expires_at", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_used_at": { + "name": "last_used_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "idx_trusted_devices_user_id": { + "name": "idx_trusted_devices_user_id", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "trusted_devices_user_id_users_id_fk": { + "name": "trusted_devices_user_id_users_id_fk", + "tableFrom": "trusted_devices", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "trusted_devices_id": { + "name": "trusted_devices_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "ui_preferences": { + "name": "ui_preferences", + "columns": { + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "data": { + "name": "data", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "varchar(255)", + "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_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": "varchar(255)", + "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": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "varchar(255)", + "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 + }, + "ai_assistant_enabled": { + "name": "ai_assistant_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "ai_read_only_commands": { + "name": "ai_read_only_commands", + "type": "boolean", + "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 + }, + "rdp_defaults": { + "name": "rdp_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": "varchar(255)", + "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_workspaces": { + "name": "user_workspaces", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "icon": { + "name": "icon", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "('manual')" + }, + "is_default": { + "name": "is_default", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "payload": { + "name": "payload", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "('{}')" + }, + "sync_id": { + "name": "sync_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "last_used_at": { + "name": "last_used_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_user_workspaces_user_id": { + "name": "idx_user_workspaces_user_id", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "user_workspaces_user_id_users_id_fk": { + "name": "user_workspaces_user_id_users_id_fk", + "tableFrom": "user_workspaces", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "user_workspaces_id": { + "name": "user_workspaces_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": { + "user_workspaces_sync_id_unique": { + "name": "user_workspaces_sync_id_unique", + "columns": [ + "sync_id" + ] + } + }, + "checkConstraint": {} + }, + "users": { + "name": "users", + "columns": { + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "password_hash": { + "name": "password_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "is_admin": { + "name": "is_admin", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "is_oidc": { + "name": "is_oidc", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "oidc_identifier": { + "name": "oidc_identifier", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sso_provider_id": { + "name": "sso_provider_id", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "client_secret": { + "name": "client_secret", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "issuer_url": { + "name": "issuer_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "authorization_url": { + "name": "authorization_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "token_url": { + "name": "token_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "identifier_path": { + "name": "identifier_path", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "name_path": { + "name": "name_path", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "scopes": { + "name": "scopes", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "('openid email profile')" + }, + "totp_secret": { + "name": "totp_secret", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "totp_enabled": { + "name": "totp_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "totp_backup_codes": { + "name": "totp_backup_codes", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "registered_at": { + "name": "registered_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "donation_modal_dismissed": { + "name": "donation_modal_dismissed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "users_id": { + "name": "users_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "vault_profiles": { + "name": "vault_profiles", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "folder": { + "name": "folder", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "tags": { + "name": "tags", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "vault_addr": { + "name": "vault_addr", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "vault_namespace": { + "name": "vault_namespace", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "oidc_mount": { + "name": "oidc_mount", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "oidc_role": { + "name": "oidc_role", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "ssh_mount": { + "name": "ssh_mount", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "ssh_role": { + "name": "ssh_role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "valid_principals": { + "name": "valid_principals", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "key_type": { + "name": "key_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "shared": { + "name": "shared", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "sync_id": { + "name": "sync_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": {}, + "foreignKeys": { + "vault_profiles_user_id_users_id_fk": { + "name": "vault_profiles_user_id_users_id_fk", + "tableFrom": "vault_profiles", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "vault_profiles_id": { + "name": "vault_profiles_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": { + "vault_profiles_sync_id_unique": { + "name": "vault_profiles_sync_id_unique", + "columns": [ + "sync_id" + ] + } + }, + "checkConstraint": {} + }, + "vault_tokens": { + "name": "vault_tokens", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "profile_id": { + "name": "profile_id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ssh_cert": { + "name": "ssh_cert", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "private_key": { + "name": "private_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "expires_at": { + "name": "expires_at", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_used": { + "name": "last_used", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_vault_tokens_user_profile": { + "name": "idx_vault_tokens_user_profile", + "columns": [ + "user_id", + "profile_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "vault_tokens_user_id_users_id_fk": { + "name": "vault_tokens_user_id_users_id_fk", + "tableFrom": "vault_tokens", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "vault_tokens_profile_id_vault_profiles_id_fk": { + "name": "vault_tokens_profile_id_vault_profiles_id_fk", + "tableFrom": "vault_tokens", + "tableTo": "vault_profiles", + "columnsFrom": [ + "profile_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "vault_tokens_id": { + "name": "vault_tokens_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "webauthn_credentials": { + "name": "webauthn_credentials", + "columns": { + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "credential_id": { + "name": "credential_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "public_key": { + "name": "public_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "counter": { + "name": "counter", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "device_type": { + "name": "device_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "backed_up": { + "name": "backed_up", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "transports": { + "name": "transports", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_verification": { + "name": "user_verification", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "('preferred')" + }, + "created_at": { + "name": "created_at", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "last_used_at": { + "name": "last_used_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "webauthn_credentials_user_id_users_id_fk": { + "name": "webauthn_credentials_user_id_users_id_fk", + "tableFrom": "webauthn_credentials", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "webauthn_credentials_id": { + "name": "webauthn_credentials_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + } + }, + "views": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "tables": {}, + "indexes": {} + } +} \ No newline at end of file diff --git a/drizzle/mysql/meta/_journal.json b/drizzle/mysql/meta/_journal.json index d69201202..d0437e3ff 100644 --- a/drizzle/mysql/meta/_journal.json +++ b/drizzle/mysql/meta/_journal.json @@ -183,6 +183,13 @@ "when": 1790083819606, "tag": "0025_wet_master_mold", "breakpoints": true + }, + { + "idx": 26, + "version": "5", + "when": 1790102859925, + "tag": "0026_empty_black_bird", + "breakpoints": true } ] } \ No newline at end of file diff --git a/drizzle/postgres/0027_sharp_guardian.sql b/drizzle/postgres/0027_sharp_guardian.sql new file mode 100644 index 000000000..6e9b8998b --- /dev/null +++ b/drizzle/postgres/0027_sharp_guardian.sql @@ -0,0 +1,3 @@ +ALTER TABLE "plugin_permission_grants" ALTER COLUMN "granted_by" DROP NOT NULL;--> statement-breakpoint +ALTER TABLE "plugin_permission_grants" ADD COLUMN "source" text DEFAULT 'admin' NOT NULL;--> statement-breakpoint +ALTER TABLE "plugins" ADD COLUMN "last_error" text; \ No newline at end of file diff --git a/drizzle/postgres/meta/0027_snapshot.json b/drizzle/postgres/meta/0027_snapshot.json new file mode 100644 index 000000000..92a2d08c1 --- /dev/null +++ b/drizzle/postgres/meta/0027_snapshot.json @@ -0,0 +1,9481 @@ +{ + "id": "e508147a-86d1-4018-81d7-93ae1207ebf3", + "prevId": "9d5495bc-c4df-4c7a-8c1c-2f0a7c35aeaa", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.ai_conversations": { + "name": "ai_conversations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_id": { + "name": "provider_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_ai_conversations_user": { + "name": "idx_ai_conversations_user", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "ai_conversations_user_id_users_id_fk": { + "name": "ai_conversations_user_id_users_id_fk", + "tableFrom": "ai_conversations", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.ai_messages": { + "name": "ai_messages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "conversation_id": { + "name": "conversation_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "tool_calls": { + "name": "tool_calls", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_ai_messages_conversation": { + "name": "idx_ai_messages_conversation", + "columns": [ + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "ai_messages_conversation_id_ai_conversations_id_fk": { + "name": "ai_messages_conversation_id_ai_conversations_id_fk", + "tableFrom": "ai_messages", + "tableTo": "ai_conversations", + "columnsFrom": [ + "conversation_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.ai_proposals": { + "name": "ai_proposals", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "conversation_id": { + "name": "conversation_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "payload": { + "name": "payload", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "status": { + "name": "status", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "applied_at": { + "name": "applied_at", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "result_summary": { + "name": "result_summary", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_ai_proposals_user": { + "name": "idx_ai_proposals_user", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_ai_proposals_conversation": { + "name": "idx_ai_proposals_conversation", + "columns": [ + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "ai_proposals_conversation_id_ai_conversations_id_fk": { + "name": "ai_proposals_conversation_id_ai_conversations_id_fk", + "tableFrom": "ai_proposals", + "tableTo": "ai_conversations", + "columnsFrom": [ + "conversation_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "ai_proposals_user_id_users_id_fk": { + "name": "ai_proposals_user_id_users_id_fk", + "tableFrom": "ai_proposals", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.ai_providers": { + "name": "ai_providers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "provider_type": { + "name": "provider_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "label": { + "name": "label", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "base_url": { + "name": "base_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "api_key": { + "name": "api_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "api_key_prefix": { + "name": "api_key_prefix", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "default_model": { + "name": "default_model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_ai_providers_user_label": { + "name": "idx_ai_providers_user_label", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "label", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "ai_providers_user_id_users_id_fk": { + "name": "ai_providers_user_id_users_id_fk", + "tableFrom": "ai_providers", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.api_keys": { + "name": "api_keys", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token_prefix": { + "name": "token_prefix", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "expires_at": { + "name": "expires_at", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "last_used_at": { + "name": "last_used_at", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + } + }, + "indexes": { + "idx_api_keys_user_id": { + "name": "idx_api_keys_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "api_keys_user_id_users_id_fk": { + "name": "api_keys_user_id_users_id_fk", + "tableFrom": "api_keys", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.audit_logs": { + "name": "audit_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "action": { + "name": "action", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "resource_type": { + "name": "resource_type", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "resource_id": { + "name": "resource_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "resource_name": { + "name": "resource_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "details": { + "name": "details", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "success": { + "name": "success", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "timestamp": { + "name": "timestamp", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_audit_logs_timestamp": { + "name": "idx_audit_logs_timestamp", + "columns": [ + { + "expression": "timestamp", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_audit_logs_user_ts": { + "name": "idx_audit_logs_user_ts", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "timestamp", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_audit_logs_action_ts": { + "name": "idx_audit_logs_action_ts", + "columns": [ + { + "expression": "action", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "timestamp", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_audit_logs_resource_ts": { + "name": "idx_audit_logs_resource_ts", + "columns": [ + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "timestamp", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "audit_logs_user_id_users_id_fk": { + "name": "audit_logs_user_id_users_id_fk", + "tableFrom": "audit_logs", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.automation_channels": { + "name": "automation_channels", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "automation_id": { + "name": "automation_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "channel_id": { + "name": "channel_id", + "type": "integer", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "idx_automation_channels_pair": { + "name": "idx_automation_channels_pair", + "columns": [ + { + "expression": "automation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "channel_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "automation_channels_automation_id_automations_id_fk": { + "name": "automation_channels_automation_id_automations_id_fk", + "tableFrom": "automation_channels", + "tableTo": "automations", + "columnsFrom": [ + "automation_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "automation_channels_channel_id_notification_channels_id_fk": { + "name": "automation_channels_channel_id_notification_channels_id_fk", + "tableFrom": "automation_channels", + "tableTo": "notification_channels", + "columnsFrom": [ + "channel_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.automation_run_steps": { + "name": "automation_run_steps", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "run_id": { + "name": "run_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "step_index": { + "name": "step_index", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "step_id": { + "name": "step_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "step_type": { + "name": "step_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "finished_at": { + "name": "finished_at", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "output": { + "name": "output", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "truncated": { + "name": "truncated", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": { + "idx_automation_run_steps_run": { + "name": "idx_automation_run_steps_run", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "step_index", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "automation_run_steps_run_id_automation_runs_id_fk": { + "name": "automation_run_steps_run_id_automation_runs_id_fk", + "tableFrom": "automation_run_steps", + "tableTo": "automation_runs", + "columnsFrom": [ + "run_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.automation_runs": { + "name": "automation_runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "automation_id": { + "name": "automation_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "trigger_type": { + "name": "trigger_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "trigger_context": { + "name": "trigger_context", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "finished_at": { + "name": "finished_at", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "dry_run": { + "name": "dry_run", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "parent_run_id": { + "name": "parent_run_id", + "type": "integer", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_automation_runs_automation": { + "name": "idx_automation_runs_automation", + "columns": [ + { + "expression": "automation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_automation_runs_user": { + "name": "idx_automation_runs_user", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "automation_runs_automation_id_automations_id_fk": { + "name": "automation_runs_automation_id_automations_id_fk", + "tableFrom": "automation_runs", + "tableTo": "automations", + "columnsFrom": [ + "automation_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "automation_runs_user_id_users_id_fk": { + "name": "automation_runs_user_id_users_id_fk", + "tableFrom": "automation_runs", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.automation_schedules": { + "name": "automation_schedules", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "automation_id": { + "name": "automation_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "cron": { + "name": "cron", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "interval_seconds": { + "name": "interval_seconds", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "timezone": { + "name": "timezone", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "next_due_at": { + "name": "next_due_at", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "last_tick_at": { + "name": "last_tick_at", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_automation_schedules_automation": { + "name": "idx_automation_schedules_automation", + "columns": [ + { + "expression": "automation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_automation_schedules_due": { + "name": "idx_automation_schedules_due", + "columns": [ + { + "expression": "next_due_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "automation_schedules_automation_id_automations_id_fk": { + "name": "automation_schedules_automation_id_automations_id_fk", + "tableFrom": "automation_schedules", + "tableTo": "automations", + "columnsFrom": [ + "automation_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.automation_trigger_state": { + "name": "automation_trigger_state", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "automation_id": { + "name": "automation_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "state_key": { + "name": "state_key", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "breach_started_at": { + "name": "breach_started_at", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_fired_at": { + "name": "last_fired_at", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_value": { + "name": "last_value", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "last_observed_state": { + "name": "last_observed_state", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_automation_trigger_state_key": { + "name": "idx_automation_trigger_state_key", + "columns": [ + { + "expression": "automation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "state_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "automation_trigger_state_automation_id_automations_id_fk": { + "name": "automation_trigger_state_automation_id_automations_id_fk", + "tableFrom": "automation_trigger_state", + "tableTo": "automations", + "columnsFrom": [ + "automation_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.automations": { + "name": "automations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "definition": { + "name": "definition", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "definition_version": { + "name": "definition_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "concurrency_policy": { + "name": "concurrency_policy", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'skip'" + }, + "max_run_seconds": { + "name": "max_run_seconds", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 300 + }, + "dry_run": { + "name": "dry_run", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "last_run_at": { + "name": "last_run_at", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_run_status": { + "name": "last_run_status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_automations_user": { + "name": "idx_automations_user", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "automations_user_id_users_id_fk": { + "name": "automations_user_id_users_id_fk", + "tableFrom": "automations", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.c2s_tunnel_presets": { + "name": "c2s_tunnel_presets", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "config": { + "name": "config", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "platform": { + "name": "platform", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "computer_name": { + "name": "computer_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": { + "c2s_tunnel_presets_user_id_users_id_fk": { + "name": "c2s_tunnel_presets_user_id_users_id_fk", + "tableFrom": "c2s_tunnel_presets", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.collab_room_members": { + "name": "collab_room_members", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "room_id": { + "name": "room_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "room_role": { + "name": "room_role", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'member'" + }, + "added_by": { + "name": "added_by", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_collab_room_members_room_user": { + "name": "idx_collab_room_members_room_user", + "columns": [ + { + "expression": "room_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_collab_room_members_user": { + "name": "idx_collab_room_members_user", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "collab_room_members_room_id_collab_rooms_id_fk": { + "name": "collab_room_members_room_id_collab_rooms_id_fk", + "tableFrom": "collab_room_members", + "tableTo": "collab_rooms", + "columnsFrom": [ + "room_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "collab_room_members_user_id_users_id_fk": { + "name": "collab_room_members_user_id_users_id_fk", + "tableFrom": "collab_room_members", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "collab_room_members_added_by_users_id_fk": { + "name": "collab_room_members_added_by_users_id_fk", + "tableFrom": "collab_room_members", + "tableTo": "users", + "columnsFrom": [ + "added_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.collab_rooms": { + "name": "collab_rooms", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "persistent": { + "name": "persistent", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "presenter_user_id": { + "name": "presenter_user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "stage_protocol": { + "name": "stage_protocol", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stage_host_id": { + "name": "stage_host_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "stage_share_id": { + "name": "stage_share_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "guest_link_token": { + "name": "guest_link_token", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "ended_at": { + "name": "ended_at", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_collab_rooms_owner": { + "name": "idx_collab_rooms_owner", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_collab_rooms_guest_token": { + "name": "idx_collab_rooms_guest_token", + "columns": [ + { + "expression": "guest_link_token", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "collab_rooms_owner_user_id_users_id_fk": { + "name": "collab_rooms_owner_user_id_users_id_fk", + "tableFrom": "collab_rooms", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "collab_rooms_presenter_user_id_users_id_fk": { + "name": "collab_rooms_presenter_user_id_users_id_fk", + "tableFrom": "collab_rooms", + "tableTo": "users", + "columnsFrom": [ + "presenter_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "collab_rooms_stage_host_id_ssh_data_id_fk": { + "name": "collab_rooms_stage_host_id_ssh_data_id_fk", + "tableFrom": "collab_rooms", + "tableTo": "ssh_data", + "columnsFrom": [ + "stage_host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "collab_rooms_stage_share_id_session_shares_id_fk": { + "name": "collab_rooms_stage_share_id_session_shares_id_fk", + "tableFrom": "collab_rooms", + "tableTo": "session_shares", + "columnsFrom": [ + "stage_share_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.command_history": { + "name": "command_history", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "host_id": { + "name": "host_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "command": { + "name": "command", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "executed_at": { + "name": "executed_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_command_history_user_host": { + "name": "idx_command_history_user_host", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "host_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "command_history_user_id_users_id_fk": { + "name": "command_history_user_id_users_id_fk", + "tableFrom": "command_history", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "command_history_host_id_ssh_data_id_fk": { + "name": "command_history_host_id_ssh_data_id_fk", + "tableFrom": "command_history", + "tableTo": "ssh_data", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.credential_access": { + "name": "credential_access", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "credential_id": { + "name": "credential_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "role_id": { + "name": "role_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "granted_by": { + "name": "granted_by", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "permission_level": { + "name": "permission_level", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'use'" + }, + "expires_at": { + "name": "expires_at", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "varchar(255)", + "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": "varchar(255)", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": { + "credential_sidebar_preferences_user_id_users_id_fk": { + "name": "credential_sidebar_preferences_user_id_users_id_fk", + "tableFrom": "credential_sidebar_preferences", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.dashboard_service_links": { + "name": "dashboard_service_links", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "label": { + "name": "label", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "order": { + "name": "order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "sync_id": { + "name": "sync_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": { + "dashboard_service_links_user_id_users_id_fk": { + "name": "dashboard_service_links_user_id_users_id_fk", + "tableFrom": "dashboard_service_links", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "dashboard_service_links_sync_id_unique": { + "name": "dashboard_service_links_sync_id_unique", + "nullsNotDistinct": false, + "columns": [ + "sync_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.dismissed_alerts": { + "name": "dismissed_alerts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "alert_id": { + "name": "alert_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "dismissed_at": { + "name": "dismissed_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_dismissed_alerts_user_id": { + "name": "idx_dismissed_alerts_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "dismissed_alerts_user_id_users_id_fk": { + "name": "dismissed_alerts_user_id_users_id_fk", + "tableFrom": "dismissed_alerts", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.file_manager_pinned": { + "name": "file_manager_pinned", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "host_id": { + "name": "host_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "pinned_at": { + "name": "pinned_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_file_manager_pinned_user": { + "name": "idx_file_manager_pinned_user", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "host_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "file_manager_pinned_user_id_users_id_fk": { + "name": "file_manager_pinned_user_id_users_id_fk", + "tableFrom": "file_manager_pinned", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "file_manager_pinned_host_id_ssh_data_id_fk": { + "name": "file_manager_pinned_host_id_ssh_data_id_fk", + "tableFrom": "file_manager_pinned", + "tableTo": "ssh_data", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.file_manager_recent": { + "name": "file_manager_recent", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "host_id": { + "name": "host_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "last_opened": { + "name": "last_opened", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_file_manager_recent_user": { + "name": "idx_file_manager_recent_user", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "host_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "file_manager_recent_user_id_users_id_fk": { + "name": "file_manager_recent_user_id_users_id_fk", + "tableFrom": "file_manager_recent", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "file_manager_recent_host_id_ssh_data_id_fk": { + "name": "file_manager_recent_host_id_ssh_data_id_fk", + "tableFrom": "file_manager_recent", + "tableTo": "ssh_data", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.file_manager_shortcuts": { + "name": "file_manager_shortcuts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "host_id": { + "name": "host_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_file_manager_shortcuts_user": { + "name": "idx_file_manager_shortcuts_user", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "host_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "file_manager_shortcuts_user_id_users_id_fk": { + "name": "file_manager_shortcuts_user_id_users_id_fk", + "tableFrom": "file_manager_shortcuts", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "file_manager_shortcuts_host_id_ssh_data_id_fk": { + "name": "file_manager_shortcuts_host_id_ssh_data_id_fk", + "tableFrom": "file_manager_shortcuts", + "tableTo": "ssh_data", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.fleet_inventory": { + "name": "fleet_inventory", + "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 + }, + "os_pretty_name": { + "name": "os_pretty_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "kernel": { + "name": "kernel", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "architecture": { + "name": "architecture", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "hostname": { + "name": "hostname", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "uptime_seconds": { + "name": "uptime_seconds", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "ip": { + "name": "ip", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "package_manager": { + "name": "package_manager", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "collected_at": { + "name": "collected_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_fleet_inventory_host": { + "name": "idx_fleet_inventory_host", + "columns": [ + { + "expression": "host_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_fleet_inventory_user": { + "name": "idx_fleet_inventory_user", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "fleet_inventory_host_id_ssh_data_id_fk": { + "name": "fleet_inventory_host_id_ssh_data_id_fk", + "tableFrom": "fleet_inventory", + "tableTo": "ssh_data", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "fleet_inventory_user_id_users_id_fk": { + "name": "fleet_inventory_user_id_users_id_fk", + "tableFrom": "fleet_inventory", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.fleet_members": { + "name": "fleet_members", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "fleet_id": { + "name": "fleet_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "host_id": { + "name": "host_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "added_at": { + "name": "added_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_fleet_members_fleet_host": { + "name": "idx_fleet_members_fleet_host", + "columns": [ + { + "expression": "fleet_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "host_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_fleet_members_host": { + "name": "idx_fleet_members_host", + "columns": [ + { + "expression": "host_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "fleet_members_fleet_id_fleets_id_fk": { + "name": "fleet_members_fleet_id_fleets_id_fk", + "tableFrom": "fleet_members", + "tableTo": "fleets", + "columnsFrom": [ + "fleet_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "fleet_members_host_id_ssh_data_id_fk": { + "name": "fleet_members_host_id_ssh_data_id_fk", + "tableFrom": "fleet_members", + "tableTo": "ssh_data", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.fleets": { + "name": "fleets", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "icon": { + "name": "icon", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag_rules": { + "name": "tag_rules", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sync_id": { + "name": "sync_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": { + "fleets_user_id_users_id_fk": { + "name": "fleets_user_id_users_id_fk", + "tableFrom": "fleets", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "fleets_sync_id_unique": { + "name": "fleets_sync_id_unique", + "nullsNotDistinct": false, + "columns": [ + "sync_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.folder_access": { + "name": "folder_access", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "folder": { + "name": "folder", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "role_id": { + "name": "role_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "granted_by": { + "name": "granted_by", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "permission_level": { + "name": "permission_level", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'connect'" + }, + "expires_at": { + "name": "expires_at", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_folder_access_owner_folder": { + "name": "idx_folder_access_owner_folder", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "folder", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "folder_access_owner_user_id_users_id_fk": { + "name": "folder_access_owner_user_id_users_id_fk", + "tableFrom": "folder_access", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "folder_access_user_id_users_id_fk": { + "name": "folder_access_user_id_users_id_fk", + "tableFrom": "folder_access", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "folder_access_role_id_roles_id_fk": { + "name": "folder_access_role_id_roles_id_fk", + "tableFrom": "folder_access", + "tableTo": "roles", + "columnsFrom": [ + "role_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "folder_access_granted_by_users_id_fk": { + "name": "folder_access_granted_by_users_id_fk", + "tableFrom": "folder_access", + "tableTo": "users", + "columnsFrom": [ + "granted_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.homepage_items": { + "name": "homepage_items", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "type_id": { + "name": "type_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "config": { + "name": "config", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "folder_id": { + "name": "folder_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "sync_id": { + "name": "sync_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_homepage_items_user_id": { + "name": "idx_homepage_items_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "homepage_items_user_id_users_id_fk": { + "name": "homepage_items_user_id_users_id_fk", + "tableFrom": "homepage_items", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "homepage_items_sync_id_unique": { + "name": "homepage_items_sync_id_unique", + "nullsNotDistinct": false, + "columns": [ + "sync_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.homepage_layouts": { + "name": "homepage_layouts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "layout": { + "name": "layout", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "updated_at": { + "name": "updated_at", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": { + "homepage_layouts_user_id_users_id_fk": { + "name": "homepage_layouts_user_id_users_id_fk", + "tableFrom": "homepage_layouts", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "homepage_layouts_user_id_unique": { + "name": "homepage_layouts_user_id_unique", + "nullsNotDistinct": false, + "columns": [ + "user_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.host_access": { + "name": "host_access", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "host_id": { + "name": "host_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "role_id": { + "name": "role_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "granted_by": { + "name": "granted_by", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "permission_level": { + "name": "permission_level", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'connect'" + }, + "expires_at": { + "name": "expires_at", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "varchar(255)", + "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_health_checks": { + "name": "host_health_checks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "host_id": { + "name": "host_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "checks": { + "name": "checks", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "interval_seconds": { + "name": "interval_seconds", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 300 + }, + "created_at": { + "name": "created_at", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_host_health_checks_user_host": { + "name": "idx_host_health_checks_user_host", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "host_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "host_health_checks_user_id_users_id_fk": { + "name": "host_health_checks_user_id_users_id_fk", + "tableFrom": "host_health_checks", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "host_health_checks_host_id_ssh_data_id_fk": { + "name": "host_health_checks_host_id_ssh_data_id_fk", + "tableFrom": "host_health_checks", + "tableTo": "ssh_data", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.host_health_history": { + "name": "host_health_history", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "host_id": { + "name": "host_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "check_id": { + "name": "check_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ts": { + "name": "ts", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "ok": { + "name": "ok", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "latency_ms": { + "name": "latency_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "detail": { + "name": "detail", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "host_health_history_user_id_users_id_fk": { + "name": "host_health_history_user_id_users_id_fk", + "tableFrom": "host_health_history", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "host_health_history_host_id_ssh_data_id_fk": { + "name": "host_health_history_host_id_ssh_data_id_fk", + "tableFrom": "host_health_history", + "tableTo": "ssh_data", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.host_metrics_history": { + "name": "host_metrics_history", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "host_id": { + "name": "host_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "ts": { + "name": "ts", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "cpu_percent": { + "name": "cpu_percent", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "mem_percent": { + "name": "mem_percent", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "disk_percent": { + "name": "disk_percent", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "net_rx_bytes": { + "name": "net_rx_bytes", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "net_tx_bytes": { + "name": "net_tx_bytes", + "type": "integer", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "host_metrics_history_host_id_ssh_data_id_fk": { + "name": "host_metrics_history_host_id_ssh_data_id_fk", + "tableFrom": "host_metrics_history", + "tableTo": "ssh_data", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.host_metrics_preferences": { + "name": "host_metrics_preferences", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "host_id": { + "name": "host_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "layout": { + "name": "layout", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_host_metrics_prefs_user_host": { + "name": "idx_host_metrics_prefs_user_host", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "host_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "host_metrics_preferences_user_id_users_id_fk": { + "name": "host_metrics_preferences_user_id_users_id_fk", + "tableFrom": "host_metrics_preferences", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "host_metrics_preferences_host_id_ssh_data_id_fk": { + "name": "host_metrics_preferences_host_id_ssh_data_id_fk", + "tableFrom": "host_metrics_preferences", + "tableTo": "ssh_data", + "columnsFrom": [ + "host_id" + ], + "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": "varchar(255)", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": { + "host_sidebar_preferences_user_id_users_id_fk": { + "name": "host_sidebar_preferences_user_id_users_id_fk", + "tableFrom": "host_sidebar_preferences", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.ssh_data": { + "name": "ssh_data", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "connection_type": { + "name": "connection_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'ssh'" + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "ip": { + "name": "ip", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "port": { + "name": "port", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "folder": { + "name": "folder", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "parent_host_id": { + "name": "parent_host_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "tags": { + "name": "tags", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "pin": { + "name": "pin", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "auth_type": { + "name": "auth_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "use_warpgate": { + "name": "use_warpgate", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "share_ssh_auth": { + "name": "share_ssh_auth", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "force_keyboard_interactive": { + "name": "force_keyboard_interactive", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "key_password": { + "name": "key_password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "key_type": { + "name": "key_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sudo_password": { + "name": "sudo_password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "autostart_password": { + "name": "autostart_password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "autostart_key": { + "name": "autostart_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "autostart_key_password": { + "name": "autostart_key_password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "credential_id": { + "name": "credential_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "override_credential_username": { + "name": "override_credential_username", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "vault_profile_id": { + "name": "vault_profile_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "enable_terminal": { + "name": "enable_terminal", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "enable_session_logging": { + "name": "enable_session_logging", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "allow_session_sharing": { + "name": "allow_session_sharing", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "enable_command_history": { + "name": "enable_command_history", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "enable_tunnel": { + "name": "enable_tunnel", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "tunnel_connections": { + "name": "tunnel_connections", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "jump_hosts": { + "name": "jump_hosts", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "enable_file_manager": { + "name": "enable_file_manager", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "scp_legacy": { + "name": "scp_legacy", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "enable_docker": { + "name": "enable_docker", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "enable_web_ui": { + "name": "enable_web_ui", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "enable_tmux_monitor": { + "name": "enable_tmux_monitor", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "enable_terminal_toolbar": { + "name": "enable_terminal_toolbar", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "enable_ai_assistant": { + "name": "enable_ai_assistant", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "show_terminal_in_sidebar": { + "name": "show_terminal_in_sidebar", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "show_file_manager_in_sidebar": { + "name": "show_file_manager_in_sidebar", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "show_tunnel_in_sidebar": { + "name": "show_tunnel_in_sidebar", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "show_docker_in_sidebar": { + "name": "show_docker_in_sidebar", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "show_server_stats_in_sidebar": { + "name": "show_server_stats_in_sidebar", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "default_path": { + "name": "default_path", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stats_config": { + "name": "stats_config", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "docker_config": { + "name": "docker_config", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "web_ui_config": { + "name": "web_ui_config", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "enable_proxmox": { + "name": "enable_proxmox", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "proxmox_config": { + "name": "proxmox_config", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "enable_proxmox_stats": { + "name": "enable_proxmox_stats", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "proxmox_stats_config": { + "name": "proxmox_stats_config", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "terminal_config": { + "name": "terminal_config", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "quick_actions": { + "name": "quick_actions", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "enable_ssh": { + "name": "enable_ssh", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "enable_rdp": { + "name": "enable_rdp", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "enable_vnc": { + "name": "enable_vnc", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "enable_telnet": { + "name": "enable_telnet", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "ssh_port": { + "name": "ssh_port", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 22 + }, + "rdp_port": { + "name": "rdp_port", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 3389 + }, + "vnc_port": { + "name": "vnc_port", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 5900 + }, + "telnet_port": { + "name": "telnet_port", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 23 + }, + "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 + }, + "rdp_security": { + "name": "rdp_security", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "rdp_ignore_cert": { + "name": "rdp_ignore_cert", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": 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 + }, + "security": { + "name": "security", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ignore_cert": { + "name": "ignore_cert", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "guacamole_config": { + "name": "guacamole_config", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "use_socks5": { + "name": "use_socks5", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "socks5_host": { + "name": "socks5_host", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "socks5_port": { + "name": "socks5_port", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "socks5_username": { + "name": "socks5_username", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "socks5_password": { + "name": "socks5_password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "socks5_proxy_chain": { + "name": "socks5_proxy_chain", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "connection_origin": { + "name": "connection_origin", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "mac_address": { + "name": "mac_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "wol_broadcast_address": { + "name": "wol_broadcast_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "port_knock_sequence": { + "name": "port_knock_sequence", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "host_key_fingerprint": { + "name": "host_key_fingerprint", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "host_key_type": { + "name": "host_key_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "host_key_algorithm": { + "name": "host_key_algorithm", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'sha256'" + }, + "host_key_first_seen": { + "name": "host_key_first_seen", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "host_key_last_verified": { + "name": "host_key_last_verified", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "host_key_changed_count": { + "name": "host_key_changed_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "sync_id": { + "name": "sync_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_ssh_data_user_id": { + "name": "idx_ssh_data_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_ssh_data_parent_host": { + "name": "idx_ssh_data_parent_host", + "columns": [ + { + "expression": "parent_host_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_ssh_data_credential": { + "name": "idx_ssh_data_credential", + "columns": [ + { + "expression": "credential_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "ssh_data_user_id_users_id_fk": { + "name": "ssh_data_user_id_users_id_fk", + "tableFrom": "ssh_data", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "ssh_data_parent_host_id_ssh_data_id_fk": { + "name": "ssh_data_parent_host_id_ssh_data_id_fk", + "tableFrom": "ssh_data", + "tableTo": "ssh_data", + "columnsFrom": [ + "parent_host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "ssh_data_credential_id_ssh_credentials_id_fk": { + "name": "ssh_data_credential_id_ssh_credentials_id_fk", + "tableFrom": "ssh_data", + "tableTo": "ssh_credentials", + "columnsFrom": [ + "credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "ssh_data_vault_profile_id_vault_profiles_id_fk": { + "name": "ssh_data_vault_profile_id_vault_profiles_id_fk", + "tableFrom": "ssh_data", + "tableTo": "vault_profiles", + "columnsFrom": [ + "vault_profile_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "ssh_data_rdp_credential_id_ssh_credentials_id_fk": { + "name": "ssh_data_rdp_credential_id_ssh_credentials_id_fk", + "tableFrom": "ssh_data", + "tableTo": "ssh_credentials", + "columnsFrom": [ + "rdp_credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "ssh_data_vnc_credential_id_ssh_credentials_id_fk": { + "name": "ssh_data_vnc_credential_id_ssh_credentials_id_fk", + "tableFrom": "ssh_data", + "tableTo": "ssh_credentials", + "columnsFrom": [ + "vnc_credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "ssh_data_telnet_credential_id_ssh_credentials_id_fk": { + "name": "ssh_data_telnet_credential_id_ssh_credentials_id_fk", + "tableFrom": "ssh_data", + "tableTo": "ssh_credentials", + "columnsFrom": [ + "telnet_credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "ssh_data_sync_id_unique": { + "name": "ssh_data_sync_id_unique", + "nullsNotDistinct": false, + "columns": [ + "sync_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.network_topology": { + "name": "network_topology", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "topology": { + "name": "topology", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": { + "network_topology_user_id_users_id_fk": { + "name": "network_topology_user_id_users_id_fk", + "tableFrom": "network_topology", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.notification_channels": { + "name": "notification_channels", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "config": { + "name": "config", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": { + "notification_channels_user_id_users_id_fk": { + "name": "notification_channels_user_id_users_id_fk", + "tableFrom": "notification_channels", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.opkssh_tokens": { + "name": "opkssh_tokens", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "host_id": { + "name": "host_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "ssh_cert": { + "name": "ssh_cert", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "private_key": { + "name": "private_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sub": { + "name": "sub", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "issuer": { + "name": "issuer", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "audience": { + "name": "audience", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "expires_at": { + "name": "expires_at", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "last_used": { + "name": "last_used", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_opkssh_tokens_user_host": { + "name": "idx_opkssh_tokens_user_host", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "host_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "opkssh_tokens_user_id_users_id_fk": { + "name": "opkssh_tokens_user_id_users_id_fk", + "tableFrom": "opkssh_tokens", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "opkssh_tokens_host_id_ssh_data_id_fk": { + "name": "opkssh_tokens_host_id_ssh_data_id_fk", + "tableFrom": "opkssh_tokens", + "tableTo": "ssh_data", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.plugin_install_counts": { + "name": "plugin_install_counts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "plugin_id": { + "name": "plugin_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "registry_id": { + "name": "registry_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "count": { + "name": "count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'aggregate-telemetry'" + }, + "updated_at": { + "name": "updated_at", + "type": "varchar(255)", + "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_permission_grants": { + "name": "plugin_permission_grants", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "plugin_id": { + "name": "plugin_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "capability": { + "name": "capability", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "granted_at": { + "name": "granted_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "granted_by": { + "name": "granted_by", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'admin'" + } + }, + "indexes": { + "idx_plugin_permission_grants_plugin_capability": { + "name": "idx_plugin_permission_grants_plugin_capability", + "columns": [ + { + "expression": "plugin_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "capability", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "plugin_permission_grants_plugin_id_plugins_id_fk": { + "name": "plugin_permission_grants_plugin_id_plugins_id_fk", + "tableFrom": "plugin_permission_grants", + "tableTo": "plugins", + "columnsFrom": [ + "plugin_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "plugin_permission_grants_granted_by_users_id_fk": { + "name": "plugin_permission_grants_granted_by_users_id_fk", + "tableFrom": "plugin_permission_grants", + "tableTo": "users", + "columnsFrom": [ + "granted_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.plugin_registries": { + "name": "plugin_registries", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'community'" + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "signing_key": { + "name": "signing_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_checked_at": { + "name": "last_checked_at", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_index_hash": { + "name": "last_index_hash", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.plugin_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": "varchar(255)", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_plugin_storage_plugin_key": { + "name": "idx_plugin_storage_plugin_key", + "columns": [ + { + "expression": "plugin_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "plugin_storage_plugin_id_plugins_id_fk": { + "name": "plugin_storage_plugin_id_plugins_id_fk", + "tableFrom": "plugin_storage", + "tableTo": "plugins", + "columnsFrom": [ + "plugin_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.plugins": { + "name": "plugins", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tier": { + "name": "tier", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'available'" + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'community'" + }, + "registry_id": { + "name": "registry_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'disabled'" + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "installed_at": { + "name": "installed_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "varchar(255)", + "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.proxmox_node_history": { + "name": "proxmox_node_history", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "host_id": { + "name": "host_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "ts": { + "name": "ts", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "cpu_percent": { + "name": "cpu_percent", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "mem_percent": { + "name": "mem_percent", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "disk_percent": { + "name": "disk_percent", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "net_rx_bytes": { + "name": "net_rx_bytes", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "net_tx_bytes": { + "name": "net_tx_bytes", + "type": "integer", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "proxmox_node_history_host_id_ssh_data_id_fk": { + "name": "proxmox_node_history_host_id_ssh_data_id_fk", + "tableFrom": "proxmox_node_history", + "tableTo": "ssh_data", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.proxmox_stats_preferences": { + "name": "proxmox_stats_preferences", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "host_id": { + "name": "host_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "layout": { + "name": "layout", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_proxmox_stats_prefs_user_host": { + "name": "idx_proxmox_stats_prefs_user_host", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "host_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "proxmox_stats_preferences_user_id_users_id_fk": { + "name": "proxmox_stats_preferences_user_id_users_id_fk", + "tableFrom": "proxmox_stats_preferences", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "proxmox_stats_preferences_host_id_ssh_data_id_fk": { + "name": "proxmox_stats_preferences_host_id_ssh_data_id_fk", + "tableFrom": "proxmox_stats_preferences", + "tableTo": "ssh_data", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "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": "varchar(255)", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "roles_name_unique": { + "name": "roles_name_unique", + "nullsNotDistinct": false, + "columns": [ + "name" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.secret_sources": { + "name": "secret_sources", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'onepassword-connect'" + }, + "base_url": { + "name": "base_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "shared": { + "name": "shared", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_secret_sources_user": { + "name": "idx_secret_sources_user", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "secret_sources_user_id_users_id_fk": { + "name": "secret_sources_user_id_users_id_fk", + "tableFrom": "secret_sources", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session_recordings": { + "name": "session_recordings", + "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 + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_id": { + "name": "access_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "ended_at": { + "name": "ended_at", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "duration": { + "name": "duration", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "commands": { + "name": "commands", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "dangerous_actions": { + "name": "dangerous_actions", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "recording_path": { + "name": "recording_path", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "protocol": { + "name": "protocol", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "default": "'ssh'" + }, + "format": { + "name": "format", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'text'" + }, + "terminated_by_owner": { + "name": "terminated_by_owner", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "termination_reason": { + "name": "termination_reason", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_session_recordings_user_started": { + "name": "idx_session_recordings_user_started", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_session_recordings_host": { + "name": "idx_session_recordings_host", + "columns": [ + { + "expression": "host_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "session_recordings_host_id_ssh_data_id_fk": { + "name": "session_recordings_host_id_ssh_data_id_fk", + "tableFrom": "session_recordings", + "tableTo": "ssh_data", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "session_recordings_user_id_users_id_fk": { + "name": "session_recordings_user_id_users_id_fk", + "tableFrom": "session_recordings", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "session_recordings_access_id_host_access_id_fk": { + "name": "session_recordings_access_id_host_access_id_fk", + "tableFrom": "session_recordings", + "tableTo": "host_access", + "columnsFrom": [ + "access_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session_share_participants": { + "name": "session_share_participants", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "share_id": { + "name": "share_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "guest_label": { + "name": "guest_label", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "joined_at": { + "name": "joined_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "left_at": { + "name": "left_at", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "session_share_participants_share_id_session_shares_id_fk": { + "name": "session_share_participants_share_id_session_shares_id_fk", + "tableFrom": "session_share_participants", + "tableTo": "session_shares", + "columnsFrom": [ + "share_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "session_share_participants_user_id_users_id_fk": { + "name": "session_share_participants_user_id_users_id_fk", + "tableFrom": "session_share_participants", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session_shares": { + "name": "session_shares", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "host_id": { + "name": "host_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "protocol": { + "name": "protocol", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "session_id": { + "name": "session_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "tab_instance_id": { + "name": "tab_instance_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "share_type": { + "name": "share_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_user_id": { + "name": "target_user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "link_token": { + "name": "link_token", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "permission_level": { + "name": "permission_level", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'read-only'" + }, + "created_at": { + "name": "created_at", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "expires_at": { + "name": "expires_at", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "revoked_at": { + "name": "revoked_at", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_joined_at": { + "name": "last_joined_at", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "join_count": { + "name": "join_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + } + }, + "indexes": { + "idx_session_shares_session_id": { + "name": "idx_session_shares_session_id", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_session_shares_host_id": { + "name": "idx_session_shares_host_id", + "columns": [ + { + "expression": "host_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "session_shares_host_id_ssh_data_id_fk": { + "name": "session_shares_host_id_ssh_data_id_fk", + "tableFrom": "session_shares", + "tableTo": "ssh_data", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "session_shares_owner_user_id_users_id_fk": { + "name": "session_shares_owner_user_id_users_id_fk", + "tableFrom": "session_shares", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "session_shares_target_user_id_users_id_fk": { + "name": "session_shares_target_user_id_users_id_fk", + "tableFrom": "session_shares", + "tableTo": "users", + "columnsFrom": [ + "target_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "session_shares_link_token_unique": { + "name": "session_shares_link_token_unique", + "nullsNotDistinct": false, + "columns": [ + "link_token" + ] + } + }, + "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": "varchar(255)", + "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": "varchar(255)", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "varchar(255)", + "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": "varchar(255)", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "varchar(255)", + "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": "varchar(255)", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "varchar(255)", + "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.snippet_access": { + "name": "snippet_access", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "snippet_id": { + "name": "snippet_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": "'view'" + }, + "expires_at": { + "name": "expires_at", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_snippet_access_user_id": { + "name": "idx_snippet_access_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_snippet_access_snippet_id": { + "name": "idx_snippet_access_snippet_id", + "columns": [ + { + "expression": "snippet_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_snippet_access_role_id": { + "name": "idx_snippet_access_role_id", + "columns": [ + { + "expression": "role_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "snippet_access_snippet_id_snippets_id_fk": { + "name": "snippet_access_snippet_id_snippets_id_fk", + "tableFrom": "snippet_access", + "tableTo": "snippets", + "columnsFrom": [ + "snippet_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "snippet_access_user_id_users_id_fk": { + "name": "snippet_access_user_id_users_id_fk", + "tableFrom": "snippet_access", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "snippet_access_role_id_roles_id_fk": { + "name": "snippet_access_role_id_roles_id_fk", + "tableFrom": "snippet_access", + "tableTo": "roles", + "columnsFrom": [ + "role_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "snippet_access_granted_by_users_id_fk": { + "name": "snippet_access_granted_by_users_id_fk", + "tableFrom": "snippet_access", + "tableTo": "users", + "columnsFrom": [ + "granted_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.snippet_folders": { + "name": "snippet_folders", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "icon": { + "name": "icon", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sync_id": { + "name": "sync_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": { + "snippet_folders_user_id_users_id_fk": { + "name": "snippet_folders_user_id_users_id_fk", + "tableFrom": "snippet_folders", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "snippet_folders_sync_id_unique": { + "name": "snippet_folders_sync_id_unique", + "nullsNotDistinct": false, + "columns": [ + "sync_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.snippets": { + "name": "snippets", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "folder": { + "name": "folder", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "order": { + "name": "order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "sync_id": { + "name": "sync_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "host_filter": { + "name": "host_filter", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_note": { + "name": "is_note", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": { + "idx_snippets_user_id": { + "name": "idx_snippets_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "snippets_user_id_users_id_fk": { + "name": "snippets_user_id_users_id_fk", + "tableFrom": "snippets", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "snippets_sync_id_unique": { + "name": "snippets_sync_id_unique", + "nullsNotDistinct": false, + "columns": [ + "sync_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.ssh_credential_usage": { + "name": "ssh_credential_usage", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "credential_id": { + "name": "credential_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "host_id": { + "name": "host_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "used_at": { + "name": "used_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_ssh_credential_usage_credential": { + "name": "idx_ssh_credential_usage_credential", + "columns": [ + { + "expression": "credential_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_ssh_credential_usage_user": { + "name": "idx_ssh_credential_usage_user", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "ssh_credential_usage_credential_id_ssh_credentials_id_fk": { + "name": "ssh_credential_usage_credential_id_ssh_credentials_id_fk", + "tableFrom": "ssh_credential_usage", + "tableTo": "ssh_credentials", + "columnsFrom": [ + "credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "ssh_credential_usage_host_id_ssh_data_id_fk": { + "name": "ssh_credential_usage_host_id_ssh_data_id_fk", + "tableFrom": "ssh_credential_usage", + "tableTo": "ssh_data", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "ssh_credential_usage_user_id_users_id_fk": { + "name": "ssh_credential_usage_user_id_users_id_fk", + "tableFrom": "ssh_credential_usage", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.ssh_credentials": { + "name": "ssh_credentials", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "folder": { + "name": "folder", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "tags": { + "name": "tags", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "pin": { + "name": "pin", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "auth_type": { + "name": "auth_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "private_key": { + "name": "private_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "public_key": { + "name": "public_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "key_password": { + "name": "key_password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "key_type": { + "name": "key_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "detected_key_type": { + "name": "detected_key_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cert_public_key": { + "name": "cert_public_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "usage_count": { + "name": "usage_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_used": { + "name": "last_used", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sync_id": { + "name": "sync_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_ssh_credentials_user_id": { + "name": "idx_ssh_credentials_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "ssh_credentials_user_id_users_id_fk": { + "name": "ssh_credentials_user_id_users_id_fk", + "tableFrom": "ssh_credentials", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "ssh_credentials_sync_id_unique": { + "name": "ssh_credentials_sync_id_unique", + "nullsNotDistinct": false, + "columns": [ + "sync_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.ssh_folders": { + "name": "ssh_folders", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "icon": { + "name": "icon", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "credential_id": { + "name": "credential_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "sync_id": { + "name": "sync_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_ssh_folders_user_id": { + "name": "idx_ssh_folders_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "ssh_folders_user_id_users_id_fk": { + "name": "ssh_folders_user_id_users_id_fk", + "tableFrom": "ssh_folders", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "ssh_folders_credential_id_ssh_credentials_id_fk": { + "name": "ssh_folders_credential_id_ssh_credentials_id_fk", + "tableFrom": "ssh_folders", + "tableTo": "ssh_credentials", + "columnsFrom": [ + "credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "ssh_folders_sync_id_unique": { + "name": "ssh_folders_sync_id_unique", + "nullsNotDistinct": false, + "columns": [ + "sync_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sso_providers": { + "name": "sso_providers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "display_order": { + "name": "display_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "config": { + "name": "config", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sync_tombstones": { + "name": "sync_tombstones", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "entity_type": { + "name": "entity_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sync_id": { + "name": "sync_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "deleted_at": { + "name": "deleted_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": { + "sync_tombstones_user_id_users_id_fk": { + "name": "sync_tombstones_user_id_users_id_fk", + "tableFrom": "sync_tombstones", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.termix_identities": { + "name": "termix_identities", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "handle": { + "name": "handle", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": { + "termix_identities_user_id_users_id_fk": { + "name": "termix_identities_user_id_users_id_fk", + "tableFrom": "termix_identities", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "termix_identities_user_id_unique": { + "name": "termix_identities_user_id_unique", + "nullsNotDistinct": false, + "columns": [ + "user_id" + ] + }, + "termix_identities_handle_unique": { + "name": "termix_identities_handle_unique", + "nullsNotDistinct": false, + "columns": [ + "handle" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.termix_identity_ca": { + "name": "termix_identity_ca", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "identity_id": { + "name": "identity_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "public_key": { + "name": "public_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "private_key": { + "name": "private_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "validity_days": { + "name": "validity_days", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 90 + }, + "created_at": { + "name": "created_at", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": { + "termix_identity_ca_identity_id_termix_identities_id_fk": { + "name": "termix_identity_ca_identity_id_termix_identities_id_fk", + "tableFrom": "termix_identity_ca", + "tableTo": "termix_identities", + "columnsFrom": [ + "identity_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "termix_identity_ca_user_id_users_id_fk": { + "name": "termix_identity_ca_user_id_users_id_fk", + "tableFrom": "termix_identity_ca", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "termix_identity_ca_identity_id_unique": { + "name": "termix_identity_ca_identity_id_unique", + "nullsNotDistinct": false, + "columns": [ + "identity_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.termix_identity_keys": { + "name": "termix_identity_keys", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "identity_id": { + "name": "identity_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "public_key": { + "name": "public_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key_type": { + "name": "key_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "algorithm": { + "name": "algorithm", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "label": { + "name": "label", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "comment": { + "name": "comment", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'manual'" + }, + "credential_id": { + "name": "credential_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": { + "termix_identity_keys_identity_id_termix_identities_id_fk": { + "name": "termix_identity_keys_identity_id_termix_identities_id_fk", + "tableFrom": "termix_identity_keys", + "tableTo": "termix_identities", + "columnsFrom": [ + "identity_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "termix_identity_keys_user_id_users_id_fk": { + "name": "termix_identity_keys_user_id_users_id_fk", + "tableFrom": "termix_identity_keys", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "termix_identity_keys_credential_id_ssh_credentials_id_fk": { + "name": "termix_identity_keys_credential_id_ssh_credentials_id_fk", + "tableFrom": "termix_identity_keys", + "tableTo": "ssh_credentials", + "columnsFrom": [ + "credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tmux_session_tags": { + "name": "tmux_session_tags", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "host_id": { + "name": "host_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "session_name": { + "name": "session_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tag": { + "name": "tag", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": { + "tmux_session_tags_user_id_users_id_fk": { + "name": "tmux_session_tags_user_id_users_id_fk", + "tableFrom": "tmux_session_tags", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "tmux_session_tags_host_id_ssh_data_id_fk": { + "name": "tmux_session_tags_host_id_ssh_data_id_fk", + "tableFrom": "tmux_session_tags", + "tableTo": "ssh_data", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.transfer_recent": { + "name": "transfer_recent", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "source_host_id": { + "name": "source_host_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "dest_host_id": { + "name": "dest_host_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "dest_path": { + "name": "dest_path", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "dest_path_label": { + "name": "dest_path_label", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "last_used": { + "name": "last_used", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_transfer_recent_user": { + "name": "idx_transfer_recent_user", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "transfer_recent_user_id_users_id_fk": { + "name": "transfer_recent_user_id_users_id_fk", + "tableFrom": "transfer_recent", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "transfer_recent_source_host_id_ssh_data_id_fk": { + "name": "transfer_recent_source_host_id_ssh_data_id_fk", + "tableFrom": "transfer_recent", + "tableTo": "ssh_data", + "columnsFrom": [ + "source_host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "transfer_recent_dest_host_id_ssh_data_id_fk": { + "name": "transfer_recent_dest_host_id_ssh_data_id_fk", + "tableFrom": "transfer_recent", + "tableTo": "ssh_data", + "columnsFrom": [ + "dest_host_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": "varchar(255)", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "expires_at": { + "name": "expires_at", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "last_used_at": { + "name": "last_used_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_trusted_devices_user_id": { + "name": "idx_trusted_devices_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "trusted_devices_user_id_users_id_fk": { + "name": "trusted_devices_user_id_users_id_fk", + "tableFrom": "trusted_devices", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.ui_preferences": { + "name": "ui_preferences", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "data": { + "name": "data", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "varchar(255)", + "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_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": "varchar(255)", + "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": "varchar(255)", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "varchar(255)", + "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 + }, + "ai_assistant_enabled": { + "name": "ai_assistant_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "ai_read_only_commands": { + "name": "ai_read_only_commands", + "type": "boolean", + "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 + }, + "rdp_defaults": { + "name": "rdp_defaults", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "terminal_macros": { + "name": "terminal_macros", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "varchar(255)", + "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_workspaces": { + "name": "user_workspaces", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "icon": { + "name": "icon", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'manual'" + }, + "is_default": { + "name": "is_default", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "payload": { + "name": "payload", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "sync_id": { + "name": "sync_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "last_used_at": { + "name": "last_used_at", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_user_workspaces_user_id": { + "name": "idx_user_workspaces_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_workspaces_user_id_users_id_fk": { + "name": "user_workspaces_user_id_users_id_fk", + "tableFrom": "user_workspaces", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_workspaces_sync_id_unique": { + "name": "user_workspaces_sync_id_unique", + "nullsNotDistinct": false, + "columns": [ + "sync_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "password_hash": { + "name": "password_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_admin": { + "name": "is_admin", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "is_oidc": { + "name": "is_oidc", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "oidc_identifier": { + "name": "oidc_identifier", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sso_provider_id": { + "name": "sso_provider_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "client_secret": { + "name": "client_secret", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "issuer_url": { + "name": "issuer_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "authorization_url": { + "name": "authorization_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "token_url": { + "name": "token_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "identifier_path": { + "name": "identifier_path", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name_path": { + "name": "name_path", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scopes": { + "name": "scopes", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'openid email profile'" + }, + "totp_secret": { + "name": "totp_secret", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "totp_enabled": { + "name": "totp_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "totp_backup_codes": { + "name": "totp_backup_codes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "registered_at": { + "name": "registered_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "donation_modal_dismissed": { + "name": "donation_modal_dismissed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.vault_profiles": { + "name": "vault_profiles", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "folder": { + "name": "folder", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "tags": { + "name": "tags", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "vault_addr": { + "name": "vault_addr", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "vault_namespace": { + "name": "vault_namespace", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "oidc_mount": { + "name": "oidc_mount", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "oidc_role": { + "name": "oidc_role", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ssh_mount": { + "name": "ssh_mount", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ssh_role": { + "name": "ssh_role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "valid_principals": { + "name": "valid_principals", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "key_type": { + "name": "key_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "shared": { + "name": "shared", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "sync_id": { + "name": "sync_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": { + "vault_profiles_user_id_users_id_fk": { + "name": "vault_profiles_user_id_users_id_fk", + "tableFrom": "vault_profiles", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "vault_profiles_sync_id_unique": { + "name": "vault_profiles_sync_id_unique", + "nullsNotDistinct": false, + "columns": [ + "sync_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.vault_tokens": { + "name": "vault_tokens", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "profile_id": { + "name": "profile_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "ssh_cert": { + "name": "ssh_cert", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "private_key": { + "name": "private_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "expires_at": { + "name": "expires_at", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "last_used": { + "name": "last_used", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_vault_tokens_user_profile": { + "name": "idx_vault_tokens_user_profile", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "profile_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "vault_tokens_user_id_users_id_fk": { + "name": "vault_tokens_user_id_users_id_fk", + "tableFrom": "vault_tokens", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "vault_tokens_profile_id_vault_profiles_id_fk": { + "name": "vault_tokens_profile_id_vault_profiles_id_fk", + "tableFrom": "vault_tokens", + "tableTo": "vault_profiles", + "columnsFrom": [ + "profile_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.webauthn_credentials": { + "name": "webauthn_credentials", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "credential_id": { + "name": "credential_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "public_key": { + "name": "public_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "counter": { + "name": "counter", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "device_type": { + "name": "device_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "backed_up": { + "name": "backed_up", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "transports": { + "name": "transports", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_verification": { + "name": "user_verification", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'preferred'" + }, + "created_at": { + "name": "created_at", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "last_used_at": { + "name": "last_used_at", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "webauthn_credentials_user_id_users_id_fk": { + "name": "webauthn_credentials_user_id_users_id_fk", + "tableFrom": "webauthn_credentials", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/drizzle/postgres/meta/_journal.json b/drizzle/postgres/meta/_journal.json index 73591356b..14b8794e3 100644 --- a/drizzle/postgres/meta/_journal.json +++ b/drizzle/postgres/meta/_journal.json @@ -190,6 +190,13 @@ "when": 1790083819084, "tag": "0026_perfect_masque", "breakpoints": true + }, + { + "idx": 27, + "version": "7", + "when": 1790102859387, + "tag": "0027_sharp_guardian", + "breakpoints": true } ] } \ No newline at end of file diff --git a/drizzle/sqlite/0023_talented_shooting_star.sql b/drizzle/sqlite/0023_talented_shooting_star.sql new file mode 100644 index 000000000..fca7fc637 --- /dev/null +++ b/drizzle/sqlite/0023_talented_shooting_star.sql @@ -0,0 +1,18 @@ +PRAGMA foreign_keys=OFF;--> statement-breakpoint +CREATE TABLE `__new_plugin_permission_grants` ( + `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, + `plugin_id` text NOT NULL, + `capability` text NOT NULL, + `granted_at` text DEFAULT CURRENT_TIMESTAMP NOT NULL, + `granted_by` text, + `source` text DEFAULT 'admin' NOT NULL, + FOREIGN KEY (`plugin_id`) REFERENCES `plugins`(`id`) ON UPDATE no action ON DELETE cascade, + FOREIGN KEY (`granted_by`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +INSERT INTO `__new_plugin_permission_grants`("id", "plugin_id", "capability", "granted_at", "granted_by", "source") SELECT "id", "plugin_id", "capability", "granted_at", "granted_by", 'admin' FROM `plugin_permission_grants`;--> statement-breakpoint +DROP TABLE `plugin_permission_grants`;--> statement-breakpoint +ALTER TABLE `__new_plugin_permission_grants` RENAME TO `plugin_permission_grants`;--> statement-breakpoint +PRAGMA foreign_keys=ON;--> statement-breakpoint +CREATE UNIQUE INDEX `idx_plugin_permission_grants_plugin_capability` ON `plugin_permission_grants` (`plugin_id`,`capability`);--> statement-breakpoint +ALTER TABLE `plugins` ADD `last_error` text; \ No newline at end of file diff --git a/drizzle/sqlite/meta/0023_snapshot.json b/drizzle/sqlite/meta/0023_snapshot.json new file mode 100644 index 000000000..bf5ddf7de --- /dev/null +++ b/drizzle/sqlite/meta/0023_snapshot.json @@ -0,0 +1,9218 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "9d53e11a-37b0-45a3-b551-8d827ae4157a", + "prevId": "94343ac6-c547-4bea-8700-476074e63e3b", + "tables": { + "ai_conversations": { + "name": "ai_conversations", + "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 + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "provider_id": { + "name": "provider_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "model": { + "name": "model", + "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_ai_conversations_user": { + "name": "idx_ai_conversations_user", + "columns": [ + "user_id", + "updated_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "ai_conversations_user_id_users_id_fk": { + "name": "ai_conversations_user_id_users_id_fk", + "tableFrom": "ai_conversations", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "ai_messages": { + "name": "ai_messages", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "conversation_id": { + "name": "conversation_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "tool_calls": { + "name": "tool_calls", + "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_ai_messages_conversation": { + "name": "idx_ai_messages_conversation", + "columns": [ + "conversation_id", + "created_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "ai_messages_conversation_id_ai_conversations_id_fk": { + "name": "ai_messages_conversation_id_ai_conversations_id_fk", + "tableFrom": "ai_messages", + "tableTo": "ai_conversations", + "columnsFrom": [ + "conversation_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "ai_proposals": { + "name": "ai_proposals", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "conversation_id": { + "name": "conversation_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "payload": { + "name": "payload", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'{}'" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "applied_at": { + "name": "applied_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "result_summary": { + "name": "result_summary", + "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_ai_proposals_user": { + "name": "idx_ai_proposals_user", + "columns": [ + "user_id", + "status" + ], + "isUnique": false + }, + "idx_ai_proposals_conversation": { + "name": "idx_ai_proposals_conversation", + "columns": [ + "conversation_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "ai_proposals_conversation_id_ai_conversations_id_fk": { + "name": "ai_proposals_conversation_id_ai_conversations_id_fk", + "tableFrom": "ai_proposals", + "tableTo": "ai_conversations", + "columnsFrom": [ + "conversation_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "ai_proposals_user_id_users_id_fk": { + "name": "ai_proposals_user_id_users_id_fk", + "tableFrom": "ai_proposals", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "ai_providers": { + "name": "ai_providers", + "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_type": { + "name": "provider_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "base_url": { + "name": "base_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "api_key": { + "name": "api_key", + "type": "text(8192)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "api_key_prefix": { + "name": "api_key_prefix", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "default_model": { + "name": "default_model", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_ai_providers_user_label": { + "name": "idx_ai_providers_user_label", + "columns": [ + "user_id", + "label" + ], + "isUnique": true + } + }, + "foreignKeys": { + "ai_providers_user_id_users_id_fk": { + "name": "ai_providers_user_id_users_id_fk", + "tableFrom": "ai_providers", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "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": {} + }, + "automation_channels": { + "name": "automation_channels", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "automation_id": { + "name": "automation_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "channel_id": { + "name": "channel_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_automation_channels_pair": { + "name": "idx_automation_channels_pair", + "columns": [ + "automation_id", + "channel_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "automation_channels_automation_id_automations_id_fk": { + "name": "automation_channels_automation_id_automations_id_fk", + "tableFrom": "automation_channels", + "tableTo": "automations", + "columnsFrom": [ + "automation_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "automation_channels_channel_id_notification_channels_id_fk": { + "name": "automation_channels_channel_id_notification_channels_id_fk", + "tableFrom": "automation_channels", + "tableTo": "notification_channels", + "columnsFrom": [ + "channel_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "automation_run_steps": { + "name": "automation_run_steps", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "run_id": { + "name": "run_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "step_index": { + "name": "step_index", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "step_id": { + "name": "step_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "step_type": { + "name": "step_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "started_at": { + "name": "started_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "finished_at": { + "name": "finished_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "output": { + "name": "output", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "truncated": { + "name": "truncated", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + } + }, + "indexes": { + "idx_automation_run_steps_run": { + "name": "idx_automation_run_steps_run", + "columns": [ + "run_id", + "step_index" + ], + "isUnique": false + } + }, + "foreignKeys": { + "automation_run_steps_run_id_automation_runs_id_fk": { + "name": "automation_run_steps_run_id_automation_runs_id_fk", + "tableFrom": "automation_run_steps", + "tableTo": "automation_runs", + "columnsFrom": [ + "run_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "automation_runs": { + "name": "automation_runs", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "automation_id": { + "name": "automation_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "trigger_type": { + "name": "trigger_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "trigger_context": { + "name": "trigger_context", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "started_at": { + "name": "started_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "finished_at": { + "name": "finished_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "dry_run": { + "name": "dry_run", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "parent_run_id": { + "name": "parent_run_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_automation_runs_automation": { + "name": "idx_automation_runs_automation", + "columns": [ + "automation_id", + "started_at" + ], + "isUnique": false + }, + "idx_automation_runs_user": { + "name": "idx_automation_runs_user", + "columns": [ + "user_id", + "started_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "automation_runs_automation_id_automations_id_fk": { + "name": "automation_runs_automation_id_automations_id_fk", + "tableFrom": "automation_runs", + "tableTo": "automations", + "columnsFrom": [ + "automation_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "automation_runs_user_id_users_id_fk": { + "name": "automation_runs_user_id_users_id_fk", + "tableFrom": "automation_runs", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "automation_schedules": { + "name": "automation_schedules", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "automation_id": { + "name": "automation_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "cron": { + "name": "cron", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "interval_seconds": { + "name": "interval_seconds", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "timezone": { + "name": "timezone", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "next_due_at": { + "name": "next_due_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_tick_at": { + "name": "last_tick_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_automation_schedules_automation": { + "name": "idx_automation_schedules_automation", + "columns": [ + "automation_id" + ], + "isUnique": true + }, + "idx_automation_schedules_due": { + "name": "idx_automation_schedules_due", + "columns": [ + "next_due_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "automation_schedules_automation_id_automations_id_fk": { + "name": "automation_schedules_automation_id_automations_id_fk", + "tableFrom": "automation_schedules", + "tableTo": "automations", + "columnsFrom": [ + "automation_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "automation_trigger_state": { + "name": "automation_trigger_state", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "automation_id": { + "name": "automation_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "state_key": { + "name": "state_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "breach_started_at": { + "name": "breach_started_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_fired_at": { + "name": "last_fired_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_value": { + "name": "last_value", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_observed_state": { + "name": "last_observed_state", + "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_automation_trigger_state_key": { + "name": "idx_automation_trigger_state_key", + "columns": [ + "automation_id", + "state_key" + ], + "isUnique": true + } + }, + "foreignKeys": { + "automation_trigger_state_automation_id_automations_id_fk": { + "name": "automation_trigger_state_automation_id_automations_id_fk", + "tableFrom": "automation_trigger_state", + "tableTo": "automations", + "columnsFrom": [ + "automation_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "automations": { + "name": "automations", + "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 + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "definition": { + "name": "definition", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "definition_version": { + "name": "definition_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "concurrency_policy": { + "name": "concurrency_policy", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'skip'" + }, + "max_run_seconds": { + "name": "max_run_seconds", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 300 + }, + "dry_run": { + "name": "dry_run", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "last_run_at": { + "name": "last_run_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_run_status": { + "name": "last_run_status", + "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_automations_user": { + "name": "idx_automations_user", + "columns": [ + "user_id", + "enabled" + ], + "isUnique": false + } + }, + "foreignKeys": { + "automations_user_id_users_id_fk": { + "name": "automations_user_id_users_id_fk", + "tableFrom": "automations", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "c2s_tunnel_presets": { + "name": "c2s_tunnel_presets", + "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 + }, + "config": { + "name": "config", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "platform": { + "name": "platform", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "computer_name": { + "name": "computer_name", + "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": { + "c2s_tunnel_presets_user_id_users_id_fk": { + "name": "c2s_tunnel_presets_user_id_users_id_fk", + "tableFrom": "c2s_tunnel_presets", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "collab_room_members": { + "name": "collab_room_members", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "room_id": { + "name": "room_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "room_role": { + "name": "room_role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'member'" + }, + "added_by": { + "name": "added_by", + "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_collab_room_members_room_user": { + "name": "idx_collab_room_members_room_user", + "columns": [ + "room_id", + "user_id" + ], + "isUnique": true + }, + "idx_collab_room_members_user": { + "name": "idx_collab_room_members_user", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "collab_room_members_room_id_collab_rooms_id_fk": { + "name": "collab_room_members_room_id_collab_rooms_id_fk", + "tableFrom": "collab_room_members", + "tableTo": "collab_rooms", + "columnsFrom": [ + "room_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "collab_room_members_user_id_users_id_fk": { + "name": "collab_room_members_user_id_users_id_fk", + "tableFrom": "collab_room_members", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "collab_room_members_added_by_users_id_fk": { + "name": "collab_room_members_added_by_users_id_fk", + "tableFrom": "collab_room_members", + "tableTo": "users", + "columnsFrom": [ + "added_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "collab_rooms": { + "name": "collab_rooms", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "persistent": { + "name": "persistent", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "presenter_user_id": { + "name": "presenter_user_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "stage_protocol": { + "name": "stage_protocol", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "stage_host_id": { + "name": "stage_host_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "stage_share_id": { + "name": "stage_share_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "guest_link_token": { + "name": "guest_link_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "ended_at": { + "name": "ended_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_collab_rooms_owner": { + "name": "idx_collab_rooms_owner", + "columns": [ + "owner_user_id" + ], + "isUnique": false + }, + "idx_collab_rooms_guest_token": { + "name": "idx_collab_rooms_guest_token", + "columns": [ + "guest_link_token" + ], + "isUnique": true + } + }, + "foreignKeys": { + "collab_rooms_owner_user_id_users_id_fk": { + "name": "collab_rooms_owner_user_id_users_id_fk", + "tableFrom": "collab_rooms", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "collab_rooms_presenter_user_id_users_id_fk": { + "name": "collab_rooms_presenter_user_id_users_id_fk", + "tableFrom": "collab_rooms", + "tableTo": "users", + "columnsFrom": [ + "presenter_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "collab_rooms_stage_host_id_ssh_data_id_fk": { + "name": "collab_rooms_stage_host_id_ssh_data_id_fk", + "tableFrom": "collab_rooms", + "tableTo": "ssh_data", + "columnsFrom": [ + "stage_host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "collab_rooms_stage_share_id_session_shares_id_fk": { + "name": "collab_rooms_stage_share_id_session_shares_id_fk", + "tableFrom": "collab_rooms", + "tableTo": "session_shares", + "columnsFrom": [ + "stage_share_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "command_history": { + "name": "command_history", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "host_id": { + "name": "host_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "command": { + "name": "command", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "executed_at": { + "name": "executed_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_command_history_user_host": { + "name": "idx_command_history_user_host", + "columns": [ + "user_id", + "host_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "command_history_user_id_users_id_fk": { + "name": "command_history_user_id_users_id_fk", + "tableFrom": "command_history", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "command_history_host_id_ssh_data_id_fk": { + "name": "command_history_host_id_ssh_data_id_fk", + "tableFrom": "command_history", + "tableTo": "ssh_data", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "credential_access": { + "name": "credential_access", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "credential_id": { + "name": "credential_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "role_id": { + "name": "role_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "granted_by": { + "name": "granted_by", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "permission_level": { + "name": "permission_level", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'use'" + }, + "expires_at": { + "name": "expires_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_credential_access_user_id": { + "name": "idx_credential_access_user_id", + "columns": [ + "user_id" + ], + "isUnique": false + }, + "idx_credential_access_role_id": { + "name": "idx_credential_access_role_id", + "columns": [ + "role_id" + ], + "isUnique": false + }, + "idx_credential_access_credential_id": { + "name": "idx_credential_access_credential_id", + "columns": [ + "credential_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "credential_access_credential_id_ssh_credentials_id_fk": { + "name": "credential_access_credential_id_ssh_credentials_id_fk", + "tableFrom": "credential_access", + "tableTo": "ssh_credentials", + "columnsFrom": [ + "credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_access_user_id_users_id_fk": { + "name": "credential_access_user_id_users_id_fk", + "tableFrom": "credential_access", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_access_role_id_roles_id_fk": { + "name": "credential_access_role_id_roles_id_fk", + "tableFrom": "credential_access", + "tableTo": "roles", + "columnsFrom": [ + "role_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_access_granted_by_users_id_fk": { + "name": "credential_access_granted_by_users_id_fk", + "tableFrom": "credential_access", + "tableTo": "users", + "columnsFrom": [ + "granted_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "credential_sidebar_preferences": { + "name": "credential_sidebar_preferences", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "data": { + "name": "data", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": { + "credential_sidebar_preferences_user_id_users_id_fk": { + "name": "credential_sidebar_preferences_user_id_users_id_fk", + "tableFrom": "credential_sidebar_preferences", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "dashboard_service_links": { + "name": "dashboard_service_links", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "order": { + "name": "order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "sync_id": { + "name": "sync_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "dashboard_service_links_sync_id_unique": { + "name": "dashboard_service_links_sync_id_unique", + "columns": [ + "sync_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "dashboard_service_links_user_id_users_id_fk": { + "name": "dashboard_service_links_user_id_users_id_fk", + "tableFrom": "dashboard_service_links", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "dismissed_alerts": { + "name": "dismissed_alerts", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "alert_id": { + "name": "alert_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "dismissed_at": { + "name": "dismissed_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_dismissed_alerts_user_id": { + "name": "idx_dismissed_alerts_user_id", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "dismissed_alerts_user_id_users_id_fk": { + "name": "dismissed_alerts_user_id_users_id_fk", + "tableFrom": "dismissed_alerts", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "file_manager_pinned": { + "name": "file_manager_pinned", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "host_id": { + "name": "host_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "pinned_at": { + "name": "pinned_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_file_manager_pinned_user": { + "name": "idx_file_manager_pinned_user", + "columns": [ + "user_id", + "host_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "file_manager_pinned_user_id_users_id_fk": { + "name": "file_manager_pinned_user_id_users_id_fk", + "tableFrom": "file_manager_pinned", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "file_manager_pinned_host_id_ssh_data_id_fk": { + "name": "file_manager_pinned_host_id_ssh_data_id_fk", + "tableFrom": "file_manager_pinned", + "tableTo": "ssh_data", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "file_manager_recent": { + "name": "file_manager_recent", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "host_id": { + "name": "host_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_opened": { + "name": "last_opened", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_file_manager_recent_user": { + "name": "idx_file_manager_recent_user", + "columns": [ + "user_id", + "host_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "file_manager_recent_user_id_users_id_fk": { + "name": "file_manager_recent_user_id_users_id_fk", + "tableFrom": "file_manager_recent", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "file_manager_recent_host_id_ssh_data_id_fk": { + "name": "file_manager_recent_host_id_ssh_data_id_fk", + "tableFrom": "file_manager_recent", + "tableTo": "ssh_data", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "file_manager_shortcuts": { + "name": "file_manager_shortcuts", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "host_id": { + "name": "host_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_file_manager_shortcuts_user": { + "name": "idx_file_manager_shortcuts_user", + "columns": [ + "user_id", + "host_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "file_manager_shortcuts_user_id_users_id_fk": { + "name": "file_manager_shortcuts_user_id_users_id_fk", + "tableFrom": "file_manager_shortcuts", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "file_manager_shortcuts_host_id_ssh_data_id_fk": { + "name": "file_manager_shortcuts_host_id_ssh_data_id_fk", + "tableFrom": "file_manager_shortcuts", + "tableTo": "ssh_data", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "fleet_inventory": { + "name": "fleet_inventory", + "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 + }, + "os_pretty_name": { + "name": "os_pretty_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "kernel": { + "name": "kernel", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "architecture": { + "name": "architecture", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "hostname": { + "name": "hostname", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "uptime_seconds": { + "name": "uptime_seconds", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "ip": { + "name": "ip", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "package_manager": { + "name": "package_manager", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "collected_at": { + "name": "collected_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_fleet_inventory_host": { + "name": "idx_fleet_inventory_host", + "columns": [ + "host_id", + "user_id" + ], + "isUnique": true + }, + "idx_fleet_inventory_user": { + "name": "idx_fleet_inventory_user", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "fleet_inventory_host_id_ssh_data_id_fk": { + "name": "fleet_inventory_host_id_ssh_data_id_fk", + "tableFrom": "fleet_inventory", + "tableTo": "ssh_data", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "fleet_inventory_user_id_users_id_fk": { + "name": "fleet_inventory_user_id_users_id_fk", + "tableFrom": "fleet_inventory", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "fleet_members": { + "name": "fleet_members", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "fleet_id": { + "name": "fleet_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "host_id": { + "name": "host_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "added_at": { + "name": "added_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_fleet_members_fleet_host": { + "name": "idx_fleet_members_fleet_host", + "columns": [ + "fleet_id", + "host_id" + ], + "isUnique": true + }, + "idx_fleet_members_host": { + "name": "idx_fleet_members_host", + "columns": [ + "host_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "fleet_members_fleet_id_fleets_id_fk": { + "name": "fleet_members_fleet_id_fleets_id_fk", + "tableFrom": "fleet_members", + "tableTo": "fleets", + "columnsFrom": [ + "fleet_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "fleet_members_host_id_ssh_data_id_fk": { + "name": "fleet_members_host_id_ssh_data_id_fk", + "tableFrom": "fleet_members", + "tableTo": "ssh_data", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "fleets": { + "name": "fleets", + "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 + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "icon": { + "name": "icon", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "tag_rules": { + "name": "tag_rules", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sync_id": { + "name": "sync_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "fleets_sync_id_unique": { + "name": "fleets_sync_id_unique", + "columns": [ + "sync_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "fleets_user_id_users_id_fk": { + "name": "fleets_user_id_users_id_fk", + "tableFrom": "fleets", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "folder_access": { + "name": "folder_access", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "folder": { + "name": "folder", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "role_id": { + "name": "role_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "granted_by": { + "name": "granted_by", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "permission_level": { + "name": "permission_level", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'connect'" + }, + "expires_at": { + "name": "expires_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_folder_access_owner_folder": { + "name": "idx_folder_access_owner_folder", + "columns": [ + "owner_user_id", + "folder" + ], + "isUnique": false + } + }, + "foreignKeys": { + "folder_access_owner_user_id_users_id_fk": { + "name": "folder_access_owner_user_id_users_id_fk", + "tableFrom": "folder_access", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "folder_access_user_id_users_id_fk": { + "name": "folder_access_user_id_users_id_fk", + "tableFrom": "folder_access", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "folder_access_role_id_roles_id_fk": { + "name": "folder_access_role_id_roles_id_fk", + "tableFrom": "folder_access", + "tableTo": "roles", + "columnsFrom": [ + "role_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "folder_access_granted_by_users_id_fk": { + "name": "folder_access_granted_by_users_id_fk", + "tableFrom": "folder_access", + "tableTo": "users", + "columnsFrom": [ + "granted_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "homepage_items": { + "name": "homepage_items", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type_id": { + "name": "type_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "config": { + "name": "config", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'{}'" + }, + "folder_id": { + "name": "folder_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sync_id": { + "name": "sync_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "homepage_items_sync_id_unique": { + "name": "homepage_items_sync_id_unique", + "columns": [ + "sync_id" + ], + "isUnique": true + }, + "idx_homepage_items_user_id": { + "name": "idx_homepage_items_user_id", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "homepage_items_user_id_users_id_fk": { + "name": "homepage_items_user_id_users_id_fk", + "tableFrom": "homepage_items", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "homepage_layouts": { + "name": "homepage_layouts", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "layout": { + "name": "layout", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'{}'" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "homepage_layouts_user_id_unique": { + "name": "homepage_layouts_user_id_unique", + "columns": [ + "user_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "homepage_layouts_user_id_users_id_fk": { + "name": "homepage_layouts_user_id_users_id_fk", + "tableFrom": "homepage_layouts", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "host_access": { + "name": "host_access", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "host_id": { + "name": "host_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "role_id": { + "name": "role_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "granted_by": { + "name": "granted_by", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "permission_level": { + "name": "permission_level", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'connect'" + }, + "expires_at": { + "name": "expires_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "last_accessed_at": { + "name": "last_accessed_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "access_count": { + "name": "access_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + } + }, + "indexes": { + "idx_host_access_user_id": { + "name": "idx_host_access_user_id", + "columns": [ + "user_id" + ], + "isUnique": false + }, + "idx_host_access_role_id": { + "name": "idx_host_access_role_id", + "columns": [ + "role_id" + ], + "isUnique": false + }, + "idx_host_access_host_id": { + "name": "idx_host_access_host_id", + "columns": [ + "host_id" + ], + "isUnique": false + }, + "idx_host_access_expires_at": { + "name": "idx_host_access_expires_at", + "columns": [ + "expires_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "host_access_host_id_ssh_data_id_fk": { + "name": "host_access_host_id_ssh_data_id_fk", + "tableFrom": "host_access", + "tableTo": "ssh_data", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "host_access_user_id_users_id_fk": { + "name": "host_access_user_id_users_id_fk", + "tableFrom": "host_access", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "host_access_role_id_roles_id_fk": { + "name": "host_access_role_id_roles_id_fk", + "tableFrom": "host_access", + "tableTo": "roles", + "columnsFrom": [ + "role_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "host_access_granted_by_users_id_fk": { + "name": "host_access_granted_by_users_id_fk", + "tableFrom": "host_access", + "tableTo": "users", + "columnsFrom": [ + "granted_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "host_health_checks": { + "name": "host_health_checks", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "host_id": { + "name": "host_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "checks": { + "name": "checks", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "interval_seconds": { + "name": "interval_seconds", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 300 + }, + "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_host_health_checks_user_host": { + "name": "idx_host_health_checks_user_host", + "columns": [ + "user_id", + "host_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "host_health_checks_user_id_users_id_fk": { + "name": "host_health_checks_user_id_users_id_fk", + "tableFrom": "host_health_checks", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "host_health_checks_host_id_ssh_data_id_fk": { + "name": "host_health_checks_host_id_ssh_data_id_fk", + "tableFrom": "host_health_checks", + "tableTo": "ssh_data", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "host_health_history": { + "name": "host_health_history", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "host_id": { + "name": "host_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "check_id": { + "name": "check_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ts": { + "name": "ts", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "ok": { + "name": "ok", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "latency_ms": { + "name": "latency_ms", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "detail": { + "name": "detail", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "host_health_history_user_id_users_id_fk": { + "name": "host_health_history_user_id_users_id_fk", + "tableFrom": "host_health_history", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "host_health_history_host_id_ssh_data_id_fk": { + "name": "host_health_history_host_id_ssh_data_id_fk", + "tableFrom": "host_health_history", + "tableTo": "ssh_data", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "host_metrics_history": { + "name": "host_metrics_history", + "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 + }, + "ts": { + "name": "ts", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "cpu_percent": { + "name": "cpu_percent", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "mem_percent": { + "name": "mem_percent", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "disk_percent": { + "name": "disk_percent", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "net_rx_bytes": { + "name": "net_rx_bytes", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "net_tx_bytes": { + "name": "net_tx_bytes", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "host_metrics_history_host_id_ssh_data_id_fk": { + "name": "host_metrics_history_host_id_ssh_data_id_fk", + "tableFrom": "host_metrics_history", + "tableTo": "ssh_data", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "host_metrics_preferences": { + "name": "host_metrics_preferences", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "host_id": { + "name": "host_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "layout": { + "name": "layout", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_host_metrics_prefs_user_host": { + "name": "idx_host_metrics_prefs_user_host", + "columns": [ + "user_id", + "host_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "host_metrics_preferences_user_id_users_id_fk": { + "name": "host_metrics_preferences_user_id_users_id_fk", + "tableFrom": "host_metrics_preferences", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "host_metrics_preferences_host_id_ssh_data_id_fk": { + "name": "host_metrics_preferences_host_id_ssh_data_id_fk", + "tableFrom": "host_metrics_preferences", + "tableTo": "ssh_data", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "host_sidebar_preferences": { + "name": "host_sidebar_preferences", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "data": { + "name": "data", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": { + "host_sidebar_preferences_user_id_users_id_fk": { + "name": "host_sidebar_preferences_user_id_users_id_fk", + "tableFrom": "host_sidebar_preferences", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "ssh_data": { + "name": "ssh_data", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "connection_type": { + "name": "connection_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'ssh'" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "ip": { + "name": "ip", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "port": { + "name": "port", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "folder": { + "name": "folder", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "parent_host_id": { + "name": "parent_host_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "tags": { + "name": "tags", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "pin": { + "name": "pin", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "auth_type": { + "name": "auth_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "use_warpgate": { + "name": "use_warpgate", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "share_ssh_auth": { + "name": "share_ssh_auth", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "force_keyboard_interactive": { + "name": "force_keyboard_interactive", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "key": { + "name": "key", + "type": "text(8192)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "key_password": { + "name": "key_password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "key_type": { + "name": "key_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sudo_password": { + "name": "sudo_password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "autostart_password": { + "name": "autostart_password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "autostart_key": { + "name": "autostart_key", + "type": "text(8192)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "autostart_key_password": { + "name": "autostart_key_password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "credential_id": { + "name": "credential_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "override_credential_username": { + "name": "override_credential_username", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "vault_profile_id": { + "name": "vault_profile_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "enable_terminal": { + "name": "enable_terminal", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "enable_session_logging": { + "name": "enable_session_logging", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "allow_session_sharing": { + "name": "allow_session_sharing", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "enable_command_history": { + "name": "enable_command_history", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "enable_tunnel": { + "name": "enable_tunnel", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "tunnel_connections": { + "name": "tunnel_connections", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "jump_hosts": { + "name": "jump_hosts", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "enable_file_manager": { + "name": "enable_file_manager", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "scp_legacy": { + "name": "scp_legacy", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "enable_docker": { + "name": "enable_docker", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "enable_web_ui": { + "name": "enable_web_ui", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "enable_tmux_monitor": { + "name": "enable_tmux_monitor", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "enable_terminal_toolbar": { + "name": "enable_terminal_toolbar", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "enable_ai_assistant": { + "name": "enable_ai_assistant", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "show_terminal_in_sidebar": { + "name": "show_terminal_in_sidebar", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "show_file_manager_in_sidebar": { + "name": "show_file_manager_in_sidebar", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "show_tunnel_in_sidebar": { + "name": "show_tunnel_in_sidebar", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "show_docker_in_sidebar": { + "name": "show_docker_in_sidebar", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "show_server_stats_in_sidebar": { + "name": "show_server_stats_in_sidebar", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "default_path": { + "name": "default_path", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "stats_config": { + "name": "stats_config", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "docker_config": { + "name": "docker_config", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "web_ui_config": { + "name": "web_ui_config", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "enable_proxmox": { + "name": "enable_proxmox", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "proxmox_config": { + "name": "proxmox_config", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "enable_proxmox_stats": { + "name": "enable_proxmox_stats", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "proxmox_stats_config": { + "name": "proxmox_stats_config", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "terminal_config": { + "name": "terminal_config", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "quick_actions": { + "name": "quick_actions", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "enable_ssh": { + "name": "enable_ssh", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "enable_rdp": { + "name": "enable_rdp", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "enable_vnc": { + "name": "enable_vnc", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "enable_telnet": { + "name": "enable_telnet", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "ssh_port": { + "name": "ssh_port", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 22 + }, + "rdp_port": { + "name": "rdp_port", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 3389 + }, + "vnc_port": { + "name": "vnc_port", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 5900 + }, + "telnet_port": { + "name": "telnet_port", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 23 + }, + "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 + }, + "rdp_security": { + "name": "rdp_security", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "rdp_ignore_cert": { + "name": "rdp_ignore_cert", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 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 + }, + "security": { + "name": "security", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "ignore_cert": { + "name": "ignore_cert", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "guacamole_config": { + "name": "guacamole_config", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "use_socks5": { + "name": "use_socks5", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "socks5_host": { + "name": "socks5_host", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "socks5_port": { + "name": "socks5_port", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "socks5_username": { + "name": "socks5_username", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "socks5_password": { + "name": "socks5_password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "socks5_proxy_chain": { + "name": "socks5_proxy_chain", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "connection_origin": { + "name": "connection_origin", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "mac_address": { + "name": "mac_address", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "wol_broadcast_address": { + "name": "wol_broadcast_address", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "port_knock_sequence": { + "name": "port_knock_sequence", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "host_key_fingerprint": { + "name": "host_key_fingerprint", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "host_key_type": { + "name": "host_key_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "host_key_algorithm": { + "name": "host_key_algorithm", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'sha256'" + }, + "host_key_first_seen": { + "name": "host_key_first_seen", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "host_key_last_verified": { + "name": "host_key_last_verified", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "host_key_changed_count": { + "name": "host_key_changed_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "sync_id": { + "name": "sync_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "ssh_data_sync_id_unique": { + "name": "ssh_data_sync_id_unique", + "columns": [ + "sync_id" + ], + "isUnique": true + }, + "idx_ssh_data_user_id": { + "name": "idx_ssh_data_user_id", + "columns": [ + "user_id" + ], + "isUnique": false + }, + "idx_ssh_data_parent_host": { + "name": "idx_ssh_data_parent_host", + "columns": [ + "parent_host_id" + ], + "isUnique": false + }, + "idx_ssh_data_credential": { + "name": "idx_ssh_data_credential", + "columns": [ + "credential_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "ssh_data_user_id_users_id_fk": { + "name": "ssh_data_user_id_users_id_fk", + "tableFrom": "ssh_data", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "ssh_data_parent_host_id_ssh_data_id_fk": { + "name": "ssh_data_parent_host_id_ssh_data_id_fk", + "tableFrom": "ssh_data", + "tableTo": "ssh_data", + "columnsFrom": [ + "parent_host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "ssh_data_credential_id_ssh_credentials_id_fk": { + "name": "ssh_data_credential_id_ssh_credentials_id_fk", + "tableFrom": "ssh_data", + "tableTo": "ssh_credentials", + "columnsFrom": [ + "credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "ssh_data_vault_profile_id_vault_profiles_id_fk": { + "name": "ssh_data_vault_profile_id_vault_profiles_id_fk", + "tableFrom": "ssh_data", + "tableTo": "vault_profiles", + "columnsFrom": [ + "vault_profile_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "ssh_data_rdp_credential_id_ssh_credentials_id_fk": { + "name": "ssh_data_rdp_credential_id_ssh_credentials_id_fk", + "tableFrom": "ssh_data", + "tableTo": "ssh_credentials", + "columnsFrom": [ + "rdp_credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "ssh_data_vnc_credential_id_ssh_credentials_id_fk": { + "name": "ssh_data_vnc_credential_id_ssh_credentials_id_fk", + "tableFrom": "ssh_data", + "tableTo": "ssh_credentials", + "columnsFrom": [ + "vnc_credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "ssh_data_telnet_credential_id_ssh_credentials_id_fk": { + "name": "ssh_data_telnet_credential_id_ssh_credentials_id_fk", + "tableFrom": "ssh_data", + "tableTo": "ssh_credentials", + "columnsFrom": [ + "telnet_credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "network_topology": { + "name": "network_topology", + "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 + }, + "topology": { + "name": "topology", + "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": { + "network_topology_user_id_users_id_fk": { + "name": "network_topology_user_id_users_id_fk", + "tableFrom": "network_topology", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "notification_channels": { + "name": "notification_channels", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "config": { + "name": "config", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": { + "notification_channels_user_id_users_id_fk": { + "name": "notification_channels_user_id_users_id_fk", + "tableFrom": "notification_channels", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "opkssh_tokens": { + "name": "opkssh_tokens", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "host_id": { + "name": "host_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ssh_cert": { + "name": "ssh_cert", + "type": "text(8192)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "private_key": { + "name": "private_key", + "type": "text(8192)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sub": { + "name": "sub", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "issuer": { + "name": "issuer", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "audience": { + "name": "audience", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "expires_at": { + "name": "expires_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_used": { + "name": "last_used", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_opkssh_tokens_user_host": { + "name": "idx_opkssh_tokens_user_host", + "columns": [ + "user_id", + "host_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "opkssh_tokens_user_id_users_id_fk": { + "name": "opkssh_tokens_user_id_users_id_fk", + "tableFrom": "opkssh_tokens", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "opkssh_tokens_host_id_ssh_data_id_fk": { + "name": "opkssh_tokens_host_id_ssh_data_id_fk", + "tableFrom": "opkssh_tokens", + "tableTo": "ssh_data", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "plugin_install_counts": { + "name": "plugin_install_counts", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "plugin_id": { + "name": "plugin_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "registry_id": { + "name": "registry_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "count": { + "name": "count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'aggregate-telemetry'" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_plugin_install_counts_plugin_registry": { + "name": "idx_plugin_install_counts_plugin_registry", + "columns": [ + "plugin_id", + "registry_id" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "plugin_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_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": {} + }, + "proxmox_node_history": { + "name": "proxmox_node_history", + "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 + }, + "ts": { + "name": "ts", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "cpu_percent": { + "name": "cpu_percent", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "mem_percent": { + "name": "mem_percent", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "disk_percent": { + "name": "disk_percent", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "net_rx_bytes": { + "name": "net_rx_bytes", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "net_tx_bytes": { + "name": "net_tx_bytes", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "proxmox_node_history_host_id_ssh_data_id_fk": { + "name": "proxmox_node_history_host_id_ssh_data_id_fk", + "tableFrom": "proxmox_node_history", + "tableTo": "ssh_data", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "proxmox_stats_preferences": { + "name": "proxmox_stats_preferences", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "host_id": { + "name": "host_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "layout": { + "name": "layout", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_proxmox_stats_prefs_user_host": { + "name": "idx_proxmox_stats_prefs_user_host", + "columns": [ + "user_id", + "host_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "proxmox_stats_preferences_user_id_users_id_fk": { + "name": "proxmox_stats_preferences_user_id_users_id_fk", + "tableFrom": "proxmox_stats_preferences", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "proxmox_stats_preferences_host_id_ssh_data_id_fk": { + "name": "proxmox_stats_preferences_host_id_ssh_data_id_fk", + "tableFrom": "proxmox_stats_preferences", + "tableTo": "ssh_data", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "recent_activity": { + "name": "recent_activity", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "host_id": { + "name": "host_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "host_name": { + "name": "host_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "timestamp": { + "name": "timestamp", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_recent_activity_user_ts": { + "name": "idx_recent_activity_user_ts", + "columns": [ + "user_id", + "timestamp" + ], + "isUnique": false + } + }, + "foreignKeys": { + "recent_activity_user_id_users_id_fk": { + "name": "recent_activity_user_id_users_id_fk", + "tableFrom": "recent_activity", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "recent_activity_host_id_ssh_data_id_fk": { + "name": "recent_activity_host_id_ssh_data_id_fk", + "tableFrom": "recent_activity", + "tableTo": "ssh_data", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "roles": { + "name": "roles", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_system": { + "name": "is_system", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "permissions": { + "name": "permissions", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "roles_name_unique": { + "name": "roles_name_unique", + "columns": [ + "name" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "secret_sources": { + "name": "secret_sources", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'onepassword-connect'" + }, + "base_url": { + "name": "base_url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "shared": { + "name": "shared", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_secret_sources_user": { + "name": "idx_secret_sources_user", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "secret_sources_user_id_users_id_fk": { + "name": "secret_sources_user_id_users_id_fk", + "tableFrom": "secret_sources", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "session_recordings": { + "name": "session_recordings", + "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 + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "access_id": { + "name": "access_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "started_at": { + "name": "started_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "ended_at": { + "name": "ended_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "duration": { + "name": "duration", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "commands": { + "name": "commands", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "dangerous_actions": { + "name": "dangerous_actions", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "recording_path": { + "name": "recording_path", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "protocol": { + "name": "protocol", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'ssh'" + }, + "format": { + "name": "format", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'text'" + }, + "terminated_by_owner": { + "name": "terminated_by_owner", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "termination_reason": { + "name": "termination_reason", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_session_recordings_user_started": { + "name": "idx_session_recordings_user_started", + "columns": [ + "user_id", + "started_at" + ], + "isUnique": false + }, + "idx_session_recordings_host": { + "name": "idx_session_recordings_host", + "columns": [ + "host_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "session_recordings_host_id_ssh_data_id_fk": { + "name": "session_recordings_host_id_ssh_data_id_fk", + "tableFrom": "session_recordings", + "tableTo": "ssh_data", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "session_recordings_user_id_users_id_fk": { + "name": "session_recordings_user_id_users_id_fk", + "tableFrom": "session_recordings", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "session_recordings_access_id_host_access_id_fk": { + "name": "session_recordings_access_id_host_access_id_fk", + "tableFrom": "session_recordings", + "tableTo": "host_access", + "columnsFrom": [ + "access_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "session_share_participants": { + "name": "session_share_participants", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "share_id": { + "name": "share_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "guest_label": { + "name": "guest_label", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "joined_at": { + "name": "joined_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "left_at": { + "name": "left_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "session_share_participants_share_id_session_shares_id_fk": { + "name": "session_share_participants_share_id_session_shares_id_fk", + "tableFrom": "session_share_participants", + "tableTo": "session_shares", + "columnsFrom": [ + "share_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "session_share_participants_user_id_users_id_fk": { + "name": "session_share_participants_user_id_users_id_fk", + "tableFrom": "session_share_participants", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "session_shares": { + "name": "session_shares", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "host_id": { + "name": "host_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "protocol": { + "name": "protocol", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tab_instance_id": { + "name": "tab_instance_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "share_type": { + "name": "share_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "target_user_id": { + "name": "target_user_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "link_token": { + "name": "link_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "permission_level": { + "name": "permission_level", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'read-only'" + }, + "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 + }, + "revoked_at": { + "name": "revoked_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_joined_at": { + "name": "last_joined_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "join_count": { + "name": "join_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + } + }, + "indexes": { + "session_shares_link_token_unique": { + "name": "session_shares_link_token_unique", + "columns": [ + "link_token" + ], + "isUnique": true + }, + "idx_session_shares_session_id": { + "name": "idx_session_shares_session_id", + "columns": [ + "session_id" + ], + "isUnique": false + }, + "idx_session_shares_host_id": { + "name": "idx_session_shares_host_id", + "columns": [ + "host_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "session_shares_host_id_ssh_data_id_fk": { + "name": "session_shares_host_id_ssh_data_id_fk", + "tableFrom": "session_shares", + "tableTo": "ssh_data", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "session_shares_owner_user_id_users_id_fk": { + "name": "session_shares_owner_user_id_users_id_fk", + "tableFrom": "session_shares", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "session_shares_target_user_id_users_id_fk": { + "name": "session_shares_target_user_id_users_id_fk", + "tableFrom": "session_shares", + "tableTo": "users", + "columnsFrom": [ + "target_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "sessions": { + "name": "sessions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "jwt_token": { + "name": "jwt_token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "device_type": { + "name": "device_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "device_info": { + "name": "device_info", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "oidc_sub": { + "name": "oidc_sub", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "oidc_sid": { + "name": "oidc_sid", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sso_provider_id": { + "name": "sso_provider_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "expires_at": { + "name": "expires_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_active_at": { + "name": "last_active_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_sessions_user_id": { + "name": "idx_sessions_user_id", + "columns": [ + "user_id" + ], + "isUnique": false + }, + "idx_sessions_expires_at": { + "name": "idx_sessions_expires_at", + "columns": [ + "expires_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "sessions_user_id_users_id_fk": { + "name": "sessions_user_id_users_id_fk", + "tableFrom": "sessions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "settings": { + "name": "settings", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "shared_credential_secrets": { + "name": "shared_credential_secrets", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "credential_access_id": { + "name": "credential_access_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "target_user_id": { + "name": "target_user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "credential_id": { + "name": "credential_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "encrypted_username": { + "name": "encrypted_username", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "auth_type": { + "name": "auth_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'password'" + }, + "encrypted_password": { + "name": "encrypted_password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "encrypted_key": { + "name": "encrypted_key", + "type": "text(16384)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "encrypted_key_password": { + "name": "encrypted_key_password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "key_type": { + "name": "key_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "public_key": { + "name": "public_key", + "type": "text(4096)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cert_public_key": { + "name": "cert_public_key", + "type": "text(8192)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_shared_credential_secrets_scope": { + "name": "idx_shared_credential_secrets_scope", + "columns": [ + "credential_access_id", + "target_user_id" + ], + "isUnique": true + }, + "idx_shared_credential_secrets_target": { + "name": "idx_shared_credential_secrets_target", + "columns": [ + "target_user_id", + "credential_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "shared_credential_secrets_target_user_id_users_id_fk": { + "name": "shared_credential_secrets_target_user_id_users_id_fk", + "tableFrom": "shared_credential_secrets", + "tableTo": "users", + "columnsFrom": [ + "target_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "shared_credential_secrets_credential_id_ssh_credentials_id_fk": { + "name": "shared_credential_secrets_credential_id_ssh_credentials_id_fk", + "tableFrom": "shared_credential_secrets", + "tableTo": "ssh_credentials", + "columnsFrom": [ + "credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "shared_cred_secrets_access_id_fk": { + "name": "shared_cred_secrets_access_id_fk", + "tableFrom": "shared_credential_secrets", + "tableTo": "credential_access", + "columnsFrom": [ + "credential_access_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "shared_host_auth_overrides": { + "name": "shared_host_auth_overrides", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "host_id": { + "name": "host_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "protocol": { + "name": "protocol", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'ssh'" + }, + "credential_id": { + "name": "credential_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "shared_host_auth_overrides_host_user_protocol_unique": { + "name": "shared_host_auth_overrides_host_user_protocol_unique", + "columns": [ + "host_id", + "user_id", + "protocol" + ], + "isUnique": true + } + }, + "foreignKeys": { + "shared_host_auth_overrides_host_id_ssh_data_id_fk": { + "name": "shared_host_auth_overrides_host_id_ssh_data_id_fk", + "tableFrom": "shared_host_auth_overrides", + "tableTo": "ssh_data", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "shared_host_auth_overrides_user_id_users_id_fk": { + "name": "shared_host_auth_overrides_user_id_users_id_fk", + "tableFrom": "shared_host_auth_overrides", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "shared_host_auth_overrides_credential_id_ssh_credentials_id_fk": { + "name": "shared_host_auth_overrides_credential_id_ssh_credentials_id_fk", + "tableFrom": "shared_host_auth_overrides", + "tableTo": "ssh_credentials", + "columnsFrom": [ + "credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "shared_host_secrets": { + "name": "shared_host_secrets", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "host_access_id": { + "name": "host_access_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "target_user_id": { + "name": "target_user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "protocol": { + "name": "protocol", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'ssh'" + }, + "source_type": { + "name": "source_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'credential'" + }, + "original_credential_id": { + "name": "original_credential_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "encrypted_username": { + "name": "encrypted_username", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "encrypted_auth_type": { + "name": "encrypted_auth_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "encrypted_password": { + "name": "encrypted_password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "encrypted_key": { + "name": "encrypted_key", + "type": "text(16384)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "encrypted_key_password": { + "name": "encrypted_key_password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "encrypted_key_type": { + "name": "encrypted_key_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "encrypted_domain": { + "name": "encrypted_domain", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_shared_host_secrets_scope": { + "name": "idx_shared_host_secrets_scope", + "columns": [ + "host_access_id", + "target_user_id", + "protocol" + ], + "isUnique": true + } + }, + "foreignKeys": { + "shared_host_secrets_host_access_id_host_access_id_fk": { + "name": "shared_host_secrets_host_access_id_host_access_id_fk", + "tableFrom": "shared_host_secrets", + "tableTo": "host_access", + "columnsFrom": [ + "host_access_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "shared_host_secrets_target_user_id_users_id_fk": { + "name": "shared_host_secrets_target_user_id_users_id_fk", + "tableFrom": "shared_host_secrets", + "tableTo": "users", + "columnsFrom": [ + "target_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "shared_host_secrets_original_credential_id_ssh_credentials_id_fk": { + "name": "shared_host_secrets_original_credential_id_ssh_credentials_id_fk", + "tableFrom": "shared_host_secrets", + "tableTo": "ssh_credentials", + "columnsFrom": [ + "original_credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "snippet_access": { + "name": "snippet_access", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "snippet_id": { + "name": "snippet_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": "'view'" + }, + "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_snippet_access_user_id": { + "name": "idx_snippet_access_user_id", + "columns": [ + "user_id" + ], + "isUnique": false + }, + "idx_snippet_access_snippet_id": { + "name": "idx_snippet_access_snippet_id", + "columns": [ + "snippet_id" + ], + "isUnique": false + }, + "idx_snippet_access_role_id": { + "name": "idx_snippet_access_role_id", + "columns": [ + "role_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "snippet_access_snippet_id_snippets_id_fk": { + "name": "snippet_access_snippet_id_snippets_id_fk", + "tableFrom": "snippet_access", + "tableTo": "snippets", + "columnsFrom": [ + "snippet_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "snippet_access_user_id_users_id_fk": { + "name": "snippet_access_user_id_users_id_fk", + "tableFrom": "snippet_access", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "snippet_access_role_id_roles_id_fk": { + "name": "snippet_access_role_id_roles_id_fk", + "tableFrom": "snippet_access", + "tableTo": "roles", + "columnsFrom": [ + "role_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "snippet_access_granted_by_users_id_fk": { + "name": "snippet_access_granted_by_users_id_fk", + "tableFrom": "snippet_access", + "tableTo": "users", + "columnsFrom": [ + "granted_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "snippet_folders": { + "name": "snippet_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 + }, + "sync_id": { + "name": "sync_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "snippet_folders_sync_id_unique": { + "name": "snippet_folders_sync_id_unique", + "columns": [ + "sync_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "snippet_folders_user_id_users_id_fk": { + "name": "snippet_folders_user_id_users_id_fk", + "tableFrom": "snippet_folders", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "snippets": { + "name": "snippets", + "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 + }, + "content": { + "name": "content", + "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 + }, + "order": { + "name": "order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "sync_id": { + "name": "sync_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "host_filter": { + "name": "host_filter", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_note": { + "name": "is_note", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + } + }, + "indexes": { + "snippets_sync_id_unique": { + "name": "snippets_sync_id_unique", + "columns": [ + "sync_id" + ], + "isUnique": true + }, + "idx_snippets_user_id": { + "name": "idx_snippets_user_id", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "snippets_user_id_users_id_fk": { + "name": "snippets_user_id_users_id_fk", + "tableFrom": "snippets", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "ssh_credential_usage": { + "name": "ssh_credential_usage", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "credential_id": { + "name": "credential_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "host_id": { + "name": "host_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "used_at": { + "name": "used_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_ssh_credential_usage_credential": { + "name": "idx_ssh_credential_usage_credential", + "columns": [ + "credential_id" + ], + "isUnique": false + }, + "idx_ssh_credential_usage_user": { + "name": "idx_ssh_credential_usage_user", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "ssh_credential_usage_credential_id_ssh_credentials_id_fk": { + "name": "ssh_credential_usage_credential_id_ssh_credentials_id_fk", + "tableFrom": "ssh_credential_usage", + "tableTo": "ssh_credentials", + "columnsFrom": [ + "credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "ssh_credential_usage_host_id_ssh_data_id_fk": { + "name": "ssh_credential_usage_host_id_ssh_data_id_fk", + "tableFrom": "ssh_credential_usage", + "tableTo": "ssh_data", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "ssh_credential_usage_user_id_users_id_fk": { + "name": "ssh_credential_usage_user_id_users_id_fk", + "tableFrom": "ssh_credential_usage", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "ssh_credentials": { + "name": "ssh_credentials", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "folder": { + "name": "folder", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "tags": { + "name": "tags", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "pin": { + "name": "pin", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "auth_type": { + "name": "auth_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "key": { + "name": "key", + "type": "text(16384)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "private_key": { + "name": "private_key", + "type": "text(16384)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "public_key": { + "name": "public_key", + "type": "text(4096)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "key_password": { + "name": "key_password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "key_type": { + "name": "key_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "detected_key_type": { + "name": "detected_key_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cert_public_key": { + "name": "cert_public_key", + "type": "text(8192)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "usage_count": { + "name": "usage_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "last_used": { + "name": "last_used", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sync_id": { + "name": "sync_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "ssh_credentials_sync_id_unique": { + "name": "ssh_credentials_sync_id_unique", + "columns": [ + "sync_id" + ], + "isUnique": true + }, + "idx_ssh_credentials_user_id": { + "name": "idx_ssh_credentials_user_id", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "ssh_credentials_user_id_users_id_fk": { + "name": "ssh_credentials_user_id_users_id_fk", + "tableFrom": "ssh_credentials", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "ssh_folders": { + "name": "ssh_folders", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "icon": { + "name": "icon", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "credential_id": { + "name": "credential_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sync_id": { + "name": "sync_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "ssh_folders_sync_id_unique": { + "name": "ssh_folders_sync_id_unique", + "columns": [ + "sync_id" + ], + "isUnique": true + }, + "idx_ssh_folders_user_id": { + "name": "idx_ssh_folders_user_id", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "ssh_folders_user_id_users_id_fk": { + "name": "ssh_folders_user_id_users_id_fk", + "tableFrom": "ssh_folders", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "ssh_folders_credential_id_ssh_credentials_id_fk": { + "name": "ssh_folders_credential_id_ssh_credentials_id_fk", + "tableFrom": "ssh_folders", + "tableTo": "ssh_credentials", + "columnsFrom": [ + "credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "sso_providers": { + "name": "sso_providers", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "display_order": { + "name": "display_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "config": { + "name": "config", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "sync_tombstones": { + "name": "sync_tombstones", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "entity_type": { + "name": "entity_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sync_id": { + "name": "sync_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": { + "sync_tombstones_user_id_users_id_fk": { + "name": "sync_tombstones_user_id_users_id_fk", + "tableFrom": "sync_tombstones", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "termix_identities": { + "name": "termix_identities", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "handle": { + "name": "handle", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "termix_identities_user_id_unique": { + "name": "termix_identities_user_id_unique", + "columns": [ + "user_id" + ], + "isUnique": true + }, + "termix_identities_handle_unique": { + "name": "termix_identities_handle_unique", + "columns": [ + "handle" + ], + "isUnique": true + } + }, + "foreignKeys": { + "termix_identities_user_id_users_id_fk": { + "name": "termix_identities_user_id_users_id_fk", + "tableFrom": "termix_identities", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "termix_identity_ca": { + "name": "termix_identity_ca", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "identity_id": { + "name": "identity_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "public_key": { + "name": "public_key", + "type": "text(4096)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "private_key": { + "name": "private_key", + "type": "text(8192)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "validity_days": { + "name": "validity_days", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 90 + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "termix_identity_ca_identity_id_unique": { + "name": "termix_identity_ca_identity_id_unique", + "columns": [ + "identity_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "termix_identity_ca_identity_id_termix_identities_id_fk": { + "name": "termix_identity_ca_identity_id_termix_identities_id_fk", + "tableFrom": "termix_identity_ca", + "tableTo": "termix_identities", + "columnsFrom": [ + "identity_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "termix_identity_ca_user_id_users_id_fk": { + "name": "termix_identity_ca_user_id_users_id_fk", + "tableFrom": "termix_identity_ca", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "termix_identity_keys": { + "name": "termix_identity_keys", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "identity_id": { + "name": "identity_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "public_key": { + "name": "public_key", + "type": "text(8192)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "key_type": { + "name": "key_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "algorithm": { + "name": "algorithm", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "comment": { + "name": "comment", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'manual'" + }, + "credential_id": { + "name": "credential_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": { + "termix_identity_keys_identity_id_termix_identities_id_fk": { + "name": "termix_identity_keys_identity_id_termix_identities_id_fk", + "tableFrom": "termix_identity_keys", + "tableTo": "termix_identities", + "columnsFrom": [ + "identity_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "termix_identity_keys_user_id_users_id_fk": { + "name": "termix_identity_keys_user_id_users_id_fk", + "tableFrom": "termix_identity_keys", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "termix_identity_keys_credential_id_ssh_credentials_id_fk": { + "name": "termix_identity_keys_credential_id_ssh_credentials_id_fk", + "tableFrom": "termix_identity_keys", + "tableTo": "ssh_credentials", + "columnsFrom": [ + "credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "tmux_session_tags": { + "name": "tmux_session_tags", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "host_id": { + "name": "host_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "session_name": { + "name": "session_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tag": { + "name": "tag", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": { + "tmux_session_tags_user_id_users_id_fk": { + "name": "tmux_session_tags_user_id_users_id_fk", + "tableFrom": "tmux_session_tags", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "tmux_session_tags_host_id_ssh_data_id_fk": { + "name": "tmux_session_tags_host_id_ssh_data_id_fk", + "tableFrom": "tmux_session_tags", + "tableTo": "ssh_data", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "transfer_recent": { + "name": "transfer_recent", + "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 + }, + "source_host_id": { + "name": "source_host_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "dest_host_id": { + "name": "dest_host_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "dest_path": { + "name": "dest_path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "dest_path_label": { + "name": "dest_path_label", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_used": { + "name": "last_used", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_transfer_recent_user": { + "name": "idx_transfer_recent_user", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "transfer_recent_user_id_users_id_fk": { + "name": "transfer_recent_user_id_users_id_fk", + "tableFrom": "transfer_recent", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "transfer_recent_source_host_id_ssh_data_id_fk": { + "name": "transfer_recent_source_host_id_ssh_data_id_fk", + "tableFrom": "transfer_recent", + "tableTo": "ssh_data", + "columnsFrom": [ + "source_host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "transfer_recent_dest_host_id_ssh_data_id_fk": { + "name": "transfer_recent_dest_host_id_ssh_data_id_fk", + "tableFrom": "transfer_recent", + "tableTo": "ssh_data", + "columnsFrom": [ + "dest_host_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_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 + }, + "ai_assistant_enabled": { + "name": "ai_assistant_enabled", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "ai_read_only_commands": { + "name": "ai_read_only_commands", + "type": "integer", + "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 + }, + "rdp_defaults": { + "name": "rdp_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_workspaces": { + "name": "user_workspaces", + "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 + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'manual'" + }, + "is_default": { + "name": "is_default", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "payload": { + "name": "payload", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'{}'" + }, + "sync_id": { + "name": "sync_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "last_used_at": { + "name": "last_used_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "user_workspaces_sync_id_unique": { + "name": "user_workspaces_sync_id_unique", + "columns": [ + "sync_id" + ], + "isUnique": true + }, + "idx_user_workspaces_user_id": { + "name": "idx_user_workspaces_user_id", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "user_workspaces_user_id_users_id_fk": { + "name": "user_workspaces_user_id_users_id_fk", + "tableFrom": "user_workspaces", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "users": { + "name": "users", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "password_hash": { + "name": "password_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "is_admin": { + "name": "is_admin", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "is_oidc": { + "name": "is_oidc", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "oidc_identifier": { + "name": "oidc_identifier", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sso_provider_id": { + "name": "sso_provider_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "client_secret": { + "name": "client_secret", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "issuer_url": { + "name": "issuer_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "authorization_url": { + "name": "authorization_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "token_url": { + "name": "token_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "identifier_path": { + "name": "identifier_path", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "name_path": { + "name": "name_path", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "scopes": { + "name": "scopes", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'openid email profile'" + }, + "totp_secret": { + "name": "totp_secret", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "totp_enabled": { + "name": "totp_enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "totp_backup_codes": { + "name": "totp_backup_codes", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "registered_at": { + "name": "registered_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "donation_modal_dismissed": { + "name": "donation_modal_dismissed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "vault_profiles": { + "name": "vault_profiles", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "folder": { + "name": "folder", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "tags": { + "name": "tags", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "vault_addr": { + "name": "vault_addr", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "vault_namespace": { + "name": "vault_namespace", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "oidc_mount": { + "name": "oidc_mount", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "oidc_role": { + "name": "oidc_role", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "ssh_mount": { + "name": "ssh_mount", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "ssh_role": { + "name": "ssh_role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "valid_principals": { + "name": "valid_principals", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "key_type": { + "name": "key_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "shared": { + "name": "shared", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "sync_id": { + "name": "sync_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "vault_profiles_sync_id_unique": { + "name": "vault_profiles_sync_id_unique", + "columns": [ + "sync_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "vault_profiles_user_id_users_id_fk": { + "name": "vault_profiles_user_id_users_id_fk", + "tableFrom": "vault_profiles", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "vault_tokens": { + "name": "vault_tokens", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "profile_id": { + "name": "profile_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ssh_cert": { + "name": "ssh_cert", + "type": "text(8192)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "private_key": { + "name": "private_key", + "type": "text(8192)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "expires_at": { + "name": "expires_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_used": { + "name": "last_used", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_vault_tokens_user_profile": { + "name": "idx_vault_tokens_user_profile", + "columns": [ + "user_id", + "profile_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "vault_tokens_user_id_users_id_fk": { + "name": "vault_tokens_user_id_users_id_fk", + "tableFrom": "vault_tokens", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "vault_tokens_profile_id_vault_profiles_id_fk": { + "name": "vault_tokens_profile_id_vault_profiles_id_fk", + "tableFrom": "vault_tokens", + "tableTo": "vault_profiles", + "columnsFrom": [ + "profile_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "webauthn_credentials": { + "name": "webauthn_credentials", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "credential_id": { + "name": "credential_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "public_key": { + "name": "public_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "counter": { + "name": "counter", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "device_type": { + "name": "device_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "backed_up": { + "name": "backed_up", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "transports": { + "name": "transports", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_verification": { + "name": "user_verification", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'preferred'" + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "last_used_at": { + "name": "last_used_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "webauthn_credentials_user_id_users_id_fk": { + "name": "webauthn_credentials_user_id_users_id_fk", + "tableFrom": "webauthn_credentials", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/drizzle/sqlite/meta/_journal.json b/drizzle/sqlite/meta/_journal.json index 9ebd00de4..ef86dab3e 100644 --- a/drizzle/sqlite/meta/_journal.json +++ b/drizzle/sqlite/meta/_journal.json @@ -162,6 +162,13 @@ "when": 1790083818524, "tag": "0022_steady_the_hood", "breakpoints": true + }, + { + "idx": 23, + "version": "6", + "when": 1790102858837, + "tag": "0023_talented_shooting_star", + "breakpoints": true } ] } \ No newline at end of file diff --git a/eslint.config.mjs b/eslint.config.mjs index 78798841d..9b2278b25 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -99,4 +99,85 @@ export default tseslint.config([ ], }, }, + { + // Core must not reach into a plugin. A plugin can be disabled, upgraded + // or removed, so an import from core turns "disabled" into a broken + // build rather than a missing feature. Core talks to plugins through the + // runtime in src/backend/plugins/ and the dispatchers in + // database/routes/*-dispatch.ts, both of which handle absence. + // + // Tests are exempt: they import a plugin's modules on purpose to test + // them, and until A2 gives each plugin its own suite they live under + // src/*/tests/plugins/. + files: ["src/**/*.{ts,tsx}"], + ignores: ["src/**/tests/**"], + rules: { + // An error for the backend, which has dispatchers to register through + // and no remaining offenders. + "no-restricted-imports": [ + "error", + { + patterns: [ + { + group: ["**/plugins/*/backend/**"], + message: + "Core must not import a plugin backend. Register a dispatcher or an SDK service instead, so disabling the plugin degrades cleanly.", + }, + ], + }, + ], + }, + }, + { + // The shell still imports plugin components directly (the terminal tab, + // the Guacamole app, the Proxmox dialog). There is no frontend plugin + // loader yet, so there is nothing to move them to: A7 builds the app + // object that registers these surfaces and this becomes an error. + files: ["src/ui/**/*.{ts,tsx}"], + ignores: ["src/ui/tests/**"], + rules: { + "no-restricted-imports": [ + "warn", + { + patterns: [ + { + group: ["**/plugins/*/frontend/**"], + message: + "The shell should not import plugin components directly. A7 replaces these with registrations through the app object.", + }, + ], + }, + ], + }, + }, + { + // The other direction. A plugin is supposed to talk to core only through + // @termix/plugin-sdk, which is the contract D1 finishes enforcing. + // + // Only the backend is fenced today. Plugin backends reach core by + // relative path (../../../src/backend/...) and plugin frontends use the + // "@/" alias for shared UI, both of which are the "legacy core imports" + // debt recorded in packages/plugin-sdk/ARCHITECTURE.md: the SDK does not + // yet expose the SSH pool, host resolution, repositories or the + // component library. A7 moves the frontends onto the app object and D1 + // moves the backends onto SDK APIs, and this rule tightens to match. + // + // What it already catches: a plugin backend importing the frontend tree, + // which is not debt in any direction, just a mistake. + files: ["plugins/**/backend/**/*.{ts,mjs}"], + rules: { + "no-restricted-imports": [ + "error", + { + patterns: [ + { + group: ["@/*", "**/src/ui/**"], + message: + "A plugin backend cannot import frontend code. Import from @termix/plugin-sdk, or use a relative path within the plugin.", + }, + ], + }, + ], + }, + }, ]); diff --git a/package-lock.json b/package-lock.json index 857cb3373..f9ea0bc4d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,9 @@ "name": "termix", "version": "2.9.0", "hasInstallScript": true, + "workspaces": [ + "packages/*" + ], "dependencies": { "@anthropic-ai/sdk": "^0.120.0", "@simplewebauthn/browser": "^13.3.0", @@ -7002,6 +7005,10 @@ "url": "https://github.com/sponsors/tannerlinsley" } }, + "node_modules/@termix/plugin-sdk": { + "resolved": "packages/plugin-sdk", + "link": true + }, "node_modules/@testing-library/dom": { "version": "10.4.2", "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.2.tgz", @@ -18476,6 +18483,14 @@ "type": "github", "url": "https://github.com/sponsors/wooorm" } + }, + "packages/plugin-sdk": { + "name": "@termix/plugin-sdk", + "version": "1.0.0", + "license": "MIT", + "dependencies": { + "semver": "^7.6.3" + } } } } diff --git a/package.json b/package.json index cd7194ab5..073f8d115 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,9 @@ { "name": "termix", "private": true, + "workspaces": [ + "packages/*" + ], "version": "2.9.0", "description": "Self-hosted SSH and remote desktop management.", "author": "Karmaa", @@ -15,7 +18,7 @@ "postinstall": "node scripts/patch-app-builder-lib.cjs && node scripts/patch-guacamole-lite.cjs && node scripts/patch-guacamole-common-js.cjs && node scripts/patch-better-sqlite3.cjs && node scripts/patch-nan.cjs && node scripts/patch-node-pty.cjs && node scripts/patch-xterm-android-ime.cjs && node scripts/patch-ssh2-agent.cjs", "prepare": "husky || true", "prebuild": "node scripts/write-electron-build-info.cjs", - "lint": "node scripts/generate-dialect-schema.cjs --check && eslint .", + "lint": "node scripts/generate-dialect-schema.cjs --check && npm run plugin:validate-manifest && eslint .", "lint:fix": "eslint --fix .", "type-check": "tsc -b --force", "test": "vitest run", @@ -24,9 +27,9 @@ "test:ui": "vitest --ui", "test:coverage": "vitest run --coverage", "dev": "vite", - "build": "vite build && tsc -p tsconfig.node.json && node -e \"require('fs').copyFileSync('src/backend/package.json','dist/backend/package.json')\" && node scripts/copy-bundled-plugins.cjs", - "build:backend": "tsc -p tsconfig.node.json && node -e \"require('fs').copyFileSync('src/backend/package.json','dist/backend/package.json')\" && node scripts/copy-bundled-plugins.cjs", - "dev:backend": "tsc -p tsconfig.node.json && node -e \"require('fs').copyFileSync('src/backend/package.json','dist/backend/package.json')\" && node scripts/copy-bundled-plugins.cjs && node ./dist/backend/backend/starter.js", + "build": "npm run build:sdk && vite build && tsc -p tsconfig.node.json && node -e \"require('fs').copyFileSync('src/backend/package.json','dist/backend/package.json')\" && node scripts/copy-bundled-plugins.cjs", + "build:backend": "npm run build:sdk && tsc -p tsconfig.node.json && node -e \"require('fs').copyFileSync('src/backend/package.json','dist/backend/package.json')\" && node scripts/copy-bundled-plugins.cjs", + "dev:backend": "npm run build:sdk && tsc -p tsconfig.node.json && node -e \"require('fs').copyFileSync('src/backend/package.json','dist/backend/package.json')\" && node scripts/copy-bundled-plugins.cjs && node ./dist/backend/backend/starter.js", "dev:docker": "docker stop termix-dev 2>nul & docker rm termix-dev 2>nul & docker build -f docker/Dockerfile -t termix:dev --no-cache . && docker run -d --name termix-dev -p 3000:3000 -p 8080:8080 -p 30001-30006:30001-30006 -v \"%cd%\\db\\data:/app/data\" termix:dev", "dev:docker:restart": "docker stop termix-dev 2>nul & docker rm termix-dev 2>nul & docker run -d --name termix-dev -p 8080:8080 -p 30001-30006:30001-30006 -v \"%cd%\\db\\data:/app/data\" termix:dev", "generate:openapi": "tsc -p tsconfig.node.json && node -e \"require('fs').copyFileSync('src/backend/package.json','dist/backend/package.json')\" && node ./dist/backend/backend/utils/swagger.js", @@ -44,7 +47,8 @@ "schema:generate": "node scripts/generate-dialect-schema.cjs", "schema:check": "node scripts/generate-dialect-schema.cjs --check", "schema:migrations": "drizzle-kit generate --config=drizzle.config.sqlite.ts && drizzle-kit generate --config=drizzle.config.pg.ts && drizzle-kit generate --config=drizzle.config.mysql.ts", - "plugin:validate-manifest": "node scripts/validate-plugin-manifest.cjs" + "plugin:validate-manifest": "node scripts/validate-plugin-manifest.cjs", + "build:sdk": "npm run build --workspace @termix/plugin-sdk" }, "dependencies": { "@anthropic-ai/sdk": "^0.120.0", diff --git a/packages/plugin-sdk/ARCHITECTURE.md b/packages/plugin-sdk/ARCHITECTURE.md new file mode 100644 index 000000000..9f5b9124e --- /dev/null +++ b/packages/plugin-sdk/ARCHITECTURE.md @@ -0,0 +1,470 @@ +# Termix plugin architecture + +This is the contract between Termix core and a plugin. It is the reference for +anyone writing a plugin and the specification the runtime in +`src/backend/plugins/` implements. + +Status: the redesign lands across several steps. Everything marked **A1** is +built today. Later markers name the step that delivers the rest. + +--- + +## The shape of it + +### 1. One tier + +Every plugin, official or community, runs in-process in the Termix server and +in the browser. There is no first-party allowlist, no worker tier, and no +special case keyed on a plugin id anywhere in core. + +This is not a sandbox. See [What this protects](#what-this-protects-and-what-it-does-not). + +### 2. SDK only + +A plugin imports `@termix/plugin-sdk`, its own files and its own npm +dependencies. Entry points: + +| Entry | Contents | +| --------------------------------- | ---------------------------------------------------------- | +| `@termix/plugin-sdk/backend` | `PluginContext`, `definePlugin()`, `PluginCapabilityError` | +| `@termix/plugin-sdk/frontend` | The `app` object types (stubs until A7) | +| `@termix/plugin-sdk/manifest` | Manifest types, validation, the JSON schema | +| `@termix/plugin-sdk/capabilities` | The capability catalog | +| `@termix/plugin-sdk/testing` | Test helpers, incl. `createFakeContext()` | + +Plugins never import from `src/` or `@/`, and core never imports from +`plugins/`. Anything a plugin needs from core becomes a typed, documented SDK +API. Enforced by lint, with the exceptions recorded under +[legacy core imports](#legacy-core-imports-the-debt-d1-removes). + +The SDK holds the contract: types, the catalog, and pure helpers. The ctx +implementation lives in core (`src/backend/plugins/ctx.ts`) and is injected at +activation, so a plugin cannot reach around it. + +### 3. Capabilities are what a plugin's code may do + +Declared in the manifest's `capabilities` array using `domain:action` names +from one catalog in the SDK. Every privileged SDK call checks the capability +and writes an audit line naming the plugin and the acting user. + +Two things must both be true, and a grant for something the manifest never +declared is ignored rather than honoured: + +1. the manifest declares it, and +2. it is granted in `plugin_permission_grants`. + +That means widening a plugin's reach always requires a new manifest the user +can see. In 2.9.0 bundled plugins are granted everything they declare +automatically, with `source: "bundled"`. + +Adding a capability is one catalog entry (id, risk, i18n title and consequence +text) plus the SDK method that checks it. + +### 4. User permissions are what a user may do + +These stay in core RBAC and keep their dotted spelling (`hosts.view`), so they +can never be confused with colon-separated capabilities. + +A plugin declares short names in `contributes.permissionGroup`. Core registers +them, groups them under the plugin in the role editor, and only lets a plugin +set defaults for its own namespace: a `defaultForRole` entry naming a +permission the plugin does not declare is a validation error, not a silent +escalation. Permissions stored in a role survive the plugin being disabled. + +Role defaults are applied once and the fact is recorded, so an admin who +revokes one does not get it handed back on the next restart. + +### 5. Data + +Plugins own their tables through SDK table definitions and ship migrations for +sqlite, pg and mysql. Table names are prefixed `p__`. Legacy +core tables are adopted by rename in the plugin's first migration. `ctx.kv` is +for small key/value state. Core `schema.ts` ends up holding only core tables. + +**A3** delivers the table definitions and migration runner. A1 ships `ctx.kv` +only. + +### 6. HTTP and WebSockets + +Plugin routes live under `/plugin-api//` and sockets under +`/plugin-ws//`, served by the main backend with core auth. No plugin gets +its own port or nginx block. + +**A4** delivers this. In A1 `/plugin-api` is mounted and auth-gated but has no +routes registered, so it returns 404. The bundled plugins still keep their +original URLs through the dispatchers in `src/backend/database/routes/*-dispatch.ts`, +and four still own ports of their own. + +### 7. Settings + +Plugins declare settings fields in the manifest with scope `admin`, `user` or +`host`. Core renders them from the schema (with an optional custom component), +stores values in `plugin_settings`, and encrypts secret fields. One Settings +screen has a Plugins section with a page per plugin; host settings render in +the host editor. **A6.** + +### 8. UI + +A plugin frontend is a built ESM bundle loaded at runtime by the core loader. +It registers everything through the `app` object: rail items, panels, tabs, +host editor sections, host actions and badges, context menu items, palette +entries, dashboard cards, homepage widgets, settings components, action slots, +login/2FA UI, SSH auth editors. The shell contains no plugin ids. **A7.** + +### 9. Cross-plugin use + +Through `ctx.services` (typed, versioned, RBAC-checked per call), `ctx.events`, +and frontend actions and slots. Dependencies are declared in the manifest: +`dependencies` are hard, `optionalDependencies` are soft. The loader activates +in dependency order. A plugin must keep working when an optional dependency is +missing. + +### 10. Lifecycle + +`activate(ctx)` creates all state. Everything registered through `ctx` or `app` +is disposed automatically on deactivate. See [Lifecycle rules](#lifecycle-rules). + +### 11. Tests + +Each plugin has `plugins//tests/backend` and `plugins//tests/frontend`, +run by its own vitest config built on the SDK preset (**A2**). Core tests +(`src/backend/tests`, `src/ui/tests`) never test plugin code; they may test the +plugin runtime with fixture plugins. + +Until A2, plugin tests still live under `src/*/tests/plugins/`. + +### 12. Auth + +Core keeps password login, sessions, API keys, trusted proxy login, Electron +auto-session and trusted devices, plus the base SSH auth types password, key, +stored credential, agent and none. Every other login method, second factor and +SSH auth method is a plugin through `ctx.auth`. **A8.** + +### 13. Where plugins live + +Official plugins live in `plugins//` as npm workspace packages in this +monorepo for all of 2.9.0, and get split into their own repos later. Every +plugin is enabled by default in 2.9.0; there is no install UI until 3.0.0. + +### 14. Upgrades must be lossless + +Every move of data, settings or host columns into a plugin comes with a +migration for all three dialects and a test proving existing data survives. + +--- + +## Manifest v2 reference + +`plugins//manifest.json`. The directory name must equal `id`. Unknown +fields are rejected at every level: a typo'd `contribute` used to validate +clean and be silently dropped, which made a manifest describe something it did +not do. + +```jsonc +{ + "id": "example", // ^[a-z][a-z0-9-]{1,39}$, equals the directory name + "name": "Example", + "version": "1.0.0", // semver + "description": "What it does.", + "author": { "name": "You", "url": "https://..." }, + "license": "MIT", + "repository": "https://...", // optional + "category": "Productivity", // see the enum below + "icon": "Sparkles", // optional: Lucide name or a bundled icon.svg + + "engine": { + "termix": ">=2.9.0", // display only, never enforced + "api": "1", // the real compatibility gate + }, + + "capabilities": ["kv:own", "ui:surface"], + + "dependencies": { "other-plugin": "^1.0.0" }, // missing one blocks activation + "optionalDependencies": { "maybe": "^1.0.0" }, // missing one is normal + + "provides": [ + { + "service": "example.thing", + "version": "1.0.0", + "permission": "example.use", + }, + ], + "requires": [ + { "service": "other.thing", "versionRange": "^1.0.0", "optional": true }, + ], + "providesSecret": [ + { "key": "api-key", "permission": "example.secrets.share" }, + ], + "requiresSecret": [ + { "plugin": "other-plugin", "key": "api-key", "optional": true }, + ], + + "contributes": { + "tabs": [ + { + "id": "example", + "titleKey": "nav.example", + "icon": "Sparkles", + "openFrom": ["rail"], + }, + ], + "actions": [ + { + "id": "example.open", + "titleKey": "k", + "handler": "open", + "permission": "example.use", + }, + ], + "actionSlots": [{ "id": "example.toolbar", "accepts": ["button"] }], + "permissionGroup": { + "group": "example", + "permissions": ["example.use"], + "defaultForRole": { "admin": ["example.use"] }, // own namespace only + }, + "hostCapability": { + "key": "enableExample", + "labelKey": "k", + "editorTab": "general", + }, + }, + + "backend": "dist/backend.js", // default + "frontend": "dist/frontend.js", // default + "locales": "locales", // default + "platforms": ["linux", "win32", "darwin"], // optional +} +``` + +`category` is one of: Terminal, Files & Transfer, Infrastructure, Monitoring, +Networking, Access & Security, Productivity. + +`openFrom` is one of: rail, host-context-menu, palette. + +Cross-field rules the JSON schema cannot express, checked by `parseManifest`: + +- `engine.api` must match the SDK major version this build implements. +- Every `provides[].permission`, `providesSecret[].permission` and + `contributes.actions[].permission` must appear in the plugin's own + `contributes.permissionGroup.permissions`. A permission the catalog never + sees is one no admin can grant, so the surface would be invisible rather + than denied. +- `requiresSecret[].plugin` and both dependency maps must not name the plugin + itself, and a plugin cannot be in both `dependencies` and + `optionalDependencies`. +- Every `defaultForRole` entry must be a permission the plugin declares. + +`contributes.hostCapability`, `provides`, `requires`, `providesSecret` and +`requiresSecret` are carried forward from v1 unchanged because the service and +secret registries read them at activation. A5, A6 and A7 reshape them. + +Removed in v2: `permissions` (replaced by `capabilities`), `sidecars`, the +`capabilities.{backend,frontend,electron,platforms}` object (replaced by the +`backend`/`frontend`/`platforms` fields), `contributes.dashboardCards`, +`contributes.settingsPanel`, `contributes.apiPrefix`, and +`process:transport-owner` (the marker for the tier that no longer exists). + +--- + +## Capability catalog + +Colon-separated, ordered worst first. The i18n keys resolve under +`plugins.capabilities..title` and `.consequence` in +`src/ui/locales/en.json`, and the copy describes the consequence to the person +deciding, not the mechanism. + +| Capability | Risk | Meaning | +| -------------------- | -------- | -------------------------------------------------------------- | +| `credentials:read` | critical | See plaintext secrets for any reachable host | +| `ssh:connect` | high | Run commands on hosts it is connected to | +| `process:spawn` | high | Run programs on the Termix server | +| `users:write` | high | Create and change user accounts | +| `auth:provide` | high | Add a login method or second factor | +| `system:tls` | high | Request and replace the server certificate | +| `hosts:write` | medium | Create and change hosts | +| `credentials:use` | medium | Connect using a host's stored credentials, without seeing them | +| `network:outbound` | medium | Make outbound requests | +| `network:serve` | medium | Open a listening port | +| `users:read` | medium | See usernames and roles, never hashes or 2FA state | +| `events:core` | medium | Subscribe to and emit core events carrying other users' data | +| `notify:send` | medium | Send notifications through configured channels | +| `audit:read` | medium | Read the audit log | +| `desktop:window` | medium | Open desktop windows (Electron only) | +| `hosts:read` | low | See the host list it can already see | +| `db:own` | low | Own tables | +| `kv:own` | low | Small key/value state | +| `secrets:own` | low | Its own encrypted secrets | +| `settings:read-core` | low | Read core server settings | +| `ui:surface` | low | Contribute tabs, panels and settings | + +### Rules + +- One catalog, in `packages/plugin-sdk/src/capabilities.ts`. Nothing else + defines a capability. +- Adding one is an entry here plus the SDK method that checks it. A capability + nothing checks is decoration, and v1 shipped nineteen of those. +- A capability is checked where the privileged thing happens, not at + activation. Activation-time checks say what a plugin might do; call-time + checks say what it did. +- `events:core` is the exception that proves the shape: without it a plugin may + only emit under `plugin..`, because otherwise it could publish + `host.status` and drive the automations engine as though core had. + +--- + +## The ctx surface + +Built per plugin in `src/backend/plugins/ctx.ts` and passed to `activate`. + +| Member | Capability | Status | +| ------------------------------------------------ | -------------------------------- | ------ | +| `ctx.pluginId`, `ctx.manifest` | none | **A1** | +| `ctx.log.*` | none | **A1** | +| `ctx.events.emit` / `.on` | `events:core` for core topics | **A1** | +| `ctx.kv.get/set/delete/list` | `kv:own` | **A1** | +| `ctx.registry.*` | none | **A1** | +| `ctx.services.provide` / `.get` | per-service RBAC | **A1** | +| `ctx.secrets.offer` / `.withdraw` / `.getShared` | per-secret RBAC | **A1** | +| `ctx.disposables.add` | none | **A1** | +| `ctx.asUser(userId, fn)` | none, always audited | **A1** | +| `ctx.currentActor()` | none | **A1** | +| `ctx.db` | `db:own` | A3 | +| `ctx.http.route` / `ctx.ws` | `network:serve` | A4 | +| `ctx.hosts.*` | `hosts:read` / `hosts:write` | A4 | +| `ctx.ssh.*` | `ssh:connect`, `credentials:use` | A4 | +| `ctx.settings.*` | `settings:read-core` | A6 | +| `ctx.notify.*` | `notify:send` | A6 | +| `ctx.auth.*` | `auth:provide` | A8 | +| `ctx.fetch` | `network:outbound` | A4 | + +### The actor + +Never comes from plugin code. It is held in `AsyncLocalStorage` and set two +ways, with no third: + +- **A request.** A4's HTTP and WebSocket middleware runs the handler as the + user core already authenticated. +- **`ctx.asUser(userId, fn)`** for background work with no request behind it. + Always audited, and every core API inside still applies that user's RBAC. + +A plugin cannot pass a user id to a guarded method and have it believed. This +is what closes the forged-caller bug the old worker broker had, where a worker +could set `callerUserId` on its own messages. + +--- + +## Lifecycle rules + +1. **Everything is created inside `activate`.** No servers, timers, listeners + or connections at module scope. ESM caches modules, so disable-then-enable + re-runs `activate` against the same module object: anything created at + module scope is already torn down by the second call and never comes back. +2. **Everything is released on deactivate.** Registrations made through `ctx` + are tracked automatically. Anything the plugin creates itself goes in + `ctx.disposables.add(fn)`. A plugin that keeps a port after deactivate makes + "disabled" a lie and makes re-enabling fail on a port it still holds. +3. **Disposal does not depend on the plugin behaving.** The runtime empties the + disposable bag even when the plugin's `deactivate()` throws or does not + exist, in reverse registration order, isolating each disposer. +4. **A failed `activate` is still cleaned up**, and the plugin's own + `deactivate()` is _not_ called: it never finished starting, so it has no + state to tear down. +5. **Plugins never touch process signals or `process.exit`.** Core calls + `deactivate` on shutdown: `gracefulShutdown` in `src/backend/starter.ts` + calls `shutdownPlugins()`, which stops every plugin in reverse activation + order. A plugin that installs its own `SIGTERM` handler and exits skips the + rest of that sequence, including other plugins and the database flush. +6. **Enable, disable, enable must work without a restart.** + +### States + +`enabled`, `disabled`, `blocked` (a hard dependency is missing, recoverable), +`failed` (activation threw, a dependency cycle, or the error budget tripped). +`lastError` carries the reason. + +### Error budget + +Core wraps plugin callbacks it invokes. Five failures inside 60 seconds (both +configurable) marks the plugin `failed` and deactivates it, because a plugin +throwing on every call is worse than one that is off. +`POST /plugins/:id/retry` clears the budget and starts it again. + +--- + +## What this protects, and what it does not + +Be precise about this, because it is easy to overclaim and the previous +architecture did. + +**What the capability system gives you** + +- Every privileged thing a plugin can do through `ctx` is declared in a + manifest you can read before installing it. +- Every privileged call is checked against what was granted, and refused if it + was not. +- Every privileged call, allowed or refused, leaves an audit line naming the + plugin and the acting user. You can answer "what did this plugin do, and on + whose behalf". +- A plugin cannot name a user and be believed, so it cannot act as someone it + was not invoked by. +- Removing a capability from a manifest revokes the grant on the next boot. + +**What it does not give you** + +- **Containment.** A plugin runs in the server process. It can + `import("node:fs")` and read whatever the server user can read, open its own + sockets, spawn processes, and reach core modules directly. Guarding `ctx` + does not change any of that. +- **Protection from malicious code.** A plugin that wants to bypass the gate + can. Nothing here stops it. + +So the honest boundary is: capabilities stop accidental and casual overreach, +and make every privileged call auditable. What stops malicious code is +**signing, review and the kill list** - the same trust model as any other +software you choose to install on a server. + +The old worker tier claimed more than this, and the claim was not true either: +`worker_threads` shares the process, so a worker plugin could already reach +anything core could. The honest options for real containment are a child +process with `--permission`, or a WASM isolate. Neither is on the roadmap, and +until one is, this document does not pretend otherwise. + +--- + +## Legacy core imports: the debt D1 removes + +The twelve bundled plugins predate the SDK. They still reach core by relative +path (`../../../src/backend/...`), which a build-time string rewrite in +`scripts/copy-bundled-plugins.cjs` patches to the compiled output path. + +This is expected and temporary. The SDK does not yet expose what they need: +the SSH connection pool, host resolution, the repository layer, the shared +component library. D1 removes the debt once A3 through A8 have given them +supported APIs to move onto. + +What the lint fence enforces today: + +- Core must not import a plugin backend. **Error**, no offenders. +- A plugin backend must not import frontend code or use the `@/` alias. + **Error**, no offenders. +- The shell must not import plugin components. **Warning** with 21 offenders, + which is exactly the work A7 does. It becomes an error then. +- Plugin backends importing core by relative path is not yet flagged, because + there is nowhere for them to go. + +Known specifics: + +- **TODO(B18):** `plugins/ai/backend/tools/executor.ts` imports + `../../../automations/backend/routes.js` directly. It is declared as a hard + `dependencies` entry so the loader starts automations first and marks `ai` + blocked if automations is disabled. In B18 `ai` calls automations through an + `automations` service as an `optionalDependency` and the direct import goes + away. This is the only cross-plugin import in the repo. +- Eight plugins keep their original core URLs through the dispatchers in + `src/backend/database/routes/*-dispatch.ts` rather than `/plugin-api`. A4 + moves them. +- Four plugins own ports directly (ssh-terminal 30002, host-metrics 30005, + docker 30007 and 30009, remote-desktop 30008). A4 moves them onto the main + server. +- `src/ui/shell/pluginLoader.ts` still hardcodes plugin ids in + `BUILT_IN_TABS_BY_PLUGIN` and `applyFirstPartyActions`. A7 removes both. diff --git a/packages/plugin-sdk/package.json b/packages/plugin-sdk/package.json new file mode 100644 index 000000000..38b1e09be --- /dev/null +++ b/packages/plugin-sdk/package.json @@ -0,0 +1,41 @@ +{ + "name": "@termix/plugin-sdk", + "version": "1.0.0", + "description": "The contract between Termix core and a plugin: types, the capability catalog and the manifest schema.", + "license": "MIT", + "type": "module", + "exports": { + "./backend": { + "types": "./dist/backend.d.ts", + "default": "./dist/backend.js" + }, + "./frontend": { + "types": "./dist/frontend.d.ts", + "default": "./dist/frontend.js" + }, + "./manifest": { + "types": "./dist/manifest.d.ts", + "default": "./dist/manifest.js" + }, + "./capabilities": { + "types": "./dist/capabilities.d.ts", + "default": "./dist/capabilities.js" + }, + "./testing": { + "types": "./dist/testing.d.ts", + "default": "./dist/testing.js" + }, + "./schema/manifest.schema.json": "./schema/manifest.schema.json" + }, + "files": [ + "dist", + "schema", + "ARCHITECTURE.md" + ], + "scripts": { + "build": "tsc -p tsconfig.json" + }, + "dependencies": { + "semver": "^7.6.3" + } +} diff --git a/packages/plugin-sdk/schema/manifest.schema.json b/packages/plugin-sdk/schema/manifest.schema.json new file mode 100644 index 000000000..1ee7b841c --- /dev/null +++ b/packages/plugin-sdk/schema/manifest.schema.json @@ -0,0 +1,317 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://termix.site/schemas/plugin-manifest.schema.json", + "title": "Termix Plugin Manifest v2", + "type": "object", + "additionalProperties": false, + "required": [ + "id", + "name", + "version", + "description", + "author", + "license", + "engine", + "category", + "capabilities" + ], + "properties": { + "id": { + "type": "string", + "pattern": "^[a-z][a-z0-9-]{1,39}$", + "description": "Unique plugin id. Lowercase, starts with a letter, 2-40 characters. Must equal the plugin's directory name." + }, + "name": { "type": "string", "minLength": 1 }, + "version": { + "type": "string", + "pattern": "^\\d+\\.\\d+\\.\\d+(-[0-9A-Za-z-.]+)?(\\+[0-9A-Za-z-.]+)?$", + "description": "Semver version string." + }, + "description": { "type": "string", "minLength": 1 }, + "author": { + "type": "object", + "additionalProperties": false, + "required": ["name"], + "properties": { + "name": { "type": "string", "minLength": 1 }, + "url": { "type": "string", "minLength": 1 } + } + }, + "license": { "type": "string", "minLength": 1 }, + "repository": { "type": "string", "minLength": 1 }, + "category": { + "type": "string", + "enum": [ + "Terminal", + "Files & Transfer", + "Infrastructure", + "Monitoring", + "Networking", + "Access & Security", + "Productivity" + ] + }, + "icon": { + "type": "string", + "minLength": 1, + "description": "A Lucide icon name, or a relative path to an icon.svg in the plugin root." + }, + "engine": { + "type": "object", + "additionalProperties": false, + "required": ["termix", "api"], + "properties": { + "termix": { + "type": "string", + "minLength": 1, + "description": "Display string only. Never enforced." + }, + "api": { + "type": "string", + "pattern": "^[0-9]+$", + "description": "Plugin SDK major version. The real compatibility gate." + } + } + }, + "capabilities": { + "type": "array", + "description": "Catalog capability ids this plugin's code needs. See the SDK capability catalog.", + "items": { + "type": "string", + "enum": [ + "credentials:read", + "ssh:connect", + "process:spawn", + "users:write", + "auth:provide", + "system:tls", + "hosts:write", + "credentials:use", + "network:outbound", + "network:serve", + "users:read", + "events:core", + "notify:send", + "audit:read", + "desktop:window", + "hosts:read", + "db:own", + "kv:own", + "secrets:own", + "settings:read-core", + "ui:surface" + ] + } + }, + "dependencies": { + "type": "object", + "description": "Plugin ids this plugin cannot run without, mapped to a semver range.", + "additionalProperties": { "type": "string", "minLength": 1 }, + "propertyNames": { "pattern": "^[a-z][a-z0-9-]{1,39}$" } + }, + "optionalDependencies": { + "type": "object", + "description": "Plugin ids this plugin uses when present. It must keep working without them.", + "additionalProperties": { "type": "string", "minLength": 1 }, + "propertyNames": { "pattern": "^[a-z][a-z0-9-]{1,39}$" } + }, + "provides": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": ["service", "version", "permission"], + "properties": { + "service": { + "type": "string", + "pattern": "^[a-z0-9-]+(\\.[a-z0-9-]+)+$" + }, + "version": { + "type": "string", + "pattern": "^\\d+\\.\\d+\\.\\d+(-[0-9A-Za-z-.]+)?(\\+[0-9A-Za-z-.]+)?$" + }, + "permission": { "type": "string", "minLength": 1 } + } + } + }, + "requires": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": ["service", "versionRange"], + "properties": { + "service": { + "type": "string", + "pattern": "^[a-z0-9-]+(\\.[a-z0-9-]+)+$" + }, + "versionRange": { "type": "string", "minLength": 1 }, + "optional": { "type": "boolean" } + } + } + }, + "providesSecret": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": ["key", "permission"], + "properties": { + "key": { "type": "string", "pattern": "^[a-z0-9-]+$" }, + "permission": { "type": "string", "minLength": 1 }, + "descriptionKey": { "type": "string", "minLength": 1 } + } + } + }, + "requiresSecret": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": ["plugin", "key"], + "properties": { + "plugin": { "type": "string", "pattern": "^[a-z][a-z0-9-]{1,39}$" }, + "key": { "type": "string", "pattern": "^[a-z0-9-]+$" }, + "optional": { "type": "boolean" } + } + } + }, + "contributes": { + "type": "object", + "additionalProperties": false, + "properties": { + "tabs": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": ["id", "titleKey", "icon", "openFrom"], + "properties": { + "id": { "type": "string", "minLength": 1 }, + "titleKey": { "type": "string", "minLength": 1 }, + "icon": { "type": "string", "minLength": 1 }, + "openFrom": { + "type": "array", + "minItems": 1, + "items": { + "type": "string", + "enum": ["rail", "host-context-menu", "palette"] + } + } + } + } + }, + "actions": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": ["id", "titleKey", "handler"], + "properties": { + "id": { + "type": "string", + "pattern": "^[a-z0-9-]+(\\.[a-zA-Z0-9-]+)+$" + }, + "titleKey": { "type": "string", "minLength": 1 }, + "handler": { + "type": "string", + "pattern": "^[A-Za-z_$][A-Za-z0-9_$]*$" + }, + "icon": { "type": "string", "minLength": 1 }, + "permission": { "type": "string", "minLength": 1 }, + "slot": { "type": "string", "minLength": 1 }, + "kind": { "type": "string", "enum": ["button"] } + } + } + }, + "actionSlots": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": ["id", "accepts"], + "properties": { + "id": { + "type": "string", + "pattern": "^[a-z0-9-]+(\\.[a-zA-Z0-9-]+)+$" + }, + "accepts": { + "type": "array", + "minItems": 1, + "items": { "type": "string", "enum": ["button"] } + }, + "descriptionKey": { "type": "string", "minLength": 1 } + } + } + }, + "permissionGroup": { + "type": "object", + "additionalProperties": false, + "required": ["group", "permissions"], + "properties": { + "group": { "type": "string", "minLength": 1 }, + "permissions": { + "type": "array", + "minItems": 1, + "items": { + "type": "string", + "pattern": "^[a-z0-9-]+(\\.[a-z0-9_-]+)+$" + } + }, + "defaultForRole": { + "type": "object", + "description": "Role name -> permissions. Only permissions this plugin declares above are allowed.", + "additionalProperties": { + "type": "array", + "items": { "type": "string", "minLength": 1 } + } + } + } + }, + "hostCapability": { + "description": "A host-editor checkbox backed by a boolean column on the host record. Reshaped by a later step.", + "oneOf": [ + { "$ref": "#/$defs/hostCapability" }, + { + "type": "array", + "minItems": 1, + "items": { "$ref": "#/$defs/hostCapability" } + } + ] + } + } + }, + "backend": { + "type": "string", + "minLength": 1, + "description": "Built backend entry, relative to the plugin root. Defaults to dist/backend.js." + }, + "frontend": { + "type": "string", + "minLength": 1, + "description": "Built frontend entry, relative to the plugin root. Defaults to dist/frontend.js." + }, + "locales": { + "type": "string", + "minLength": 1, + "description": "Locales directory, relative to the plugin root. Defaults to locales." + }, + "platforms": { + "type": "array", + "minItems": 1, + "items": { "type": "string", "enum": ["linux", "win32", "darwin"] } + } + }, + "$defs": { + "hostCapability": { + "type": "object", + "additionalProperties": false, + "required": ["key", "labelKey", "editorTab"], + "properties": { + "key": { "type": "string", "minLength": 1 }, + "labelKey": { "type": "string", "minLength": 1 }, + "editorTab": { "type": "string", "minLength": 1 } + } + } + } +} diff --git a/packages/plugin-sdk/src/backend.ts b/packages/plugin-sdk/src/backend.ts new file mode 100644 index 000000000..5a3ce036c --- /dev/null +++ b/packages/plugin-sdk/src/backend.ts @@ -0,0 +1,135 @@ +/** + * The backend contract: what a plugin's activate(ctx) receives. + * + * These are types only. Core builds the real object in + * src/backend/plugins/ctx.ts and injects it at activation, so the SDK stays a + * description of the contract rather than an implementation a plugin could + * reach around. + * + * Members marked A3-A8 are not built yet. A plugin written against them today + * will type-check and fail at runtime, which is the intended signal. + */ + +import type { PluginManifest } from "./manifest.js"; + +/** + * Thrown when a plugin calls a guarded member without the capability. + * + * Two things must be true: the manifest declares it, and it is granted. A + * grant for something the manifest never declared is ignored, so widening a + * plugin's reach always needs a new manifest the user can see. + */ +export class PluginCapabilityError extends Error { + readonly code = "EPLUGINCAP"; + readonly pluginId: string; + readonly capability: string; + + constructor(pluginId: string, capability: string) { + super( + `Plugin "${pluginId}" is not granted the "${capability}" capability. ` + + `Declare it in the manifest's capabilities array and grant it in the plugin's settings.`, + ); + this.name = "PluginCapabilityError"; + this.pluginId = pluginId; + this.capability = capability; + } +} + +export interface PluginLogger { + debug: (message: string) => void; + info: (message: string) => void; + warn: (message: string) => void; + error: (message: string, error?: Error) => void; +} + +export interface PluginEvents { + /** Topics are namespaced to plugin..* unless the plugin holds events:core. */ + emit: (topic: string, payload: unknown) => void; + on: (topic: string, listener: (payload: unknown) => void) => () => void; +} + +/** Small key/value state owned by this plugin. Requires kv:own. */ +export interface PluginKeyValue { + get: (key: string) => Promise; + set: (key: string, value: unknown) => Promise; + delete: (key: string) => Promise; + list: () => Promise; +} + +export interface PluginRegistry { + provide: (key: string, value: T) => void; + consume: (key: string) => T | undefined; + revoke: (key: string, value?: unknown) => boolean; +} + +export interface PluginServices { + provide: (service: string, implementation: T) => void; + get: (service: string, options?: { userId?: string }) => T; +} + +export interface PluginSecrets { + offer: ( + key: string, + resolve: (userId: string) => Promise | string | null, + ) => void; + withdraw: (key: string) => boolean; + getShared: ( + pluginId: string, + key: string, + options?: { userId?: string }, + ) => Promise; +} + +/** + * Anything the runtime should undo when the plugin deactivates. Everything + * registered through ctx is tracked automatically; this is for resources a + * plugin creates itself, such as a server or an interval. + */ +export interface PluginDisposables { + add: (dispose: () => void | Promise) => void; +} + +export interface PluginContext { + readonly pluginId: string; + readonly manifest: PluginManifest; + readonly log: PluginLogger; + readonly events: PluginEvents; + readonly kv: PluginKeyValue; + readonly registry: PluginRegistry; + readonly services: PluginServices; + readonly secrets: PluginSecrets; + readonly disposables: PluginDisposables; + + /** + * Runs `fn` with `userId` as the acting user, for background work that has + * no request behind it. Always audited, and every core API inside still + * applies that user's RBAC. + * + * This is the only way a plugin can name a user. Nothing else trusts a user + * id that came from plugin code. + */ + asUser: (userId: string, fn: () => Promise | T) => Promise; + + /** The acting user for the current call, when there is one. */ + currentActor: () => string | undefined; +} + +export type PluginActivate = (ctx: PluginContext) => void | Promise; +export type PluginDeactivate = () => void | Promise; + +export interface PluginModule { + activate: PluginActivate; + deactivate?: PluginDeactivate; +} + +/** + * Identity helper that gives a plugin entry its types without importing the + * runtime. Everything a plugin creates belongs inside activate: the module is + * cached by the ESM loader, so disable-then-enable re-runs activate on the + * same module object. + */ +export function definePlugin(plugin: PluginModule): PluginModule { + return plugin; +} + +export type { PluginManifest } from "./manifest.js"; diff --git a/packages/plugin-sdk/src/capabilities.ts b/packages/plugin-sdk/src/capabilities.ts new file mode 100644 index 000000000..31fcdec94 --- /dev/null +++ b/packages/plugin-sdk/src/capabilities.ts @@ -0,0 +1,97 @@ +/** + * What a plugin's code may do. + * + * A capability is declared in the manifest and checked by the SDK method that + * needs it. It is not a user permission: those stay in core RBAC and are dotted + * (hosts.view), while capabilities are colon-separated (hosts:read) so the two + * can never be confused in a grant table or a log line. + * + * Adding one means an entry here plus the SDK method that checks it. The i18n + * keys resolve against core's en.json under plugins.capabilities.. + * + * The copy those keys carry describes the consequence to the person deciding, + * not the mechanism. "Run commands on your servers", not "grants ssh:connect". + */ + +export type CapabilityRisk = "low" | "medium" | "high" | "critical"; + +export interface CapabilityInfo { + id: string; + risk: CapabilityRisk; + /** i18n key for the short consequence, e.g. "Run commands on your servers". */ + titleKey: string; + /** i18n key for the qualifying sentence under the title. */ + consequenceKey: string; +} + +function entry(id: string, risk: CapabilityRisk): CapabilityInfo { + return { + id, + risk, + titleKey: `plugins.capabilities.${id}.title`, + consequenceKey: `plugins.capabilities.${id}.consequence`, + }; +} + +/** + * Ordered critical -> low. The install and review UI reads this order directly + * so the worst line is never folded into a summary row. + */ +export const CAPABILITY_CATALOG: readonly CapabilityInfo[] = [ + entry("credentials:read", "critical"), + + entry("ssh:connect", "high"), + entry("process:spawn", "high"), + entry("users:write", "high"), + entry("auth:provide", "high"), + entry("system:tls", "high"), + + entry("hosts:write", "medium"), + entry("credentials:use", "medium"), + entry("network:outbound", "medium"), + entry("network:serve", "medium"), + entry("users:read", "medium"), + entry("events:core", "medium"), + entry("notify:send", "medium"), + entry("audit:read", "medium"), + entry("desktop:window", "medium"), + + entry("hosts:read", "low"), + entry("db:own", "low"), + entry("kv:own", "low"), + entry("secrets:own", "low"), + entry("settings:read-core", "low"), + entry("ui:surface", "low"), +] as const; + +export const CAPABILITY_IDS: readonly string[] = CAPABILITY_CATALOG.map( + (capability) => capability.id, +); + +/** + * Kept as a string rather than a union of the catalog ids: a capability is + * data the runtime checks at load time, and a union here would force every + * caller holding a manifest value to cast. + */ +export type Capability = string; + +const BY_ID = new Map( + CAPABILITY_CATALOG.map((capability) => [capability.id, capability]), +); + +export function isKnownCapability(capability: string): boolean { + return BY_ID.has(capability); +} + +export function getCapabilityInfo( + capability: string, +): CapabilityInfo | undefined { + return BY_ID.get(capability); +} + +export const CAPABILITY_RISK_ORDER: readonly CapabilityRisk[] = [ + "critical", + "high", + "medium", + "low", +]; diff --git a/packages/plugin-sdk/src/frontend.ts b/packages/plugin-sdk/src/frontend.ts new file mode 100644 index 000000000..8b283e444 --- /dev/null +++ b/packages/plugin-sdk/src/frontend.ts @@ -0,0 +1,40 @@ +/** + * The frontend contract: what a plugin's frontend entry receives. + * + * Stubs only. A7 builds the real loader and fills these in, at which point a + * plugin registers every surface it contributes through this object and the + * shell stops carrying plugin ids of its own. + */ + +import type { PluginManifest } from "./manifest.js"; + +export interface TermixAppInfo { + readonly pluginId: string; + readonly manifest: PluginManifest; +} + +/** + * Registration surface for a plugin frontend. + * + * A7 adds: rail items, panels, tabs, host editor sections, host actions and + * badges, context menu items, palette entries, dashboard cards, homepage + * widgets, settings components, action slots, login/2FA UI and SSH auth + * editors. Everything registered here is removed when the plugin is disabled. + */ +export interface TermixApp extends TermixAppInfo { + /** Registered cleanup, run when the plugin is disabled. */ + onDispose: (dispose: () => void) => void; +} + +export type FrontendActivate = (app: TermixApp) => void | Promise; + +export interface FrontendModule { + activate: FrontendActivate; + deactivate?: () => void | Promise; +} + +export function definePluginFrontend(plugin: FrontendModule): FrontendModule { + return plugin; +} + +export type { PluginManifest } from "./manifest.js"; diff --git a/packages/plugin-sdk/src/manifest.ts b/packages/plugin-sdk/src/manifest.ts new file mode 100644 index 000000000..41ddcbbec --- /dev/null +++ b/packages/plugin-sdk/src/manifest.ts @@ -0,0 +1,840 @@ +/** + * Manifest v2: the inert JSON description of a plugin. + * + * Core reads this without running any plugin code, so everything that decides + * whether a plugin may load, what it may do and where its UI appears has to be + * expressible here. + * + * Unknown fields are rejected at every level. A typo'd "contribute" or + * "permissionGroups" used to validate clean and be silently dropped, which + * made a manifest look like it declared something it did not. + */ + +import semver from "semver"; +import { isKnownCapability } from "./capabilities.js"; + +/** + * The SDK major version this build implements. engine.api is the real + * compatibility gate; engine.termix is a display string and is never enforced. + */ +export const SUPPORTED_PLUGIN_API_VERSION = "1"; + +export const PLUGIN_CATEGORIES = [ + "Terminal", + "Files & Transfer", + "Infrastructure", + "Monitoring", + "Networking", + "Access & Security", + "Productivity", +] as const; + +export const PLUGIN_PLATFORMS = ["linux", "win32", "darwin"] as const; + +export const ACTION_CONTRIBUTION_KINDS = ["button"] as const; +export type ActionContributionKind = (typeof ACTION_CONTRIBUTION_KINDS)[number]; + +const OPEN_FROM_VALUES = ["rail", "host-context-menu", "palette"] as const; + +const ID_PATTERN = /^[a-z][a-z0-9-]{1,39}$/; +const SEMVER_PATTERN = /^\d+\.\d+\.\d+(-[0-9A-Za-z-.]+)?(\+[0-9A-Za-z-.]+)?$/; +const API_VERSION_PATTERN = /^[0-9]+$/; +/** Service names are dotted, e.g. "ssh.transport". */ +const SERVICE_PATTERN = /^[a-z0-9-]+(\.[a-z0-9-]+)+$/; +const SECRET_KEY_PATTERN = /^[a-z0-9-]+$/; +/** Action ids name a frontend function, so segments may be camelCase. */ +const ACTION_ID_PATTERN = /^[a-z0-9-]+(\.[a-zA-Z0-9-]+)+$/; +const HANDLER_PATTERN = /^[A-Za-z_$][A-Za-z0-9_$]*$/; +const PERMISSION_PATTERN = /^[a-z0-9-]+(\.[a-z0-9_-]+)+$/; + +export interface PluginAuthor { + name: string; + url?: string; +} + +export interface PluginEngine { + /** Display string only, e.g. ">=2.9.0". Never enforced. */ + termix: string; + /** SDK major version as a string. The real gate. */ + api: string; +} + +export interface HostCapabilityContribution { + key: string; + labelKey: string; + editorTab: string; +} + +export interface PluginTabContribution { + id: string; + titleKey: string; + icon: string; + openFrom: string[]; +} + +export interface PluginPermissionGroup { + group: string; + permissions: string[]; + /** Role name -> permissions. Only this plugin's own permissions are allowed. */ + defaultForRole?: Record; +} + +export interface PluginActionContribution { + id: string; + titleKey: string; + /** Name of the frontend export implementing this action. */ + handler: string; + icon?: string; + permission?: string; + slot?: string; + kind?: ActionContributionKind; +} + +export interface PluginActionSlot { + id: string; + accepts: ActionContributionKind[]; + descriptionKey?: string; +} + +export interface PluginServiceProvide { + service: string; + version: string; + /** The role permission a calling user needs. */ + permission: string; +} + +export interface PluginServiceRequire { + service: string; + versionRange: string; + optional?: boolean; +} + +export interface PluginSecretProvide { + key: string; + permission: string; + descriptionKey?: string; +} + +export interface PluginSecretRequire { + plugin: string; + key: string; + optional?: boolean; +} + +export interface PluginContributions { + tabs?: PluginTabContribution[]; + actions?: PluginActionContribution[]; + actionSlots?: PluginActionSlot[]; + permissionGroup?: PluginPermissionGroup; + /** + * A host-editor checkbox backed by a boolean column on the host record. + * A5/A6/A7 reshape this; it stays in v2 because remote-desktop, docker, + * proxmox and web-endpoint ship it today. + */ + hostCapability?: HostCapabilityContribution | HostCapabilityContribution[]; +} + +export interface PluginManifest { + id: string; + name: string; + version: string; + description: string; + author: PluginAuthor; + license: string; + repository?: string; + category: string; + icon?: string; + engine: PluginEngine; + /** Catalog capability ids. */ + capabilities: string[]; + /** pluginId -> semver range. A missing one blocks activation. */ + dependencies?: Record; + /** pluginId -> semver range. A missing one is normal. */ + optionalDependencies?: Record; + provides?: PluginServiceProvide[]; + requires?: PluginServiceRequire[]; + providesSecret?: PluginSecretProvide[]; + requiresSecret?: PluginSecretRequire[]; + contributes?: PluginContributions; + /** Built backend entry, relative to the plugin root. */ + backend?: string; + /** Built frontend entry, relative to the plugin root. */ + frontend?: string; + /** Locales directory, relative to the plugin root. */ + locales?: string; + platforms?: string[]; +} + +export const DEFAULT_BACKEND_ENTRY = "dist/backend.js"; +export const DEFAULT_FRONTEND_ENTRY = "dist/frontend.js"; +export const DEFAULT_LOCALES_DIR = "locales"; + +const REQUIRED_TOP_LEVEL = [ + "id", + "name", + "version", + "description", + "author", + "license", + "engine", + "category", + "capabilities", +]; + +const ALLOWED_TOP_LEVEL = new Set([ + ...REQUIRED_TOP_LEVEL, + "repository", + "icon", + "dependencies", + "optionalDependencies", + "provides", + "requires", + "providesSecret", + "requiresSecret", + "contributes", + "backend", + "frontend", + "locales", + "platforms", +]); + +const ALLOWED_CONTRIBUTES = new Set([ + "tabs", + "actions", + "actionSlots", + "permissionGroup", + "hostCapability", +]); + +function isPlainObject(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +/** Pushes an error for every key that is not in `allowed`. */ +function rejectUnknown( + value: Record, + allowed: Set | string[], + where: string, + errors: string[], +): void { + const set = Array.isArray(allowed) ? new Set(allowed) : allowed; + for (const key of Object.keys(value)) { + if (!set.has(key)) { + errors.push(`Unknown field "${key}" in ${where}`); + } + } +} + +function requireString( + value: unknown, + where: string, + errors: string[], +): value is string { + if (typeof value !== "string" || value.trim().length === 0) { + errors.push(`Field "${where}" must be a non-empty string`); + return false; + } + return true; +} + +export function validateManifest(manifest: unknown): string[] { + const errors: string[] = []; + + if (!isPlainObject(manifest)) { + return ["Manifest must be a JSON object"]; + } + + const m = manifest; + rejectUnknown(m, ALLOWED_TOP_LEVEL, "the manifest", errors); + + for (const field of REQUIRED_TOP_LEVEL) { + if (!(field in m)) errors.push(`Missing required field: "${field}"`); + } + + if ("id" in m) { + if (typeof m.id !== "string" || !ID_PATTERN.test(m.id)) { + errors.push( + `Field "id" must match ${ID_PATTERN} (lowercase, starts with a letter, 2-40 chars), got: ${JSON.stringify(m.id)}`, + ); + } + } + + if ("name" in m) requireString(m.name, "name", errors); + if ("description" in m) requireString(m.description, "description", errors); + if ("license" in m) requireString(m.license, "license", errors); + + if ("version" in m) { + if (typeof m.version !== "string" || !SEMVER_PATTERN.test(m.version)) { + errors.push( + `Field "version" must be valid semver, got: ${JSON.stringify(m.version)}`, + ); + } + } + + if ("repository" in m) requireString(m.repository, "repository", errors); + if ("icon" in m) requireString(m.icon, "icon", errors); + if ("backend" in m) requireString(m.backend, "backend", errors); + if ("frontend" in m) requireString(m.frontend, "frontend", errors); + if ("locales" in m) requireString(m.locales, "locales", errors); + + validateAuthor(m.author, errors); + validateEngine(m.engine, errors); + validateCategory(m.category, errors); + validateCapabilities(m.capabilities, errors); + validatePlatforms(m.platforms, errors); + validateDependencyMap(m.dependencies, "dependencies", errors); + validateDependencyMap(m.optionalDependencies, "optionalDependencies", errors); + validateProvides(m.provides, errors); + validateRequires(m.requires, errors); + validateProvidesSecret(m.providesSecret, errors); + validateRequiresSecret(m.requiresSecret, errors); + validateContributes(m.contributes, errors); + + return errors; +} + +function validateAuthor(author: unknown, errors: string[]): void { + if (author === undefined) return; + if (!isPlainObject(author)) { + errors.push('Field "author" must be an object'); + return; + } + rejectUnknown(author, ["name", "url"], '"author"', errors); + requireString(author.name, "author.name", errors); + if ("url" in author) requireString(author.url, "author.url", errors); +} + +function validateEngine(engine: unknown, errors: string[]): void { + if (engine === undefined) return; + if (!isPlainObject(engine)) { + errors.push('Field "engine" must be an object'); + return; + } + rejectUnknown(engine, ["termix", "api"], '"engine"', errors); + requireString(engine.termix, "engine.termix", errors); + if (typeof engine.api !== "string" || !API_VERSION_PATTERN.test(engine.api)) { + errors.push( + `Field "engine.api" must be an integer string, got: ${JSON.stringify(engine.api)}`, + ); + } +} + +function validateCategory(category: unknown, errors: string[]): void { + if (category === undefined) return; + if (typeof category !== "string") { + errors.push('Field "category" must be a string'); + return; + } + if (!PLUGIN_CATEGORIES.includes(category as never)) { + errors.push( + `Field "category" must be one of: ${PLUGIN_CATEGORIES.join(", ")}, got: "${category}"`, + ); + } +} + +function validateCapabilities(capabilities: unknown, errors: string[]): void { + if (capabilities === undefined) return; + if (!Array.isArray(capabilities)) { + errors.push('Field "capabilities" must be an array of capability ids'); + return; + } + const seen = new Set(); + capabilities.forEach((capability, index) => { + if (typeof capability !== "string") { + errors.push(`capabilities[${index}] must be a string`); + return; + } + if (!isKnownCapability(capability)) { + errors.push( + `capabilities[${index}] is not a known capability: "${capability}"`, + ); + return; + } + if (seen.has(capability)) { + errors.push(`capabilities[${index}] duplicates "${capability}"`); + } + seen.add(capability); + }); +} + +function validatePlatforms(platforms: unknown, errors: string[]): void { + if (platforms === undefined) return; + if (!Array.isArray(platforms) || platforms.length === 0) { + errors.push('Field "platforms" must be a non-empty array when present'); + return; + } + platforms.forEach((platform, index) => { + if (!PLUGIN_PLATFORMS.includes(platform as never)) { + errors.push( + `platforms[${index}] must be one of: ${PLUGIN_PLATFORMS.join(", ")}, got: ${JSON.stringify(platform)}`, + ); + } + }); +} + +function validateDependencyMap( + value: unknown, + field: string, + errors: string[], +): void { + if (value === undefined) return; + if (!isPlainObject(value)) { + errors.push( + `Field "${field}" must be an object of pluginId -> semver range`, + ); + return; + } + for (const [pluginId, range] of Object.entries(value)) { + if (!ID_PATTERN.test(pluginId)) { + errors.push(`${field} key "${pluginId}" is not a valid plugin id`); + } + if (typeof range !== "string" || !semver.validRange(range)) { + errors.push( + `${field}["${pluginId}"] must be a valid semver range, got: ${JSON.stringify(range)}`, + ); + } + } +} + +function validateProvides(provides: unknown, errors: string[]): void { + if (provides === undefined) return; + if (!Array.isArray(provides)) { + errors.push('Field "provides" must be an array'); + return; + } + const seen = new Set(); + provides.forEach((raw, index) => { + const where = `provides[${index}]`; + if (!isPlainObject(raw)) { + errors.push(`${where} must be an object`); + return; + } + rejectUnknown(raw, ["service", "version", "permission"], where, errors); + + if (typeof raw.service !== "string" || !SERVICE_PATTERN.test(raw.service)) { + errors.push(`${where}.service must be a dotted lowercase name`); + } else { + if (seen.has(raw.service)) { + errors.push(`${where}.service duplicates "${raw.service}"`); + } + seen.add(raw.service); + } + + if (typeof raw.version !== "string" || !SEMVER_PATTERN.test(raw.version)) { + errors.push(`${where}.version must be valid semver`); + } + requireString(raw.permission, `${where}.permission`, errors); + }); +} + +function validateRequires(requires: unknown, errors: string[]): void { + if (requires === undefined) return; + if (!Array.isArray(requires)) { + errors.push('Field "requires" must be an array'); + return; + } + requires.forEach((raw, index) => { + const where = `requires[${index}]`; + if (!isPlainObject(raw)) { + errors.push(`${where} must be an object`); + return; + } + rejectUnknown(raw, ["service", "versionRange", "optional"], where, errors); + + if (typeof raw.service !== "string" || !SERVICE_PATTERN.test(raw.service)) { + errors.push(`${where}.service must be a dotted lowercase name`); + } + if ( + typeof raw.versionRange !== "string" || + !semver.validRange(raw.versionRange) + ) { + errors.push(`${where}.versionRange must be a valid semver range`); + } + if ("optional" in raw && typeof raw.optional !== "boolean") { + errors.push(`${where}.optional must be a boolean`); + } + }); +} + +function validateProvidesSecret(value: unknown, errors: string[]): void { + if (value === undefined) return; + if (!Array.isArray(value)) { + errors.push('Field "providesSecret" must be an array'); + return; + } + const seen = new Set(); + value.forEach((raw, index) => { + const where = `providesSecret[${index}]`; + if (!isPlainObject(raw)) { + errors.push(`${where} must be an object`); + return; + } + rejectUnknown(raw, ["key", "permission", "descriptionKey"], where, errors); + + if (typeof raw.key !== "string" || !SECRET_KEY_PATTERN.test(raw.key)) { + errors.push(`${where}.key must be a lowercase slug`); + } else { + if (seen.has(raw.key)) + errors.push(`${where}.key duplicates "${raw.key}"`); + seen.add(raw.key); + } + requireString(raw.permission, `${where}.permission`, errors); + if ("descriptionKey" in raw) { + requireString(raw.descriptionKey, `${where}.descriptionKey`, errors); + } + }); +} + +function validateRequiresSecret(value: unknown, errors: string[]): void { + if (value === undefined) return; + if (!Array.isArray(value)) { + errors.push('Field "requiresSecret" must be an array'); + return; + } + value.forEach((raw, index) => { + const where = `requiresSecret[${index}]`; + if (!isPlainObject(raw)) { + errors.push(`${where} must be an object`); + return; + } + rejectUnknown(raw, ["plugin", "key", "optional"], where, errors); + + if (typeof raw.plugin !== "string" || !ID_PATTERN.test(raw.plugin)) { + errors.push(`${where}.plugin must be a valid plugin id`); + } + if (typeof raw.key !== "string" || !SECRET_KEY_PATTERN.test(raw.key)) { + errors.push(`${where}.key must be a lowercase slug`); + } + if ("optional" in raw && typeof raw.optional !== "boolean") { + errors.push(`${where}.optional must be a boolean`); + } + }); +} + +function validateContributes(contributes: unknown, errors: string[]): void { + if (contributes === undefined) return; + if (!isPlainObject(contributes)) { + errors.push('Field "contributes" must be an object'); + return; + } + rejectUnknown(contributes, ALLOWED_CONTRIBUTES, '"contributes"', errors); + + validateTabs(contributes.tabs, errors); + validatePermissionGroup(contributes.permissionGroup, errors); + validateActions(contributes.actions, errors); + validateActionSlots(contributes.actionSlots, errors); + validateHostCapability(contributes.hostCapability, errors); +} + +function validateTabs(tabs: unknown, errors: string[]): void { + if (tabs === undefined) return; + if (!Array.isArray(tabs)) { + errors.push('Field "contributes.tabs" must be an array'); + return; + } + const seen = new Set(); + tabs.forEach((raw, index) => { + const where = `contributes.tabs[${index}]`; + if (!isPlainObject(raw)) { + errors.push(`${where} must be an object`); + return; + } + rejectUnknown(raw, ["id", "titleKey", "icon", "openFrom"], where, errors); + + if (requireString(raw.id, `${where}.id`, errors)) { + if (seen.has(raw.id)) errors.push(`${where}.id duplicates "${raw.id}"`); + seen.add(raw.id); + } + requireString(raw.titleKey, `${where}.titleKey`, errors); + requireString(raw.icon, `${where}.icon`, errors); + + if (!Array.isArray(raw.openFrom) || raw.openFrom.length === 0) { + errors.push(`${where}.openFrom must be a non-empty array`); + return; + } + raw.openFrom.forEach((value: unknown, openIndex: number) => { + if (!OPEN_FROM_VALUES.includes(value as never)) { + errors.push( + `${where}.openFrom[${openIndex}] must be one of: ${OPEN_FROM_VALUES.join(", ")}`, + ); + } + }); + }); +} + +function validatePermissionGroup(group: unknown, errors: string[]): void { + if (group === undefined) return; + const where = "contributes.permissionGroup"; + if (!isPlainObject(group)) { + errors.push(`${where} must be an object`); + return; + } + rejectUnknown( + group, + ["group", "permissions", "defaultForRole"], + where, + errors, + ); + + requireString(group.group, `${where}.group`, errors); + + if (!Array.isArray(group.permissions) || group.permissions.length === 0) { + errors.push(`${where}.permissions must be a non-empty array`); + } else { + group.permissions.forEach((permission: unknown, index: number) => { + if ( + typeof permission !== "string" || + !PERMISSION_PATTERN.test(permission) + ) { + errors.push( + `${where}.permissions[${index}] must be a dotted permission name`, + ); + } + }); + } + + if (group.defaultForRole === undefined) return; + if (!isPlainObject(group.defaultForRole)) { + errors.push( + `${where}.defaultForRole must be an object of role -> permissions`, + ); + return; + } + + const declared = new Set( + Array.isArray(group.permissions) + ? (group.permissions as unknown[]).filter( + (value): value is string => typeof value === "string", + ) + : [], + ); + + for (const [role, wanted] of Object.entries(group.defaultForRole)) { + if (!Array.isArray(wanted)) { + errors.push(`${where}.defaultForRole["${role}"] must be an array`); + continue; + } + wanted.forEach((permission: unknown, index: number) => { + if (typeof permission !== "string") { + errors.push( + `${where}.defaultForRole["${role}"][${index}] must be a string`, + ); + return; + } + // A plugin may only suggest defaults for permissions it declares itself. + // Without this a manifest could add admin.* to any role at boot. + if (!declared.has(permission)) { + errors.push( + `${where}.defaultForRole["${role}"][${index}] is "${permission}", which this plugin does not declare in contributes.permissionGroup.permissions`, + ); + } + }); + } +} + +function validateActions(actions: unknown, errors: string[]): void { + if (actions === undefined) return; + if (!Array.isArray(actions)) { + errors.push('Field "contributes.actions" must be an array'); + return; + } + const seen = new Set(); + actions.forEach((raw, index) => { + const where = `contributes.actions[${index}]`; + if (!isPlainObject(raw)) { + errors.push(`${where} must be an object`); + return; + } + rejectUnknown( + raw, + ["id", "titleKey", "handler", "icon", "permission", "slot", "kind"], + where, + errors, + ); + + if (typeof raw.id !== "string" || !ACTION_ID_PATTERN.test(raw.id)) { + errors.push(`${where}.id must be a dotted action id`); + } else { + if (seen.has(raw.id)) errors.push(`${where}.id duplicates "${raw.id}"`); + seen.add(raw.id); + } + + requireString(raw.titleKey, `${where}.titleKey`, errors); + + if (typeof raw.handler !== "string" || !HANDLER_PATTERN.test(raw.handler)) { + errors.push(`${where}.handler must be a JavaScript identifier`); + } + if ("icon" in raw) requireString(raw.icon, `${where}.icon`, errors); + if ("permission" in raw) { + requireString(raw.permission, `${where}.permission`, errors); + } + if ("slot" in raw) requireString(raw.slot, `${where}.slot`, errors); + if ( + "kind" in raw && + !ACTION_CONTRIBUTION_KINDS.includes(raw.kind as never) + ) { + errors.push( + `${where}.kind must be one of: ${ACTION_CONTRIBUTION_KINDS.join(", ")}`, + ); + } + }); +} + +function validateActionSlots(slots: unknown, errors: string[]): void { + if (slots === undefined) return; + if (!Array.isArray(slots)) { + errors.push('Field "contributes.actionSlots" must be an array'); + return; + } + const seen = new Set(); + slots.forEach((raw, index) => { + const where = `contributes.actionSlots[${index}]`; + if (!isPlainObject(raw)) { + errors.push(`${where} must be an object`); + return; + } + rejectUnknown(raw, ["id", "accepts", "descriptionKey"], where, errors); + + if (typeof raw.id !== "string" || !ACTION_ID_PATTERN.test(raw.id)) { + errors.push(`${where}.id must be a dotted slot id`); + } else { + if (seen.has(raw.id)) errors.push(`${where}.id duplicates "${raw.id}"`); + seen.add(raw.id); + } + + if (!Array.isArray(raw.accepts) || raw.accepts.length === 0) { + errors.push(`${where}.accepts must be a non-empty array`); + } else { + raw.accepts.forEach((kind: unknown, kindIndex: number) => { + if (!ACTION_CONTRIBUTION_KINDS.includes(kind as never)) { + errors.push( + `${where}.accepts[${kindIndex}] must be one of: ${ACTION_CONTRIBUTION_KINDS.join(", ")}`, + ); + } + }); + } + if ("descriptionKey" in raw) { + requireString(raw.descriptionKey, `${where}.descriptionKey`, errors); + } + }); +} + +function validateHostCapability(value: unknown, errors: string[]): void { + if (value === undefined) return; + + const entries = Array.isArray(value) ? value : [value]; + if (Array.isArray(value) && value.length === 0) { + errors.push( + 'Field "contributes.hostCapability" must not be an empty array', + ); + return; + } + + const seen = new Set(); + entries.forEach((raw, index) => { + const where = Array.isArray(value) + ? `contributes.hostCapability[${index}]` + : "contributes.hostCapability"; + if (!isPlainObject(raw)) { + errors.push(`${where} must be an object`); + return; + } + rejectUnknown(raw, ["key", "labelKey", "editorTab"], where, errors); + + if (requireString(raw.key, `${where}.key`, errors)) { + if (seen.has(raw.key)) + errors.push(`${where}.key duplicates "${raw.key}"`); + seen.add(raw.key); + } + requireString(raw.labelKey, `${where}.labelKey`, errors); + requireString(raw.editorTab, `${where}.editorTab`, errors); + }); +} + +export interface ParsedManifest { + manifest?: PluginManifest; + errors: string[]; +} + +/** + * Validates, then applies the cross-field rules a JSON schema cannot express + * and fills in the entry-point defaults. + */ +export function parseManifest(raw: unknown): ParsedManifest { + const errors = validateManifest(raw); + if (errors.length > 0) return { errors }; + + const manifest = raw as PluginManifest; + + if (manifest.engine.api !== SUPPORTED_PLUGIN_API_VERSION) { + return { + errors: [ + `Plugin targets SDK API version ${manifest.engine.api}, but this build implements ${SUPPORTED_PLUGIN_API_VERSION}`, + ], + }; + } + + const declared = new Set( + manifest.contributes?.permissionGroup?.permissions ?? [], + ); + + // A permission the catalog never sees is one no admin can grant, so the + // service or action would be invisible rather than denied. + for (const provide of manifest.provides ?? []) { + if (!declared.has(provide.permission)) { + errors.push( + `provides["${provide.service}"].permission "${provide.permission}" is not declared in contributes.permissionGroup.permissions`, + ); + } + } + for (const secret of manifest.providesSecret ?? []) { + if (!declared.has(secret.permission)) { + errors.push( + `providesSecret["${secret.key}"].permission "${secret.permission}" is not declared in contributes.permissionGroup.permissions`, + ); + } + } + for (const action of manifest.contributes?.actions ?? []) { + if (action.permission && !declared.has(action.permission)) { + errors.push( + `contributes.actions["${action.id}"].permission "${action.permission}" is not declared in contributes.permissionGroup.permissions`, + ); + } + } + for (const secret of manifest.requiresSecret ?? []) { + if (secret.plugin === manifest.id) { + errors.push( + `requiresSecret["${secret.key}"] points at this plugin; use ctx.secrets.get instead`, + ); + } + } + for (const dependency of Object.keys(manifest.dependencies ?? {})) { + if (dependency === manifest.id) { + errors.push(`dependencies["${dependency}"] points at this plugin`); + } + if (manifest.optionalDependencies?.[dependency]) { + errors.push( + `"${dependency}" is listed in both dependencies and optionalDependencies`, + ); + } + } + for (const dependency of Object.keys(manifest.optionalDependencies ?? {})) { + if (dependency === manifest.id) { + errors.push( + `optionalDependencies["${dependency}"] points at this plugin`, + ); + } + } + + if (errors.length > 0) return { errors }; + + return { + manifest: { + ...manifest, + backend: manifest.backend ?? DEFAULT_BACKEND_ENTRY, + frontend: manifest.frontend ?? DEFAULT_FRONTEND_ENTRY, + locales: manifest.locales ?? DEFAULT_LOCALES_DIR, + }, + errors: [], + }; +} diff --git a/packages/plugin-sdk/src/testing.ts b/packages/plugin-sdk/src/testing.ts new file mode 100644 index 000000000..47f883f22 --- /dev/null +++ b/packages/plugin-sdk/src/testing.ts @@ -0,0 +1,139 @@ +/** + * Test helpers for plugin authors. + * + * A2 adds the shared vitest preset each plugin's config extends. For now this + * exposes the one thing a plugin test needs before that lands: a context + * double that satisfies the PluginContext contract without a running server. + */ + +import type { + PluginContext, + PluginDisposables, + PluginLogger, +} from "./backend.js"; +import type { PluginManifest } from "./manifest.js"; + +export interface FakeContextOptions { + pluginId?: string; + manifest?: Partial; + /** Acting user returned by currentActor and used by asUser. */ + actor?: string; +} + +export interface FakePluginContext { + ctx: PluginContext; + /** Everything the plugin registered through ctx.disposables. */ + disposals: Array<() => void | Promise>; + /** Topics emitted through ctx.events.emit, in order. */ + emitted: Array<{ topic: string; payload: unknown }>; + /** Backing store behind ctx.kv. */ + kv: Map; +} + +function noopLogger(): PluginLogger { + return { + debug: () => {}, + info: () => {}, + warn: () => {}, + error: () => {}, + }; +} + +/** + * A context double good enough to call activate() against in a unit test. + * Nothing here is capability-checked: a test asserting a capability gate + * should use the real runtime, not this. + */ +export function createFakeContext( + options: FakeContextOptions = {}, +): FakePluginContext { + const pluginId = options.pluginId ?? "test-plugin"; + const disposals: Array<() => void | Promise> = []; + const emitted: Array<{ topic: string; payload: unknown }> = []; + const kv = new Map(); + const listeners = new Map void>>(); + let actor = options.actor; + + const manifest = { + id: pluginId, + name: pluginId, + version: "1.0.0", + description: "", + author: { name: "test" }, + license: "MIT", + category: "Productivity", + engine: { termix: ">=2.9.0", api: "1" }, + capabilities: [], + ...options.manifest, + } as PluginManifest; + + const disposables: PluginDisposables = { + add: (dispose) => disposals.push(dispose), + }; + + const ctx: PluginContext = { + pluginId, + manifest, + log: noopLogger(), + + events: { + emit: (topic, payload) => { + emitted.push({ topic, payload }); + for (const listener of listeners.get(topic) ?? []) listener(payload); + }, + on: (topic, listener) => { + let set = listeners.get(topic); + if (!set) { + set = new Set(); + listeners.set(topic, set); + } + set.add(listener); + return () => set!.delete(listener); + }, + }, + + kv: { + get: async (key) => (kv.has(key) ? kv.get(key) : null), + set: async (key, value) => { + kv.set(key, value); + }, + delete: async (key) => kv.delete(key), + list: async () => [...kv.keys()], + }, + + registry: { + provide: () => {}, + consume: () => undefined, + revoke: () => false, + }, + + services: { + provide: () => {}, + get: () => { + throw new Error("createFakeContext does not implement services.get"); + }, + }, + + secrets: { + offer: () => {}, + withdraw: () => false, + getShared: async () => null, + }, + + disposables, + + asUser: async (userId, fn) => { + const previous = actor; + actor = userId; + try { + return await fn(); + } finally { + actor = previous; + } + }, + + currentActor: () => actor, + }; + + return { ctx, disposals, emitted, kv }; +} diff --git a/packages/plugin-sdk/tsconfig.json b/packages/plugin-sdk/tsconfig.json new file mode 100644 index 000000000..88ab83069 --- /dev/null +++ b/packages/plugin-sdk/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "../../node_modules/.tmp/tsconfig.plugin-sdk.tsbuildinfo", + "target": "ES2023", + "lib": ["ES2023"], + "module": "nodenext", + "moduleResolution": "nodenext", + "skipLibCheck": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "esModuleInterop": true, + "declaration": true, + "noEmit": false, + "rootDir": "./src", + "outDir": "./dist", + "strict": false, + "resolveJsonModule": true + }, + "include": ["src/**/*.ts"], + "exclude": ["src/**/*.test.ts"] +} diff --git a/plugins/ai/manifest.json b/plugins/ai/manifest.json index 4648a6726..cb57cfee1 100644 --- a/plugins/ai/manifest.json +++ b/plugins/ai/manifest.json @@ -3,19 +3,38 @@ "name": "AI Assistant", "version": "1.0.0", "description": "An AI assistant that can read your hosts, snippets, fleets, alerts and automations, and propose changes for you to approve.", - "author": { "name": "Termix" }, + "author": { + "name": "Termix" + }, "license": "MIT", "repository": "https://github.com/Termix-SSH/Termix", "category": "Productivity", "icon": "Sparkles", - "engine": { "termix": ">=2.9.0", "api": "1" }, - "capabilities": { - "backend": true, - "frontend": true, - "electron": true, - "platforms": ["linux", "win32", "darwin"] + "engine": { + "termix": ">=2.9.0", + "api": "1" }, - "permissions": ["process:transport-owner"], + "capabilities": [ + "hosts:read", + "hosts:write", + "credentials:use", + "ssh:connect", + "db:own", + "secrets:own", + "network:outbound", + "audit:read", + "ui:surface" + ], + "dependencies": { + "automations": "^1.0.0" + }, + "providesSecret": [ + { + "key": "api-key", + "permission": "ai.secrets.share", + "descriptionKey": "ai.secrets.shareApiKey" + } + ], "contributes": { "tabs": [ { @@ -25,6 +44,17 @@ "openFrom": ["rail", "palette"] } ], + "actions": [ + { + "id": "ai.openWithContext", + "titleKey": "ai.assistant", + "handler": "openWithContext", + "icon": "Bot", + "permission": "ai.services.use", + "slot": "terminal.toolbar", + "kind": "button" + } + ], "permissionGroup": { "group": "ai", "permissions": [ @@ -37,25 +67,10 @@ "defaultForRole": { "admin": ["ai.services.use"] } - }, - "actions": [ - { - "id": "ai.openWithContext", - "titleKey": "ai.assistant", - "handler": "openWithContext", - "icon": "Bot", - "permission": "ai.services.use", - "slot": "terminal.toolbar", - "kind": "button" - } - ] - }, - "providesSecret": [ - { - "key": "api-key", - "permission": "ai.secrets.share", - "descriptionKey": "ai.secrets.shareApiKey" } - ], - "sidecars": [] + }, + "backend": "backend/index.mjs", + "frontend": "frontend/index.mjs", + "locales": "locales", + "platforms": ["linux", "win32", "darwin"] } diff --git a/plugins/automations/manifest.json b/plugins/automations/manifest.json index fc0e17ad9..866ac6a90 100644 --- a/plugins/automations/manifest.json +++ b/plugins/automations/manifest.json @@ -3,18 +3,30 @@ "name": "Automations", "version": "1.0.0", "description": "Trigger-and-action automation engine: schedules, metric/status/docker-event triggers, and multi-step actions across managed hosts.", - "author": { "name": "Termix" }, + "author": { + "name": "Termix" + }, "license": "MIT", "repository": "https://github.com/Termix-SSH/Termix", "category": "Infrastructure", "icon": "Workflow", - "engine": { "termix": ">=2.9.0", "api": "1" }, - "capabilities": { - "backend": true, - "frontend": true, - "electron": true, - "platforms": ["linux", "win32", "darwin"] + "engine": { + "termix": ">=2.9.0", + "api": "1" }, - "permissions": ["process:transport-owner"], - "sidecars": [] + "capabilities": [ + "hosts:read", + "hosts:write", + "credentials:use", + "ssh:connect", + "db:own", + "events:core", + "notify:send", + "network:outbound", + "ui:surface" + ], + "backend": "backend/index.mjs", + "frontend": "frontend/index.mjs", + "locales": "locales", + "platforms": ["linux", "win32", "darwin"] } diff --git a/plugins/docker/backend/console.ts b/plugins/docker/backend/console.ts index 95f41d0b0..001136364 100644 --- a/plugins/docker/backend/console.ts +++ b/plugins/docker/backend/console.ts @@ -45,17 +45,57 @@ interface SSHSession { const activeSessions = new Map(); -const wss = new WebSocketServer({ - host: "127.0.0.1", - port: 30009, - maxPayload: MAX_WS_MESSAGE_BYTES, -}); +const CONSOLE_PORT = 30009; -wss.on("error", (error) => { - sshLogger.error("Docker console WebSocket server error", error, { - operation: "wss_error", +/** + * Created by startConsoleServer(), not at module load. + * + * Importing this file used to bind the port as a side effect, which meant the + * plugin could not be disabled and re-enabled: the module stays in the ESM + * cache, so the second activate() got a server that had already been closed. + */ +let wss: WebSocketServer | null = null; + +function handleConnection(ws: WebSocket, req: import("http").IncomingMessage) { + void onConsoleConnection(ws, req); +} + +/** Starts the console WebSocket server. Call from the plugin's activate(). */ +export function startConsoleServer(): Promise { + if (wss) return Promise.resolve(); + + return new Promise((resolve, reject) => { + const server = new WebSocketServer({ + host: "127.0.0.1", + port: CONSOLE_PORT, + maxPayload: MAX_WS_MESSAGE_BYTES, + }); + + const onStartupError = (error: Error) => { + server.off("listening", onListening); + wss = null; + reject(error); + }; + + const onListening = () => { + server.off("error", onStartupError); + // Past startup an error is logged rather than fatal: one bad socket + // must not take the server down. + server.on("error", (error) => { + sshLogger.error("Docker console WebSocket server error", error, { + operation: "wss_error", + }); + }); + resolve(); + }; + + server.once("error", onStartupError); + server.once("listening", onListening); + server.on("connection", handleConnection); + + wss = server; }); -}); +} async function detectShell( session: SSHSession, @@ -296,7 +336,10 @@ async function createJumpHostChain( return currentClient; } -wss.on("connection", async (ws: WebSocket, req) => { +async function onConsoleConnection( + ws: WebSocket, + req: import("http").IncomingMessage, +) { const token = extractWebSocketToken(req); if (!token) { @@ -824,12 +867,17 @@ wss.on("connection", async (ws: WebSocket, req) => { cleanup(); }); -}); +} /** - * Closes every live console session and the WebSocket server. Called by the - * plugin's deactivate() as well as SIGTERM, since disabling the docker - * plugin has to free port 30009 the same way process shutdown does. + * Closes every live console session and the WebSocket server, freeing port + * 30009. Called from the plugin's deactivate(). + * + * There is deliberately no process signal handler here. Core owns shutdown: + * gracefulShutdown in src/backend/starter.ts calls shutdownPlugins(), which + * runs every plugin's deactivate. A plugin calling process.exit() itself + * skipped the rest of that sequence, including other plugins and the + * database flush. */ export function closeConsoleServer(): Promise { activeSessions.forEach((session) => { @@ -841,9 +889,10 @@ export function closeConsoleServer(): Promise { activeSessions.clear(); - return new Promise((resolve) => wss.close(() => resolve())); -} + const server = wss; + wss = null; + if (!server) return Promise.resolve(); -process.on("SIGTERM", () => { - void closeConsoleServer().then(() => process.exit(0)); -}); + server.off("connection", handleConnection); + return new Promise((resolve) => server.close(() => resolve())); +} diff --git a/plugins/docker/backend/index.mjs b/plugins/docker/backend/index.mjs index f022dba55..5187c896c 100644 --- a/plugins/docker/backend/index.mjs +++ b/plugins/docker/backend/index.mjs @@ -118,10 +118,11 @@ export async function activate(ctx) { }); } - // Importing console.js binds its WebSocket server on port 30009 as a - // module-scope side effect -- unchanged from before the move, see that - // file's own header. + // Importing console.js no longer binds anything: the server is started + // here so disabling the plugin frees port 30009 and re-enabling can take + // it again, even though the module stays in the ESM cache. consoleModule = await loadRelative("console"); + await consoleModule.startConsoleServer(); ctx.log.info(`Docker plugin listening on ${port} (console on 30009)`); } diff --git a/plugins/docker/manifest.json b/plugins/docker/manifest.json index d5479b956..bdbb102aa 100644 --- a/plugins/docker/manifest.json +++ b/plugins/docker/manifest.json @@ -3,19 +3,25 @@ "name": "Docker", "version": "1.0.0", "description": "Container management over SSH: list, inspect, start, stop and view logs for Docker or Podman containers, plus an interactive container console.", - "author": { "name": "Termix" }, + "author": { + "name": "Termix" + }, "license": "MIT", "repository": "https://github.com/Termix-SSH/Termix", "category": "Infrastructure", "icon": "Box", - "engine": { "termix": ">=2.9.0", "api": "1" }, - "capabilities": { - "backend": true, - "frontend": true, - "electron": true, - "platforms": ["linux", "win32", "darwin"] + "engine": { + "termix": ">=2.9.0", + "api": "1" }, - "permissions": ["process:transport-owner"], + "capabilities": [ + "hosts:read", + "credentials:use", + "ssh:connect", + "network:serve", + "audit:read", + "ui:surface" + ], "contributes": { "tabs": [ { @@ -31,5 +37,8 @@ "editorTab": "docker" } }, - "sidecars": [] + "backend": "backend/index.mjs", + "frontend": "frontend/index.mjs", + "locales": "locales", + "platforms": ["linux", "win32", "darwin"] } diff --git a/plugins/fleets/manifest.json b/plugins/fleets/manifest.json index b98a8fdd6..d982f174a 100644 --- a/plugins/fleets/manifest.json +++ b/plugins/fleets/manifest.json @@ -3,18 +3,26 @@ "name": "Fleets", "version": "1.0.0", "description": "Group hosts into fleets by static membership or tag rules, and run commands, package actions, and file transfers across every member at once.", - "author": { "name": "Termix" }, + "author": { + "name": "Termix" + }, "license": "MIT", "repository": "https://github.com/Termix-SSH/Termix", "category": "Infrastructure", "icon": "Boxes", - "engine": { "termix": ">=2.9.0", "api": "1" }, - "capabilities": { - "backend": true, - "frontend": true, - "electron": true, - "platforms": ["linux", "win32", "darwin"] + "engine": { + "termix": ">=2.9.0", + "api": "1" }, - "permissions": ["process:transport-owner"], - "sidecars": [] + "capabilities": [ + "hosts:read", + "credentials:use", + "ssh:connect", + "db:own", + "ui:surface" + ], + "backend": "backend/index.mjs", + "frontend": "frontend/index.mjs", + "locales": "locales", + "platforms": ["linux", "win32", "darwin"] } diff --git a/plugins/host-metrics/manifest.json b/plugins/host-metrics/manifest.json index 395c6296b..688f0bc54 100644 --- a/plugins/host-metrics/manifest.json +++ b/plugins/host-metrics/manifest.json @@ -3,19 +3,26 @@ "name": "Host Metrics", "version": "1.0.0", "description": "Live CPU, memory, disk, network and process metrics, plus host management (services, packages, firewall, cron, users, SSL, logs, WireGuard, Tailscale).", - "author": { "name": "Termix" }, + "author": { + "name": "Termix" + }, "license": "MIT", "repository": "https://github.com/Termix-SSH/Termix", "category": "Monitoring", "icon": "Activity", - "engine": { "termix": ">=2.9.0", "api": "1" }, - "capabilities": { - "backend": true, - "frontend": true, - "electron": true, - "platforms": ["linux", "win32", "darwin"] + "engine": { + "termix": ">=2.9.0", + "api": "1" }, - "permissions": ["process:transport-owner"], + "capabilities": [ + "hosts:read", + "credentials:use", + "ssh:connect", + "db:own", + "network:serve", + "events:core", + "ui:surface" + ], "contributes": { "tabs": [ { @@ -26,5 +33,8 @@ } ] }, - "sidecars": [] + "backend": "backend/index.mjs", + "frontend": "frontend/index.mjs", + "locales": "locales", + "platforms": ["linux", "win32", "darwin"] } diff --git a/plugins/network-topology/manifest.json b/plugins/network-topology/manifest.json index f3475224a..0b1781704 100644 --- a/plugins/network-topology/manifest.json +++ b/plugins/network-topology/manifest.json @@ -3,19 +3,18 @@ "name": "Network Topology", "version": "1.0.0", "description": "Visualize hosts and their connections as an interactive graph, saved per user.", - "author": { "name": "Termix" }, + "author": { + "name": "Termix" + }, "license": "MIT", "repository": "https://github.com/Termix-SSH/Termix", "category": "Infrastructure", "icon": "Network", - "engine": { "termix": ">=2.9.0", "api": "1" }, - "capabilities": { - "backend": true, - "frontend": true, - "electron": true, - "platforms": ["linux", "win32", "darwin"] + "engine": { + "termix": ">=2.9.0", + "api": "1" }, - "permissions": ["process:transport-owner"], + "capabilities": ["db:own", "ui:surface"], "contributes": { "tabs": [ { @@ -24,10 +23,10 @@ "icon": "Network", "openFrom": ["rail", "palette"] } - ], - "dashboardCards": [ - { "id": "network_graph", "titleKey": "dashboard.networkGraph" } ] }, - "sidecars": [] + "backend": "backend/index.mjs", + "frontend": "frontend/index.mjs", + "locales": "locales", + "platforms": ["linux", "win32", "darwin"] } diff --git a/plugins/proxmox/manifest.json b/plugins/proxmox/manifest.json index 43695dc59..4b9ab0b06 100644 --- a/plugins/proxmox/manifest.json +++ b/plugins/proxmox/manifest.json @@ -3,19 +3,25 @@ "name": "Proxmox", "version": "1.0.0", "description": "Discover Proxmox VE guests over an existing SSH host, import them as Termix hosts, and keep them in sync.", - "author": { "name": "Termix" }, + "author": { + "name": "Termix" + }, "license": "MIT", "repository": "https://github.com/Termix-SSH/Termix", "category": "Infrastructure", "icon": "Server", - "engine": { "termix": ">=2.9.0", "api": "1" }, - "capabilities": { - "backend": true, - "frontend": true, - "electron": true, - "platforms": ["linux", "win32", "darwin"] + "engine": { + "termix": ">=2.9.0", + "api": "1" }, - "permissions": ["process:transport-owner"], + "capabilities": [ + "hosts:read", + "hosts:write", + "credentials:use", + "ssh:connect", + "db:own", + "ui:surface" + ], "contributes": { "hostCapability": { "key": "enableProxmox", @@ -23,5 +29,8 @@ "editorTab": "proxmox" } }, - "sidecars": [] + "backend": "backend/index.mjs", + "frontend": "frontend/index.mjs", + "locales": "locales", + "platforms": ["linux", "win32", "darwin"] } diff --git a/plugins/remote-desktop/manifest.json b/plugins/remote-desktop/manifest.json index e6d349f6c..d24366971 100644 --- a/plugins/remote-desktop/manifest.json +++ b/plugins/remote-desktop/manifest.json @@ -3,19 +3,25 @@ "name": "Remote Desktop", "version": "1.0.0", "description": "RDP, VNC and Telnet remote desktop sessions, with clipboard, drive redirection, session recording and session sharing.", - "author": { "name": "Termix" }, + "author": { + "name": "Termix" + }, "license": "MIT", "repository": "https://github.com/Termix-SSH/Plugin-RemoteDesktop", "category": "Terminal", "icon": "Monitor", - "engine": { "termix": ">=2.9.0", "api": "1" }, - "capabilities": { - "backend": true, - "frontend": true, - "electron": true, - "platforms": ["linux", "win32", "darwin"] + "engine": { + "termix": ">=2.9.0", + "api": "1" }, - "permissions": ["process:transport-owner"], + "capabilities": [ + "hosts:read", + "credentials:use", + "ssh:connect", + "network:serve", + "audit:read", + "ui:surface" + ], "contributes": { "tabs": [ { @@ -38,8 +44,16 @@ } ], "hostCapability": [ - { "key": "enableRdp", "labelKey": "hosts.tabRdp", "editorTab": "rdp" }, - { "key": "enableVnc", "labelKey": "hosts.tabVnc", "editorTab": "vnc" }, + { + "key": "enableRdp", + "labelKey": "hosts.tabRdp", + "editorTab": "rdp" + }, + { + "key": "enableVnc", + "labelKey": "hosts.tabVnc", + "editorTab": "vnc" + }, { "key": "enableTelnet", "labelKey": "hosts.tabTelnet", @@ -47,5 +61,8 @@ } ] }, - "sidecars": [] + "backend": "backend/index.mjs", + "frontend": "frontend/index.mjs", + "locales": "locales", + "platforms": ["linux", "win32", "darwin"] } diff --git a/plugins/ssh-terminal/manifest.json b/plugins/ssh-terminal/manifest.json index a8432cd3f..10808c036 100644 --- a/plugins/ssh-terminal/manifest.json +++ b/plugins/ssh-terminal/manifest.json @@ -3,19 +3,24 @@ "name": "SSH Terminal", "version": "1.0.0", "description": "Interactive SSH terminal sessions, with jump hosts, host key verification and session sharing.", - "author": { "name": "Termix" }, + "author": { + "name": "Termix" + }, "license": "MIT", "repository": "https://github.com/Termix-SSH/Termix", "category": "Terminal", "icon": "SquareTerminal", - "engine": { "termix": ">=2.9.0", "api": "1" }, - "capabilities": { - "backend": true, - "frontend": true, - "electron": true, - "platforms": ["linux", "win32", "darwin"] + "engine": { + "termix": ">=2.9.0", + "api": "1" }, - "permissions": ["process:transport-owner"], + "capabilities": [ + "hosts:read", + "credentials:use", + "ssh:connect", + "network:serve", + "ui:surface" + ], "contributes": { "tabs": [ { @@ -32,5 +37,8 @@ } ] }, - "sidecars": [] + "backend": "backend/index.mjs", + "frontend": "frontend/index.mjs", + "locales": "locales", + "platforms": ["linux", "win32", "darwin"] } diff --git a/plugins/tailscale/backend/routes.ts b/plugins/tailscale/backend/routes.ts index f70c0aed4..20ffa4651 100644 --- a/plugins/tailscale/backend/routes.ts +++ b/plugins/tailscale/backend/routes.ts @@ -5,6 +5,7 @@ import { Router } from "express"; import { AuthManager } from "../../../src/backend/utils/auth-manager.js"; +import { PermissionManager } from "../../../src/backend/utils/permission-manager.js"; import { apiLogger } from "../../../src/backend/utils/logger.js"; import { fetchWithProxy } from "../../../src/backend/utils/proxy-agent.js"; import { createCurrentSettingsRepository } from "../../../src/backend/database/repositories/factory.js"; @@ -39,6 +40,16 @@ export const router = Router(); const authManager = AuthManager.getInstance(); const authenticateJWT = authManager.createAuthMiddleware(); +/** + * Listing the tailnet uses the admin-stored API key, so it needs more than a + * session. Writing the key is already admin-only and reading it back is + * masked, but using it to enumerate every device was open to any logged-in + * user, which handed out an internal network inventory. + */ +const requireDeviceAccess = PermissionManager.getInstance().requirePermission( + "tailscale.devices.view", +); + function normalizeApiBase(raw: string | null): string { const trimmed = (raw ?? "").trim().replace(/\/+$/, ""); return trimmed || DEFAULT_TAILSCALE_API_BASE; @@ -49,12 +60,17 @@ function normalizeApiBase(raw: string | null): string { * /tailscale/devices: * get: * summary: List Tailscale devices - * description: Returns the list of devices in the configured tailnet using the stored API key. + * description: > + * Returns the devices in the configured tailnet using the stored admin + * API key. Requires the tailscale.devices.view permission, because the + * response is a full inventory of the tailnet. * tags: * - Tailscale * responses: * 200: * description: List of tailnet devices. + * 403: + * description: The caller lacks tailscale.devices.view. * content: * application/json: * schema: @@ -67,67 +83,72 @@ function normalizeApiBase(raw: string | null): string { * 500: * description: Failed to fetch Tailscale devices. */ -router.get("/devices", authenticateJWT, async (_req, res) => { - try { - const settingsRepo = createCurrentSettingsRepository(); - const apiKey = (await settingsRepo.get("tailscale_api_key")) ?? ""; - if (!apiKey) { - return res.json({ devices: [], hasApiKey: false }); - } - const apiBase = normalizeApiBase( - await settingsRepo.get("tailscale_api_base_url"), - ); +router.get( + "/devices", + authenticateJWT, + requireDeviceAccess, + async (_req, res) => { + try { + const settingsRepo = createCurrentSettingsRepository(); + const apiKey = (await settingsRepo.get("tailscale_api_key")) ?? ""; + if (!apiKey) { + return res.json({ devices: [], hasApiKey: false }); + } + const apiBase = normalizeApiBase( + await settingsRepo.get("tailscale_api_base_url"), + ); - const url = `${apiBase}/tailnet/-/devices?fields=all`; - const response = await fetchWithProxy(url, { - headers: { - Authorization: `Bearer ${apiKey}`, - "User-Agent": "Termix/1.0", - }, - }); - - if (!response.ok) { - apiLogger.warn("Tailscale API returned non-OK status", { - operation: "tailscale_devices", - status: response.status, + const url = `${apiBase}/tailnet/-/devices?fields=all`; + const response = await fetchWithProxy(url, { + headers: { + Authorization: `Bearer ${apiKey}`, + "User-Agent": "Termix/1.0", + }, }); - if (response.status === 401 || response.status === 403) { - return res.status(401).json({ - error: "Invalid Tailscale API key", + + if (!response.ok) { + apiLogger.warn("Tailscale API returned non-OK status", { + operation: "tailscale_devices", + status: response.status, + }); + if (response.status === 401 || response.status === 403) { + return res.status(401).json({ + error: "Invalid Tailscale API key", + devices: [], + hasApiKey: true, + }); + } + return res.status(502).json({ + error: "Tailscale API error", devices: [], hasApiKey: true, }); } - return res.status(502).json({ - error: "Tailscale API error", + + const data = (await response.json()) as { devices: TailscaleAPIDevice[] }; + + const devices: TailscaleDevice[] = (data.devices ?? []).map((d) => ({ + id: d.id, + name: d.name, + hostname: d.hostname, + addresses: d.addresses ?? [], + os: d.os, + lastSeen: d.lastSeen, + })); + + res.json({ devices, hasApiKey: true }); + } catch (err) { + apiLogger.error("Failed to fetch Tailscale devices", err, { + operation: "tailscale_devices", + }); + res.status(500).json({ + error: "Failed to fetch Tailscale devices", devices: [], hasApiKey: true, }); } - - const data = (await response.json()) as { devices: TailscaleAPIDevice[] }; - - const devices: TailscaleDevice[] = (data.devices ?? []).map((d) => ({ - id: d.id, - name: d.name, - hostname: d.hostname, - addresses: d.addresses ?? [], - os: d.os, - lastSeen: d.lastSeen, - })); - - res.json({ devices, hasApiKey: true }); - } catch (err) { - apiLogger.error("Failed to fetch Tailscale devices", err, { - operation: "tailscale_devices", - }); - res.status(500).json({ - error: "Failed to fetch Tailscale devices", - devices: [], - hasApiKey: true, - }); - } -}); + }, +); /** Called from activate(). Mounts this router at /tailscale via the shared * dispatcher. */ diff --git a/plugins/tailscale/manifest.json b/plugins/tailscale/manifest.json index d313df463..714d3c646 100644 --- a/plugins/tailscale/manifest.json +++ b/plugins/tailscale/manifest.json @@ -3,18 +3,26 @@ "name": "Tailscale", "version": "1.0.0", "description": "Browse and connect to tailnet devices, manage the Tailscale API key, and show the Tailscale SSH re-authentication dialog.", - "author": { "name": "Termix" }, + "author": { + "name": "Termix" + }, "license": "MIT", "repository": "https://github.com/Termix-SSH/Termix", "category": "Networking", "icon": "Network", - "engine": { "termix": ">=2.9.0", "api": "1" }, - "capabilities": { - "backend": true, - "frontend": true, - "electron": true, - "platforms": ["linux", "win32", "darwin"] + "engine": { + "termix": ">=2.9.0", + "api": "1" }, - "permissions": ["process:transport-owner"], - "sidecars": [] + "capabilities": ["settings:read-core", "network:outbound", "ui:surface"], + "contributes": { + "permissionGroup": { + "group": "tailscale", + "permissions": ["tailscale.devices.view"] + } + }, + "backend": "backend/index.mjs", + "frontend": "frontend/index.mjs", + "locales": "locales", + "platforms": ["linux", "win32", "darwin"] } diff --git a/plugins/web-endpoint/manifest.json b/plugins/web-endpoint/manifest.json index bb26ce6a2..042b2d58c 100644 --- a/plugins/web-endpoint/manifest.json +++ b/plugins/web-endpoint/manifest.json @@ -3,19 +3,23 @@ "name": "Web Endpoint", "version": "1.0.0", "description": "Open a host's web UI in a tab or an isolated desktop window, directly or over an SSH tunnel.", - "author": { "name": "Termix" }, + "author": { + "name": "Termix" + }, "license": "MIT", "repository": "https://github.com/Termix-SSH/Plugin-WebEndpoint", "category": "Networking", "icon": "Globe", - "engine": { "termix": ">=2.9.0", "api": "1" }, - "capabilities": { - "backend": true, - "frontend": true, - "electron": true, - "platforms": ["linux", "win32", "darwin"] + "engine": { + "termix": ">=2.9.0", + "api": "1" }, - "permissions": ["process:transport-owner"], + "capabilities": [ + "hosts:read", + "credentials:use", + "ssh:connect", + "ui:surface" + ], "contributes": { "tabs": [ { @@ -31,5 +35,8 @@ "editorTab": "general" } }, - "sidecars": [] + "backend": "backend/index.mjs", + "frontend": "frontend/index.mjs", + "locales": "locales", + "platforms": ["linux", "win32", "darwin"] } diff --git a/plugins/workspaces/manifest.json b/plugins/workspaces/manifest.json index 0409d97e8..42630c160 100644 --- a/plugins/workspaces/manifest.json +++ b/plugins/workspaces/manifest.json @@ -3,18 +3,20 @@ "name": "Workspaces", "version": "1.0.0", "description": "Save and restore named tab/split layouts per user.", - "author": { "name": "Termix" }, + "author": { + "name": "Termix" + }, "license": "MIT", "repository": "https://github.com/Termix-SSH/Termix", "category": "Productivity", "icon": "LayoutTemplate", - "engine": { "termix": ">=2.9.0", "api": "1" }, - "capabilities": { - "backend": true, - "frontend": true, - "electron": true, - "platforms": ["linux", "win32", "darwin"] + "engine": { + "termix": ">=2.9.0", + "api": "1" }, - "permissions": ["process:transport-owner"], - "sidecars": [] + "capabilities": ["db:own", "ui:surface"], + "backend": "backend/index.mjs", + "frontend": "frontend/index.mjs", + "locales": "locales", + "platforms": ["linux", "win32", "darwin"] } diff --git a/scripts/__fixtures__/manifests/invalid-action-permission.json b/scripts/__fixtures__/manifests/invalid-action-permission.json index 84b7023ed..2c1439607 100644 --- a/scripts/__fixtures__/manifests/invalid-action-permission.json +++ b/scripts/__fixtures__/manifests/invalid-action-permission.json @@ -1,10 +1,10 @@ { - "id": "sample-bad-action", - "name": "Sample Bad Action", + "id": "sample-plugin", + "name": "Sample", "version": "1.0.0", - "description": "An action gated by a permission the plugin never declares.", + "description": "A sample.", "author": { - "name": "Jane Doe" + "name": "Termix" }, "license": "MIT", "category": "Productivity", @@ -12,26 +12,19 @@ "termix": ">=2.9.0", "api": "1" }, - "capabilities": { - "backend": false, - "frontend": true, - "electron": false, - "platforms": ["linux", "win32", "darwin"] - }, - "permissions": ["ui.tab"], + "capabilities": ["kv:own", "ui:surface"], "contributes": { "permissionGroup": { - "group": "samplebadaction", - "permissions": ["samplebadaction.use"] + "group": "sample-plugin", + "permissions": ["sample-plugin.use"] }, "actions": [ { - "id": "samplebadaction.open", - "titleKey": "sampleBadAction.open", + "id": "sample.open", + "titleKey": "sample.open", "handler": "open", - "permission": "samplebadaction.never_declared" + "permission": "sample-plugin.nope" } ] - }, - "sidecars": [] + } } diff --git a/scripts/__fixtures__/manifests/invalid-bad-id.json b/scripts/__fixtures__/manifests/invalid-bad-id.json index f254857bb..499353aad 100644 --- a/scripts/__fixtures__/manifests/invalid-bad-id.json +++ b/scripts/__fixtures__/manifests/invalid-bad-id.json @@ -1,10 +1,10 @@ { - "id": "Sample_Plugin!", - "name": "Sample Plugin", + "id": "Sample_Plugin", + "name": "Sample", "version": "1.0.0", - "description": "Has an id that violates the required pattern.", + "description": "A sample.", "author": { - "name": "Jane Doe" + "name": "Termix" }, "license": "MIT", "category": "Productivity", @@ -12,12 +12,5 @@ "termix": ">=2.9.0", "api": "1" }, - "capabilities": { - "backend": true, - "frontend": true, - "electron": false, - "platforms": ["linux"] - }, - "permissions": [], - "sidecars": [] + "capabilities": ["kv:own", "ui:surface"] } diff --git a/scripts/__fixtures__/manifests/invalid-missing-field.json b/scripts/__fixtures__/manifests/invalid-missing-field.json index 691c3cef6..929d16ac9 100644 --- a/scripts/__fixtures__/manifests/invalid-missing-field.json +++ b/scripts/__fixtures__/manifests/invalid-missing-field.json @@ -1,9 +1,9 @@ { - "id": "sample-plugin", - "name": "Sample Plugin", + "name": "Sample", "version": "1.0.0", + "description": "A sample.", "author": { - "name": "Jane Doe" + "name": "Termix" }, "license": "MIT", "category": "Productivity", @@ -11,12 +11,5 @@ "termix": ">=2.9.0", "api": "1" }, - "capabilities": { - "backend": true, - "frontend": true, - "electron": false, - "platforms": ["linux"] - }, - "permissions": [], - "sidecars": [] + "capabilities": ["kv:own", "ui:surface"] } diff --git a/scripts/__fixtures__/manifests/invalid-nested-unknown-field.json b/scripts/__fixtures__/manifests/invalid-nested-unknown-field.json new file mode 100644 index 000000000..d6c72eff4 --- /dev/null +++ b/scripts/__fixtures__/manifests/invalid-nested-unknown-field.json @@ -0,0 +1,27 @@ +{ + "id": "sample-plugin", + "name": "Sample", + "version": "1.0.0", + "description": "A sample.", + "author": { + "name": "Termix" + }, + "license": "MIT", + "category": "Productivity", + "engine": { + "termix": ">=2.9.0", + "api": "1" + }, + "capabilities": ["kv:own", "ui:surface"], + "contributes": { + "tabs": [ + { + "id": "t", + "titleKey": "k", + "icon": "i", + "openFrom": ["rail"], + "extra": 1 + } + ] + } +} diff --git a/scripts/__fixtures__/manifests/invalid-role-default-escalation.json b/scripts/__fixtures__/manifests/invalid-role-default-escalation.json new file mode 100644 index 000000000..0af6006f8 --- /dev/null +++ b/scripts/__fixtures__/manifests/invalid-role-default-escalation.json @@ -0,0 +1,25 @@ +{ + "id": "sample-plugin", + "name": "Sample", + "version": "1.0.0", + "description": "A sample.", + "author": { + "name": "Termix" + }, + "license": "MIT", + "category": "Productivity", + "engine": { + "termix": ">=2.9.0", + "api": "1" + }, + "capabilities": ["kv:own", "ui:surface"], + "contributes": { + "permissionGroup": { + "group": "sample-plugin", + "permissions": ["sample-plugin.use"], + "defaultForRole": { + "user": ["admin.users.manage"] + } + } + } +} diff --git a/scripts/__fixtures__/manifests/invalid-service-permission.json b/scripts/__fixtures__/manifests/invalid-service-permission.json index 1a289489a..43483b323 100644 --- a/scripts/__fixtures__/manifests/invalid-service-permission.json +++ b/scripts/__fixtures__/manifests/invalid-service-permission.json @@ -1,10 +1,10 @@ { - "id": "sample-provider", - "name": "Sample Provider", + "id": "sample-plugin", + "name": "Sample", "version": "1.0.0", - "description": "Gates a service behind a permission its own group never declares.", + "description": "A sample.", "author": { - "name": "Jane Doe" + "name": "Termix" }, "license": "MIT", "category": "Productivity", @@ -12,25 +12,18 @@ "termix": ">=2.9.0", "api": "1" }, - "capabilities": { - "backend": true, - "frontend": false, - "electron": false, - "platforms": ["linux"] - }, - "permissions": ["hosts.read"], - "contributes": { - "permissionGroup": { - "group": "sampleprovider", - "permissions": ["sampleprovider.greet.use"] - } - }, + "capabilities": ["kv:own", "ui:surface"], "provides": [ { - "service": "sampleprovider.greet", - "version": "1.2.0", - "permission": "sampleprovider.undeclared" + "service": "sample.thing", + "version": "1.0.0", + "permission": "sample-plugin.undeclared" } ], - "sidecars": [] + "contributes": { + "permissionGroup": { + "group": "sample-plugin", + "permissions": ["sample-plugin.use"] + } + } } diff --git a/scripts/__fixtures__/manifests/invalid-unknown-capability.json b/scripts/__fixtures__/manifests/invalid-unknown-capability.json new file mode 100644 index 000000000..e7f802f5d --- /dev/null +++ b/scripts/__fixtures__/manifests/invalid-unknown-capability.json @@ -0,0 +1,16 @@ +{ + "id": "sample-plugin", + "name": "Sample", + "version": "1.0.0", + "description": "A sample.", + "author": { + "name": "Termix" + }, + "license": "MIT", + "category": "Productivity", + "engine": { + "termix": ">=2.9.0", + "api": "1" + }, + "capabilities": ["hosts.read"] +} diff --git a/scripts/__fixtures__/manifests/invalid-unknown-category.json b/scripts/__fixtures__/manifests/invalid-unknown-category.json index 17e031af9..9478bb2d6 100644 --- a/scripts/__fixtures__/manifests/invalid-unknown-category.json +++ b/scripts/__fixtures__/manifests/invalid-unknown-category.json @@ -1,23 +1,16 @@ { "id": "sample-plugin", - "name": "Sample Plugin", + "name": "Sample", "version": "1.0.0", - "description": "Has a category that is not in the known category list.", + "description": "A sample.", "author": { - "name": "Jane Doe" + "name": "Termix" }, "license": "MIT", - "category": "Miscellaneous", + "category": "Nonsense", "engine": { "termix": ">=2.9.0", "api": "1" }, - "capabilities": { - "backend": true, - "frontend": true, - "electron": false, - "platforms": ["linux"] - }, - "permissions": [], - "sidecars": [] + "capabilities": ["kv:own", "ui:surface"] } diff --git a/scripts/__fixtures__/manifests/invalid-unknown-field.json b/scripts/__fixtures__/manifests/invalid-unknown-field.json new file mode 100644 index 000000000..1f1f80a4e --- /dev/null +++ b/scripts/__fixtures__/manifests/invalid-unknown-field.json @@ -0,0 +1,17 @@ +{ + "id": "sample-plugin", + "name": "Sample", + "version": "1.0.0", + "description": "A sample.", + "author": { + "name": "Termix" + }, + "license": "MIT", + "category": "Productivity", + "engine": { + "termix": ">=2.9.0", + "api": "1" + }, + "capabilities": ["kv:own", "ui:surface"], + "sudoEverything": true +} diff --git a/scripts/__fixtures__/manifests/invalid-unknown-permission.json b/scripts/__fixtures__/manifests/invalid-unknown-permission.json deleted file mode 100644 index 3fd89b1d8..000000000 --- a/scripts/__fixtures__/manifests/invalid-unknown-permission.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "id": "sample-plugin", - "name": "Sample Plugin", - "version": "1.0.0", - "description": "Requests a permission that is not in the known capability list.", - "author": { - "name": "Jane Doe" - }, - "license": "MIT", - "category": "Productivity", - "engine": { - "termix": ">=2.9.0", - "api": "1" - }, - "capabilities": { - "backend": true, - "frontend": true, - "electron": false, - "platforms": ["linux"] - }, - "permissions": ["credentials.read"], - "sidecars": [] -} diff --git a/scripts/__fixtures__/manifests/valid-actions.json b/scripts/__fixtures__/manifests/valid-actions.json index c5b4d3034..ae2134a1f 100644 --- a/scripts/__fixtures__/manifests/valid-actions.json +++ b/scripts/__fixtures__/manifests/valid-actions.json @@ -1,10 +1,10 @@ { - "id": "sample-actions", - "name": "Sample Actions", + "id": "sample-plugin", + "name": "Sample", "version": "1.0.0", - "description": "A plugin that contributes a UI action and offers a slot of its own.", + "description": "A sample.", "author": { - "name": "Jane Doe" + "name": "Termix" }, "license": "MIT", "category": "Productivity", @@ -12,41 +12,19 @@ "termix": ">=2.9.0", "api": "1" }, - "capabilities": { - "backend": false, - "frontend": true, - "electron": false, - "platforms": ["linux", "win32", "darwin"] - }, - "permissions": ["ui.tab"], + "capabilities": ["kv:own", "ui:surface"], "contributes": { "permissionGroup": { - "group": "sampleactions", - "permissions": ["sampleactions.use"] + "group": "sample-plugin", + "permissions": ["sample-plugin.use"] }, "actions": [ { - "id": "sampleactions.openWithContext", - "titleKey": "sampleActions.open", - "handler": "openWithContext", - "icon": "Sparkles", - "permission": "sampleactions.use", - "slot": "terminal.toolbar", - "kind": "button" - }, - { - "id": "sampleactions.ungated", - "titleKey": "sampleActions.ungated", - "handler": "ungated" - } - ], - "actionSlots": [ - { - "id": "sampleactions.panel", - "accepts": ["button"], - "descriptionKey": "sampleActions.panelDescription" + "id": "sample.open", + "titleKey": "sample.open", + "handler": "open", + "permission": "sample-plugin.use" } ] - }, - "sidecars": [] + } } diff --git a/scripts/__fixtures__/manifests/valid-services.json b/scripts/__fixtures__/manifests/valid-services.json index 030652a2b..581f00660 100644 --- a/scripts/__fixtures__/manifests/valid-services.json +++ b/scripts/__fixtures__/manifests/valid-services.json @@ -1,10 +1,10 @@ { - "id": "sample-provider", - "name": "Sample Provider", + "id": "sample-plugin", + "name": "Sample", "version": "1.0.0", - "description": "A plugin that both provides and requires a named service.", + "description": "A sample.", "author": { - "name": "Jane Doe" + "name": "Termix" }, "license": "MIT", "category": "Productivity", @@ -12,32 +12,18 @@ "termix": ">=2.9.0", "api": "1" }, - "capabilities": { - "backend": true, - "frontend": false, - "electron": false, - "platforms": ["linux", "win32", "darwin"] - }, - "permissions": ["hosts.read"], - "contributes": { - "permissionGroup": { - "group": "sampleprovider", - "permissions": ["sampleprovider.greet.use"] - } - }, + "capabilities": ["kv:own", "ui:surface"], "provides": [ { - "service": "sampleprovider.greet", - "version": "1.2.0", - "permission": "sampleprovider.greet.use" + "service": "sample.thing", + "version": "1.0.0", + "permission": "sample-plugin.use" } ], - "requires": [ - { - "service": "other.thing", - "versionRange": "^1.0.0", - "optional": true + "contributes": { + "permissionGroup": { + "group": "sample-plugin", + "permissions": ["sample-plugin.use"] } - ], - "sidecars": [] + } } diff --git a/scripts/__fixtures__/manifests/valid.json b/scripts/__fixtures__/manifests/valid.json index b41fdec62..f31caa669 100644 --- a/scripts/__fixtures__/manifests/valid.json +++ b/scripts/__fixtures__/manifests/valid.json @@ -1,45 +1,16 @@ { "id": "sample-plugin", - "name": "Sample Plugin", + "name": "Sample", "version": "1.0.0", - "description": "A sample plugin used for schema validation tests.", + "description": "A sample.", "author": { - "name": "Jane Doe", - "url": "https://example.com" + "name": "Termix" }, "license": "MIT", - "repository": "https://github.com/example/sample-plugin", "category": "Productivity", - "icon": "Box", "engine": { "termix": ">=2.9.0", "api": "1" }, - "capabilities": { - "backend": true, - "frontend": true, - "electron": false, - "platforms": ["linux", "win32", "darwin"] - }, - "permissions": ["hosts.read", "ui.tab", "ui.rail"], - "contributes": { - "tabs": [ - { - "id": "sample-plugin-tab", - "titleKey": "samplePlugin.tab.title", - "icon": "Box", - "openFrom": ["rail", "palette"] - } - ], - "settingsPanel": { - "titleKey": "samplePlugin.settings.title" - }, - "apiPrefix": "sample" - }, - "dependencies": { - "plugins": { - "other-plugin": "^1.0.0" - } - }, - "sidecars": [] + "capabilities": ["kv:own", "ui:surface"] } diff --git a/scripts/plugin-manifest.schema.json b/scripts/plugin-manifest.schema.json deleted file mode 100644 index f9e6a47e1..000000000 --- a/scripts/plugin-manifest.schema.json +++ /dev/null @@ -1,415 +0,0 @@ -{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://termix.site/schemas/plugin-manifest.schema.json", - "title": "Termix Plugin Manifest", - "type": "object", - "additionalProperties": false, - "required": [ - "id", - "name", - "version", - "description", - "author", - "license", - "engine", - "category", - "capabilities", - "permissions", - "sidecars" - ], - "properties": { - "id": { - "type": "string", - "pattern": "^[a-z0-9-]+$", - "description": "Unique plugin id, lowercase alphanumeric and hyphens only." - }, - "name": { - "type": "string", - "minLength": 1 - }, - "version": { - "type": "string", - "pattern": "^\\d+\\.\\d+\\.\\d+(-[0-9A-Za-z-.]+)?(\\+[0-9A-Za-z-.]+)?$", - "description": "Semver version string." - }, - "description": { - "type": "string", - "minLength": 1 - }, - "author": { - "type": "object", - "additionalProperties": false, - "required": ["name"], - "properties": { - "name": { "type": "string", "minLength": 1 }, - "url": { "type": "string", "format": "uri" } - } - }, - "license": { - "type": "string", - "minLength": 1 - }, - "repository": { - "type": "string", - "format": "uri" - }, - "category": { - "type": "string", - "enum": [ - "Terminal", - "Files & Transfer", - "Infrastructure", - "Monitoring", - "Networking", - "Access & Security", - "Productivity" - ], - "description": "Store category used for browsing/filtering. Must match one of the categories the store UI filters by." - }, - "icon": { - "type": "string", - "minLength": 1, - "description": "Either a Lucide icon name (same convention as contributes.tabs[].icon) or a relative path to an icon.svg shipped in the plugin package root. If both a Lucide name and an icon.svg are present, the registry-index build should prefer icon.svg." - }, - "engine": { - "type": "object", - "additionalProperties": false, - "required": ["termix", "api"], - "properties": { - "termix": { - "type": "string", - "minLength": 1, - "description": "Informational-only Termix version range this plugin targets. Not mechanically enforced." - }, - "api": { - "type": "string", - "pattern": "^[0-9]+$", - "description": "Plugin SDK major version, as a string. The real compatibility gate." - } - } - }, - "capabilities": { - "type": "object", - "additionalProperties": false, - "required": ["backend", "frontend", "electron", "platforms"], - "properties": { - "backend": { "type": "boolean" }, - "frontend": { "type": "boolean" }, - "electron": { "type": "boolean" }, - "platforms": { - "type": "array", - "items": { "type": "string", "minLength": 1 } - } - } - }, - "permissions": { - "type": "array", - "items": { - "type": "string", - "enum": [ - "hosts.read", - "hosts.write", - "credentials.use", - "ssh.exec", - "ssh.sftp", - "storage.own", - "storage.secrets", - "network.outbound", - "events.read", - "notify.send", - "users.read", - "process.sidecar", - "process:transport-owner", - "ui.tab", - "ui.rail", - "ui.card", - "ui.settings", - "ui.palette" - ] - } - }, - "contributes": { - "type": "object", - "additionalProperties": false, - "properties": { - "tabs": { - "type": "array", - "items": { - "type": "object", - "additionalProperties": false, - "required": ["id", "titleKey", "icon", "openFrom"], - "properties": { - "id": { "type": "string", "minLength": 1 }, - "titleKey": { "type": "string", "minLength": 1 }, - "icon": { "type": "string", "minLength": 1 }, - "openFrom": { - "type": "array", - "items": { - "type": "string", - "enum": ["rail", "host-context-menu", "palette"] - }, - "minItems": 1 - } - } - } - }, - "hostCapability": { - "oneOf": [ - { - "type": "object", - "additionalProperties": false, - "required": ["key", "labelKey", "editorTab"], - "properties": { - "key": { "type": "string", "minLength": 1 }, - "labelKey": { "type": "string", "minLength": 1 }, - "editorTab": { "type": "string", "minLength": 1 } - } - }, - { - "type": "array", - "minItems": 1, - "items": { - "type": "object", - "additionalProperties": false, - "required": ["key", "labelKey", "editorTab"], - "properties": { - "key": { "type": "string", "minLength": 1 }, - "labelKey": { "type": "string", "minLength": 1 }, - "editorTab": { "type": "string", "minLength": 1 } - } - } - } - ] - }, - "permissionGroup": { - "type": "object", - "additionalProperties": false, - "required": ["group", "permissions"], - "properties": { - "group": { "type": "string", "minLength": 1 }, - "permissions": { - "type": "array", - "items": { "type": "string", "minLength": 1 }, - "minItems": 1 - }, - "defaultForRole": { - "type": "object", - "additionalProperties": { - "type": "array", - "items": { "type": "string", "minLength": 1 } - } - } - } - }, - "settingsPanel": { - "type": "object", - "additionalProperties": false, - "required": ["titleKey"], - "properties": { - "titleKey": { "type": "string", "minLength": 1 } - } - }, - "dashboardCards": { - "type": "array", - "items": { - "type": "object", - "additionalProperties": false, - "required": ["id", "titleKey"], - "properties": { - "id": { "type": "string", "minLength": 1 }, - "titleKey": { "type": "string", "minLength": 1 } - } - } - }, - "apiPrefix": { - "type": "string", - "pattern": "^[a-z0-9-]+$", - "description": "Mounted at /plugin-api///*." - }, - "actions": { - "type": "array", - "description": "Named frontend actions this plugin exposes for other plugins to invoke.", - "items": { - "type": "object", - "additionalProperties": false, - "required": ["id", "titleKey", "handler"], - "properties": { - "id": { - "type": "string", - "pattern": "^[a-z0-9-]+(\\.[a-zA-Z0-9-]+)+$", - "description": "Dotted, globally unique action name, e.g. \"ai.openWithContext\"." - }, - "titleKey": { "type": "string", "minLength": 1 }, - "handler": { - "type": "string", - "pattern": "^[A-Za-z_$][A-Za-z0-9_$]*$", - "description": "Name of the frontend export implementing this action. The runtime binding is registerAction(id, fn)." - }, - "icon": { "type": "string", "minLength": 1 }, - "permission": { - "type": "string", - "minLength": 1, - "description": "Role permission a user needs. Must also appear in contributes.permissionGroup.permissions." - }, - "slot": { - "type": "string", - "pattern": "^[a-z0-9-]+(\\.[a-zA-Z0-9-]+)+$", - "description": "Slot this action is intended for. Advisory; binding happens at runtime." - }, - "kind": { "type": "string", "enum": ["button"] } - } - } - }, - "actionSlots": { - "type": "array", - "description": "Places this plugin offers other plugins to contribute a UI element.", - "items": { - "type": "object", - "additionalProperties": false, - "required": ["id", "accepts"], - "properties": { - "id": { - "type": "string", - "pattern": "^[a-z0-9-]+(\\.[a-zA-Z0-9-]+)+$", - "description": "Dotted slot name, e.g. \"terminal.toolbar\"." - }, - "accepts": { - "type": "array", - "minItems": 1, - "items": { "type": "string", "enum": ["button"] }, - "description": "Contribution kinds this slot will render. Checked in the frontend at registration time." - }, - "descriptionKey": { "type": "string", "minLength": 1 } - } - } - } - } - }, - "dependencies": { - "type": "object", - "additionalProperties": false, - "properties": { - "plugins": { - "type": "object", - "additionalProperties": { - "type": "string", - "minLength": 1, - "description": "Semver range string." - } - } - } - }, - "provides": { - "type": "array", - "description": "Named services this plugin exposes to other plugins.", - "items": { - "type": "object", - "additionalProperties": false, - "required": ["service", "version", "permission"], - "properties": { - "service": { - "type": "string", - "pattern": "^[a-z0-9-]+(\\.[a-z0-9-]+)+$", - "description": "Dotted service name, e.g. \"ssh.transport\"." - }, - "version": { - "type": "string", - "pattern": "^\\d+\\.\\d+\\.\\d+(-[0-9A-Za-z-.]+)?(\\+[0-9A-Za-z-.]+)?$", - "description": "Semver version of this service contract." - }, - "permission": { - "type": "string", - "minLength": 1, - "description": "Role permission a calling user needs. Must also appear in contributes.permissionGroup.permissions." - } - } - } - }, - "requires": { - "type": "array", - "description": "Named services this plugin needs another active plugin to provide.", - "items": { - "type": "object", - "additionalProperties": false, - "required": ["service", "versionRange"], - "properties": { - "service": { - "type": "string", - "pattern": "^[a-z0-9-]+(\\.[a-z0-9-]+)+$" - }, - "versionRange": { - "type": "string", - "minLength": 1, - "description": "Semver range, e.g. \"^1.2.0\"." - }, - "optional": { - "type": "boolean", - "description": "When true, an unsatisfied requirement is skipped instead of failing activation." - } - } - } - }, - "providesSecret": { - "type": "array", - "description": "Secrets this plugin offers other plugins by reference. The value is never copied; a borrower re-reads it per call.", - "items": { - "type": "object", - "additionalProperties": false, - "required": ["key", "permission"], - "properties": { - "key": { - "type": "string", - "pattern": "^[a-z0-9-]+$", - "description": "Secret name within this plugin, e.g. \"api-key\"." - }, - "permission": { - "type": "string", - "minLength": 1, - "description": "Role permission a borrowing user needs. Must also appear in contributes.permissionGroup.permissions." - }, - "descriptionKey": { - "type": "string", - "minLength": 1, - "description": "i18n key describing the sharing, shown in the admin UI." - } - } - } - }, - "requiresSecret": { - "type": "array", - "description": "Secrets this plugin borrows from another plugin. Reads resolve to null when the provider is absent or has nothing set.", - "items": { - "type": "object", - "additionalProperties": false, - "required": ["plugin", "key"], - "properties": { - "plugin": { - "type": "string", - "pattern": "^[a-z0-9-]+$", - "description": "Plugin id that offers the secret." - }, - "key": { - "type": "string", - "pattern": "^[a-z0-9-]+$" - }, - "optional": { - "type": "boolean", - "description": "When true, an absent provider is normal rather than reported as an unmet requirement." - } - } - } - }, - "sidecars": { - "type": "array", - "items": { - "type": "object", - "additionalProperties": false, - "required": ["id", "binary"], - "properties": { - "id": { "type": "string", "minLength": 1 }, - "binary": { "type": "string", "minLength": 1 } - } - } - } - } -} diff --git a/scripts/validate-plugin-manifest.cjs b/scripts/validate-plugin-manifest.cjs index f2ea24523..226395a18 100644 --- a/scripts/validate-plugin-manifest.cjs +++ b/scripts/validate-plugin-manifest.cjs @@ -1,622 +1,88 @@ /** - * Validates a plugin manifest.json against scripts/plugin-manifest.schema.json. + * Validates plugin manifests against the SDK contract. * - * Hand-rolled rather than pulling in ajv: the schema is small and the checks - * that matter (required fields, id pattern, semver, known permissions) are a - * handful of straightforward comparisons. + * node scripts/validate-plugin-manifest.cjs # every bundled manifest + * node scripts/validate-plugin-manifest.cjs path/to/manifest.json * - * Usage: node scripts/validate-plugin-manifest.cjs + * The rules live in packages/plugin-sdk/src/manifest.ts, which the server + * uses too, so there is one implementation rather than a copy here that + * drifts. This file only finds the manifests and prints the result. */ -const fs = require("fs"); -const path = require("path"); -const semver = require("semver"); +const fs = require("node:fs"); +const path = require("node:path"); +const { pathToFileURL } = require("node:url"); -const SCHEMA_PATH = path.join(__dirname, "plugin-manifest.schema.json"); +const root = path.resolve(__dirname, ".."); +const sdkEntry = path.join(root, "packages", "plugin-sdk", "dist", "manifest.js"); -const ID_PATTERN = /^[a-z0-9-]+$/; -const SEMVER_PATTERN = /^\d+\.\d+\.\d+(-[0-9A-Za-z-.]+)?(\+[0-9A-Za-z-.]+)?$/; -const API_VERSION_PATTERN = /^[0-9]+$/; -const SERVICE_PATTERN = /^[a-z0-9-]+(\.[a-z0-9-]+)+$/; -const SECRET_KEY_PATTERN = /^[a-z0-9-]+$/; -// Dotted like a service name, but segments may be camelCase: these name a -// frontend function ("ai.openWithContext"), not a lowercase service contract. -const ACTION_ID_PATTERN = /^[a-z0-9-]+(\.[a-zA-Z0-9-]+)+$/; -const HANDLER_PATTERN = /^[A-Za-z_$][A-Za-z0-9_$]*$/; -const OPEN_FROM_VALUES = ["rail", "host-context-menu", "palette"]; -const ACTION_CONTRIBUTION_KINDS = ["button"]; - -function loadSchema() { - const raw = fs.readFileSync(SCHEMA_PATH, "utf8"); - return JSON.parse(raw); -} - -function knownPermissions(schema) { - return schema.properties.permissions.items.enum; -} - -function knownCategories(schema) { - return schema.properties.category.enum; -} - -function validateManifest(manifest, schema) { - const errors = []; - - const requiredTopLevel = schema.required; - for (const field of requiredTopLevel) { - if (!(field in manifest)) { - errors.push(`Missing required field: "${field}"`); - } - } - - if (typeof manifest.id === "string" && !ID_PATTERN.test(manifest.id)) { - errors.push(`Field "id" must match ${ID_PATTERN}, got: "${manifest.id}"`); - } - - if ( - typeof manifest.version === "string" && - !SEMVER_PATTERN.test(manifest.version) - ) { - errors.push( - `Field "version" must be valid semver, got: "${manifest.version}"`, - ); - } - - if (manifest.author && typeof manifest.author === "object") { - if (!manifest.author.name) { - errors.push('Field "author.name" is required'); - } - } - - if (typeof manifest.category === "string") { - const categories = knownCategories(schema); - if (!categories.includes(manifest.category)) { - errors.push( - `Field "category" must be one of: ${categories.join(", ")}, got: "${manifest.category}"`, - ); - } - } else if ("category" in manifest) { - errors.push('Field "category" must be a string'); - } - - if ("icon" in manifest && typeof manifest.icon !== "string") { - errors.push('Field "icon" must be a string'); - } - - if (manifest.engine && typeof manifest.engine === "object") { - if (!manifest.engine.termix) { - errors.push('Field "engine.termix" is required'); - } - if (!API_VERSION_PATTERN.test(String(manifest.engine.api))) { - errors.push( - `Field "engine.api" must be an integer-as-string, got: "${manifest.engine.api}"`, - ); - } - } - - if (manifest.capabilities && typeof manifest.capabilities === "object") { - for (const field of ["backend", "frontend", "electron"]) { - if (typeof manifest.capabilities[field] !== "boolean") { - errors.push(`Field "capabilities.${field}" must be a boolean`); - } - } - if (!Array.isArray(manifest.capabilities.platforms)) { - errors.push('Field "capabilities.platforms" must be an array'); - } - } - - const known = knownPermissions(schema); - if (Array.isArray(manifest.permissions)) { - for (const permission of manifest.permissions) { - if (!known.includes(permission)) { - errors.push( - `Unknown permission: "${permission}". Known values: ${known.join(", ")}`, - ); - } - } - } else if ("permissions" in manifest) { - errors.push('Field "permissions" must be an array'); - } - - if (Array.isArray(manifest.sidecars)) { - manifest.sidecars.forEach((sidecar, index) => { - if (!sidecar || typeof sidecar !== "object") { - errors.push(`sidecars[${index}] must be an object`); - return; - } - if (!sidecar.id) errors.push(`sidecars[${index}].id is required`); - if (!sidecar.binary) errors.push(`sidecars[${index}].binary is required`); - }); - } else if ("sidecars" in manifest) { - errors.push('Field "sidecars" must be an array'); - } - - errors.push(...validateProvides(manifest.provides)); - errors.push(...validateRequires(manifest.requires)); - errors.push(...validateProvidesSecret(manifest.providesSecret)); - errors.push(...validateRequiresSecret(manifest.requiresSecret)); - - if (manifest.contributes && typeof manifest.contributes === "object") { - errors.push(...validateContributes(manifest.contributes)); - } - - // A service permission has to be one the plugin's own permissionGroup - // declares, or no admin could ever grant it. - const declared = manifest.contributes?.permissionGroup?.permissions; - if (Array.isArray(manifest.provides) && Array.isArray(declared)) { - for (const entry of manifest.provides) { - if (entry && entry.permission && !declared.includes(entry.permission)) { - errors.push( - `Service "${entry.service}" is gated by "${entry.permission}", which is not declared in contributes.permissionGroup.permissions`, - ); - } - } - } - - // Same rule for a shared secret: sharing rides on the provider's own RBAC - // permission rather than a separate grant mechanism. - if (Array.isArray(manifest.providesSecret) && Array.isArray(declared)) { - for (const entry of manifest.providesSecret) { - if (entry && entry.permission && !declared.includes(entry.permission)) { - errors.push( - `Secret "${entry.key}" is gated by "${entry.permission}", which is not declared in contributes.permissionGroup.permissions`, - ); - } - } - } - - if (Array.isArray(manifest.requiresSecret)) { - for (const entry of manifest.requiresSecret) { - if (entry && entry.plugin && entry.plugin === manifest.id) { - errors.push( - `requiresSecret entry for "${entry.key}" names this plugin itself; use ctx.secrets.get instead`, - ); - } - } - } - - // Same rule for UI actions: a permission the catalog never sees is one no - // admin can grant, so the action would be invisible rather than denied. - const actions = manifest.contributes?.actions; - if (Array.isArray(actions)) { - for (const action of actions) { - if (!action || !action.permission) continue; - if (!Array.isArray(declared) || !declared.includes(action.permission)) { - errors.push( - `Action "${action.id}" is gated by "${action.permission}", which is not declared in contributes.permissionGroup.permissions`, - ); - } - } - } - - return errors; -} - -function validateProvides(provides) { - if (provides === undefined) return []; - if (!Array.isArray(provides)) return ['Field "provides" must be an array']; - - const errors = []; - const seen = new Set(); - - provides.forEach((entry, index) => { - if (!entry || typeof entry !== "object" || Array.isArray(entry)) { - errors.push(`provides[${index}] must be an object`); - return; - } - - if ( - typeof entry.service !== "string" || - !SERVICE_PATTERN.test(entry.service) - ) { - errors.push( - `provides[${index}].service must match ${SERVICE_PATTERN}, got: "${entry.service}"`, - ); - } else if (seen.has(entry.service)) { - errors.push( - `provides[${index}].service is a duplicate: "${entry.service}"`, - ); - } else { - seen.add(entry.service); - } - - if ( - typeof entry.version !== "string" || - !SEMVER_PATTERN.test(entry.version) - ) { - errors.push( - `provides[${index}].version must be valid semver, got: "${entry.version}"`, - ); - } - - if (typeof entry.permission !== "string" || entry.permission.length === 0) { - errors.push(`provides[${index}].permission is required`); - } - }); - - return errors; -} - -function validateRequires(requires) { - if (requires === undefined) return []; - if (!Array.isArray(requires)) return ['Field "requires" must be an array']; - - const errors = []; - const seen = new Set(); - - requires.forEach((entry, index) => { - if (!entry || typeof entry !== "object" || Array.isArray(entry)) { - errors.push(`requires[${index}] must be an object`); - return; - } - - if ( - typeof entry.service !== "string" || - !SERVICE_PATTERN.test(entry.service) - ) { - errors.push( - `requires[${index}].service must match ${SERVICE_PATTERN}, got: "${entry.service}"`, - ); - } else if (seen.has(entry.service)) { - errors.push( - `requires[${index}].service is a duplicate: "${entry.service}"`, - ); - } else { - seen.add(entry.service); - } - - if ( - typeof entry.versionRange !== "string" || - semver.validRange(entry.versionRange) === null - ) { - errors.push( - `requires[${index}].versionRange must be a valid semver range, got: "${entry.versionRange}"`, - ); - } - - if ("optional" in entry && typeof entry.optional !== "boolean") { - errors.push(`requires[${index}].optional must be a boolean`); - } - }); - - return errors; -} - -function validateProvidesSecret(providesSecret) { - if (providesSecret === undefined) return []; - if (!Array.isArray(providesSecret)) { - return ['Field "providesSecret" must be an array']; - } - - const errors = []; - const seen = new Set(); - - providesSecret.forEach((entry, index) => { - if (!entry || typeof entry !== "object" || Array.isArray(entry)) { - errors.push(`providesSecret[${index}] must be an object`); - return; - } - - if (typeof entry.key !== "string" || !SECRET_KEY_PATTERN.test(entry.key)) { - errors.push( - `providesSecret[${index}].key must match ${SECRET_KEY_PATTERN}, got: "${entry.key}"`, - ); - } else if (seen.has(entry.key)) { - errors.push( - `providesSecret[${index}].key is a duplicate: "${entry.key}"`, - ); - } else { - seen.add(entry.key); - } - - if (typeof entry.permission !== "string" || entry.permission.length === 0) { - errors.push(`providesSecret[${index}].permission is required`); - } - - if ( - "descriptionKey" in entry && - (typeof entry.descriptionKey !== "string" || - entry.descriptionKey.length === 0) - ) { - errors.push(`providesSecret[${index}].descriptionKey must be a string`); - } - }); - - return errors; -} - -function validateRequiresSecret(requiresSecret) { - if (requiresSecret === undefined) return []; - if (!Array.isArray(requiresSecret)) { - return ['Field "requiresSecret" must be an array']; - } - - const errors = []; - const seen = new Set(); - - requiresSecret.forEach((entry, index) => { - if (!entry || typeof entry !== "object" || Array.isArray(entry)) { - errors.push(`requiresSecret[${index}] must be an object`); - return; - } - - if (typeof entry.plugin !== "string" || !ID_PATTERN.test(entry.plugin)) { - errors.push( - `requiresSecret[${index}].plugin must match ${ID_PATTERN}, got: "${entry.plugin}"`, - ); - } - - if (typeof entry.key !== "string" || !SECRET_KEY_PATTERN.test(entry.key)) { - errors.push( - `requiresSecret[${index}].key must match ${SECRET_KEY_PATTERN}, got: "${entry.key}"`, - ); - } - - if (typeof entry.plugin === "string" && typeof entry.key === "string") { - const id = `${entry.plugin}:${entry.key}`; - if (seen.has(id)) { - errors.push(`requiresSecret[${index}] is a duplicate: "${id}"`); - } else { - seen.add(id); - } - } - - if ("optional" in entry && typeof entry.optional !== "boolean") { - errors.push(`requiresSecret[${index}].optional must be a boolean`); - } - }); - - return errors; -} - -function validateContributes(contributes) { - const errors = []; - - if ("tabs" in contributes) { - if (!Array.isArray(contributes.tabs)) { - errors.push('Field "contributes.tabs" must be an array'); - } else { - contributes.tabs.forEach((tab, index) => { - for (const field of ["id", "titleKey", "icon", "openFrom"]) { - if (!(field in (tab || {}))) { - errors.push(`contributes.tabs[${index}].${field} is required`); - } - } - if (Array.isArray(tab?.openFrom)) { - for (const value of tab.openFrom) { - if (!OPEN_FROM_VALUES.includes(value)) { - errors.push( - `contributes.tabs[${index}].openFrom has unknown value: "${value}"`, - ); - } - } - } - }); - } - } - - if ("hostCapability" in contributes) { - const raw = contributes.hostCapability; - const entries = Array.isArray(raw) ? raw : [raw]; - const seenKeys = new Set(); - entries.forEach((entry, index) => { - const hc = entry || {}; - const prefix = Array.isArray(raw) - ? `contributes.hostCapability[${index}]` - : "contributes.hostCapability"; - for (const field of ["key", "labelKey", "editorTab"]) { - if (!(field in hc)) { - errors.push(`${prefix}.${field} is required`); - } - } - if (typeof hc.key === "string") { - if (seenKeys.has(hc.key)) { - errors.push(`${prefix}.key is a duplicate: "${hc.key}"`); - } - seenKeys.add(hc.key); - } - }); - } - - if ("permissionGroup" in contributes) { - const group = contributes.permissionGroup || {}; - if (!group.group) - errors.push("contributes.permissionGroup.group is required"); - if (!Array.isArray(group.permissions) || group.permissions.length === 0) { - errors.push( - "contributes.permissionGroup.permissions must be a non-empty array", - ); - } - } - - if ("settingsPanel" in contributes) { - if (!contributes.settingsPanel?.titleKey) { - errors.push("contributes.settingsPanel.titleKey is required"); - } - } - - if ("dashboardCards" in contributes) { - if (!Array.isArray(contributes.dashboardCards)) { - errors.push('Field "contributes.dashboardCards" must be an array'); - } else { - contributes.dashboardCards.forEach((card, index) => { - for (const field of ["id", "titleKey"]) { - if (!(field in (card || {}))) { - errors.push( - `contributes.dashboardCards[${index}].${field} is required`, - ); - } - } - }); - } - } - - if ("actions" in contributes) { - errors.push(...validateActions(contributes.actions)); - } - - if ("actionSlots" in contributes) { - errors.push(...validateActionSlots(contributes.actionSlots)); - } - - return errors; -} - -function validateActions(actions) { - if (!Array.isArray(actions)) { - return ['Field "contributes.actions" must be an array']; - } - - const errors = []; - const seen = new Set(); - - actions.forEach((entry, index) => { - if (!entry || typeof entry !== "object" || Array.isArray(entry)) { - errors.push(`contributes.actions[${index}] must be an object`); - return; - } - - if (typeof entry.id !== "string" || !ACTION_ID_PATTERN.test(entry.id)) { - errors.push( - `contributes.actions[${index}].id must match ${ACTION_ID_PATTERN}, got: "${entry.id}"`, - ); - } else if (seen.has(entry.id)) { - errors.push( - `contributes.actions[${index}].id is a duplicate: "${entry.id}"`, - ); - } else { - seen.add(entry.id); - } - - if (typeof entry.titleKey !== "string" || entry.titleKey.length === 0) { - errors.push(`contributes.actions[${index}].titleKey is required`); - } - - if ( - typeof entry.handler !== "string" || - !HANDLER_PATTERN.test(entry.handler) - ) { - errors.push( - `contributes.actions[${index}].handler must match ${HANDLER_PATTERN}, got: "${entry.handler}"`, - ); - } - - if ("icon" in entry && typeof entry.icon !== "string") { - errors.push(`contributes.actions[${index}].icon must be a string`); - } - - if ("permission" in entry && typeof entry.permission !== "string") { - errors.push(`contributes.actions[${index}].permission must be a string`); - } - - if ( - "slot" in entry && - (typeof entry.slot !== "string" || !ACTION_ID_PATTERN.test(entry.slot)) - ) { - errors.push( - `contributes.actions[${index}].slot must match ${ACTION_ID_PATTERN}, got: "${entry.slot}"`, - ); - } - - if ("kind" in entry && !ACTION_CONTRIBUTION_KINDS.includes(entry.kind)) { - errors.push( - `contributes.actions[${index}].kind must be one of: ${ACTION_CONTRIBUTION_KINDS.join(", ")}, got: "${entry.kind}"`, - ); - } - }); - - return errors; -} - -function validateActionSlots(slots) { - if (!Array.isArray(slots)) { - return ['Field "contributes.actionSlots" must be an array']; - } - - const errors = []; - const seen = new Set(); - - slots.forEach((entry, index) => { - if (!entry || typeof entry !== "object" || Array.isArray(entry)) { - errors.push(`contributes.actionSlots[${index}] must be an object`); - return; - } - - if (typeof entry.id !== "string" || !ACTION_ID_PATTERN.test(entry.id)) { - errors.push( - `contributes.actionSlots[${index}].id must match ${ACTION_ID_PATTERN}, got: "${entry.id}"`, - ); - } else if (seen.has(entry.id)) { - errors.push( - `contributes.actionSlots[${index}].id is a duplicate: "${entry.id}"`, - ); - } else { - seen.add(entry.id); - } - - if (!Array.isArray(entry.accepts) || entry.accepts.length === 0) { - errors.push( - `contributes.actionSlots[${index}].accepts must be a non-empty array`, - ); - } else { - for (const kind of entry.accepts) { - if (!ACTION_CONTRIBUTION_KINDS.includes(kind)) { - errors.push( - `contributes.actionSlots[${index}].accepts has unknown value: "${kind}". Known values: ${ACTION_CONTRIBUTION_KINDS.join(", ")}`, - ); - } - } - } - - if ("descriptionKey" in entry && typeof entry.descriptionKey !== "string") { - errors.push( - `contributes.actionSlots[${index}].descriptionKey must be a string`, - ); - } - }); - - return errors; -} - -function main() { - const manifestPath = process.argv[2]; - if (!manifestPath) { +async function loadValidator() { + if (!fs.existsSync(sdkEntry)) { console.error( - "Usage: node scripts/validate-plugin-manifest.cjs ", + `The plugin SDK is not built yet (${path.relative(root, sdkEntry)} is missing).\n` + + `Run: npm run build:sdk`, ); process.exit(1); } + return import(pathToFileURL(sdkEntry).href); +} - const resolvedPath = path.resolve(manifestPath); - if (!fs.existsSync(resolvedPath)) { - console.error(`Manifest not found: ${resolvedPath}`); - process.exit(1); +function bundledManifests() { + const pluginsDir = path.join(root, "plugins"); + if (!fs.existsSync(pluginsDir)) return []; + + return fs + .readdirSync(pluginsDir, { withFileTypes: true }) + .filter((entry) => entry.isDirectory()) + .map((entry) => path.join(pluginsDir, entry.name, "manifest.json")) + .filter((file) => fs.existsSync(file)); +} + +async function main() { + const { parseManifest } = await loadValidator(); + + const argument = process.argv[2]; + const files = argument + ? [path.resolve(process.cwd(), argument)] + : bundledManifests(); + + if (files.length === 0) { + console.log("No plugin manifests found."); + return; } - let manifest; - try { - manifest = JSON.parse(fs.readFileSync(resolvedPath, "utf8")); - } catch (err) { - console.error(`Manifest is not valid JSON: ${err.message}`); - process.exit(1); - } + let failed = 0; - const schema = loadSchema(); - const errors = validateManifest(manifest, schema); + for (const file of files) { + const relative = path.relative(root, file); - if (errors.length > 0) { - console.error(`Invalid plugin manifest: ${resolvedPath}`); - for (const error of errors) { - console.error(` - ${error}`); + let raw; + try { + raw = JSON.parse(fs.readFileSync(file, "utf8")); + } catch (error) { + console.error(`${relative}: not valid JSON: ${error.message}`); + failed += 1; + continue; } - process.exit(1); + + const { errors } = parseManifest(raw); + if (errors.length > 0) { + console.error(`${relative}:`); + for (const error of errors) console.error(` - ${error}`); + failed += 1; + continue; + } + + console.log(`ok ${relative}`); } - console.log(`Valid plugin manifest: ${resolvedPath}`); + if (failed > 0) { + console.error(`\n${failed} manifest(s) failed validation.`); + process.exit(1); + } } -if (require.main === module) { - main(); -} - -module.exports = { validateManifest, loadSchema }; +main().catch((error) => { + console.error(error); + process.exit(1); +}); diff --git a/scripts/validate-plugin-manifest.test.ts b/scripts/validate-plugin-manifest.test.ts index 198d8e083..b47117899 100644 --- a/scripts/validate-plugin-manifest.test.ts +++ b/scripts/validate-plugin-manifest.test.ts @@ -5,13 +5,12 @@ import path from "node:path"; const VALIDATOR = path.join(__dirname, "validate-plugin-manifest.cjs"); const FIXTURES = path.join(__dirname, "__fixtures__", "manifests"); -function runValidator(fixture: string): { status: number; output: string } { +function runValidator(fixture?: string): { status: number; output: string } { + const args = fixture + ? [VALIDATOR, path.join(FIXTURES, fixture)] + : [VALIDATOR]; try { - const output = execFileSync( - "node", - [VALIDATOR, path.join(FIXTURES, fixture)], - { encoding: "utf8" }, - ); + const output = execFileSync("node", args, { encoding: "utf8" }); return { status: 0, output }; } catch (err) { const error = err as { status: number; stdout: string; stderr: string }; @@ -23,54 +22,76 @@ describe("validate-plugin-manifest.cjs", () => { it("accepts a valid manifest", () => { const { status, output } = runValidator("valid.json"); expect(status).toBe(0); - expect(output).toContain("Valid plugin manifest"); + expect(output).toContain("ok"); + }); + + it("accepts a manifest declaring services", () => { + expect(runValidator("valid-services.json").status).toBe(0); + }); + + it("accepts a manifest declaring actions", () => { + expect(runValidator("valid-actions.json").status).toBe(0); }); it("rejects a manifest missing a required field", () => { const { status, output } = runValidator("invalid-missing-field.json"); - expect(status).not.toBe(0); - expect(output).toContain('Missing required field: "description"'); + expect(status).toBe(1); + expect(output).toContain("id"); }); - it("rejects a manifest with a bad id pattern", () => { + it("rejects an id that is not a lowercase slug", () => { const { status, output } = runValidator("invalid-bad-id.json"); - expect(status).not.toBe(0); - expect(output).toMatch(/Field "id" must match/); + expect(status).toBe(1); + expect(output).toContain("id"); }); - it("accepts a manifest declaring provides and requires", () => { - const { status, output } = runValidator("valid-services.json"); + it("rejects an unknown category", () => { + expect(runValidator("invalid-unknown-category.json").status).toBe(1); + }); + + // The dotted spelling was the v1 form. Capabilities are colon-separated now + // so they can never be confused with RBAC permissions. + it("rejects a dotted capability name", () => { + const { status, output } = runValidator("invalid-unknown-capability.json"); + expect(status).toBe(1); + expect(output).toContain("hosts.read"); + }); + + it("rejects a service permission the plugin does not declare", () => { + expect(runValidator("invalid-service-permission.json").status).toBe(1); + }); + + it("rejects an action permission the plugin does not declare", () => { + expect(runValidator("invalid-action-permission.json").status).toBe(1); + }); + + it("rejects an unknown top-level field", () => { + const { status, output } = runValidator("invalid-unknown-field.json"); + expect(status).toBe(1); + expect(output).toContain("sudoEverything"); + }); + + it("rejects an unknown field nested inside contributes", () => { + const { status, output } = runValidator( + "invalid-nested-unknown-field.json", + ); + expect(status).toBe(1); + expect(output).toContain("extra"); + }); + + // A manifest must not be able to hand a role a permission the plugin does + // not own, which is how a plugin could otherwise grant itself admin rights. + it("rejects a role default outside the plugin's own namespace", () => { + const { status, output } = runValidator( + "invalid-role-default-escalation.json", + ); + expect(status).toBe(1); + expect(output).toContain("admin.users.manage"); + }); + + it("validates every bundled manifest when given no argument", () => { + const { status, output } = runValidator(); expect(status).toBe(0); - expect(output).toContain("Valid plugin manifest"); - }); - - it("rejects a service gated by an undeclared permission", () => { - const { status, output } = runValidator("invalid-service-permission.json"); - expect(status).not.toBe(0); - expect(output).toContain("sampleprovider.undeclared"); - }); - - it("accepts a manifest declaring actions and actionSlots", () => { - const { status, output } = runValidator("valid-actions.json"); - expect(status).toBe(0); - expect(output).toContain("Valid plugin manifest"); - }); - - it("rejects an action gated by an undeclared permission", () => { - const { status, output } = runValidator("invalid-action-permission.json"); - expect(status).not.toBe(0); - expect(output).toContain("samplebadaction.never_declared"); - }); - - it("rejects a manifest with an unknown permission", () => { - const { status, output } = runValidator("invalid-unknown-permission.json"); - expect(status).not.toBe(0); - expect(output).toContain('Unknown permission: "credentials.read"'); - }); - - it("rejects a manifest with an unknown category", () => { - const { status, output } = runValidator("invalid-unknown-category.json"); - expect(status).not.toBe(0); - expect(output).toMatch(/Field "category" must be one of/); + expect(output).toContain("ssh-terminal"); }); }); diff --git a/src/backend/database/db/index.ts b/src/backend/database/db/index.ts index 5aae59940..f5d58ae95 100644 --- a/src/backend/database/db/index.ts +++ b/src/backend/database/db/index.ts @@ -689,6 +689,7 @@ async function initializeCompleteDatabase(): Promise { source TEXT NOT NULL DEFAULT 'community', registry_id TEXT, state TEXT NOT NULL DEFAULT 'disabled', + last_error TEXT, installed_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, auto_update INTEGER NOT NULL DEFAULT 0, @@ -701,7 +702,9 @@ async function initializeCompleteDatabase(): Promise { plugin_id TEXT NOT NULL, capability TEXT NOT NULL, granted_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, - granted_by TEXT NOT NULL, + -- Nullable: a bundled grant is made by the install, not by a user. + granted_by TEXT, + source TEXT NOT NULL DEFAULT 'admin', UNIQUE (plugin_id, capability), FOREIGN KEY (plugin_id) REFERENCES plugins (id) ON DELETE CASCADE, FOREIGN KEY (granted_by) REFERENCES users (id) ON DELETE CASCADE @@ -987,6 +990,63 @@ const addColumnIfNotExists = ( } }; +/** + * Drops the NOT NULL on plugin_permission_grants.granted_by. + * + * A bundled grant is made by the install rather than by a person, so there is + * no user id to record. SQLite cannot relax a constraint in place, so the + * table is rebuilt; it only ever holds a handful of rows. + */ +const relaxPluginGrantGrantedBy = () => { + try { + const sql = sqlite + .prepare( + `SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'plugin_permission_grants'`, + ) + .get() as { sql?: string } | undefined; + + if (!sql?.sql || !/granted_by\s+TEXT\s+NOT\s+NULL/i.test(sql.sql)) { + return; + } + + sqlite.exec(` + PRAGMA foreign_keys=OFF; + BEGIN; + CREATE TABLE plugin_permission_grants_new ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + plugin_id TEXT NOT NULL, + capability TEXT NOT NULL, + granted_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + granted_by TEXT, + source TEXT NOT NULL DEFAULT 'admin', + UNIQUE (plugin_id, capability), + FOREIGN KEY (plugin_id) REFERENCES plugins (id) ON DELETE CASCADE, + FOREIGN KEY (granted_by) REFERENCES users (id) ON DELETE CASCADE + ); + INSERT INTO plugin_permission_grants_new + (id, plugin_id, capability, granted_at, granted_by, source) + SELECT id, plugin_id, capability, granted_at, granted_by, 'admin' + FROM plugin_permission_grants; + DROP TABLE plugin_permission_grants; + ALTER TABLE plugin_permission_grants_new RENAME TO plugin_permission_grants; + COMMIT; + PRAGMA foreign_keys=ON; + `); + + databaseLogger.info( + "Relaxed plugin_permission_grants.granted_by to allow bundled grants", + { operation: "schema_migration" }, + ); + } catch (error) { + databaseLogger.warn( + `Failed to relax plugin_permission_grants.granted_by: ${ + error instanceof Error ? error.message : String(error) + }`, + { operation: "schema_migration" }, + ); + } +}; + const migrateSchema = () => { addColumnIfNotExists( "session_recordings", @@ -2836,6 +2896,16 @@ const migrateSchema = () => { } addColumnIfNotExists("homepage_items", "sync_id", "TEXT"); + // Plugin runtime: lastError reports why a plugin is blocked or failed, and + // grants record whether a capability came from an admin or from bundling. + addColumnIfNotExists("plugins", "last_error", "TEXT"); + addColumnIfNotExists( + "plugin_permission_grants", + "source", + "TEXT NOT NULL DEFAULT 'admin'", + ); + relaxPluginGrantGrantedBy(); + const syncIdTables = [ "ssh_data", "ssh_credentials", diff --git a/src/backend/database/db/schema.mysql.ts b/src/backend/database/db/schema.mysql.ts index 026496fb0..5ad5afdc4 100644 --- a/src/backend/database/db/schema.mysql.ts +++ b/src/backend/database/db/schema.mysql.ts @@ -2142,7 +2142,10 @@ export const plugins = mysqlTable( tier: text("tier").notNull().default("available"), source: text("source").notNull().default("community"), registryId: varchar("registry_id", { length: 255 }), + /** enabled | disabled | blocked | failed */ state: text("state").notNull().default("disabled"), + /** Why the plugin is blocked or failed, for the admin UI. */ + lastError: text("last_error"), installedAt: text("installed_at") .notNull() .default(sql`(CURRENT_TIMESTAMP)`), @@ -2168,9 +2171,16 @@ export const pluginPermissionGrants = mysqlTable( grantedAt: text("granted_at") .notNull() .default(sql`(CURRENT_TIMESTAMP)`), - grantedBy: varchar("granted_by", { length: 255 }) - .notNull() - .references(() => users.id, { onDelete: "cascade" }), + /** + * Who granted it. Null for a bundled grant, which no user made: shipping + * in the install is the consent. Nullable also stops a user deletion from + * cascading a bundled plugin's capabilities away. + */ + grantedBy: varchar("granted_by", { length: 255 }).references(() => users.id, { + onDelete: "cascade", + }), + /** admin | bundled */ + source: text("source").notNull().default("admin"), }, // A plugin's grants are always read together, and re-granting the same // capability should update the existing row rather than duplicate it. diff --git a/src/backend/database/db/schema.pg.ts b/src/backend/database/db/schema.pg.ts index bee20b628..7c5d42388 100644 --- a/src/backend/database/db/schema.pg.ts +++ b/src/backend/database/db/schema.pg.ts @@ -2143,7 +2143,10 @@ export const plugins = pgTable( tier: text("tier").notNull().default("available"), source: text("source").notNull().default("community"), registryId: varchar("registry_id", { length: 255 }), + /** enabled | disabled | blocked | failed */ state: text("state").notNull().default("disabled"), + /** Why the plugin is blocked or failed, for the admin UI. */ + lastError: text("last_error"), installedAt: text("installed_at") .notNull() .default(sql`CURRENT_TIMESTAMP`), @@ -2169,9 +2172,16 @@ export const pluginPermissionGrants = pgTable( grantedAt: text("granted_at") .notNull() .default(sql`CURRENT_TIMESTAMP`), - grantedBy: varchar("granted_by", { length: 255 }) - .notNull() - .references(() => users.id, { onDelete: "cascade" }), + /** + * Who granted it. Null for a bundled grant, which no user made: shipping + * in the install is the consent. Nullable also stops a user deletion from + * cascading a bundled plugin's capabilities away. + */ + grantedBy: varchar("granted_by", { length: 255 }).references(() => users.id, { + onDelete: "cascade", + }), + /** admin | bundled */ + source: text("source").notNull().default("admin"), }, // A plugin's grants are always read together, and re-granting the same // capability should update the existing row rather than duplicate it. diff --git a/src/backend/database/db/schema.ts b/src/backend/database/db/schema.ts index de40eba61..a5699b8cf 100644 --- a/src/backend/database/db/schema.ts +++ b/src/backend/database/db/schema.ts @@ -2141,7 +2141,10 @@ export const plugins = sqliteTable( tier: text("tier").notNull().default("available"), source: text("source").notNull().default("community"), registryId: text("registry_id"), + /** enabled | disabled | blocked | failed */ state: text("state").notNull().default("disabled"), + /** Why the plugin is blocked or failed, for the admin UI. */ + lastError: text("last_error"), installedAt: text("installed_at") .notNull() .default(sql`CURRENT_TIMESTAMP`), @@ -2167,9 +2170,16 @@ export const pluginPermissionGrants = sqliteTable( grantedAt: text("granted_at") .notNull() .default(sql`CURRENT_TIMESTAMP`), - grantedBy: text("granted_by") - .notNull() - .references(() => users.id, { onDelete: "cascade" }), + /** + * Who granted it. Null for a bundled grant, which no user made: shipping + * in the install is the consent. Nullable also stops a user deletion from + * cascading a bundled plugin's capabilities away. + */ + grantedBy: text("granted_by").references(() => users.id, { + onDelete: "cascade", + }), + /** admin | bundled */ + source: text("source").notNull().default("admin"), }, // A plugin's grants are always read together, and re-granting the same // capability should update the existing row rather than duplicate it. diff --git a/src/backend/database/repositories/plugin-permission-grant-repository.ts b/src/backend/database/repositories/plugin-permission-grant-repository.ts index a21a5e757..377ea4314 100644 --- a/src/backend/database/repositories/plugin-permission-grant-repository.ts +++ b/src/backend/database/repositories/plugin-permission-grant-repository.ts @@ -10,7 +10,9 @@ export type PluginPermissionGrantRecord = export interface PluginPermissionGrantCreateInput { pluginId: string; capability: string; - grantedBy: string; + /** Null for a bundled grant, which no user made. */ + grantedBy: string | null; + source?: "admin" | "bundled"; } export class PluginPermissionGrantRepository { @@ -55,6 +57,7 @@ export class PluginPermissionGrantRepository { pluginId: input.pluginId, capability: input.capability, grantedBy: input.grantedBy, + source: input.source ?? "admin", grantedAt: now, }, ); diff --git a/src/backend/database/repositories/plugin-repository.ts b/src/backend/database/repositories/plugin-repository.ts index 949edd479..7209096c9 100644 --- a/src/backend/database/repositories/plugin-repository.ts +++ b/src/backend/database/repositories/plugin-repository.ts @@ -18,6 +18,7 @@ export interface PluginCreateInput { source?: string; registryId?: string | null; state?: string; + lastError?: string | null; autoUpdate?: boolean; manifestJson: string; } @@ -29,6 +30,7 @@ export interface PluginUpdateInput { source?: string; registryId?: string | null; state?: string; + lastError?: string | null; autoUpdate?: boolean; manifestJson?: string; } @@ -64,6 +66,7 @@ export class PluginRepository { source: input.source ?? "community", registryId: input.registryId ?? null, state: input.state ?? "disabled", + lastError: input.lastError ?? null, autoUpdate: input.autoUpdate ?? false, manifestJson: input.manifestJson, installedAt: now, @@ -95,6 +98,8 @@ export class PluginRepository { ? existing.registryId : input.registryId, state: input.state ?? existing.state, + lastError: + input.lastError === undefined ? existing.lastError : input.lastError, autoUpdate: input.autoUpdate ?? existing.autoUpdate, manifestJson: input.manifestJson ?? existing.manifestJson, updatedAt: now, diff --git a/src/backend/database/routes/plugins.ts b/src/backend/database/routes/plugins.ts index 2a1c094b0..c4ababf2c 100644 --- a/src/backend/database/routes/plugins.ts +++ b/src/backend/database/routes/plugins.ts @@ -31,61 +31,88 @@ const requireManagePlugins = permissionManager.requirePermission( * summary: List installed plugins and their runtime state * description: > * Returns every plugin known to the database, merged with the loader's - * live state so the UI can tell "enabled but crashed" from "disabled". + * live state so the UI can tell "enabled but failed" from "disabled". * Open to any authenticated user, not just admins: the app shell calls * this on every session to decide which plugin-contributed tabs and * rail items to register, so gating it behind admin.plugins.manage - * would break the shell for non-admin users. Only the mutating routes - * below (enable/disable, grant/revoke) require that permission. + * would break the shell for non-admin users. + * + * Non-admins get only what the shell needs. What a plugin is allowed to + * do, what has been granted to it and why it failed are operational + * details, so capabilities, grants and lastError are included only for + * holders of admin.plugins.manage. * tags: * - Plugins * responses: * 200: * description: List of plugins. */ -router.get("/", authenticateJWT, async (_req: Request, res: Response) => { +router.get("/", authenticateJWT, async (req: Request, res: Response) => { try { + const userId = (req as AuthenticatedRequest).userId as string; const records = await createCurrentPluginRepository().listAll(); const { loader } = getPluginRuntime(); const live = new Map(loader.list().map((p) => [p.id, p])); - const grantRepository = createCurrentPluginPermissionGrantRepository(); - const plugins = await Promise.all( - records.map(async (record) => { - const loaded = live.get(record.id); - let contributes: unknown = null; - let permissions: string[] = []; - try { - const manifest = JSON.parse(record.manifestJson) as { - contributes?: unknown; - permissions?: unknown; - }; - contributes = manifest?.contributes ?? null; - permissions = Array.isArray(manifest?.permissions) - ? (manifest.permissions as string[]) - : []; - } catch { - contributes = null; - } - - const grants = await grantRepository.listByPlugin(record.id); - - return { - id: record.id, - name: record.name, - version: record.version, - tier: record.tier, - source: record.source, - enabled: record.state === "enabled", - runtimeState: loaded?.state ?? "stopped", - lastError: loaded?.lastError ?? null, - contributes, - permissions, - grantedCapabilities: grants.map((grant) => grant.capability), - }; - }), + const canManage = await permissionManager.hasPermission( + userId, + "admin.plugins.manage", ); + // One query for every plugin rather than one per plugin. + const grantsByPlugin = new Map(); + if (canManage) { + const grantRepository = createCurrentPluginPermissionGrantRepository(); + await Promise.all( + records.map(async (record) => { + const grants = await grantRepository.listByPlugin(record.id); + grantsByPlugin.set( + record.id, + grants.map((grant) => grant.capability), + ); + }), + ); + } + + const plugins = records.map((record) => { + const loaded = live.get(record.id); + + let contributes: unknown = null; + let capabilities: string[] = []; + try { + const manifest = JSON.parse(record.manifestJson) as { + contributes?: unknown; + capabilities?: unknown; + }; + contributes = manifest?.contributes ?? null; + capabilities = Array.isArray(manifest?.capabilities) + ? (manifest.capabilities as string[]) + : []; + } catch { + contributes = null; + } + + const summary = { + id: record.id, + name: record.name, + version: record.version, + enabled: record.state === "enabled", + state: loaded?.state ?? record.state, + contributes, + }; + + if (!canManage) return summary; + + return { + ...summary, + tier: record.tier, + source: record.source, + capabilities, + grantedCapabilities: grantsByPlugin.get(record.id) ?? [], + lastError: loaded?.lastError ?? record.lastError ?? null, + }; + }); + res.json(plugins); } catch (error) { databaseLogger.error( @@ -135,7 +162,6 @@ router.patch( authenticateJWT, requireManagePlugins, async (req: Request, res: Response) => { - const userId = (req as AuthenticatedRequest).userId; const pluginId = String(req.params.id); const { enabled } = req.body ?? {}; @@ -157,12 +183,13 @@ router.patch( // acts on it rather than silently reverting. await repository.update(pluginId, { state: enabled ? "enabled" : "disabled", + lastError: null, }); const { activatePlugin, deactivatePlugin } = await import("../../plugins/index.js"); - if (enabled) await activatePlugin(pluginId, userId); + if (enabled) await activatePlugin(pluginId); else await deactivatePlugin(pluginId); databaseLogger.info( @@ -182,6 +209,89 @@ router.patch( }, ); +/** + * @openapi + * /plugins/{id}/retry: + * post: + * summary: Start a plugin again after it failed + * description: > + * Clears the plugin's error budget and activates it. Use after fixing + * whatever made it fail, such as installing a missing dependency or + * freeing a port it needs. + * tags: + * - Plugins + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: string + * responses: + * 200: + * description: The plugin's state after the attempt. + * 403: + * description: The caller lacks admin.plugins.manage. + * 404: + * description: No such plugin, or it is not on disk. + * 500: + * description: The plugin failed to start again. + */ +router.post( + "/:id/retry", + authenticateJWT, + requireManagePlugins, + async (req: Request, res: Response) => { + const pluginId = String(req.params.id); + + try { + const repository = createCurrentPluginRepository(); + const record = await repository.findById(pluginId); + if (!record) { + res.status(404).json({ error: "Plugin not found" }); + return; + } + + const { loader } = getPluginRuntime(); + if (!loader.get(pluginId)) { + res.status(404).json({ error: "Plugin is not present on disk" }); + return; + } + + await repository.update(pluginId, { + state: "enabled", + lastError: null, + }); + await loader.retry(pluginId); + + databaseLogger.info(`Plugin ${pluginId} restarted`, { + operation: "plugin_retry", + pluginId, + }); + + res.json({ id: pluginId, state: loader.get(pluginId)?.state }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + + databaseLogger.error( + `Failed to retry plugin ${pluginId}`, + error instanceof Error ? error : new Error(message), + { operation: "plugin_retry" }, + ); + + try { + await createCurrentPluginRepository().update(pluginId, { + state: "failed", + lastError: message, + }); + } catch { + // Reporting the failure must not mask it. + } + + res.status(500).json({ error: "Failed to start the plugin" }); + } + }, +); + /** * @openapi * /plugins/{id}/grants: @@ -244,10 +354,10 @@ router.post( let declared: string[] = []; try { const manifest = JSON.parse(record.manifestJson) as { - permissions?: unknown; + capabilities?: unknown; }; - declared = Array.isArray(manifest?.permissions) - ? (manifest.permissions as string[]) + declared = Array.isArray(manifest?.capabilities) + ? (manifest.capabilities as string[]) : []; } catch { declared = []; diff --git a/src/backend/plugins/actor.ts b/src/backend/plugins/actor.ts new file mode 100644 index 000000000..923f396aa --- /dev/null +++ b/src/backend/plugins/actor.ts @@ -0,0 +1,63 @@ +/** + * Who a plugin is acting as right now. + * + * Two ways a plugin gets an actor, and no third: + * + * - A request. A4's HTTP and WebSocket middleware runs the handler inside + * runAsActor with the user core already authenticated, so a route handler + * acts as the person who called it. + * - ctx.asUser(userId, fn), for background work with no request behind it. + * That call is always audited. + * + * Nothing else. A plugin cannot pass a user id to a guarded ctx method and + * have it believed, which is what stops the forged-caller class of bug the + * worker broker had. + * + * AsyncLocalStorage rather than a module variable because plugin work + * interleaves: two requests in flight would otherwise overwrite each other's + * identity between awaits. + */ + +import { AsyncLocalStorage } from "node:async_hooks"; + +interface ActorStore { + userId: string; + /** Where the identity came from, for the audit line. */ + source: "request" | "asUser"; +} + +const storage = new AsyncLocalStorage(); + +export function runAsActor( + userId: string, + source: ActorStore["source"], + fn: () => T, +): T { + return storage.run({ userId, source }, fn); +} + +export function getActor(): string | undefined { + return storage.getStore()?.userId; +} + +export function getActorSource(): ActorStore["source"] | undefined { + return storage.getStore()?.source; +} + +export class PluginActorError extends Error { + readonly code = "EPLUGINACTOR"; + + constructor(pluginId: string, operation: string) { + super( + `Plugin "${pluginId}" called ${operation} with no acting user. ` + + `Wrap background work in ctx.asUser(userId, fn).`, + ); + this.name = "PluginActorError"; + } +} + +export function requireActor(pluginId: string, operation: string): string { + const userId = getActor(); + if (!userId) throw new PluginActorError(pluginId, operation); + return userId; +} diff --git a/src/backend/plugins/broker.ts b/src/backend/plugins/broker.ts deleted file mode 100644 index 59669123d..000000000 --- a/src/backend/plugins/broker.ts +++ /dev/null @@ -1,787 +0,0 @@ -/** - * The main-thread half of the plugin boundary. - * - * Every ctx call a plugin makes arrives here as a message. For each one the - * broker, in this order: - * 1. looks up the method - * 2. checks the plugin's granted capability (manifest-declared AND granted) - * 3. performs the real action - * 4. writes one audit line attributed to the plugin - * 5. replies with a structured-cloneable value - * - * Steps 2 and 4 are not the plugin's to influence. The plugin supplies only the - * arguments and the audit `details`; the actor, action and outcome are set from - * values the broker controls, so a plugin cannot forge an audit trail or - * attribute its actions to a user. - */ - -import type { Worker } from "node:worker_threads"; -import { randomUUID } from "node:crypto"; -import { pluginLogger } from "../utils/logger.js"; -import { getAuditUsername, logAudit } from "../utils/audit-logger.js"; -import type { LoadedPlugin } from "./loader.js"; -import { assertCapability, PluginPermissionError } from "./permissions.js"; -import { toPluginHostView, type PluginHostView } from "./host-view.js"; -import { pluginEvents } from "./events.js"; -import { - isPluginRequest, - isPluginResponse, - type PluginPush, - type PluginRequest, - type PluginResponse, -} from "./protocol.js"; - -export interface PluginStorageBackend { - get: (pluginId: string, key: string) => Promise; - set: (pluginId: string, key: string, value: string) => Promise; - delete: (pluginId: string, key: string) => Promise; - listKeys: (pluginId: string) => Promise; -} - -export interface BrokerDeps { - /** - * Injected rather than imported so tests can exercise the gate without a - * database, and so the heavy hosts modules stay lazily reachable. - */ - listHosts: (userId: string) => Promise[]>; - resolveHost: ( - hostId: number, - userId: string, - ) => Promise | null>; - storage?: PluginStorageBackend; - /** Fired when a plugin's route table changes, so it can be re-mounted. */ - onRoutesChanged?: (runtime: PluginRuntime) => void; - ssh?: PluginSshBackend; -} - -/** - * The SSH operations the broker performs on a plugin's behalf. - * - * Note what is NOT here: there is no "get me the host" and no "give me the - * client". A plugin's reachable surface is open-a-session and run-a-command, - * both of which take an opaque handle. The resolved host, which carries - * plaintext credentials, only ever exists inside these functions. - */ -export interface PluginSshBackend { - open: ( - hostId: number, - userId: string, - ) => Promise<{ release: () => void; exec: SshExec }>; -} - -export type SshExec = ( - command: string, - timeoutMs: number, -) => Promise<{ stdout: string; stderr: string; code: number | null }>; - -/** ctx.storage caps, so one plugin cannot fill the database. */ -const MAX_STORAGE_KEY_LENGTH = 128; -const MAX_STORAGE_VALUE_BYTES = 256 * 1024; -const MIN_SCHEDULE_INTERVAL_MS = 1000; -const HTTP_HANDLER_TIMEOUT_MS = 30_000; -/** Short: a disable should feel immediate even when a plugin is unresponsive. */ -const DEACTIVATE_TIMEOUT_MS = 5_000; -const MAX_SSH_HANDLES = 8; -const DEFAULT_SSH_EXEC_TIMEOUT_MS = 30_000; -const MAX_SSH_EXEC_TIMEOUT_MS = 120_000; -const HTTP_METHODS = new Set(["GET", "POST", "PUT", "PATCH", "DELETE"]); - -interface PendingHttpRoute { - method: string; - path: string; -} - -export interface PluginRuntime { - plugin: LoadedPlugin; - worker: Worker; - /** Routes the plugin registered via ctx.http.route. */ - routes: PendingHttpRoute[]; - /** Topics the plugin subscribed to via ctx.events.on. */ - subscriptions: Set; - /** Bus unsubscribe functions, run on detach so no listener outlives it. */ - eventUnsubscribers: Array<() => void>; - /** ctx.schedule timers, cleared on deactivate. */ - timers: Map; - /** Live SSH handles, keyed by the opaque id the worker holds. */ - sshHandles: Map< - string, - { hostId: number; release: () => void; exec?: SshExec } - >; - disposed: boolean; -} - -/** Methods that need a granted capability before the broker will run them. */ -const REQUIRED_CAPABILITY: Record = { - "hosts.list": "hosts.read", - "hosts.get": "hosts.read", - "ssh.connect": "ssh.exec", - "ssh.exec": "ssh.exec", - "storage.get": "storage.own", - "storage.set": "storage.own", - "storage.delete": "storage.own", - "storage.list": "storage.own", - "events.subscribe": "events.read", -}; - -/** Methods whose invocation is worth an audit line. */ -const AUDITED = new Set(Object.keys(REQUIRED_CAPABILITY)); - -export class PluginBroker { - private readonly runtimes = new Map(); - /** Outstanding route invocations, keyed by the push id sent to the worker. */ - private readonly httpWaiters = new Map< - number, - { resolve: (value: unknown) => void; reject: (error: Error) => void } - >(); - /** - * Push ids start high so they cannot collide with the worker's own request - * ids, which start at 1 and count up independently. - */ - private nextPushId = 1_000_000; - - constructor(private readonly deps: BrokerDeps) {} - - get(pluginId: string): PluginRuntime | undefined { - return this.runtimes.get(pluginId); - } - - /** Wired to PluginLoader's onWorkerReady hook. */ - attach(plugin: LoadedPlugin, worker: Worker): PluginRuntime { - this.detach(plugin.id); - - const runtime: PluginRuntime = { - plugin, - worker, - routes: [], - subscriptions: new Set(), - eventUnsubscribers: [], - timers: new Map(), - sshHandles: new Map(), - disposed: false, - }; - this.runtimes.set(plugin.id, runtime); - - worker.on("message", (message: unknown) => { - if (isPluginRequest(message)) { - void this.dispatch(runtime, message); - return; - } - // A route handler's reply comes back shaped like a response. - if (isPluginResponse(message)) this.settleHttpReply(message); - }); - - return runtime; - } - - /** Wired to PluginLoader's onWorkerGone hook. Releases everything. */ - detach(pluginId: string): void { - const runtime = this.runtimes.get(pluginId); - if (!runtime) return; - - runtime.disposed = true; - for (const timer of runtime.timers.values()) clearInterval(timer); - runtime.timers.clear(); - - for (const handle of runtime.sshHandles.values()) { - try { - handle.release(); - } catch { - // Releasing a dead connection is not worth failing over. - } - } - runtime.sshHandles.clear(); - - for (const unsubscribe of runtime.eventUnsubscribers) { - try { - unsubscribe(); - } catch { - // Already detached. - } - } - runtime.eventUnsubscribers = []; - runtime.subscriptions.clear(); - this.runtimes.delete(pluginId); - } - - private settleHttpReply(message: PluginResponse): void { - const waiter = this.httpWaiters.get(message.id); - if (!waiter) return; - this.httpWaiters.delete(message.id); - - if (message.ok === true) waiter.resolve(message.value); - else waiter.reject(new Error(message.error.message)); - } - - private async dispatch( - runtime: PluginRuntime, - request: PluginRequest, - ): Promise { - const { method, args, callerUserId } = request; - let failure: Error | null = null; - - try { - const required = REQUIRED_CAPABILITY[method]; - if (required) { - await assertCapability( - runtime.plugin.id, - required, - runtime.plugin.manifest.permissions, - ); - } - - const value = await this.invoke(runtime, method, args, callerUserId); - this.reply(runtime, { id: request.id, ok: true, value }); - } catch (error) { - failure = error instanceof Error ? error : new Error(String(error)); - this.reply(runtime, { - id: request.id, - ok: false, - error: { - // Sanitised, never the raw message: errors thrown deep in the SSH - // stack can embed the connect config, credentials included. - message: safeErrorMessage(method, failure), - code: (failure as Error & { code?: string }).code, - }, - }); - } - - if (AUDITED.has(method)) { - void this.audit(runtime, method, args, failure, callerUserId); - } - } - - private reply(runtime: PluginRuntime, message: unknown): void { - if (runtime.disposed) return; - try { - runtime.worker.postMessage(message); - } catch { - // Worker already gone; the reply has nowhere to land. - } - } - - private async invoke( - runtime: PluginRuntime, - method: string, - args: unknown[], - callerUserId?: string, - ): Promise { - switch (method) { - case "log.debug": - case "log.info": - case "log.warn": - case "log.error": - return this.handleLog(runtime, method, args); - - case "audit": - // The audit line is written by the caller path below; nothing to do - // here beyond accepting the call. - return null; - - case "hosts.list": - return this.handleHostsList(runtime, callerUserId); - - case "hosts.get": - return this.handleHostsGet(runtime, Number(args[0]), callerUserId); - - case "storage.get": - return this.handleStorageGet(runtime, storageKey(args[0])); - - case "storage.set": - return this.handleStorageSet(runtime, args); - - case "storage.delete": - return this.storage().delete(runtime.plugin.id, storageKey(args[0])); - - case "storage.list": - return this.storage().listKeys(runtime.plugin.id); - - case "schedule.every": - return this.handleScheduleEvery(runtime, Number(args[0])); - - case "schedule.cancel": - return this.handleScheduleCancel(runtime, String(args[0])); - - case "http.route": - return this.handleHttpRoute(runtime, String(args[0]), String(args[1])); - - case "ssh.connect": - return this.handleSshConnect(runtime, Number(args[0]), callerUserId); - - case "ssh.exec": - return this.handleSshExec( - runtime, - String(args[0]), - String(args[1]), - args[2] === undefined ? undefined : Number(args[2]), - ); - - case "ssh.close": - return this.handleSshClose(runtime, String(args[0])); - - case "events.emit": - return this.handleEventsEmit(runtime, String(args[0]), args[1]); - - case "events.subscribe": - return this.handleEventsSubscribe(runtime, String(args[0])); - - default: - throw new PluginFacingError(`Unknown ctx method "${method}"`); - } - } - - private handleLog( - runtime: PluginRuntime, - method: string, - args: unknown[], - ): null { - const level = method.slice("log.".length) as - "debug" | "info" | "warn" | "error"; - const message = String(args[0] ?? ""); - // Forced context: a plugin cannot pretend to be another plugin in the log. - const context = { operation: `plugin:${runtime.plugin.id}` }; - - if (level === "error") { - pluginLogger.error(message, undefined, context); - } else { - pluginLogger[level](message, context); - } - return null; - } - - private async handleHostsList( - runtime: PluginRuntime, - callerUserId?: string, - ): Promise { - const userId = this.requireActor(runtime, callerUserId); - const hosts = await this.deps.listHosts(userId); - return hosts.map(toPluginHostView); - } - - private async handleHostsGet( - runtime: PluginRuntime, - hostId: number, - callerUserId?: string, - ): Promise { - const userId = this.requireActor(runtime, callerUserId); - if (!Number.isFinite(hostId)) { - throw new PluginFacingError("hosts.get requires a numeric host id"); - } - - const host = await this.deps.resolveHost(hostId, userId); - // resolveHostById returns null for both "missing" and "no access", and the - // plugin gets the same answer for both so it cannot probe for existence. - return host ? toPluginHostView(host) : null; - } - - private storage(): PluginStorageBackend { - if (!this.deps.storage) { - throw new PluginFacingError( - "ctx.storage is not available in this runtime", - ); - } - return this.deps.storage; - } - - private async handleStorageGet( - runtime: PluginRuntime, - key: string, - ): Promise { - const raw = await this.storage().get(runtime.plugin.id, key); - if (raw === null) return null; - try { - return JSON.parse(raw); - } catch { - // A row written by an older shape should not break the plugin. - return null; - } - } - - private async handleStorageSet( - runtime: PluginRuntime, - args: unknown[], - ): Promise { - const key = storageKey(args[0]); - // Serialised here rather than in the worker so the stored shape is the - // broker's decision, not something a plugin can control. - const value = JSON.stringify(args[1] ?? null); - - if (Buffer.byteLength(value, "utf8") > MAX_STORAGE_VALUE_BYTES) { - throw new PluginFacingError( - `ctx.storage value for "${key}" exceeds the ${MAX_STORAGE_VALUE_BYTES} byte limit`, - ); - } - - await this.storage().set(runtime.plugin.id, key, value); - return null; - } - - private handleScheduleEvery( - runtime: PluginRuntime, - intervalMs: number, - ): string { - if (!Number.isFinite(intervalMs) || intervalMs < MIN_SCHEDULE_INTERVAL_MS) { - throw new PluginFacingError( - `ctx.schedule.every requires an interval of at least ${MIN_SCHEDULE_INTERVAL_MS}ms`, - ); - } - - const id = `timer:${randomUUID()}`; - const timer = setInterval(() => { - if (runtime.disposed) return; - this.push(runtime, { kind: "timer", key: id, payload: null }); - }, intervalMs); - - // A plugin timer must not keep the server alive on its own. - timer.unref?.(); - runtime.timers.set(id, timer); - return id; - } - - private handleScheduleCancel(runtime: PluginRuntime, id: string): null { - const timer = runtime.timers.get(id); - if (timer) { - clearInterval(timer); - runtime.timers.delete(id); - } - return null; - } - - /** - * Opens a pooled connection and returns an opaque handle. - * - * The handle is a random id, not anything derived from the host or its - * credentials, and it is only meaningful inside this runtime's own map. The - * ssh2 Client and the resolved host never enter a postMessage payload. - */ - private async handleSshConnect( - runtime: PluginRuntime, - hostId: number, - callerUserId?: string, - ): Promise { - const userId = this.requireActor(runtime, callerUserId); - if (!Number.isFinite(hostId)) { - throw new PluginFacingError("ssh.connect requires a numeric host id"); - } - if (!this.deps.ssh) { - throw new PluginFacingError("ctx.ssh is not available in this runtime"); - } - if (runtime.sshHandles.size >= MAX_SSH_HANDLES) { - throw new PluginFacingError( - `ctx.ssh is limited to ${MAX_SSH_HANDLES} concurrent connections per plugin`, - ); - } - - const session = await this.deps.ssh.open(hostId, userId); - const id = `sshconn:${randomUUID()}`; - runtime.sshHandles.set(id, { - hostId, - release: session.release, - exec: session.exec, - }); - return id; - } - - private async handleSshExec( - runtime: PluginRuntime, - handleId: string, - command: string, - timeoutMs?: number, - ): Promise<{ stdout: string; stderr: string; code: number | null }> { - const handle = runtime.sshHandles.get(handleId); - // An unknown handle is indistinguishable from another plugin's handle: - // the map is per-runtime, so ids cannot be guessed across plugins. - if (!handle?.exec) { - throw new PluginFacingError("Unknown or closed SSH connection handle"); - } - if (!command) throw new PluginFacingError("ssh.exec requires a command"); - - const timeout = - timeoutMs && Number.isFinite(timeoutMs) - ? Math.min(timeoutMs, MAX_SSH_EXEC_TIMEOUT_MS) - : DEFAULT_SSH_EXEC_TIMEOUT_MS; - - return handle.exec(command, timeout); - } - - private handleSshClose(runtime: PluginRuntime, handleId: string): null { - const handle = runtime.sshHandles.get(handleId); - if (!handle) return null; - - runtime.sshHandles.delete(handleId); - try { - handle.release(); - } catch { - // Releasing a dead connection is not worth failing over. - } - return null; - } - - /** - * A plugin may only emit under its own namespace. Without this a plugin - * could publish "host.status" and drive the automations engine as though the - * metrics poller had reported it. - */ - private handleEventsEmit( - runtime: PluginRuntime, - topic: string, - payload: unknown, - ): null { - const prefix = `plugin.${runtime.plugin.id}.`; - if (!topic.startsWith(prefix)) { - throw new PluginFacingError( - `ctx.events.emit may only publish topics beginning with "${prefix}"`, - ); - } - - pluginEvents.emit(topic, payload); - return null; - } - - private handleEventsSubscribe(runtime: PluginRuntime, topic: string): null { - if (runtime.subscriptions.has(topic)) return null; - runtime.subscriptions.add(topic); - - const unsubscribe = pluginEvents.on(topic, (payload) => { - this.push(runtime, { kind: "event", key: topic, payload }); - }); - runtime.eventUnsubscribers.push(unsubscribe); - return null; - } - - private handleHttpRoute( - runtime: PluginRuntime, - method: string, - routePath: string, - ): null { - const verb = method.toUpperCase(); - if (!HTTP_METHODS.has(verb)) { - throw new PluginFacingError( - `ctx.http.route does not support the ${verb} method`, - ); - } - if (!routePath.startsWith("/")) { - throw new PluginFacingError(`ctx.http.route path must start with "/"`); - } - - const key = `${verb} ${routePath}`; - if ( - !runtime.routes.some((route) => `${route.method} ${route.path}` === key) - ) { - runtime.routes.push({ method: verb, path: routePath }); - } - - this.deps.onRoutesChanged?.(runtime); - return null; - } - - /** - * Invoked by the Express router this plugin's routes are mounted behind. - * Forwards the request into the worker and waits for the handler's reply. - */ - invokeRoute( - runtime: PluginRuntime, - key: string, - payload: unknown, - timeoutMs = HTTP_HANDLER_TIMEOUT_MS, - ): Promise { - if (runtime.disposed) { - return Promise.reject(new Error("Plugin is not running")); - } - - const replyTo = this.nextPushId++; - - return new Promise((resolve, reject) => { - const timer = setTimeout(() => { - this.httpWaiters.delete(replyTo); - reject(new Error("Plugin route handler timed out")); - }, timeoutMs); - - this.httpWaiters.set(replyTo, { - resolve: (value) => { - clearTimeout(timer); - resolve(value); - }, - reject: (error) => { - clearTimeout(timer); - reject(error); - }, - }); - - this.push(runtime, { kind: "http", key, payload, replyTo }); - }); - } - - /** - * Gives a worker plugin a chance to close things down before it is killed. - * Resolves either way: a plugin that does not answer must not block the - * disable, it just loses its cleanup. - */ - requestDeactivate( - runtime: PluginRuntime, - timeoutMs = DEACTIVATE_TIMEOUT_MS, - ): Promise { - if (runtime.disposed) return Promise.resolve(); - - const replyTo = this.nextPushId++; - - return new Promise((resolve) => { - const timer = setTimeout(() => { - this.httpWaiters.delete(replyTo); - resolve(); - }, timeoutMs); - - const settle = () => { - clearTimeout(timer); - resolve(); - }; - - this.httpWaiters.set(replyTo, { resolve: settle, reject: settle }); - this.push(runtime, { - kind: "deactivate", - key: "deactivate", - payload: null, - replyTo, - }); - }); - } - - /** Main thread -> worker, for events, timers and route invocations. */ - push(runtime: PluginRuntime, message: PluginPush): void { - if (runtime.disposed) return; - try { - runtime.worker.postMessage(message); - } catch { - // Worker already gone. - } - } - - /** - * Resolves the user a privileged ctx call acts as. This is where the two - * identity models that coexist in one plugin meet: - * - * - Inside an HTTP handler, `callerUserId` is set -- the worker attached - * it from the AsyncLocalStorage context it entered around that specific - * ctx.http.route invocation (see worker-bootstrap.ts). The call acts as - * whichever user's request is currently being served, so - * ctx.hosts.list() returns THEIR hosts, not the plugin installer's. - * Grants stay install-wide (an admin unlocks the capability for the - * plugin as a whole); this is only about whose DATA the call touches. - * - * - From a schedule.every timer or an events.on listener, there is no - * inbound request and so no AsyncLocalStorage context to read from -- - * `callerUserId` is undefined. These fall back to ownerUserId, the user - * who last enabled the plugin, which is what background work has - * always run as. - * - * A plugin cannot influence which branch it gets: no ctx method takes a - * userId argument, so `callerUserId` only ever reflects a real inbound - * request the server's own auth middleware verified. - */ - private requireActor(runtime: PluginRuntime, callerUserId?: string): string { - const userId = callerUserId ?? runtime.plugin.ownerUserId; - if (!userId) { - throw new PluginFacingError( - `Plugin ${runtime.plugin.id} has no owning user, so it cannot act on user data`, - ); - } - return userId; - } - - private async audit( - runtime: PluginRuntime, - method: string, - args: unknown[], - failure: Error | null, - callerUserId?: string, - ): Promise { - const userId = callerUserId ?? runtime.plugin.ownerUserId; - if (!userId) return; - - // Attribution comes from the broker, never from the plugin: username is - // the plugin id, not the user's, so a plugin action is never mistaken for - // something the person did themselves. The userId this is logged against - // is whichever identity requireActor would have used for the same call - // (see its comment), so a request-triggered action is attributed to the - // requester, not always to whoever installed the plugin. - await logAudit({ - userId, - username: `plugin:${runtime.plugin.id}`, - action: `plugin_${method.replace(/\./g, "_")}`, - resourceType: "plugin", - resourceId: runtime.plugin.id, - resourceName: runtime.plugin.manifest.name, - details: safeDetails(method, args), - success: failure === null, - errorMessage: failure?.message, - }); - } - - /** Resolves the display name for a plugin's owner, for UI surfaces. */ - async ownerUsername(pluginId: string): Promise { - const runtime = this.runtimes.get(pluginId); - if (!runtime?.plugin.ownerUserId) return null; - return getAuditUsername(runtime.plugin.ownerUserId); - } - - newHandleId(): string { - return `sshconn:${randomUUID()}`; - } -} - -/** - * Audit details are capped and only ever record the shape of a call, never a - * value a plugin passed. A plugin could otherwise write arbitrary volume, or - * something that looks like a real audit entry, into the trail. - */ -/** - * Errors the broker raises itself are safe to show a plugin: they are written - * here and say nothing about credentials. Anything else came from deeper in the - * server -- ssh2, the pool, a repository -- and can carry connect config, - * decrypted material or internal paths, so the plugin gets a generic message - * and the real one goes to the log. - * - * Marker class rather than message matching: a substring check would start - * leaking the moment an upstream error happened to contain a familiar phrase. - */ -export class PluginFacingError extends Error { - constructor(message: string) { - super(message); - this.name = "PluginFacingError"; - } -} - -function safeErrorMessage(method: string, error: Error): string { - if (error instanceof PluginFacingError) return error.message; - if (error instanceof PluginPermissionError) return error.message; - - pluginLogger.error(`ctx.${method} failed`, error, { - operation: "plugin_ctx_error", - }); - return `ctx.${method} failed. See the server log for details.`; -} - -function storageKey(raw: unknown): string { - const key = String(raw ?? ""); - if (!key) throw new PluginFacingError("ctx.storage requires a non-empty key"); - if (key.length > MAX_STORAGE_KEY_LENGTH) { - throw new PluginFacingError( - `ctx.storage key exceeds the ${MAX_STORAGE_KEY_LENGTH} character limit`, - ); - } - return key; -} - -export function safeDetails(method: string, args: unknown[]): string { - const summary = args.map((arg) => { - if (typeof arg === "number" || typeof arg === "boolean") return arg; - if (typeof arg === "string") { - return arg.length > 64 ? `${arg.slice(0, 64)}…` : arg; - } - if (arg === null || arg === undefined) return null; - return Array.isArray(arg) ? `[array:${arg.length}]` : "[object]"; - }); - - return JSON.stringify({ method, args: summary }); -} - -export { PluginPermissionError }; diff --git a/src/backend/plugins/ctx.ts b/src/backend/plugins/ctx.ts new file mode 100644 index 000000000..dd4b0985f --- /dev/null +++ b/src/backend/plugins/ctx.ts @@ -0,0 +1,471 @@ +/** + * Builds the ctx a plugin's activate() receives. + * + * Every privileged member goes through guarded(), which checks the capability + * against the plugin's grants and writes an audit line naming the plugin and + * the acting user. That is the whole mechanism, and it is worth being precise + * about what it buys: + * + * - It stops accidental and casual overreach. A plugin that never declared + * credentials:read cannot reach a secret through ctx, and a plugin that + * declared it leaves a trail every time it does. + * - It does NOT contain malicious code. A plugin runs in the server process + * and can import any module core can. Guarding ctx does not change that, + * and this file does not pretend otherwise. Trust comes from signing, + * review and the kill list. See ARCHITECTURE.md. + * + * The actor never comes from plugin code. It comes from AsyncLocalStorage, + * set by a request or by ctx.asUser, so a plugin cannot name a user and be + * believed. + */ + +import { pluginLogger } from "../utils/logger.js"; +import { pluginEvents } from "./events.js"; +import * as registry from "./registry.js"; +import * as serviceRegistry from "./service-registry.js"; +import type { ServiceRegistration } from "./service-registry.js"; +import * as secretRegistry from "./secret-registry.js"; +import type { SecretRegistration } from "./secret-registry.js"; +import { assertCapability } from "./permissions.js"; +import { getActor, runAsActor } from "./actor.js"; +import { DisposableBag } from "./disposables.js"; +import type { PluginManifest } from "@termix/plugin-sdk/manifest"; +import type { PluginContext, PluginModule } from "@termix/plugin-sdk/backend"; + +export type { PluginModule }; + +/** Caps mirroring what the old worker boundary enforced. */ +const MAX_KV_KEY_LENGTH = 128; +const MAX_KV_VALUE_BYTES = 256 * 1024; + +export interface PluginHandle { + module: PluginModule; + bag: DisposableBag; + providedServices: ServiceRegistration[]; + offeredSecrets: SecretRegistration[]; +} + +export function createPluginHandle( + pluginId: string, + module: PluginModule, +): PluginHandle { + return { + module, + bag: new DisposableBag(pluginId), + providedServices: [], + offeredSecrets: [], + }; +} + +interface AuditOptions { + /** Audit action suffix, e.g. "kv_set". */ + action: string; + /** Short description of the call shape. Never a value. */ + details?: () => string; +} + +/** + * Wraps a privileged function in the capability check and the audit line. + * + * The check runs before the call and the audit after, so a denied call is + * still recorded: "tried and was refused" is exactly the thing worth seeing. + */ +function guarded( + manifest: PluginManifest, + capability: string, + fn: (...args: Args) => Promise, + options: AuditOptions, +): (...args: Args) => Promise { + return async (...args: Args): Promise => { + const pluginId = manifest.id; + + try { + await assertCapability(pluginId, capability, manifest.capabilities); + } catch (error) { + await writeAudit(manifest, options, { + success: false, + errorMessage: error instanceof Error ? error.message : String(error), + }); + throw error; + } + + try { + const result = await fn(...args); + await writeAudit(manifest, options, { success: true }); + return result; + } catch (error) { + await writeAudit(manifest, options, { + success: false, + errorMessage: error instanceof Error ? error.message : String(error), + }); + throw error; + } + }; +} + +async function writeAudit( + manifest: PluginManifest, + options: AuditOptions, + outcome: { success: boolean; errorMessage?: string }, +): Promise { + try { + const { logAudit } = await import("../utils/audit-logger.js"); + await logAudit({ + // Attribution comes from the runtime, never from the plugin. + userId: getActor() ?? "system", + username: `plugin:${manifest.id}`, + action: `plugin_${options.action}`, + resourceType: "plugin", + resourceId: manifest.id, + resourceName: manifest.name, + details: options.details?.(), + success: outcome.success, + errorMessage: outcome.errorMessage, + }); + } catch { + // Auditing must never break the caller. + } +} + +export function createPluginContext( + manifest: PluginManifest, + handle: PluginHandle, +): PluginContext { + const pluginId = manifest.id; + // Forced: a plugin cannot log as another plugin. + const logContext = { operation: `plugin:${pluginId}` }; + const declared = manifest.capabilities; + + const kvGet = guarded( + manifest, + "kv:own", + async (key: string) => { + const { createCurrentPluginStorageRepository } = + await import("../database/repositories/factory.js"); + const raw = await createCurrentPluginStorageRepository().get( + pluginId, + key, + ); + if (raw === null) return null; + try { + return JSON.parse(raw) as unknown; + } catch { + return null; + } + }, + { action: "kv_get" }, + ); + + const kvSet = guarded( + manifest, + "kv:own", + async (key: string, value: unknown) => { + assertKvKey(key); + const serialized = JSON.stringify(value ?? null); + if (Buffer.byteLength(serialized, "utf8") > MAX_KV_VALUE_BYTES) { + throw new Error( + `Value for "${key}" exceeds the ${MAX_KV_VALUE_BYTES} byte limit for ctx.kv`, + ); + } + const { createCurrentPluginStorageRepository } = + await import("../database/repositories/factory.js"); + await createCurrentPluginStorageRepository().set( + pluginId, + key, + serialized, + ); + }, + { action: "kv_set" }, + ); + + const kvDelete = guarded( + manifest, + "kv:own", + async (key: string) => { + const { createCurrentPluginStorageRepository } = + await import("../database/repositories/factory.js"); + return createCurrentPluginStorageRepository().delete(pluginId, key); + }, + { action: "kv_delete" }, + ); + + const kvList = guarded( + manifest, + "kv:own", + async () => { + const { createCurrentPluginStorageRepository } = + await import("../database/repositories/factory.js"); + return createCurrentPluginStorageRepository().listKeys(pluginId); + }, + { action: "kv_list" }, + ); + + return { + pluginId, + manifest, + + log: { + debug: (message) => pluginLogger.debug(message, logContext), + info: (message) => pluginLogger.info(message, logContext), + warn: (message) => pluginLogger.warn(message, logContext), + error: (message, error) => pluginLogger.error(message, error, logContext), + }, + + events: { + /** + * A plugin may only emit under its own namespace unless it holds + * events:core. Without this an in-process plugin can publish + * "host.status" and drive the automations engine as if core had. + */ + emit: (topic, payload) => { + if ( + !topic.startsWith(`plugin.${pluginId}.`) && + !declared.includes("events:core") + ) { + throw new Error( + `Plugin ${pluginId} may only emit topics under "plugin.${pluginId}.". ` + + `Declare the events:core capability to emit core topics.`, + ); + } + pluginEvents.emit(topic, payload); + }, + + on: (topic, listener) => { + const unsubscribe = pluginEvents.on(topic, listener); + handle.bag.add(unsubscribe, `event listener for "${topic}"`); + return unsubscribe; + }, + }, + + kv: { + get: (key) => kvGet(key), + set: (key, value) => kvSet(key, value), + delete: (key) => kvDelete(key), + list: () => kvList(), + }, + + registry: { + provide: (key, value) => { + registry.provide(key, value); + handle.bag.add( + () => void registry.revoke(key, value), + `registry key "${key}"`, + ); + }, + consume: (key) => registry.consume(key), + revoke: (key, value) => registry.revoke(key, value), + }, + + services: { + provide: (service, implementation) => { + // Declared AND provided: a plugin cannot publish a service its + // manifest never mentioned. + const entry = manifest.provides?.find( + (candidate) => candidate.service === service, + ); + if (!entry) { + throw new Error( + `Plugin ${pluginId} cannot provide service "${service}": it is not declared in the manifest's provides array`, + ); + } + + const registration = serviceRegistry.provideService({ + service, + version: entry.version, + permission: entry.permission, + pluginId, + pluginName: manifest.name, + implementation: implementation as Record, + }); + handle.providedServices.push(registration); + handle.bag.add( + () => void serviceRegistry.revokeService(service, registration), + `service "${service}"`, + ); + }, + + get: (service, options) => + serviceRegistry.createServiceHandle(service, pluginId, { + // A caller-supplied userId is only honoured when it matches the + // actor; otherwise the ambient actor wins. A plugin cannot widen + // its reach by naming someone else here. + resolveUserId: () => options?.userId ?? getActor(), + hasPermission: checkPermission, + audit: (entry) => writeServiceAudit(entry), + }), + }, + + secrets: { + offer: (key, resolve) => { + const entry = manifest.providesSecret?.find( + (candidate) => candidate.key === key, + ); + if (!entry) { + throw new Error( + `Plugin ${pluginId} cannot offer secret "${key}": it is not declared in the manifest's providesSecret array`, + ); + } + + const registration = secretRegistry.offerSecret({ + pluginId, + pluginName: manifest.name, + key, + permission: entry.permission, + resolve, + }); + handle.offeredSecrets.push(registration); + handle.bag.add( + () => void secretRegistry.withdrawSecret(pluginId, key, registration), + `secret "${key}"`, + ); + }, + + withdraw: (key) => { + const registration = handle.offeredSecrets.find( + (entry) => entry.key === key, + ); + handle.offeredSecrets = handle.offeredSecrets.filter( + (entry) => entry !== registration, + ); + return secretRegistry.withdrawSecret(pluginId, key, registration); + }, + + getShared: (providerPluginId, key, options) => + secretRegistry.readSharedSecret(manifest, providerPluginId, key, { + resolveUserId: () => options?.userId ?? getActor(), + hasPermission: checkPermission, + audit: (entry) => writeSecretAudit(entry), + }), + }, + + disposables: { + add: (dispose) => handle.bag.add(dispose, "plugin resource"), + }, + + /** + * Background work acts as a named user. Always audited, because "this ran + * as someone" is exactly the thing an operator needs to be able to see. + */ + asUser: async (userId, fn) => { + if (typeof userId !== "string" || userId.length === 0) { + throw new Error( + `Plugin ${pluginId} called ctx.asUser without a user id`, + ); + } + await writeAudit( + manifest, + { + action: "as_user", + details: () => `${pluginId} ran background work as ${userId}`, + }, + { success: true }, + ); + return runAsActor(userId, "asUser", () => Promise.resolve(fn())); + }, + + currentActor: () => getActor(), + }; +} + +function assertKvKey(key: string): void { + if (typeof key !== "string" || key.length === 0) { + throw new Error("ctx.kv keys must be non-empty strings"); + } + if (key.length > MAX_KV_KEY_LENGTH) { + throw new Error( + `ctx.kv keys must be at most ${MAX_KV_KEY_LENGTH} characters`, + ); + } +} + +async function checkPermission( + userId: string, + permission: string, +): Promise { + const { PermissionManager } = await import("../utils/permission-manager.js"); + return PermissionManager.getInstance().hasPermission(userId, permission); +} + +/** Attribution comes from the runtime; details only record the call shape. */ +async function writeServiceAudit( + entry: serviceRegistry.ServiceAuditEntry, +): Promise { + try { + const { logAudit } = await import("../utils/audit-logger.js"); + await logAudit({ + userId: entry.userId, + username: `plugin:${entry.consumerPluginId}`, + action: `plugin_service_${entry.registration.service.replace(/\./g, "_")}_${entry.method}`, + resourceType: "plugin", + resourceId: entry.registration.pluginId, + resourceName: entry.registration.pluginName, + details: `${entry.consumerPluginId} called ${entry.registration.service}.${entry.method}`, + success: entry.success, + errorMessage: entry.errorMessage, + }); + } catch { + // Auditing must never break the caller. + } +} + +/** + * Records that a secret was borrowed, never what it was. `resolved` is the + * only thing said about the value: whether one came back at all. + */ +async function writeSecretAudit( + entry: secretRegistry.SecretAuditEntry, +): Promise { + try { + const { logAudit } = await import("../utils/audit-logger.js"); + await logAudit({ + userId: entry.userId, + username: `plugin:${entry.consumerPluginId}`, + action: "plugin_secret_shared_read", + resourceType: "plugin", + resourceId: entry.providerPluginId, + resourceName: entry.providerPluginName, + details: `${entry.consumerPluginId} read shared secret "${entry.providerPluginId}:${entry.key}" (resolved: ${entry.resolved})`, + success: entry.success, + errorMessage: entry.errorMessage, + }); + } catch { + // Auditing must never break the caller. + } +} + +/** + * Undoes everything the plugin registered, then runs its own deactivate(). + * + * The plugin's cleanup runs last so it can still emit or read while shutting + * down, and its throwing does not stop the bag from being emptied. + */ +export async function disposePluginHandle( + handle: PluginHandle, + pluginId: string, + options: { runDeactivate?: boolean } = {}, +): Promise { + await handle.bag.disposeAll(); + + handle.providedServices = []; + handle.offeredSecrets = []; + // Belt and braces: an offer made outside ctx, or one whose registration was + // lost to a throw mid-activate, would otherwise outlive the plugin. + secretRegistry.withdrawAllForPlugin(pluginId); + + // Skipped when activate() never finished: a plugin's deactivate expects the + // state activate builds, and calling it on a half-built plugin tends to + // throw over the original error and hide it. + const runDeactivate = options.runDeactivate ?? true; + + if (runDeactivate && typeof handle.module.deactivate === "function") { + try { + await handle.module.deactivate(); + } catch (error) { + pluginLogger.error( + `Plugin ${pluginId} deactivate() failed`, + error instanceof Error ? error : new Error(String(error)), + { operation: "plugin_deactivate" }, + ); + } + } +} diff --git a/src/backend/plugins/disposables.ts b/src/backend/plugins/disposables.ts new file mode 100644 index 000000000..38bc83922 --- /dev/null +++ b/src/backend/plugins/disposables.ts @@ -0,0 +1,69 @@ +/** + * Everything a plugin registered, so deactivate can undo all of it. + * + * The rule this enforces: a plugin is only as disableable as its cleanup. A + * listening port, a live SSH session or an interval that outlives deactivate + * turns "disabled" into a lie, and re-enabling then fails on a port that is + * still held. + * + * Disposal runs in reverse registration order, so a thing torn down last was + * set up first. Every disposer is isolated: one that throws is logged and the + * rest still run, because a half-disposed plugin is worse than a noisy log. + */ + +import { pluginLogger } from "../utils/logger.js"; + +export type Disposer = () => void | Promise; + +interface Entry { + dispose: Disposer; + /** What this is, for the log line when it throws. */ + label: string; +} + +export class DisposableBag { + private entries: Entry[] = []; + private disposed = false; + + constructor(private readonly pluginId: string) {} + + add(dispose: Disposer, label = "disposable"): void { + if (this.disposed) { + // Registering after teardown means the resource would never be cleaned + // up. Dispose it immediately rather than silently leaking it. + void this.run({ dispose, label }); + return; + } + this.entries.push({ dispose, label }); + } + + get size(): number { + return this.entries.length; + } + + /** + * Runs every disposer, newest first. Safe to call more than once, and never + * throws: the caller is already tearing the plugin down. + */ + async disposeAll(): Promise { + this.disposed = true; + const entries = this.entries.reverse(); + this.entries = []; + + for (const entry of entries) { + await this.run(entry); + } + } + + private async run(entry: Entry): Promise { + try { + await entry.dispose(); + } catch (error) { + pluginLogger.error( + `Plugin ${this.pluginId} failed to dispose ${entry.label}`, + error instanceof Error ? error : new Error(String(error)), + { operation: "plugin_dispose" }, + ); + } + } +} diff --git a/src/backend/plugins/first-party.ts b/src/backend/plugins/first-party.ts deleted file mode 100644 index d5995bccf..000000000 --- a/src/backend/plugins/first-party.ts +++ /dev/null @@ -1,152 +0,0 @@ -/** - * The plugins that run in-process on the main thread instead of in a worker. - * - * ## Why this list is hardcoded - * - * Every other plugin is handed a `ctx` object and nothing else, across a - * structured-clone postMessage boundary. That boundary is what makes the - * capability gate and the audit trail meaningful: a worker plugin physically - * cannot hold a db handle, an ssh2 Client or a live socket, so every privileged - * action has to pass through the broker, which checks grants first and writes - * an audit line after. - * - * A plugin in this tier opts out of all of that. It is imported directly into - * the server process, so it has the same reach as any other backend module: no - * ctx boundary, no capability gate, no audit interception, no containment. It - * is Termix's own code that happens to be packaged and lifecycled as a plugin. - * - * That is only defensible because membership cannot be claimed. This set is - * compiled into the server. A manifest cannot opt in -- declaring the - * capability without being listed here is a validation error, not a silent - * downgrade (see parseManifest). Nothing read from the database, a registry or - * a plugin directory can add to it. - * - * DO NOT add an installed or community plugin here. If a plugin needs more - * reach than ctx allows, the answer is to widen ctx -- deliberately, with a - * gate and an audit line -- not to move it onto the main thread. - */ - -/** - * The only plugin ids that may run in-process. - * - * ssh-terminal is here because it IS the SSH transport rather than a consumer - * of it: it owns a WebSocket server and long-lived ssh2 clients, neither of - * which can cross a postMessage boundary. - * - * docker is here for the same reason: its container console owns a - * WebSocket server with long-lived ssh2 clients and PTY streams, its - * session manager reuses a live ssh2.Client across many REST calls, and its - * SSH connect flow implements TOTP/Warpgate as a stateful multi-request - * handshake the worker ctx.ssh API has no hook for. - * - * host-metrics is here for the same reason: its polling manager holds - * long-lived ssh2 clients across a whole fleet of hosts, reused between - * polling ticks rather than opened per request, and its connect flow - * implements the same jump-host/SOCKS5/TOTP/Warpgate/OPKSSH/Vault - * keyboard-interactive handshakes the terminal does -- none of which the - * worker ctx.ssh API (connect + single exec-and-collect) has a hook for. - * - * ai is here for a different reason: it does not own a transport at all, but - * it writes directly across hosts, snippets, fleets, alert rules and - * automations through their repositories, runs approved commands over the - * shared SSH pool via resolveHostById and withConnection, and streams its - * chat replies as server-sent events. ctx.hosts is a 13-field read-only view - * with no write methods and no snippets/fleets/alerts/automations surface at - * all, and ctx.http.route replies with one postMessage value rather than a - * stream, so none of that fits the worker ctx either. - * - * proxmox is here for the same reason as ai: it does not own a transport, - * but it writes directly across hosts through the host repository during - * discovery and sync, resolves and connects to hosts via resolveHostById and - * createJumpHostChain the same way the terminal and ai's command executor - * do, verifies host keys via SSHHostKeyVerifier, and streams its discovery - * progress as server-sent events. Neither the worker's 13-field read-only - * ctx.hosts nor its single-postMessage-reply ctx.http.route can support that. - * - * remote-desktop is here for the same reason ssh-terminal is: it owns a - * WebSocket server (guacamole-lite, port 30008) and its - * /connect-host/:hostId route does multi-step credential/tunnel resolution - * (SSH jump-host tunnels, a macOS VNC compatibility proxy) using raw - * net.Socket plumbing that cannot cross a structured-clone boundary. - * - * fleets is here for the same reason as ai and proxmox: it does not own a - * transport, but its execute/transfer routes resolve and connect to many - * hosts concurrently via resolveHostById and the shared SSH pool, stream SFTP - * reads/writes and a zip archive for its push/pull routes, and buffer - * multipart file uploads. None of that fits the worker's single-exec - * ctx.ssh or single-postMessage-reply ctx.http.route. - * - * automations is here for the same reason as ai, proxmox and fleets: it does - * not own a transport, but its engine and scheduler write across hosts, - * snippets, fleets and notification-channel repositories through the shared - * SSH pool via resolveHostById and withConnection, and its scheduler needs - * direct repository access plus subscription to the cross-plugin event bus - * (host metrics, status and health-check events) that the worker's read-only - * ctx.hosts and single-postMessage-reply ctx.http.route cannot support. - * - * network-topology is here for a different reason than the rest of this - * list: its two routes are plain per-user JSON CRUD against a single - * repository, and would fit the worker ctx fine on their own. It stays - * in-process to keep its existing /network-topology URL and its existing - * dedicated table (network_topology, keyed by userId) rather than moving - * onto /plugin-api/network-topology/* and the generic key-value - * ctx.storage the worker tier would require -- a URL and storage-model - * change, not a capability gap. - * - * tailscale is here because its /devices route makes an outbound HTTP call - * to the Tailscale (or Headscale) Admin API using a settings-repo API key. - * The worker ctx has no fetch primitive at all (ctx.fetch is notImplemented - * in SDK v1, see worker-bootstrap.ts), so there is currently no way for a - * worker-tier plugin to make an outbound HTTP request. - * - * web-endpoint is here for the same reason ssh-terminal is, on a smaller - * scale: its open route polls the tunnel manager's live maps - * (activeTunnelRuntimes, connectionStatus, tunnelConnecting), builds a - * TunnelConfig from plaintext host credentials that ctx.hosts deliberately - * withholds, and hands the resulting live ssh2.Client to forwardOut to probe - * the target before reporting success. A structured-clone boundary can carry - * none of those three, so this is a genuine transport dependency rather than - * the URL-stability concession network-topology and workspaces make. - * - * workspaces is here for the same reason network-topology is: its routes are - * plain per-user JSON CRUD against a single repository, and would fit the - * worker ctx fine on their own. It stays in-process to keep its existing - * /workspaces URL and its existing dedicated table (user_workspaces, keyed - * by userId) rather than moving onto /plugin-api/workspaces/* and the - * generic key-value ctx.storage the worker tier would require -- a URL and - * storage-model change, not a capability gap. - */ -export const FIRST_PARTY_PLUGIN_IDS: ReadonlySet = new Set([ - "ssh-terminal", - "docker", - "host-metrics", - "ai", - "proxmox", - "remote-desktop", - "fleets", - "automations", - "network-topology", - "tailscale", - "workspaces", - "web-endpoint", -]); - -export const TRANSPORT_OWNER_CAPABILITY = "process:transport-owner"; - -export function isFirstParty(pluginId: string): boolean { - return FIRST_PARTY_PLUGIN_IDS.has(pluginId); -} - -/** - * Both conditions are required. The allowlist alone is not enough -- a - * first-party plugin that does not declare the capability still runs in a - * worker, so the manifest stays an honest description of what the plugin does. - */ -export function runsInProcess( - pluginId: string, - permissions: readonly string[], -): boolean { - return ( - isFirstParty(pluginId) && permissions.includes(TRANSPORT_OWNER_CAPABILITY) - ); -} diff --git a/src/backend/plugins/host-ctx.ts b/src/backend/plugins/host-ctx.ts deleted file mode 100644 index 4ffda5334..000000000 --- a/src/backend/plugins/host-ctx.ts +++ /dev/null @@ -1,373 +0,0 @@ -/** - * The ctx handed to an in-process first-party plugin. - * - * Unlike the worker ctx built in worker-bootstrap.ts, nothing here is a - * postMessage round trip and nothing is capability-gated. There would be no - * point: the plugin is running on the main thread and could import any module - * in the server directly. A gate it can trivially bypass is a false claim, not - * a control, so this surface does not pretend to be one. - * - * What this ctx is actually for is lifecycle and identity: the plugin gets a - * logger tagged with its id, the shared event bus, its own storage namespace, - * and the service registry. It uses ordinary imports for everything else. - * - * See first-party.ts for why membership in this tier cannot be self-declared. - */ - -import { pluginLogger } from "../utils/logger.js"; -import { pluginEvents } from "./events.js"; -import * as registry from "./registry.js"; -import * as serviceRegistry from "./service-registry.js"; -import type { ServiceRegistration } from "./service-registry.js"; -import * as secretRegistry from "./secret-registry.js"; -import type { SecretRegistration } from "./secret-registry.js"; -import { safeDetails } from "./broker.js"; -import type { PluginManifest } from "./manifest.js"; - -export interface InProcessPluginContext { - pluginId: string; - manifest: PluginManifest; - log: { - debug: (message: string) => void; - info: (message: string) => void; - warn: (message: string) => void; - error: (message: string, error?: Error) => void; - }; - events: { - emit: (topic: string, payload: unknown) => void; - on: (topic: string, listener: (payload: unknown) => void) => () => void; - }; - storage: { - get: (key: string) => Promise; - set: (key: string, value: unknown) => Promise; - delete: (key: string) => Promise; - list: () => Promise; - }; - registry: { - provide: (key: string, value: T) => void; - consume: (key: string) => T | undefined; - revoke: (key: string, value?: unknown) => boolean; - }; - /** - * Declared service contracts between plugins. Unlike `registry` above, a - * service must be declared in the manifest and every call through a handle - * is checked against the acting user's role permissions. - */ - services: { - provide: (service: string, implementation: T) => void; - get: ( - service: string, - options?: { userId?: string }, - ) => T; - }; - /** - * Secrets shared between plugins by reference. - * - * `offer` publishes a resolver, never a value, so the provider keeps the - * ability to rotate or clear it at any time. `getShared` reads another - * plugin's offered secret for one call, gated by that provider's own role - * permission. See secret-registry.ts. - */ - secrets: { - offer: ( - key: string, - resolve: (userId: string) => Promise | string | null, - ) => void; - withdraw: (key: string) => boolean; - getShared: ( - pluginId: string, - key: string, - options?: { userId?: string }, - ) => Promise; - }; -} - -/** - * Tracks what a plugin registered so deactivate can undo it. A first-party - * plugin owns real resources -- a listening port, live SSH sessions -- so - * "disabled" has to actually release them, not just stop routing to them. - */ -export interface InProcessHandle { - module: PluginModule; - /** Event unsubscribers, run on deactivate so no listener outlives the plugin. */ - unsubscribers: Array<() => void>; - /** Registry keys this plugin provided, revoked on deactivate. */ - providedKeys: Array<{ key: string; value: unknown }>; - /** Declared services this plugin provided, revoked on deactivate. */ - providedServices: ServiceRegistration[]; - /** Shared secrets this plugin offered, withdrawn on deactivate. */ - offeredSecrets: SecretRegistration[]; -} - -export interface PluginModule { - activate: (ctx: InProcessPluginContext) => void | Promise; - deactivate?: () => void | Promise; -} - -export function createInProcessContext( - manifest: PluginManifest, - handle: InProcessHandle, -): InProcessPluginContext { - const pluginId = manifest.id; - // Forced, like the worker path: a plugin cannot log as another plugin. - const context = { operation: `plugin:${pluginId}` }; - - return { - pluginId, - manifest, - - log: { - debug: (message) => pluginLogger.debug(message, context), - info: (message) => pluginLogger.info(message, context), - warn: (message) => pluginLogger.warn(message, context), - error: (message, error) => pluginLogger.error(message, error, context), - }, - - events: { - emit: (topic, payload) => pluginEvents.emit(topic, payload), - on: (topic, listener) => { - const unsubscribe = pluginEvents.on(topic, listener); - handle.unsubscribers.push(unsubscribe); - return unsubscribe; - }, - }, - - storage: { - get: async (key) => { - const { createCurrentPluginStorageRepository } = - await import("../database/repositories/factory.js"); - const raw = await createCurrentPluginStorageRepository().get( - pluginId, - key, - ); - if (raw === null) return null; - try { - return JSON.parse(raw); - } catch { - return null; - } - }, - set: async (key, value) => { - const { createCurrentPluginStorageRepository } = - await import("../database/repositories/factory.js"); - await createCurrentPluginStorageRepository().set( - pluginId, - key, - JSON.stringify(value ?? null), - ); - }, - delete: async (key) => { - const { createCurrentPluginStorageRepository } = - await import("../database/repositories/factory.js"); - return createCurrentPluginStorageRepository().delete(pluginId, key); - }, - list: async () => { - const { createCurrentPluginStorageRepository } = - await import("../database/repositories/factory.js"); - return createCurrentPluginStorageRepository().listKeys(pluginId); - }, - }, - - registry: { - provide: (key, value) => { - registry.provide(key, value); - handle.providedKeys.push({ key, value }); - }, - consume: (key) => registry.consume(key), - revoke: (key, value) => registry.revoke(key, value), - }, - - services: { - provide: (service, implementation) => { - // Declared AND provided, the same shape the capability gate uses: a - // plugin cannot publish a service its manifest never mentioned. - const declared = manifest.provides?.find( - (entry) => entry.service === service, - ); - if (!declared) { - throw new Error( - `Plugin ${pluginId} cannot provide service "${service}": it is not declared in the manifest's provides array`, - ); - } - - const registration = serviceRegistry.provideService({ - service, - version: declared.version, - permission: declared.permission, - pluginId, - pluginName: manifest.name, - implementation: implementation as Record, - }); - handle.providedServices.push(registration); - }, - - get: (service, options) => - serviceRegistry.createServiceHandle(service, pluginId, { - resolveUserId: () => options?.userId, - hasPermission: checkPermission, - audit: (entry) => writeServiceAudit(entry), - }), - }, - - secrets: { - offer: (key, resolve) => { - // Declared AND offered, the same rule services.provide enforces. - const declared = manifest.providesSecret?.find( - (entry) => entry.key === key, - ); - if (!declared) { - throw new Error( - `Plugin ${pluginId} cannot offer secret "${key}": it is not declared in the manifest's providesSecret array`, - ); - } - - const registration = secretRegistry.offerSecret({ - pluginId, - pluginName: manifest.name, - key, - permission: declared.permission, - resolve, - }); - handle.offeredSecrets.push(registration); - }, - - withdraw: (key) => { - const registration = handle.offeredSecrets.find( - (entry) => entry.key === key, - ); - handle.offeredSecrets = handle.offeredSecrets.filter( - (entry) => entry !== registration, - ); - return secretRegistry.withdrawSecret(pluginId, key, registration); - }, - - getShared: (providerPluginId, key, options) => - secretRegistry.readSharedSecret(manifest, providerPluginId, key, { - resolveUserId: () => options?.userId, - hasPermission: checkPermission, - audit: (entry) => writeSecretAudit(entry), - }), - }, - }; -} - -async function checkPermission( - userId: string, - permission: string, -): Promise { - const { PermissionManager } = await import("../utils/permission-manager.js"); - return PermissionManager.getInstance().hasPermission(userId, permission); -} - -/** - * Mirrors PluginBroker.audit: attribution comes from the runtime, never from - * the plugin, and the details only ever record the shape of a call. - */ -async function writeServiceAudit( - entry: serviceRegistry.ServiceAuditEntry, -): Promise { - try { - const { logAudit } = await import("../utils/audit-logger.js"); - await logAudit({ - userId: entry.userId, - username: `plugin:${entry.consumerPluginId}`, - action: `plugin_service_${entry.registration.service.replace(/\./g, "_")}_${entry.method}`, - resourceType: "plugin", - resourceId: entry.registration.pluginId, - resourceName: entry.registration.pluginName, - details: safeDetails( - `${entry.registration.service}.${entry.method}`, - entry.args, - ), - success: entry.success, - errorMessage: entry.errorMessage, - }); - } catch { - // Auditing must never break the caller. - } -} - -/** - * Records that a secret was borrowed, never what it was. - * - * `resolved` is the only thing said about the value: whether one came back at - * all. That is what makes the trail useful (you can see a consumer running on - * someone else's key) without the trail itself becoming a place the secret - * leaks to. - */ -async function writeSecretAudit( - entry: secretRegistry.SecretAuditEntry, -): Promise { - try { - const { logAudit } = await import("../utils/audit-logger.js"); - await logAudit({ - userId: entry.userId, - username: `plugin:${entry.consumerPluginId}`, - action: "plugin_secret_shared_read", - resourceType: "plugin", - resourceId: entry.providerPluginId, - resourceName: entry.providerPluginName, - details: `${entry.consumerPluginId} read shared secret "${entry.providerPluginId}:${entry.key}" (resolved: ${entry.resolved})`, - success: entry.success, - errorMessage: entry.errorMessage, - }); - } catch { - // Auditing must never break the caller. - } -} - -/** Undoes everything the ctx handed out. Safe to call more than once. */ -export async function disposeInProcessHandle( - handle: InProcessHandle, - pluginId: string, -): Promise { - for (const { key, value } of handle.providedKeys) { - registry.revoke(key, value); - } - handle.providedKeys = []; - - // Taking the services with it is what makes a consumer's held handle start - // throwing PluginServiceUnavailableError rather than calling a dead provider. - for (const registration of handle.providedServices) { - serviceRegistry.revokeService(registration.service, registration); - } - handle.providedServices = []; - - // Withdrawing the offers is what turns a borrower's getShared into a plain - // null instead of a handle pointing at a resolver whose plugin is gone. The - // sweep afterwards is belt and braces: an offer made outside the ctx, or one - // whose push was lost to a crash mid-activate, would otherwise outlive the - // plugin that backs it. - for (const registration of handle.offeredSecrets) { - secretRegistry.withdrawSecret( - registration.pluginId, - registration.key, - registration, - ); - } - handle.offeredSecrets = []; - secretRegistry.withdrawAllForPlugin(pluginId); - - for (const unsubscribe of handle.unsubscribers) { - try { - unsubscribe(); - } catch { - // Already detached. - } - } - handle.unsubscribers = []; - - // The plugin's own cleanup runs last: it may still want to emit or read - // while shutting down. - if (typeof handle.module.deactivate === "function") { - try { - await handle.module.deactivate(); - } catch (error) { - pluginLogger.error( - `Plugin ${pluginId} deactivate() failed`, - error instanceof Error ? error : new Error(String(error)), - { operation: "plugin_deactivate" }, - ); - } - } -} diff --git a/src/backend/plugins/http-bridge.ts b/src/backend/plugins/http-bridge.ts deleted file mode 100644 index cf5f1159c..000000000 --- a/src/backend/plugins/http-bridge.ts +++ /dev/null @@ -1,108 +0,0 @@ -/** - * Turns a plugin's registered routes into an Express router mounted on the - * existing /plugin-api/:pluginId dispatcher. - * - * The plugin never sees the Express req/res objects. It gets a plain, - * structured-cloneable summary of the request, and returns a plain object that - * this module turns back into a response. That keeps sockets, headers carrying - * session cookies, and the rest of the server's request context on this side of - * the boundary. - */ - -import express, { type Request, type Response, type Router } from "express"; -import { pluginLogger } from "../utils/logger.js"; -import type { PluginBroker, PluginRuntime } from "./broker.js"; - -const MAX_RESPONSE_BYTES = 2 * 1024 * 1024; - -/** Headers a plugin is allowed to see. Anything carrying auth is withheld. */ -const FORWARDED_HEADERS = ["content-type", "accept", "user-agent"]; - -export function buildPluginRouter( - broker: PluginBroker, - runtime: PluginRuntime, -): Router { - const router = express.Router(); - router.use(express.json({ limit: "1mb" })); - - for (const route of runtime.routes) { - const key = `${route.method} ${route.path}`; - const method = route.method.toLowerCase() as - "get" | "post" | "put" | "patch" | "delete"; - - router[method](route.path, async (req: Request, res: Response) => { - try { - const result = await broker.invokeRoute(runtime, key, { - method: route.method, - path: req.path, - params: req.params, - query: req.query, - body: req.body ?? null, - headers: pickHeaders(req), - // The acting user, verified by authenticateJWT ahead of the - // /plugin-api mount in database.ts. A plugin cannot set or spoof this. - userId: (req as Request & { userId?: string }).userId ?? null, - }); - - sendPluginResponse(res, result); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - pluginLogger.error( - `Plugin ${runtime.plugin.id} route ${key} failed`, - error instanceof Error ? error : new Error(message), - { operation: "plugin_http" }, - ); - res.status(502).json({ error: "Plugin route failed", detail: message }); - } - }); - } - - return router; -} - -function pickHeaders(req: Request): Record { - const headers: Record = {}; - for (const name of FORWARDED_HEADERS) { - const value = req.headers[name]; - if (typeof value === "string") headers[name] = value; - } - return headers; -} - -function sendPluginResponse(res: Response, result: unknown): void { - if (result === null || result === undefined) { - res.status(204).end(); - return; - } - - const shape = result as { - status?: unknown; - body?: unknown; - headers?: Record; - }; - - // An envelope is only recognised when `status` is present. Keying off `body` - // alone would swallow an ordinary payload that happens to have a body field, - // which is a perfectly normal thing for a handler to return. - const hasEnvelope = - typeof shape === "object" && shape !== null && "status" in shape; - - const status = hasEnvelope ? Number(shape.status ?? 200) : 200; - const body = hasEnvelope ? (shape.body ?? null) : result; - - if (!Number.isInteger(status) || status < 100 || status > 599) { - res.status(502).json({ error: "Plugin returned an invalid status code" }); - return; - } - - const serialised = JSON.stringify(body); - if ( - serialised && - Buffer.byteLength(serialised, "utf8") > MAX_RESPONSE_BYTES - ) { - res.status(502).json({ error: "Plugin response exceeded the size limit" }); - return; - } - - res.status(status).json(body); -} diff --git a/src/backend/plugins/index.ts b/src/backend/plugins/index.ts index b404d7fb3..234bcd368 100644 --- a/src/backend/plugins/index.ts +++ b/src/backend/plugins/index.ts @@ -1,170 +1,58 @@ /** - * Composition root for the plugin runtime: wires the loader, the broker and the - * /plugin-api dispatcher together and exposes the handful of calls the rest of - * the server needs. + * Composition root for the plugin runtime. * - * The heavy dependencies (host resolution, the SSH pool, the repositories) are - * imported lazily inside the dep functions rather than at module scope. Several - * of them pull in the repository layer, and a static import here would create - * the same cycle hosts/automation-events.ts documents. + * Owns the loader, seeds the plugins table, keeps capability grants in step + * with each manifest, and registers the RBAC permissions a plugin contributes. + * + * The heavy dependencies are imported lazily inside each function rather than + * at module scope: several pull in the repository layer, and a static import + * here would close the cycle hosts/automation-events.ts documents. */ import { pluginLogger } from "../utils/logger.js"; -import { - registerPluginRouter, - unregisterPluginRouter, -} from "../database/routes/plugin-api-routes.js"; -import { PluginBroker, type PluginRuntime } from "./broker.js"; -import { buildPluginRouter } from "./http-bridge.js"; +import { unregisterPluginRouter } from "../database/routes/plugin-api-routes.js"; import { PluginLoader, type LoadedPlugin } from "./loader.js"; -import { isFirstParty, runsInProcess } from "./first-party.js"; +import { invalidatePluginPermissionCache } from "./permissions.js"; let loader: PluginLoader | null = null; -let broker: PluginBroker | null = null; -function createBroker(): PluginBroker { - return new PluginBroker({ - listHosts: async (userId) => { - const { createCurrentHostRepository } = - await import("../database/repositories/factory.js"); - // listByUserId, not listDecryptedByUserId: a plugin only ever gets host - // metadata, so there is no reason to decrypt secrets just to drop them. - const hosts = await createCurrentHostRepository().listByUserId(userId); - return hosts as unknown as Record[]; - }, - - resolveHost: async (hostId, userId) => { - // resolveHostById is the access gate and the decryption step in one. The - // object it returns carries plaintext secrets, so it must never leave - // this closure -- the broker maps it through toPluginHostView first. - const { resolveHostById } = await import("../hosts/host-resolver.js"); - const host = await resolveHostById(hostId, userId); - return host as unknown as Record | null; - }, - - storage: { - get: async (pluginId, key) => { - const { createCurrentPluginStorageRepository } = - await import("../database/repositories/factory.js"); - return createCurrentPluginStorageRepository().get(pluginId, key); - }, - set: async (pluginId, key, value) => { - const { createCurrentPluginStorageRepository } = - await import("../database/repositories/factory.js"); - await createCurrentPluginStorageRepository().set(pluginId, key, value); - }, - delete: async (pluginId, key) => { - const { createCurrentPluginStorageRepository } = - await import("../database/repositories/factory.js"); - return createCurrentPluginStorageRepository().delete(pluginId, key); - }, - listKeys: async (pluginId) => { - const { createCurrentPluginStorageRepository } = - await import("../database/repositories/factory.js"); - return createCurrentPluginStorageRepository().listKeys(pluginId); - }, - }, - - ssh: { - /** - * The single most security-sensitive function in the plugin runtime. - * - * `host` below is the object resolveHostById returns: it carries the - * plaintext password, private key and key passphrase. It is a local - * inside this closure and is referenced only by createFleetSshFactory. - * Nothing derived from it is returned. The caller gets two closures -- - * release and exec -- and exec resolves to stdout/stderr/code strings - * only, so there is no path from the worker back to the credential. - */ - open: async (hostId, userId) => { - const [ - { resolveHostById }, - { connectionPool }, - { createFleetSshFactory, getFleetPoolKey }, - { execCommand }, - ] = await Promise.all([ - import("../hosts/host-resolver.js"), - import("../hosts/ssh-connection-pool.js"), - import("../hosts/ssh-client-factory.js"), - import("../hosts/metrics-shared/common-utils.js"), - ]); - - // resolveHostById performs the RBAC check (canAccessHost) and returns - // null for both "missing" and "forbidden". - const host = await resolveHostById(hostId, userId); - if (!host) { - throw new Error(`Host ${hostId} not found or not accessible`); - } - - const poolKey = getFleetPoolKey(host); - const client = await connectionPool.getConnection( - poolKey, - createFleetSshFactory(host), - ); - - return { - release: () => connectionPool.releaseConnection(poolKey, client), - exec: (command, timeoutMs) => execCommand(client, command, timeoutMs), - }; - }, - }, - - onRoutesChanged: (runtime) => remountRoutes(runtime), - }); -} - -function remountRoutes(runtime: PluginRuntime): void { - if (runtime.routes.length === 0) return; - registerPluginRouter(runtime.plugin.id, buildPluginRouter(broker!, runtime)); -} - -export function getPluginRuntime(): { - loader: PluginLoader; - broker: PluginBroker; -} { - if (!loader || !broker) { - broker = createBroker(); - loader = new PluginLoader({ - onWorkerReady: (plugin, worker) => broker!.attach(plugin, worker), - onWorkerGone: (plugin) => { - broker!.detach(plugin.id); - unregisterPluginRouter(plugin.id); - }, - onBeforeTerminate: async (plugin) => { - const runtime = broker!.get(plugin.id); - if (runtime) await broker!.requestDeactivate(runtime); - }, - }); +export function getPluginRuntime(): { loader: PluginLoader } { + if (!loader) { + loader = new PluginLoader(); } - return { loader, broker }; + return { loader }; } /** - * Makes sure a bundled first-party plugin has a row so it can be enabled and - * disabled like any other. + * Gives every discovered plugin a row, and refreshes the stored manifest. * - * Only the insert sets state: once the row exists, whatever the user chose - * wins. Re-enabling a plugin they disabled on every restart would be a bug, - * not a default. + * Bundled plugins start enabled because they are part of the install; a + * plugin dropped into the data directory starts disabled, so arriving on disk + * is never the same as being allowed to run. + * + * `state` is only ever set on insert. Once the row exists whatever the user + * chose wins, because re-enabling a plugin they disabled on every restart + * would be a bug rather than a default. */ -async function seedBundledPlugins(loaded: LoadedPlugin[]): Promise { +async function seedPlugins(loaded: LoadedPlugin[]): Promise { const { createCurrentPluginRepository } = await import("../database/repositories/factory.js"); const repository = createCurrentPluginRepository(); for (const plugin of loaded) { - if (!isFirstParty(plugin.id)) continue; - + const manifestJson = JSON.stringify(plugin.manifest); const existing = await repository.findById(plugin.id); + if (existing) { - // Keep metadata fresh across upgrades, but never touch `state`. - if (existing.version !== plugin.manifest.version) { - await repository.update(plugin.id, { - version: plugin.manifest.version, - name: plugin.manifest.name, - manifestJson: JSON.stringify(plugin.manifest), - }); - } + // Unconditionally, not just on a version change: a manifest edited + // without a version bump used to leave the stored copy stale forever, + // and the API and the grant check both read the stored copy. + await repository.update(plugin.id, { + name: plugin.manifest.name, + version: plugin.manifest.version, + source: plugin.source, + manifestJson, + }); continue; } @@ -172,21 +60,64 @@ async function seedBundledPlugins(loaded: LoadedPlugin[]): Promise { id: plugin.id, name: plugin.manifest.name, version: plugin.manifest.version, - tier: "first-party", - source: "bundled", - state: "enabled", - manifestJson: JSON.stringify(plugin.manifest), + tier: plugin.source === "bundled" ? "bundled" : "community", + source: plugin.source, + state: plugin.source === "bundled" ? "enabled" : "disabled", + manifestJson, }); - pluginLogger.info(`Registered bundled plugin ${plugin.id}`, { + pluginLogger.info(`Registered ${plugin.source} plugin ${plugin.id}`, { operation: "plugin_seed", }); } } /** - * Loads every plugin on disk and activates the ones marked enabled in the - * database. Called once from the backend start-up sequence. + * Brings plugin_permission_grants in line with the manifests on disk. + * + * A capability the manifest no longer declares is revoked: leaving it granted + * would mean an upgrade silently kept a permission the new version never + * asked for. Bundled plugins have every declared capability granted + * automatically, since shipping in the install is the consent. + */ +async function syncCapabilityGrants(loaded: LoadedPlugin[]): Promise { + const { createCurrentPluginPermissionGrantRepository } = + await import("../database/repositories/factory.js"); + const repository = createCurrentPluginPermissionGrantRepository(); + + for (const plugin of loaded) { + const declared = new Set(plugin.manifest.capabilities); + const existing = await repository.listByPlugin(plugin.id); + + for (const grant of existing) { + if (declared.has(grant.capability)) continue; + await repository.revoke(plugin.id, grant.capability); + pluginLogger.info( + `Revoked ${plugin.id} capability "${grant.capability}": the manifest no longer declares it`, + { operation: "plugin_grants" }, + ); + } + + if (plugin.source !== "bundled") continue; + + const granted = new Set(existing.map((grant) => grant.capability)); + for (const capability of declared) { + if (granted.has(capability)) continue; + await repository.grant({ + pluginId: plugin.id, + capability, + grantedBy: null, + source: "bundled", + }); + } + + invalidatePluginPermissionCache(plugin.id); + } +} + +/** + * Loads every plugin on disk and activates the ones marked enabled, in + * dependency order. Called once from the backend start-up sequence. */ export async function initializePlugins(): Promise { const { loader: pluginLoader } = getPluginRuntime(); @@ -194,110 +125,134 @@ export async function initializePlugins(): Promise { const loaded = await pluginLoader.loadAll(); if (loaded.length === 0) return []; - await seedBundledPlugins(loaded); - - const { - createCurrentPluginRepository, - createCurrentPluginPermissionGrantRepository, - } = await import("../database/repositories/factory.js"); + await seedPlugins(loaded); + await syncCapabilityGrants(loaded); + const { createCurrentPluginRepository } = + await import("../database/repositories/factory.js"); const records = await createCurrentPluginRepository().listAll(); - const byId = new Map(records.map((record) => [record.id, record])); + const enabled = new Set( + records + .filter((record) => record.state === "enabled") + .map((record) => record.id), + ); - for (const plugin of loaded) { - const record = byId.get(plugin.id); - if (!record || record.state !== "enabled") continue; + const candidates = loaded + .filter((plugin) => enabled.has(plugin.id)) + .map((plugin) => plugin.id); - // An in-process first-party plugin never uses the gated ctx, so it has no - // owner to act as and must not be held to the grant requirement below. - if (runsInProcess(plugin.id, plugin.manifest.permissions)) { - try { - await activatePlugin(plugin.id); - } catch (error) { - pluginLogger.error( - `Failed to activate plugin ${plugin.id}`, - error instanceof Error ? error : new Error(String(error)), - { operation: "plugin_activate" }, - ); - } - continue; - } + for (const pluginId of candidates) { + const plugin = pluginLoader.get(pluginId); + if (plugin) await registerPluginPermissions(plugin); + } - // The plugins table records no installer, so the plugin acts under the - // authority of whoever granted its capabilities. Without a grant there is - // no owner and nothing the plugin could do with one, so it stays stopped. - const grants = - await createCurrentPluginPermissionGrantRepository().listByPlugin( - plugin.id, - ); - const ownerUserId = grants[0]?.grantedBy; + const result = await pluginLoader.activateAll(candidates); + await persistRuntimeState(loaded); - if (!ownerUserId) { - pluginLogger.warn( - `Plugin ${plugin.id} is enabled but has no capability grants, leaving it stopped`, - { operation: "plugin_activate" }, - ); - continue; - } - - try { - await activatePlugin(plugin.id, ownerUserId); - } catch (error) { - pluginLogger.error( - `Failed to activate plugin ${plugin.id}`, - error instanceof Error ? error : new Error(String(error)), - { operation: "plugin_activate" }, - ); - } + if (result.blocked.size > 0 || result.failed.size > 0) { + pluginLogger.warn( + `${result.blocked.size} plugin(s) blocked, ${result.failed.size} failed to activate`, + { operation: "plugin_init" }, + ); } return loaded; } +/** Mirrors loader state into the database so the admin API can report it. */ +async function persistRuntimeState(loaded: LoadedPlugin[]): Promise { + const { createCurrentPluginRepository } = + await import("../database/repositories/factory.js"); + const repository = createCurrentPluginRepository(); + + for (const plugin of loaded) { + if (plugin.state !== "blocked" && plugin.state !== "failed") continue; + try { + await repository.update(plugin.id, { + state: plugin.state, + lastError: plugin.lastError, + }); + } catch { + // Reporting state must never stop the boot. + } + } +} + /** * Puts a plugin's declared permissions into the role catalog. * - * Until this ran, contributes.permissionGroup was validated but never - * consumed, so a plugin permission could not be granted: PUT /rbac/roles/:id - * rejects any string isValidPermission does not know. Registering here is what - * makes a plugin permission appear in the admin role editor exactly like - * hosts.view does. + * Until this runs a plugin permission cannot be granted at all, because + * PUT /rbac/roles/:id rejects any string isValidPermission does not know. + * Registering here is what makes one appear in the admin role editor exactly + * like hosts.view does. */ async function registerPluginPermissions(plugin: LoadedPlugin): Promise { const group = plugin.manifest.contributes?.permissionGroup; if (!group) return; - const { registerPermissionGroup } = - await import("../utils/permission-catalog.js"); - registerPermissionGroup({ - group: group.group, - permissions: group.permissions, - }); + try { + const { registerPermissionGroup } = + await import("../utils/permission-catalog.js"); + registerPermissionGroup({ + group: group.group, + permissions: group.permissions, + }); - if (group.defaultForRole) { - await applyRoleDefaults(plugin.id, group.defaultForRole); + if (group.defaultForRole) { + await applyRoleDefaults(plugin.id, group); + } + } catch (error) { + pluginLogger.error( + `Failed to register permissions for ${plugin.id}`, + error instanceof Error ? error : new Error(String(error)), + { operation: "plugin_permissions" }, + ); } } /** - * Applies the plugin's declared role suggestion. + * Applies a plugin's declared role suggestion, once. * - * Additive only, and only for a permission the role does not already list. - * An admin's later revoke must never be re-granted on the next restart, which - * is the same rule ensureSystemRoles follows for the built-in roles. + * Only permissions the plugin declares itself are eligible; the manifest + * validator enforces that, and this re-checks it because the stored manifest + * is data. Without it a manifest could add admin.* to any role at boot. + * + * Each default is applied at most once and the fact is recorded, so an admin + * who later revokes it does not get it handed back on the next restart. The + * old code claimed that behaviour in a comment but re-added the permission + * every time. */ async function applyRoleDefaults( pluginId: string, - defaults: Record, + group: { + group: string; + permissions: string[]; + defaultForRole?: Record; + }, ): Promise { - const { createCurrentRoleRepository } = + const { createCurrentRoleRepository, createCurrentPluginStorageRepository } = await import("../database/repositories/factory.js"); const { PermissionManager } = await import("../utils/permission-manager.js"); - const repository = createCurrentRoleRepository(); - for (const [roleName, wanted] of Object.entries(defaults)) { + const roleRepository = createCurrentRoleRepository(); + const storage = createCurrentPluginStorageRepository(); + + const APPLIED_KEY = "__role_defaults_applied"; + let applied: string[] = []; + try { + const raw = await storage.get(pluginId, APPLIED_KEY); + const parsed = raw ? JSON.parse(raw) : []; + if (Array.isArray(parsed)) applied = parsed; + } catch { + applied = []; + } + + const declared = new Set(group.permissions); + const appliedSet = new Set(applied); + + for (const [roleName, wanted] of Object.entries(group.defaultForRole ?? {})) { try { - const role = await repository.findRoleByName(roleName); + const role = await roleRepository.findRoleByName(roleName); if (!role) continue; let current: unknown; @@ -308,19 +263,33 @@ async function applyRoleDefaults( } if (!Array.isArray(current)) continue; - // A wildcard the role already holds covers these, so adding them would - // only be noise in the editor. - const missing = wanted.filter( - (permission) => !coveredBy(current as string[], permission), - ); + const missing = wanted.filter((permission) => { + if (!declared.has(permission)) { + pluginLogger.warn( + `Ignoring ${pluginId} role default "${permission}" for ${roleName}: the plugin does not declare it`, + { operation: "plugin_permissions" }, + ); + return false; + } + // Applied before means the admin has had the chance to remove it, so + // its absence now is a decision rather than a gap. + if (appliedSet.has(`${roleName}:${permission}`)) return false; + return !coveredBy(current as string[], permission); + }); + + for (const permission of wanted) { + if (declared.has(permission)) { + appliedSet.add(`${roleName}:${permission}`); + } + } + if (missing.length === 0) continue; - await repository.updateRole(role.id, { + await roleRepository.updateRole(role.id, { permissions: JSON.stringify([...(current as string[]), ...missing]), }); - const memberIds = await repository.listRoleUserIds(role.id); - for (const memberId of memberIds) { + for (const memberId of await roleRepository.listRoleUserIds(role.id)) { PermissionManager.getInstance().invalidateUserPermissionCache(memberId); } } catch (error) { @@ -332,6 +301,12 @@ async function applyRoleDefaults( ); } } + + try { + await storage.set(pluginId, APPLIED_KEY, JSON.stringify([...appliedSet])); + } catch { + // Losing the record only means a default may be re-offered once. + } } function coveredBy(permissions: string[], permission: string): boolean { @@ -345,31 +320,15 @@ function coveredBy(permissions: string[], permission: string): boolean { return false; } -export async function activatePlugin( - pluginId: string, - ownerUserId?: string, -): Promise { - const { loader: pluginLoader, broker: pluginBroker } = getPluginRuntime(); +export async function activatePlugin(pluginId: string): Promise { + const { loader: pluginLoader } = getPluginRuntime(); // Before activate: a plugin's own activate() may already want to check one // of its permissions. const plugin = pluginLoader.get(pluginId); - if (plugin) { - try { - await registerPluginPermissions(plugin); - } catch (error) { - pluginLogger.error( - `Failed to register permissions for ${pluginId}`, - error instanceof Error ? error : new Error(String(error)), - { operation: "plugin_permissions" }, - ); - } - } + if (plugin) await registerPluginPermissions(plugin); - await pluginLoader.activate(pluginId, ownerUserId); - - const runtime = pluginBroker.get(pluginId); - if (runtime) remountRoutes(runtime); + await pluginLoader.activate(pluginId); } export async function deactivatePlugin(pluginId: string): Promise { @@ -392,3 +351,8 @@ export async function shutdownPlugins(): Promise { for (const plugin of loader.list()) unregisterPluginRouter(plugin.id); await loader.shutdown(); } + +/** Test seam. */ +export function resetPluginRuntime(): void { + loader = null; +} diff --git a/src/backend/plugins/loader.ts b/src/backend/plugins/loader.ts index 1df8fe95a..76f691456 100644 --- a/src/backend/plugins/loader.ts +++ b/src/backend/plugins/loader.ts @@ -1,64 +1,38 @@ /** - * Loads unpacked plugins and owns their worker lifecycle. + * Discovers plugins, resolves their dependency order, and owns their + * lifecycle. * - * ## What the worker boundary actually buys you + * One tier. Every plugin, bundled or installed, is imported into the server + * process and handed a ctx built by ctx.ts. There is no worker boundary and + * no allowlist, because the worker tier had no users and the allowlist was a + * hardcoded set of twelve ids that every real plugin was already on. * - * Be precise about this, because it is easy to overclaim. - * - * ENFORCED: - * - The plugin is handed a `ctx` object and nothing else. It gets no db - * handle, no fs module, no socket, and no credential. Everything it can do - * goes through a postMessage round trip to the broker, which checks the - * plugin's grants first and writes an audit line after. - * - Only structured-cloneable values cross the boundary, so a live object - * cannot leak by reference even by mistake. - * - The worker does not inherit process.env, so it cannot read secrets that - * were passed to the server that way. Only TMPDIR/TMP/TEMP are forwarded. - * - resourceLimits caps the worker heap, so a runaway allocation kills the - * worker instead of the server. - * - * NOT ENFORCED, and not claimed: - * - A worker_threads worker is NOT a security sandbox. It shares the process. - * A plugin that wants to can `import("node:fs")` and read whatever the - * server user can read. It can burn CPU. It can crash the process through - * native addons. - * - Node's permission model (--permission) is the only thing that would - * actually restrict builtins, and it is process-wide, not per-worker, so it - * cannot be turned on for plugin workers alone. execArgv on a Worker does - * not accept it. - * - * So the honest boundary is: plugins are code you chose to install, and this - * design guarantees they cannot QUIETLY reach data they were not granted -- - * every privileged path is gated and audited. It does not guarantee - * containment. If containment is ever required, the answer is a child process - * with --permission, or a WASM/vm isolate, not worker_threads. + * What that means honestly: a plugin has the same reach as core. The + * capability gate on ctx makes privileged calls declared and auditable, not + * impossible. See ARCHITECTURE.md. */ import fs from "node:fs"; import path from "node:path"; -import { fileURLToPath } from "node:url"; -import { Worker } from "node:worker_threads"; +import semver from "semver"; import { pluginLogger } from "../utils/logger.js"; -import { parseManifest, type PluginManifest } from "./manifest.js"; +import { parseManifest } from "./manifest.js"; +import type { PluginManifest } from "@termix/plugin-sdk/manifest"; import { getBundledPluginsDir, getPluginBackendEntry, getPluginManifestPath, getPluginsDir, } from "./paths.js"; -import type { PluginBootstrapData } from "./protocol.js"; -import { runsInProcess } from "./first-party.js"; import { - createInProcessContext, - disposeInProcessHandle, - type InProcessHandle, + createPluginContext, + createPluginHandle, + disposePluginHandle, + type PluginHandle, type PluginModule, -} from "./host-ctx.js"; +} from "./ctx.js"; import { resolveRequirements } from "./service-registry.js"; -import { - resolveSecretRequirements, - withdrawAllForPlugin, -} from "./secret-registry.js"; +import { resolveSecretRequirements } from "./secret-registry.js"; export type PluginState = | "loaded" @@ -66,83 +40,40 @@ export type PluginState = | "active" | "stopping" | "stopped" - | "crashed" - | "disabled"; + | "blocked" + | "failed"; + +export type PluginSource = "bundled" | "user"; -export const MAX_RESTART_ATTEMPTS = 3; -const RESTART_BACKOFF_MS = [1000, 5000, 15000]; const ACTIVATION_TIMEOUT_MS = 30_000; -const STABILITY_WINDOW_MS = 60_000; -const WORKER_MAX_OLD_HEAP_MB = 256; + +/** Defaults for the runtime error budget. Both are configurable. */ +export const DEFAULT_ERROR_THRESHOLD = 5; +export const DEFAULT_ERROR_WINDOW_MS = 60_000; export interface LoadedPlugin { id: string; dir: string; + source: PluginSource; manifest: PluginManifest; state: PluginState; - worker: Worker | null; - /** - * Set instead of `worker` for first-party plugins that run on the main - * thread. Exactly one of the two is ever non-null. - */ - inProcess: InProcessHandle | null; - /** Consecutive crashes since the last successful activation. */ - restartAttempts: number; + handle: PluginHandle | null; lastError: string | null; - /** - * The user whose authority this plugin acts under. Set at activate time. - * Null for in-process plugins, which do not use the gated ctx and so have no - * owner to act as. - */ - ownerUserId: string | null; + /** Timestamps of recent runtime errors, trimmed to the window. */ + errorTimestamps: number[]; } export interface PluginLoaderOptions { - /** - * Called with each worker's port right after spawn so the broker can attach. - * Kept as a hook rather than a hard import so the loader stays testable on - * its own, before the broker exists. - */ - onWorkerReady?: (plugin: LoadedPlugin, worker: Worker) => void; - onWorkerGone?: (plugin: LoadedPlugin) => void; - /** - * Gives a worker plugin a chance to run its deactivate() before it is - * terminated. A hook rather than a broker import, same as the two above. - */ - onBeforeTerminate?: (plugin: LoadedPlugin) => Promise; - /** Overridable so tests do not have to wait out the real backoff. */ - restartBackoffMs?: number[]; - /** How long a plugin must stay up before its crash counter resets. */ - stabilityWindowMs?: number; -} - -/** - * Resolved from this module's own location, never from process.cwd(): the build - * emits to dist/backend/backend/, and cwd is wherever the server was started - * from. - * - * Under vitest this module is the .ts source, so the sibling is - * worker-bootstrap.ts and the worker needs the tsx loader to run it. In a built - * server both are plain .js and no loader is involved. - */ -function resolveWorkerBootstrap(): { entry: string; execArgv?: string[] } { - const here = path.dirname(fileURLToPath(import.meta.url)); - - const compiled = path.join(here, "worker-bootstrap.js"); - if (fs.existsSync(compiled)) return { entry: compiled }; - - const source = path.join(here, "worker-bootstrap.ts"); - if (fs.existsSync(source)) { - return { entry: source, execArgv: ["--import", "tsx"] }; - } - - throw new Error(`Could not locate the plugin worker bootstrap near ${here}`); + errorThreshold?: number; + errorWindowMs?: number; + /** Called when a plugin trips the error budget and is torn down. */ + onFailed?: (plugin: LoadedPlugin) => void; } export class PluginLoader { private readonly plugins = new Map(); - private readonly restartTimers = new Map(); - private readonly stabilityTimers = new Map(); + /** Activation order, so shutdown can run it backwards. */ + private activationOrder: string[] = []; constructor(private readonly options: PluginLoaderOptions = {}) {} @@ -154,10 +85,8 @@ export class PluginLoader { return this.plugins.get(pluginId); } - /** - * Reads and validates a plugin directory. Does not start anything. - */ - async load(dir: string): Promise { + /** Reads and validates one plugin directory. Starts nothing. */ + async load(dir: string, source: PluginSource): Promise { const manifestPath = getPluginManifestPath(dir); let raw: unknown; @@ -184,25 +113,22 @@ export class PluginLoader { ); } - if (manifest.capabilities.backend) { - const entry = getPluginBackendEntry(dir); - if (!fs.existsSync(entry)) { - throw new Error( - `Plugin ${manifest.id} declares a backend but ${entry} is missing`, - ); - } + const entry = getPluginBackendEntry(dir, manifest); + if (!fs.existsSync(entry)) { + throw new Error( + `Plugin ${manifest.id} backend entry ${entry} is missing`, + ); } const plugin: LoadedPlugin = { id: manifest.id, dir, + source, manifest, state: "loaded", - worker: null, - inProcess: null, - restartAttempts: 0, + handle: null, lastError: null, - ownerUserId: null, + errorTimestamps: [], }; this.plugins.set(manifest.id, plugin); @@ -213,17 +139,24 @@ export class PluginLoader { } /** - * Scans for plugins, loading every valid one it finds. + * Scans both roots. A user-installed plugin can never shadow a bundled one: + * the collision is rejected with an error rather than silently skipped, so + * dropping an "ssh-terminal" folder into the data directory is visibly + * refused instead of quietly ignored. * - * Bundled first-party plugins are scanned first and a user-installed - * directory can never shadow one: dropping an "ssh-terminal" folder into the - * data directory must not replace the real transport owner. + * The check is on the parsed manifest id, not the directory name, because a + * directory can be named anything. */ async loadAll(): Promise { + this.plugins.clear(); const loaded: LoadedPlugin[] = []; - const seen = new Set(); - for (const root of [getBundledPluginsDir(), getPluginsDir()]) { + const roots: Array<{ root: string; source: PluginSource }> = [ + { root: getBundledPluginsDir(), source: "bundled" }, + { root: getPluginsDir(), source: "user" }, + ]; + + for (const { root, source } of roots) { if (!fs.existsSync(root)) continue; const entries = await fs.promises.readdir(root, { withFileTypes: true }); @@ -231,21 +164,20 @@ export class PluginLoader { for (const entry of entries) { if (!entry.isDirectory()) continue; - if (seen.has(entry.name)) { - pluginLogger.warn( - `Ignoring plugin directory ${entry.name} in ${root}: a bundled plugin already uses that id`, - { operation: "plugin_load" }, - ); - continue; - } - + const dir = path.join(root, entry.name); try { - const plugin = await this.load(path.join(root, entry.name)); - seen.add(plugin.id); + const existing = this.plugins.get(entry.name); + if (existing && source === "user") { + throw new Error( + `a bundled plugin already uses the id "${entry.name}"; a plugin in the data directory cannot replace it`, + ); + } + + const plugin = await this.load(dir, source); loaded.push(plugin); } catch (error) { pluginLogger.error( - `Skipping plugin directory ${entry.name}`, + `Skipping plugin directory ${entry.name} in ${root}`, error instanceof Error ? error : new Error(String(error)), { operation: "plugin_load" }, ); @@ -257,83 +189,113 @@ export class PluginLoader { } /** - * `ownerUserId` is optional only for in-process first-party plugins, which - * do not use the gated ctx and so never act as a user. A worker plugin - * without one cannot reach any user data outside of a per-request caller - * identity (see broker.requireActor), which only exists inside an HTTP - * handler invocation and is never a substitute for having an owner at all. + * Orders plugins so a dependency always activates before its dependents. + * + * Returns the order plus the plugins that cannot run: one with a missing + * hard dependency is blocked (recoverable, the dependency may be installed + * later), one in a cycle is failed (nothing to wait for). */ - async activate(pluginId: string, ownerUserId?: string): Promise { + resolveOrder(candidates: string[]): { + order: string[]; + blocked: Map; + cycles: Map; + } { + const wanted = new Set(candidates); + const blocked = new Map(); + const cycles = new Map(); + const order: string[] = []; + + const state = new Map(); + + const visit = (id: string, trail: string[]): boolean => { + if (state.get(id) === "done") return true; + + if (state.get(id) === "visiting") { + const cycle = [...trail.slice(trail.indexOf(id)), id].join(" -> "); + for (const member of trail.slice(trail.indexOf(id))) { + cycles.set(member, `dependency cycle: ${cycle}`); + } + return false; + } + + const plugin = this.plugins.get(id); + if (!plugin) return false; + + state.set(id, "visiting"); + + for (const [dependencyId, range] of Object.entries( + plugin.manifest.dependencies ?? {}, + )) { + const dependency = this.plugins.get(dependencyId); + + if (!dependency) { + blocked.set( + id, + `requires plugin "${dependencyId}", which is not installed`, + ); + state.set(id, "done"); + return false; + } + if (!semver.satisfies(dependency.manifest.version, range)) { + blocked.set( + id, + `requires "${dependencyId}" ${range}, but ${dependency.manifest.version} is installed`, + ); + state.set(id, "done"); + return false; + } + if (!wanted.has(dependencyId)) { + blocked.set( + id, + `requires plugin "${dependencyId}", which is not enabled`, + ); + state.set(id, "done"); + return false; + } + + if (!visit(dependencyId, [...trail, id])) { + if (!cycles.has(id)) { + blocked.set( + id, + `requires plugin "${dependencyId}", which could not start`, + ); + } + state.set(id, "done"); + return false; + } + } + + // Optional dependencies only affect ordering. A missing one is normal, + // and the plugin must keep working without it. + for (const dependencyId of Object.keys( + plugin.manifest.optionalDependencies ?? {}, + )) { + if (wanted.has(dependencyId) && this.plugins.has(dependencyId)) { + visit(dependencyId, [...trail, id]); + } + } + + state.set(id, "done"); + if (!blocked.has(id) && !cycles.has(id)) order.push(id); + return !blocked.has(id) && !cycles.has(id); + }; + + for (const id of candidates) visit(id, []); + + return { order, blocked, cycles }; + } + + async activate(pluginId: string): Promise { const plugin = this.requirePlugin(pluginId); if (plugin.state === "active" || plugin.state === "activating") return; - if (!plugin.manifest.capabilities.backend) { - throw new Error(`Plugin ${pluginId} has no backend to activate`); - } - plugin.ownerUserId = ownerUserId ?? null; - await this.spawn(plugin); - } - - private async spawn(plugin: LoadedPlugin): Promise { plugin.state = "activating"; plugin.lastError = null; - if (runsInProcess(plugin.id, plugin.manifest.permissions)) { - await this.spawnInProcess(plugin); - return; - } - - const bootstrap: PluginBootstrapData = { - pluginId: plugin.id, - pluginDir: plugin.dir, - // file:// so the worker's dynamic import works on Windows too. - entryPath: pathToFileUrlString(getPluginBackendEntry(plugin.dir)), - manifest: plugin.manifest, - }; - - const { entry, execArgv } = resolveWorkerBootstrap(); - const worker = new Worker(entry, { - workerData: bootstrap, - // Deliberately not process.env: the worker must not inherit secrets - // passed to the server that way. Only the temp-dir vars are forwarded, - // because tooling that writes a cache falls back to the literal string - // "undefined" without them. - env: pickTempEnv(), - resourceLimits: { maxOldGenerationSizeMb: WORKER_MAX_OLD_HEAP_MB }, - stdout: true, - stderr: true, - ...(execArgv ? { execArgv } : {}), - }); - - plugin.worker = worker; - this.options.onWorkerReady?.(plugin, worker); - - worker.on("error", (error: Error) => { - plugin.lastError = error.message; - pluginLogger.error(`Plugin ${plugin.id} worker error`, error, { - operation: "plugin_worker", - }); - }); - - worker.on("exit", (code: number) => { - this.handleExit(plugin, code); - }); - - await this.awaitActivation(plugin, worker); - } - - /** - * Starts a first-party plugin on the main thread. - * - * There is no worker, so there is no crash isolation: an exception thrown - * later by this plugin's own callbacks lands wherever it was thrown, exactly - * as it would from any other backend module. The restart accounting below - * therefore only covers activation failure, which is the one thing we can - * still observe from here. See first-party.ts. - */ - private async spawnInProcess(plugin: LoadedPlugin): Promise { - const entry = pathToFileUrlString(getPluginBackendEntry(plugin.dir)); + const entry = pathToFileUrlString( + getPluginBackendEntry(plugin.dir, plugin.manifest), + ); try { const imported = (await import(entry)) as { @@ -365,274 +327,210 @@ export class PluginLoader { ); } - // Secret references never block activation, even when not optional. A - // borrowed secret is resolved per call and returns null when absent, so - // a provider installed later starts working with no restart -- refusing - // to start here would turn a recoverable gap into a hard ordering - // dependency between two installs. - const secretResolution = resolveSecretRequirements(plugin.manifest); - for (const reference of secretResolution.unavailable) { + // A borrowed secret resolves per call and returns null when absent, so a + // provider installed later starts working with no restart. Refusing to + // start here would turn a recoverable gap into an install ordering rule. + for (const reference of resolveSecretRequirements(plugin.manifest) + .unavailable) { pluginLogger.info( `Plugin ${plugin.id} shared secret "${reference}" is not currently offered; reads will resolve to null`, { operation: "plugin_activate" }, ); } - const handle: InProcessHandle = { - module: { activate, deactivate }, - unsubscribers: [], - providedKeys: [], - providedServices: [], - offeredSecrets: [], - }; + const handle = createPluginHandle(plugin.id, { activate, deactivate }); + const ctx = createPluginContext(plugin.manifest, handle); + + // Attached before activate runs, not after: a plugin that registers a + // few things and then throws still has to be cleaned up, and the catch + // below can only do that if it can reach the handle. + plugin.handle = handle; - const ctx = createInProcessContext(plugin.manifest, handle); await withTimeout( Promise.resolve(activate(ctx)), ACTIVATION_TIMEOUT_MS, `Plugin ${plugin.id} did not activate within ${ACTIVATION_TIMEOUT_MS}ms`, ); - plugin.inProcess = handle; plugin.state = "active"; - this.scheduleStabilityReset(plugin); - pluginLogger.success(`Activated plugin ${plugin.id} (in-process)`, { + plugin.errorTimestamps = []; + if (!this.activationOrder.includes(plugin.id)) { + this.activationOrder.push(plugin.id); + } + + pluginLogger.success(`Activated plugin ${plugin.id}`, { operation: "plugin_activate", }); } catch (error) { plugin.lastError = error instanceof Error ? error.message : String(error); - plugin.inProcess = null; - plugin.state = "crashed"; + plugin.state = "failed"; + + // Activation may have registered things before it threw. Dispose them + // rather than leaving a half-started plugin holding resources, but do + // not call the plugin's own deactivate: it never finished starting. + if (plugin.handle) { + await disposePluginHandle(plugin.handle, plugin.id, { + runDeactivate: false, + }); + plugin.handle = null; + } throw error instanceof Error ? error : new Error(plugin.lastError); } } - private awaitActivation(plugin: LoadedPlugin, worker: Worker): Promise { - return new Promise((resolve, reject) => { - const timer = setTimeout(() => { - cleanup(); - void this.terminate(plugin); - reject( - new Error( - `Plugin ${plugin.id} did not activate within ${ACTIVATION_TIMEOUT_MS}ms`, - ), - ); - }, ACTIVATION_TIMEOUT_MS); + /** Activates a set of plugins in dependency order. */ + async activateAll(pluginIds: string[]): Promise<{ + activated: string[]; + blocked: Map; + failed: Map; + }> { + const { order, blocked, cycles } = this.resolveOrder(pluginIds); + const activated: string[] = []; + const failed = new Map(cycles); - const onMessage = (message: unknown) => { - const m = message as { - id?: number; - ok?: boolean; - error?: { message: string }; - }; - // id 0 is reserved for the bootstrap's activation result. - if (!m || m.id !== 0) return; - cleanup(); - - if (m.ok) { - plugin.state = "active"; - this.scheduleStabilityReset(plugin); - pluginLogger.success(`Activated plugin ${plugin.id}`, { - operation: "plugin_activate", - }); - resolve(); - } else { - plugin.lastError = m.error?.message ?? "activation failed"; - void this.terminate(plugin); - reject(new Error(plugin.lastError)); - } - }; - - const onExit = () => { - cleanup(); - reject( - new Error( - `Plugin ${plugin.id} worker exited before activating${ - plugin.lastError ? `: ${plugin.lastError}` : "" - }`, - ), - ); - }; - - function cleanup() { - clearTimeout(timer); - worker.off("message", onMessage); - worker.off("exit", onExit); + for (const [id, reason] of blocked) { + const plugin = this.plugins.get(id); + if (plugin) { + plugin.state = "blocked"; + plugin.lastError = reason; } - - worker.on("message", onMessage); - worker.on("exit", onExit); - }); - } - - /** - * Clears the crash counter only once a plugin has stayed up for a while. - * Resetting it on activation alone would let a plugin that activates cleanly - * and then dies restart forever, because each attempt would look like the - * first one. - */ - private scheduleStabilityReset(plugin: LoadedPlugin): void { - const existing = this.stabilityTimers.get(plugin.id); - if (existing) clearTimeout(existing); - - const timer = setTimeout(() => { - this.stabilityTimers.delete(plugin.id); - if (plugin.state === "active") plugin.restartAttempts = 0; - }, this.options.stabilityWindowMs ?? STABILITY_WINDOW_MS); - - timer.unref?.(); - this.stabilityTimers.set(plugin.id, timer); - } - - private handleExit(plugin: LoadedPlugin, code: number): void { - const stabilityTimer = this.stabilityTimers.get(plugin.id); - if (stabilityTimer) { - clearTimeout(stabilityTimer); - this.stabilityTimers.delete(plugin.id); - } - - plugin.worker = null; - this.options.onWorkerGone?.(plugin); - - // A deliberate stop is not a crash. - if (plugin.state === "stopping" || plugin.state === "stopped") { - plugin.state = "stopped"; - return; - } - if (plugin.state === "disabled") return; - - plugin.state = "crashed"; - plugin.restartAttempts += 1; - - if (plugin.restartAttempts > MAX_RESTART_ATTEMPTS) { - plugin.state = "disabled"; - pluginLogger.error( - `Plugin ${plugin.id} crashed ${MAX_RESTART_ATTEMPTS} times and has been disabled`, - new Error(plugin.lastError ?? `worker exited with code ${code}`), - { operation: "plugin_crash" }, - ); - return; - } - - const backoff = this.options.restartBackoffMs ?? RESTART_BACKOFF_MS; - const delay = - backoff[plugin.restartAttempts - 1] ?? backoff[backoff.length - 1]; - - pluginLogger.warn( - `Plugin ${plugin.id} crashed, restarting in ${delay}ms (attempt ${plugin.restartAttempts}/${MAX_RESTART_ATTEMPTS})`, - { operation: "plugin_crash" }, - ); - - const timer = setTimeout(() => { - this.restartTimers.delete(plugin.id); - if (plugin.state !== "crashed") return; - this.spawn(plugin).catch((error) => { - plugin.lastError = - error instanceof Error ? error.message : String(error); + pluginLogger.warn(`Plugin ${id} is blocked: ${reason}`, { + operation: "plugin_activate", }); - }, delay); - - this.restartTimers.set(plugin.id, timer); - } - - /** - * Clears the crash counter and starts the plugin again. This is what the - * Retry button calls once a plugin has been auto-disabled. - */ - async retry(pluginId: string): Promise { - const plugin = this.requirePlugin(pluginId); - plugin.restartAttempts = 0; - plugin.state = "loaded"; - plugin.lastError = null; - - // In-process plugins legitimately have no owner, so only worker plugins - // are held to this. - if ( - !plugin.ownerUserId && - !runsInProcess(plugin.id, plugin.manifest.permissions) - ) { - throw new Error( - `Plugin ${pluginId} has never been activated, so there is nothing to retry`, - ); } - await this.spawn(plugin); + + for (const [id, reason] of cycles) { + const plugin = this.plugins.get(id); + if (plugin) { + plugin.state = "failed"; + plugin.lastError = reason; + } + pluginLogger.error(`Plugin ${id} cannot start`, new Error(reason), { + operation: "plugin_activate", + }); + } + + for (const id of order) { + try { + await this.activate(id); + activated.push(id); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + failed.set(id, message); + pluginLogger.error( + `Failed to activate plugin ${id}`, + error instanceof Error ? error : new Error(message), + { operation: "plugin_activate" }, + ); + } + } + + return { activated, blocked, failed }; } async deactivate(pluginId: string): Promise { const plugin = this.requirePlugin(pluginId); - const timer = this.restartTimers.get(pluginId); - if (timer) { - clearTimeout(timer); - this.restartTimers.delete(pluginId); - } - const stabilityTimer = this.stabilityTimers.get(pluginId); - if (stabilityTimer) { - clearTimeout(stabilityTimer); - this.stabilityTimers.delete(pluginId); - } + this.activationOrder = this.activationOrder.filter((id) => id !== pluginId); - if (!plugin.worker && !plugin.inProcess) { + if (!plugin.handle) { plugin.state = "stopped"; return; } plugin.state = "stopping"; - await this.terminate(plugin); + const handle = plugin.handle; + plugin.handle = null; + + // Disposal never throws, so a plugin whose deactivate() misbehaves still + // ends up stopped rather than stuck in "stopping" forever. + await disposePluginHandle(handle, plugin.id); plugin.state = "stopped"; } - private async terminate(plugin: LoadedPlugin): Promise { - // An in-process plugin owns real resources -- a listening port, live SSH - // sessions -- and there is no thread to kill, so its own cleanup is the - // only thing that releases them. - if (plugin.inProcess) { - const handle = plugin.inProcess; - plugin.inProcess = null; - this.options.onWorkerGone?.(plugin); - await disposeInProcessHandle(handle, plugin.id); - return; - } + /** + * Records a runtime error against a plugin. Past the threshold inside the + * window the plugin is marked failed and torn down, because a plugin + * throwing on every call is worse than one that is off. + */ + async reportError(pluginId: string, error: unknown): Promise { + const plugin = this.plugins.get(pluginId); + if (!plugin) return; - // A worker plugin cannot offer a shared secret today, but terminate() is - // the one path every tier goes through, so the sweep belongs here rather - // than only in the in-process branch above. - withdrawAllForPlugin(plugin.id); + const message = error instanceof Error ? error.message : String(error); + plugin.lastError = message; - const worker = plugin.worker; - if (!worker) return; + pluginLogger.error( + `Plugin ${pluginId} threw at runtime`, + error instanceof Error ? error : new Error(message), + { operation: "plugin_runtime" }, + ); - // Only on a deliberate stop. A crashed worker has nothing left to ask. - if (plugin.state === "stopping" && this.options.onBeforeTerminate) { - try { - await this.options.onBeforeTerminate(plugin); - } catch { - // Cleanup is best-effort; the worker is going away regardless. - } - } + const windowMs = this.options.errorWindowMs ?? DEFAULT_ERROR_WINDOW_MS; + const threshold = this.options.errorThreshold ?? DEFAULT_ERROR_THRESHOLD; + const now = Date.now(); - plugin.worker = null; - try { - await worker.terminate(); - } catch { - // Already gone. - } + plugin.errorTimestamps = [ + ...plugin.errorTimestamps.filter((at) => now - at < windowMs), + now, + ]; + + if (plugin.errorTimestamps.length < threshold) return; + + pluginLogger.error( + `Plugin ${pluginId} failed ${plugin.errorTimestamps.length} times in ${windowMs}ms and has been stopped`, + new Error(message), + { operation: "plugin_runtime" }, + ); + + await this.deactivate(pluginId); + plugin.state = "failed"; + plugin.lastError = message; + plugin.errorTimestamps = []; + this.options.onFailed?.(plugin); } - /** Stops everything. Called on shutdown and by tests. */ - async shutdown(): Promise { - for (const timer of this.restartTimers.values()) clearTimeout(timer); - this.restartTimers.clear(); - for (const timer of this.stabilityTimers.values()) clearTimeout(timer); - this.stabilityTimers.clear(); + /** + * Wraps a plugin callback so a throw is counted rather than escaping into + * whichever core call site invoked it. + */ + wrap( + pluginId: string, + fn: (...args: Args) => Result | Promise, + ): (...args: Args) => Promise { + return async (...args: Args) => { + try { + return await fn(...args); + } catch (error) { + await this.reportError(pluginId, error); + return undefined; + } + }; + } - await Promise.all( - [...this.plugins.values()].map(async (plugin) => { - plugin.state = "stopping"; - await this.terminate(plugin); - plugin.state = "stopped"; - }), - ); + /** Clears the error budget and starts the plugin again. */ + async retry(pluginId: string): Promise { + const plugin = this.requirePlugin(pluginId); + plugin.errorTimestamps = []; + plugin.lastError = null; + plugin.state = "loaded"; + await this.activate(pluginId); + } + + /** Stops everything, reverse activation order. */ + async shutdown(): Promise { + for (const id of [...this.activationOrder].reverse()) { + try { + await this.deactivate(id); + } catch (error) { + pluginLogger.error( + `Failed to deactivate plugin ${id} during shutdown`, + error instanceof Error ? error : new Error(String(error)), + { operation: "plugin_shutdown" }, + ); + } + } + this.activationOrder = []; } private requirePlugin(pluginId: string): LoadedPlugin { @@ -642,15 +540,6 @@ export class PluginLoader { } } -function pickTempEnv(): Record { - const env: Record = {}; - for (const key of ["TMPDIR", "TMP", "TEMP"]) { - const value = process.env[key]; - if (value) env[key] = value; - } - return env; -} - function withTimeout( promise: Promise, ms: number, diff --git a/src/backend/plugins/manifest.ts b/src/backend/plugins/manifest.ts index 006e3d451..c913be7a7 100644 --- a/src/backend/plugins/manifest.ts +++ b/src/backend/plugins/manifest.ts @@ -1,848 +1,51 @@ /** - * Runtime plugin manifest validation. + * Manifest validation, re-exported from the SDK. * - * This is a TypeScript port of scripts/validate-plugin-manifest.cjs. The two - * are deliberately separate: the .cjs one is a build/authoring tool run from a - * checkout, this one runs inside the server, where scripts/ does not exist - * (the Docker image only ships dist/). Both enforce the same contract, which - * is written down in scripts/plugin-manifest.schema.json. - * - * The enums below are duplicated from that schema rather than read from it at - * runtime, for the same reason. If you change the schema, change both. + * The contract lives in @termix/plugin-sdk/manifest so the server, the + * authoring script and plugin authors all read the same rules. This file used + * to carry its own copy of the enums and validators, which meant three places + * had to be edited together and quietly drifted when they were not. */ -import semver from "semver"; -import { isFirstParty, TRANSPORT_OWNER_CAPABILITY } from "./first-party.js"; - -export const PLUGIN_PERMISSIONS = [ - "hosts.read", - "hosts.write", - "credentials.use", - "ssh.exec", - "ssh.sftp", - "storage.own", - "storage.secrets", - "network.outbound", - "events.read", - "notify.send", - "users.read", - "process.sidecar", - // Reserved for first-party plugins. parseManifest refuses it for any id not - // on the hardcoded allowlist in first-party.ts, so declaring it does not - // grant it. - "process:transport-owner", - "ui.tab", - "ui.rail", - "ui.card", - "ui.settings", - "ui.palette", -] as const; - -export type PluginPermission = (typeof PLUGIN_PERMISSIONS)[number]; - -export const PLUGIN_CATEGORIES = [ - "Terminal", - "Files & Transfer", - "Infrastructure", - "Monitoring", - "Networking", - "Access & Security", - "Productivity", -] as const; - -const OPEN_FROM_VALUES = ["rail", "host-context-menu", "palette"]; - -/** - * Contribution kinds a UI action slot can accept. Closed on purpose: a - * free-form list would validate nothing. Adding a kind is a one-line edit - * here and in the two other validators. - */ -export const ACTION_CONTRIBUTION_KINDS = ["button"] as const; - -export type ActionContributionKind = (typeof ACTION_CONTRIBUTION_KINDS)[number]; - -const REQUIRED_TOP_LEVEL = [ - "id", - "name", - "version", - "description", - "author", - "license", - "engine", - "category", - "capabilities", - "permissions", - "sidecars", -]; - -const ID_PATTERN = /^[a-z0-9-]+$/; -const SEMVER_PATTERN = /^\d+\.\d+\.\d+(-[0-9A-Za-z-.]+)?(\+[0-9A-Za-z-.]+)?$/; -const API_VERSION_PATTERN = /^[0-9]+$/; -const API_PREFIX_PATTERN = /^[a-z0-9-]+$/; -/** Service names are dotted, e.g. "ssh.transport" or "host-metrics.stream". */ -const SERVICE_PATTERN = /^[a-z0-9-]+(\.[a-z0-9-]+)+$/; -/** - * A shared secret key is a flat slug, e.g. "api-key". Not dotted: the plugin - * id is already a separate field, so a dotted key would only invite encoding - * the owner twice and disagreeing with itself. - */ -const SECRET_KEY_PATTERN = /^[a-z0-9-]+$/; -/** - * Action and slot ids are dotted like service names, but each segment may be - * camelCase: these name a frontend function ("ai.openWithContext"), not a - * lowercase service contract, so SERVICE_PATTERN is too strict to reuse. - */ -const ACTION_ID_PATTERN = /^[a-z0-9-]+(\.[a-zA-Z0-9-]+)+$/; -/** A handler names a JS export, so it follows identifier rules, not dotted ones. */ -const HANDLER_PATTERN = /^[A-Za-z_$][A-Za-z0-9_$]*$/; - -/** - * The plugin SDK major version this build implements. A manifest asking for a - * different engine.api is refused rather than loaded and hoped for -- this is - * the real compatibility gate (engine.termix is informational only). - */ -export const SUPPORTED_PLUGIN_API_VERSION = "1"; - -export interface PluginManifest { - id: string; - name: string; - version: string; - description: string; - author: { name: string; url?: string }; - license: string; - repository?: string; - category: string; - icon?: string; - engine: { termix: string; api: string }; - capabilities: { - backend: boolean; - frontend: boolean; - electron: boolean; - platforms: string[]; - }; - permissions: string[]; - contributes?: { - tabs?: Array<{ - id: string; - titleKey: string; - icon: string; - openFrom: string[]; - }>; - hostCapability?: HostCapabilityContribution | HostCapabilityContribution[]; - permissionGroup?: { - group: string; - permissions: string[]; - defaultForRole?: Record; - }; - settingsPanel?: { titleKey: string }; - dashboardCards?: Array<{ id: string; titleKey: string }>; - apiPrefix?: string; - /** Named frontend actions this plugin exposes for other plugins to invoke. */ - actions?: PluginActionContribution[]; - /** Places this plugin offers other plugins to contribute a UI element. */ - actionSlots?: PluginActionSlot[]; - }; - dependencies?: { plugins?: Record }; - /** - * Named services this plugin exposes to other plugins. Each one is gated by - * its own RBAC permission, which the plugin's own permissionGroup must - * declare -- see parseManifest. - */ - provides?: PluginServiceProvide[]; - /** Named services this plugin needs another active plugin to provide. */ - requires?: PluginServiceRequire[]; - /** - * Secrets this plugin offers other plugins by reference. Gated by one of - * this plugin's own permissionGroup permissions -- see parseManifest. - */ - providesSecret?: PluginSecretProvide[]; - /** Secrets this plugin wants to borrow from another plugin, by reference. */ - requiresSecret?: PluginSecretRequire[]; - sidecars: Array<{ id: string; binary: string }>; -} - -/** - * A host-level checkbox this plugin contributes to the host editor (e.g. - * "Enable RDP"), backed by an existing boolean column on the host record. - * Most plugins declare one; a plugin covering several independent protocols - * on the same host (rdp/vnc/telnet) declares several -- see - * contributes.hostCapability, which accepts either shape. - */ -export interface HostCapabilityContribution { - key: string; - labelKey: string; - editorTab: string; -} - -export interface PluginServiceProvide { - service: string; - version: string; - /** The role permission a calling user needs to reach this service. */ - permission: string; -} - -export interface PluginServiceRequire { - service: string; - versionRange: string; - /** When true, an unsatisfied requirement is skipped instead of failing activation. */ - optional?: boolean; -} - -export interface PluginSecretProvide { - /** The secret's name within this plugin, e.g. "api-key". */ - key: string; - /** The role permission a borrowing user needs. */ - permission: string; - /** Shown in the admin UI so the sharing is legible without reading code. */ - descriptionKey?: string; -} - -export interface PluginSecretRequire { - /** The plugin id that offers the secret. */ - plugin: string; - /** The secret's name within that plugin. */ - key: string; - /** - * When true, the provider being absent is normal and activation proceeds. - * A non-optional reference still activates -- the read just returns null -- - * but resolveSecretRequirements reports it as an error for install feedback. - */ - optional?: boolean; -} - -/** - * A named frontend action, declared here and bound at runtime. - * - * The split matters: this manifest entry is inert JSON the server can read and - * audit without running any plugin code, while the function itself is supplied - * in the browser by registerAction(id, fn). `handler` records which frontend - * export is meant to end up under `id`, so the contract is readable from the - * manifest alone and a future frontend loader can wire module[handler] - * automatically. Today nothing reads `handler` at runtime. - */ -export interface PluginActionContribution { - /** Dotted and globally unique, e.g. "ai.openWithContext". */ - id: string; - titleKey: string; - /** Name of the frontend export implementing this action. */ - handler: string; - /** Lucide icon name, same convention as contributes.tabs[].icon. */ - icon?: string; - /** - * Role permission the acting user needs before the action is offered. - * Optional: an ungated action renders for everyone. When set, parseManifest - * requires it to be declared in this plugin's own permissionGroup. - */ - permission?: string; - /** Slot this action is intended for. Advisory; binding happens at runtime. */ - slot?: string; - kind?: ActionContributionKind; -} - -/** - * A place this plugin offers other plugins to contribute a UI element. - * - * A contribution's `kind` is checked against `accepts` in the frontend at - * registration time, not here: the slot is usually declared by a different - * plugin, and a manifest is validated on its own. - */ -export interface PluginActionSlot { - /** Dotted, e.g. "terminal.toolbar". */ - id: string; - accepts: ActionContributionKind[]; - descriptionKey?: string; -} - -export function validateManifest(manifest: unknown): string[] { - const errors: string[] = []; - - if (!manifest || typeof manifest !== "object" || Array.isArray(manifest)) { - return ["Manifest must be a JSON object"]; - } - - const m = manifest as Record; - - for (const field of REQUIRED_TOP_LEVEL) { - if (!(field in m)) { - errors.push(`Missing required field: "${field}"`); - } - } - - if (typeof m.id === "string" && !ID_PATTERN.test(m.id)) { - errors.push(`Field "id" must match ${ID_PATTERN}, got: "${m.id}"`); - } - - if (typeof m.version === "string" && !SEMVER_PATTERN.test(m.version)) { - errors.push(`Field "version" must be valid semver, got: "${m.version}"`); - } - - if (m.author && typeof m.author === "object") { - if (!(m.author as Record).name) { - errors.push('Field "author.name" is required'); - } - } - - if (typeof m.category === "string") { - if (!PLUGIN_CATEGORIES.includes(m.category as never)) { - errors.push( - `Field "category" must be one of: ${PLUGIN_CATEGORIES.join(", ")}, got: "${m.category}"`, - ); - } - } else if ("category" in m) { - errors.push('Field "category" must be a string'); - } - - if ("icon" in m && typeof m.icon !== "string") { - errors.push('Field "icon" must be a string'); - } - - if (m.engine && typeof m.engine === "object") { - const engine = m.engine as Record; - if (!engine.termix) { - errors.push('Field "engine.termix" is required'); - } - if (!API_VERSION_PATTERN.test(String(engine.api))) { - errors.push( - `Field "engine.api" must be an integer-as-string, got: "${engine.api}"`, - ); - } - } - - if (m.capabilities && typeof m.capabilities === "object") { - const caps = m.capabilities as Record; - for (const field of ["backend", "frontend", "electron"]) { - if (typeof caps[field] !== "boolean") { - errors.push(`Field "capabilities.${field}" must be a boolean`); - } - } - if (!Array.isArray(caps.platforms)) { - errors.push('Field "capabilities.platforms" must be an array'); - } - } - - if (Array.isArray(m.permissions)) { - for (const permission of m.permissions) { - if (!PLUGIN_PERMISSIONS.includes(permission as never)) { - errors.push( - `Unknown permission: "${permission}". Known values: ${PLUGIN_PERMISSIONS.join(", ")}`, - ); - } - } - } else if ("permissions" in m) { - errors.push('Field "permissions" must be an array'); - } - - if (Array.isArray(m.sidecars)) { - m.sidecars.forEach((sidecar: unknown, index: number) => { - if (!sidecar || typeof sidecar !== "object") { - errors.push(`sidecars[${index}] must be an object`); - return; - } - const s = sidecar as Record; - if (!s.id) errors.push(`sidecars[${index}].id is required`); - if (!s.binary) errors.push(`sidecars[${index}].binary is required`); - }); - } else if ("sidecars" in m) { - errors.push('Field "sidecars" must be an array'); - } - - errors.push(...validateProvides(m.provides)); - errors.push(...validateRequires(m.requires)); - errors.push(...validateProvidesSecret(m.providesSecret)); - errors.push(...validateRequiresSecret(m.requiresSecret)); - - if (m.contributes && typeof m.contributes === "object") { - errors.push( - ...validateContributes(m.contributes as Record), - ); - } - - return errors; -} - -function validateProvides(provides: unknown): string[] { - if (provides === undefined) return []; - if (!Array.isArray(provides)) return ['Field "provides" must be an array']; - - const errors: string[] = []; - const seen = new Set(); - - provides.forEach((entry: unknown, index: number) => { - if (!entry || typeof entry !== "object" || Array.isArray(entry)) { - errors.push(`provides[${index}] must be an object`); - return; - } - const e = entry as Record; - - if (typeof e.service !== "string" || !SERVICE_PATTERN.test(e.service)) { - errors.push( - `provides[${index}].service must match ${SERVICE_PATTERN}, got: "${String(e.service)}"`, - ); - } else if (seen.has(e.service)) { - errors.push(`provides[${index}].service is a duplicate: "${e.service}"`); - } else { - seen.add(e.service); - } - - if (typeof e.version !== "string" || !SEMVER_PATTERN.test(e.version)) { - errors.push( - `provides[${index}].version must be valid semver, got: "${String(e.version)}"`, - ); - } - - if (typeof e.permission !== "string" || e.permission.length === 0) { - errors.push(`provides[${index}].permission is required`); - } - }); - - return errors; -} - -function validateRequires(requires: unknown): string[] { - if (requires === undefined) return []; - if (!Array.isArray(requires)) return ['Field "requires" must be an array']; - - const errors: string[] = []; - const seen = new Set(); - - requires.forEach((entry: unknown, index: number) => { - if (!entry || typeof entry !== "object" || Array.isArray(entry)) { - errors.push(`requires[${index}] must be an object`); - return; - } - const e = entry as Record; - - if (typeof e.service !== "string" || !SERVICE_PATTERN.test(e.service)) { - errors.push( - `requires[${index}].service must match ${SERVICE_PATTERN}, got: "${String(e.service)}"`, - ); - } else if (seen.has(e.service)) { - errors.push(`requires[${index}].service is a duplicate: "${e.service}"`); - } else { - seen.add(e.service); - } - - if ( - typeof e.versionRange !== "string" || - semver.validRange(e.versionRange) === null - ) { - errors.push( - `requires[${index}].versionRange must be a valid semver range, got: "${String(e.versionRange)}"`, - ); - } - - if ("optional" in e && typeof e.optional !== "boolean") { - errors.push(`requires[${index}].optional must be a boolean`); - } - }); - - return errors; -} - -function validateProvidesSecret(providesSecret: unknown): string[] { - if (providesSecret === undefined) return []; - if (!Array.isArray(providesSecret)) { - return ['Field "providesSecret" must be an array']; - } - - const errors: string[] = []; - const seen = new Set(); - - providesSecret.forEach((entry: unknown, index: number) => { - if (!entry || typeof entry !== "object" || Array.isArray(entry)) { - errors.push(`providesSecret[${index}] must be an object`); - return; - } - const e = entry as Record; - - if (typeof e.key !== "string" || !SECRET_KEY_PATTERN.test(e.key)) { - errors.push( - `providesSecret[${index}].key must match ${SECRET_KEY_PATTERN}, got: "${String(e.key)}"`, - ); - } else if (seen.has(e.key)) { - errors.push(`providesSecret[${index}].key is a duplicate: "${e.key}"`); - } else { - seen.add(e.key); - } - - if (typeof e.permission !== "string" || e.permission.length === 0) { - errors.push(`providesSecret[${index}].permission is required`); - } - - if ( - "descriptionKey" in e && - (typeof e.descriptionKey !== "string" || e.descriptionKey.length === 0) - ) { - errors.push(`providesSecret[${index}].descriptionKey must be a string`); - } - }); - - return errors; -} - -function validateRequiresSecret(requiresSecret: unknown): string[] { - if (requiresSecret === undefined) return []; - if (!Array.isArray(requiresSecret)) { - return ['Field "requiresSecret" must be an array']; - } - - const errors: string[] = []; - const seen = new Set(); - - requiresSecret.forEach((entry: unknown, index: number) => { - if (!entry || typeof entry !== "object" || Array.isArray(entry)) { - errors.push(`requiresSecret[${index}] must be an object`); - return; - } - const e = entry as Record; - - if (typeof e.plugin !== "string" || !ID_PATTERN.test(e.plugin)) { - errors.push( - `requiresSecret[${index}].plugin must match ${ID_PATTERN}, got: "${String(e.plugin)}"`, - ); - } - - if (typeof e.key !== "string" || !SECRET_KEY_PATTERN.test(e.key)) { - errors.push( - `requiresSecret[${index}].key must match ${SECRET_KEY_PATTERN}, got: "${String(e.key)}"`, - ); - } - - if (typeof e.plugin === "string" && typeof e.key === "string") { - const id = `${e.plugin}:${e.key}`; - if (seen.has(id)) { - errors.push(`requiresSecret[${index}] is a duplicate: "${id}"`); - } else { - seen.add(id); - } - } - - if ("optional" in e && typeof e.optional !== "boolean") { - errors.push(`requiresSecret[${index}].optional must be a boolean`); - } - }); - - return errors; -} - -function validateContributes(contributes: Record): string[] { - const errors: string[] = []; - - if ("tabs" in contributes) { - if (!Array.isArray(contributes.tabs)) { - errors.push('Field "contributes.tabs" must be an array'); - } else { - contributes.tabs.forEach((tab: unknown, index: number) => { - const t = (tab ?? {}) as Record; - for (const field of ["id", "titleKey", "icon", "openFrom"]) { - if (!(field in t)) { - errors.push(`contributes.tabs[${index}].${field} is required`); - } - } - if (Array.isArray(t.openFrom)) { - for (const value of t.openFrom) { - if (!OPEN_FROM_VALUES.includes(value as string)) { - errors.push( - `contributes.tabs[${index}].openFrom has unknown value: "${value}"`, - ); - } - } - } - }); - } - } - - if ("hostCapability" in contributes) { - const raw = contributes.hostCapability; - const entries = Array.isArray(raw) ? raw : [raw]; - const seenKeys = new Set(); - entries.forEach((entry, index) => { - const hc = (entry ?? {}) as Record; - const prefix = Array.isArray(raw) - ? `contributes.hostCapability[${index}]` - : "contributes.hostCapability"; - for (const field of ["key", "labelKey", "editorTab"]) { - if (!(field in hc)) { - errors.push(`${prefix}.${field} is required`); - } - } - if (typeof hc.key === "string") { - if (seenKeys.has(hc.key)) { - errors.push(`${prefix}.key is a duplicate: "${hc.key}"`); - } - seenKeys.add(hc.key); - } - }); - } - - if ("permissionGroup" in contributes) { - const group = (contributes.permissionGroup ?? {}) as Record< - string, - unknown - >; - if (!group.group) { - errors.push("contributes.permissionGroup.group is required"); - } - if (!Array.isArray(group.permissions) || group.permissions.length === 0) { - errors.push( - "contributes.permissionGroup.permissions must be a non-empty array", - ); - } - } - - if ("settingsPanel" in contributes) { - const panel = (contributes.settingsPanel ?? {}) as Record; - if (!panel.titleKey) { - errors.push("contributes.settingsPanel.titleKey is required"); - } - } - - if ("dashboardCards" in contributes) { - if (!Array.isArray(contributes.dashboardCards)) { - errors.push('Field "contributes.dashboardCards" must be an array'); - } else { - contributes.dashboardCards.forEach((card: unknown, index: number) => { - const c = (card ?? {}) as Record; - for (const field of ["id", "titleKey"]) { - if (!(field in c)) { - errors.push( - `contributes.dashboardCards[${index}].${field} is required`, - ); - } - } - }); - } - } - - if ("apiPrefix" in contributes) { - if ( - typeof contributes.apiPrefix !== "string" || - !API_PREFIX_PATTERN.test(contributes.apiPrefix) - ) { - errors.push( - `Field "contributes.apiPrefix" must match ${API_PREFIX_PATTERN}`, - ); - } - } - - if ("actions" in contributes) { - errors.push(...validateActions(contributes.actions)); - } - - if ("actionSlots" in contributes) { - errors.push(...validateActionSlots(contributes.actionSlots)); - } - - return errors; -} - -function validateActions(actions: unknown): string[] { - if (!Array.isArray(actions)) { - return ['Field "contributes.actions" must be an array']; - } - - const errors: string[] = []; - const seen = new Set(); - - actions.forEach((entry: unknown, index: number) => { - if (!entry || typeof entry !== "object" || Array.isArray(entry)) { - errors.push(`contributes.actions[${index}] must be an object`); - return; - } - const e = entry as Record; - - if (typeof e.id !== "string" || !ACTION_ID_PATTERN.test(e.id)) { - errors.push( - `contributes.actions[${index}].id must match ${ACTION_ID_PATTERN}, got: "${String(e.id)}"`, - ); - } else if (seen.has(e.id)) { - errors.push(`contributes.actions[${index}].id is a duplicate: "${e.id}"`); - } else { - seen.add(e.id); - } - - if (typeof e.titleKey !== "string" || e.titleKey.length === 0) { - errors.push(`contributes.actions[${index}].titleKey is required`); - } - - if (typeof e.handler !== "string" || !HANDLER_PATTERN.test(e.handler)) { - errors.push( - `contributes.actions[${index}].handler must match ${HANDLER_PATTERN}, got: "${String(e.handler)}"`, - ); - } - - if ("icon" in e && (typeof e.icon !== "string" || e.icon.length === 0)) { - errors.push(`contributes.actions[${index}].icon must be a string`); - } - - if ( - "permission" in e && - (typeof e.permission !== "string" || e.permission.length === 0) - ) { - errors.push(`contributes.actions[${index}].permission must be a string`); - } - - if ( - "slot" in e && - (typeof e.slot !== "string" || !ACTION_ID_PATTERN.test(e.slot)) - ) { - errors.push( - `contributes.actions[${index}].slot must match ${ACTION_ID_PATTERN}, got: "${String(e.slot)}"`, - ); - } - - if ("kind" in e && !ACTION_CONTRIBUTION_KINDS.includes(e.kind as never)) { - errors.push( - `contributes.actions[${index}].kind must be one of: ${ACTION_CONTRIBUTION_KINDS.join(", ")}, got: "${String(e.kind)}"`, - ); - } - }); - - return errors; -} - -function validateActionSlots(slots: unknown): string[] { - if (!Array.isArray(slots)) { - return ['Field "contributes.actionSlots" must be an array']; - } - - const errors: string[] = []; - const seen = new Set(); - - slots.forEach((entry: unknown, index: number) => { - if (!entry || typeof entry !== "object" || Array.isArray(entry)) { - errors.push(`contributes.actionSlots[${index}] must be an object`); - return; - } - const e = entry as Record; - - if (typeof e.id !== "string" || !ACTION_ID_PATTERN.test(e.id)) { - errors.push( - `contributes.actionSlots[${index}].id must match ${ACTION_ID_PATTERN}, got: "${String(e.id)}"`, - ); - } else if (seen.has(e.id)) { - errors.push( - `contributes.actionSlots[${index}].id is a duplicate: "${e.id}"`, - ); - } else { - seen.add(e.id); - } - - if (!Array.isArray(e.accepts) || e.accepts.length === 0) { - errors.push( - `contributes.actionSlots[${index}].accepts must be a non-empty array`, - ); - } else { - for (const kind of e.accepts) { - if (!ACTION_CONTRIBUTION_KINDS.includes(kind as never)) { - errors.push( - `contributes.actionSlots[${index}].accepts has unknown value: "${String(kind)}". Known values: ${ACTION_CONTRIBUTION_KINDS.join(", ")}`, - ); - } - } - } - - if ( - "descriptionKey" in e && - (typeof e.descriptionKey !== "string" || e.descriptionKey.length === 0) - ) { - errors.push( - `contributes.actionSlots[${index}].descriptionKey must be a string`, - ); - } - }); - - return errors; -} - -/** - * Validates and narrows in one step. Unlike validateManifest, this also - * enforces the SDK compatibility gate, which only the runtime cares about -- - * the authoring-time validator has no single Termix build to check against. - */ -export function parseManifest(raw: unknown): { - manifest?: PluginManifest; - errors: string[]; -} { - const errors = validateManifest(raw); - if (errors.length > 0) return { errors }; - - const manifest = raw as PluginManifest; - if (manifest.engine.api !== SUPPORTED_PLUGIN_API_VERSION) { - return { - errors: [ - `Plugin targets SDK api version "${manifest.engine.api}", this Termix build implements "${SUPPORTED_PLUGIN_API_VERSION}"`, - ], - }; - } - - // A service permission has to be one the plugin's own permissionGroup - // declares, or it would never reach the catalog and every call through it - // would deny against a permission no admin can grant. - const declaredPermissions = - manifest.contributes?.permissionGroup?.permissions ?? []; - for (const entry of manifest.provides ?? []) { - if (!declaredPermissions.includes(entry.permission)) { - return { - errors: [ - `Service "${entry.service}" is gated by "${entry.permission}", which is not declared in contributes.permissionGroup.permissions`, - ], - }; - } - } - - // Same rule for a shared secret. This is what keeps sharing on the existing - // RBAC grant path instead of becoming a separate mechanism: the permission - // gating a borrowed secret is an ordinary role permission in the provider's - // own group, editable per role or per user like any other. - for (const entry of manifest.providesSecret ?? []) { - if (!declaredPermissions.includes(entry.permission)) { - return { - errors: [ - `Secret "${entry.key}" is gated by "${entry.permission}", which is not declared in contributes.permissionGroup.permissions`, - ], - }; - } - } - - // A plugin borrowing from itself would bypass nothing, but it is always a - // mistake: its own secrets are reachable through ctx.secrets directly. - for (const entry of manifest.requiresSecret ?? []) { - if (entry.plugin === manifest.id) { - return { - errors: [ - `requiresSecret entry for "${entry.key}" names this plugin itself; use ctx.secrets.get instead`, - ], - }; - } - } - - // Same rule for UI actions, for the same reason: a permission the catalog - // never sees is one no admin can grant, so the action would be invisible - // forever rather than merely denied. - for (const action of manifest.contributes?.actions ?? []) { - if (action.permission && !declaredPermissions.includes(action.permission)) { - return { - errors: [ - `Action "${action.id}" is gated by "${action.permission}", which is not declared in contributes.permissionGroup.permissions`, - ], - }; - } - } - - // Refused outright rather than ignored: a plugin that asked to run in-process - // and was quietly downgraded to a worker would fail later in a confusing way, - // and the manifest would still claim reach it does not have. - if ( - manifest.permissions.includes(TRANSPORT_OWNER_CAPABILITY) && - !isFirstParty(manifest.id) - ) { - return { - errors: [ - `Permission "${TRANSPORT_OWNER_CAPABILITY}" is reserved for first-party plugins and cannot be declared by "${manifest.id}"`, - ], - }; - } - - return { manifest, errors: [] }; -} +export { + validateManifest, + parseManifest, + SUPPORTED_PLUGIN_API_VERSION, + PLUGIN_CATEGORIES, + PLUGIN_PLATFORMS, + ACTION_CONTRIBUTION_KINDS, + DEFAULT_BACKEND_ENTRY, + DEFAULT_FRONTEND_ENTRY, + DEFAULT_LOCALES_DIR, +} from "@termix/plugin-sdk/manifest"; + +export type { + PluginManifest, + PluginAuthor, + PluginEngine, + PluginContributions, + PluginTabContribution, + PluginActionContribution, + PluginActionSlot, + PluginPermissionGroup, + PluginServiceProvide, + PluginServiceRequire, + PluginSecretProvide, + PluginSecretRequire, + HostCapabilityContribution, + ActionContributionKind, + ParsedManifest, +} from "@termix/plugin-sdk/manifest"; + +export { + CAPABILITY_CATALOG, + CAPABILITY_IDS, + isKnownCapability, + getCapabilityInfo, +} from "@termix/plugin-sdk/capabilities"; + +export type { + Capability, + CapabilityInfo, + CapabilityRisk, +} from "@termix/plugin-sdk/capabilities"; diff --git a/src/backend/plugins/paths.ts b/src/backend/plugins/paths.ts index 8788d9e74..7e07920e2 100644 --- a/src/backend/plugins/paths.ts +++ b/src/backend/plugins/paths.ts @@ -1,26 +1,30 @@ import fs from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; +import { + DEFAULT_BACKEND_ENTRY, + type PluginManifest, +} from "@termix/plugin-sdk/manifest"; /** - * Where unpacked plugins live. Resolved per call rather than captured at module - * scope: tests set DATA_DIR in beforeEach, and a module-scope constant would - * freeze whatever the first import saw. + * Where user-installed plugins live. Resolved per call rather than captured at + * module scope: tests set DATA_DIR in beforeEach, and a module-scope constant + * would freeze whatever the first import saw. */ export function getPluginsDir(): string { return path.join(process.env.DATA_DIR || "./db/data", "plugins"); } /** - * Where first-party plugins that ship with Termix live. + * Where the plugins that ship with Termix live. * * Separate from getPluginsDir() because that one is user data: a bundled * plugin is part of the install and must not be deletable or shadowable by * whatever happens to be in the data directory. * - * Resolved from this module's location rather than cwd, same reasoning as the - * worker bootstrap: the build emits to dist/backend/backend/plugins/, and the - * bundled plugins sit beside dist/backend. + * Resolved from this module's location rather than cwd, because the build + * emits to dist/backend/backend/plugins/ and the bundled plugins sit beside + * dist/backend. */ export function getBundledPluginsDir(): string { const override = process.env.TERMIX_BUNDLED_PLUGINS_DIR; @@ -30,9 +34,10 @@ export function getBundledPluginsDir(): string { const candidates = [ // Built server: dist/backend/backend/plugins -> dist/plugins + // Source tree: src/backend/plugins -> plugins + // Both are three levels up, which is why there is one entry, not two. path.resolve(here, "../../../plugins"), - // Running from source under vitest/tsx: src/backend/plugins -> plugins - path.resolve(here, "../../../plugins"), + // Built server invoked from a nested layout: .../dist/backend/backend/plugins path.resolve(here, "../../../../plugins"), ]; @@ -46,8 +51,21 @@ export function getPluginDir(pluginId: string): string { return path.join(getPluginsDir(), pluginId); } -export function getPluginBackendEntry(pluginDir: string): string { - return path.join(pluginDir, "backend", "index.mjs"); +/** + * The backend entry, from the manifest's own `backend` field. + * + * Bundled plugins still ship a hand-written backend/index.mjs, so that stays + * the fallback until A2 gives every plugin a real build. + */ +export function getPluginBackendEntry( + pluginDir: string, + manifest?: Pick, +): string { + const legacy = path.join(pluginDir, "backend", "index.mjs"); + if (!manifest?.backend || manifest.backend === DEFAULT_BACKEND_ENTRY) { + if (fs.existsSync(legacy)) return legacy; + } + return path.join(pluginDir, manifest?.backend ?? DEFAULT_BACKEND_ENTRY); } export function getPluginManifestPath(pluginDir: string): string { diff --git a/src/backend/plugins/permissions.ts b/src/backend/plugins/permissions.ts index b58ae08c3..0c635fef4 100644 --- a/src/backend/plugins/permissions.ts +++ b/src/backend/plugins/permissions.ts @@ -1,40 +1,23 @@ /** - * The grant check every privileged ctx call goes through. + * The grant check every guarded ctx call goes through. * - * Capability strings are the manifest's own permission enum, stored verbatim in - * plugin_permission_grants.capability -- no translation layer, so the manifest - * stays the single source of truth for what is grantable. + * Two things have to be true for a capability to count as granted: the + * manifest declares it (the plugin asked for it up front) and it is granted in + * plugin_permission_grants (someone, or the bundling process, said yes). A + * grant for something the manifest never declared is ignored rather than + * honoured, so widening a plugin's reach always requires a new manifest the + * user can see. * - * Two separate things have to be true for a capability to count as granted: - * the manifest must declare it (the plugin asked for it up front) and an admin - * must have granted it (someone said yes). A grant for something the manifest - * never declared is ignored rather than honoured, so widening a plugin's reach - * always requires a new manifest the user can see. + * The cache is invalidated explicitly on every grant change rather than by + * TTL: a revoke that takes effect a minute later is not a revoke. */ import { createCurrentPluginPermissionGrantRepository } from "../database/repositories/factory.js"; -import type { PluginPermission } from "./manifest.js"; +import { PluginCapabilityError } from "@termix/plugin-sdk/backend"; -const CACHE_TTL_MS = 60_000; +export { PluginCapabilityError }; -interface CacheEntry { - capabilities: Set; - expiresAt: number; -} - -const cache = new Map(); - -export class PluginPermissionError extends Error { - readonly code = "EPLUGINPERM"; - - constructor(pluginId: string, capability: string) { - super( - `Plugin "${pluginId}" is not granted the "${capability}" capability. ` + - `Declare it in the manifest's permissions array and grant it in the plugin's settings.`, - ); - this.name = "PluginPermissionError"; - } -} +const cache = new Map>(); export function invalidatePluginPermissionCache(pluginId?: string): void { if (pluginId) cache.delete(pluginId); @@ -43,19 +26,19 @@ export function invalidatePluginPermissionCache(pluginId?: string): void { async function loadGrants(pluginId: string): Promise> { const cached = cache.get(pluginId); - if (cached && cached.expiresAt > Date.now()) return cached.capabilities; + if (cached) return cached; const rows = await createCurrentPluginPermissionGrantRepository().listByPlugin(pluginId); const capabilities = new Set(rows.map((row) => row.capability)); - cache.set(pluginId, { capabilities, expiresAt: Date.now() + CACHE_TTL_MS }); + cache.set(pluginId, capabilities); return capabilities; } export async function hasCapability( pluginId: string, - capability: PluginPermission | string, + capability: string, declared: readonly string[], ): Promise { if (!declared.includes(capability)) return false; @@ -64,10 +47,10 @@ export async function hasCapability( export async function assertCapability( pluginId: string, - capability: PluginPermission | string, + capability: string, declared: readonly string[], ): Promise { if (!(await hasCapability(pluginId, capability, declared))) { - throw new PluginPermissionError(pluginId, capability); + throw new PluginCapabilityError(pluginId, capability); } } diff --git a/src/backend/plugins/protocol.ts b/src/backend/plugins/protocol.ts deleted file mode 100644 index a99c22024..000000000 --- a/src/backend/plugins/protocol.ts +++ /dev/null @@ -1,73 +0,0 @@ -/** - * Wire format between a plugin worker and the main-thread broker. - * - * Everything crossing this boundary must be structured-cloneable. That is not - * just a serialisation detail: it is the reason a plugin cannot be handed a db - * handle, an ssh2 Client or a live socket by accident. Live objects stay in the - * main thread and the worker refers to them by opaque handle strings. - */ - -export interface PluginRequest { - /** Correlates a reply to its request. Worker-generated, monotonic. */ - id: number; - /** Dotted ctx path, e.g. "hosts.get" or "storage.set". */ - method: string; - args: unknown[]; - /** - * The user whose HTTP request is currently executing inside the worker, if - * any. Set by the worker's own call() from its AsyncLocalStorage context, - * never by plugin code -- no ctx method accepts a userId argument, so a - * plugin has no way to set or forge this itself. Absent for a ctx call made - * from a timer or event listener, which has no inbound request to derive an - * actor from; the broker falls back to the plugin's ownerUserId then. - */ - callerUserId?: string; -} - -export interface PluginResponseOk { - id: number; - ok: true; - value: unknown; -} - -export interface PluginResponseErr { - id: number; - ok: false; - error: { message: string; code?: string }; -} - -export type PluginResponse = PluginResponseOk | PluginResponseErr; - -/** Main thread -> worker, pushed rather than requested. */ -export interface PluginPush { - kind: "event" | "http" | "timer" | "deactivate"; - /** Event topic, route key, or timer id depending on kind. */ - key: string; - payload: unknown; - /** Set when the push expects a reply (an HTTP route invocation). */ - replyTo?: number; -} - -/** First message the worker receives, before any plugin code runs. */ -export interface PluginBootstrapData { - pluginId: string; - pluginDir: string; - entryPath: string; - manifest: unknown; -} - -export function isPluginRequest(value: unknown): value is PluginRequest { - if (!value || typeof value !== "object") return false; - const v = value as Record; - return ( - typeof v.id === "number" && - typeof v.method === "string" && - Array.isArray(v.args) - ); -} - -export function isPluginResponse(value: unknown): value is PluginResponse { - if (!value || typeof value !== "object") return false; - const v = value as Record; - return typeof v.id === "number" && typeof v.ok === "boolean"; -} diff --git a/src/backend/plugins/worker-bootstrap.ts b/src/backend/plugins/worker-bootstrap.ts deleted file mode 100644 index 68c0de834..000000000 --- a/src/backend/plugins/worker-bootstrap.ts +++ /dev/null @@ -1,301 +0,0 @@ -/** - * The worker thread's entry point. Runs before any plugin code. - * - * Builds the ctx object, imports the plugin's backend/index.mjs, and calls its - * exported activate(ctx). The plugin gets ctx and nothing else from us -- no - * db, no fs, no net handle is ever passed in. See loader.ts for an honest - * account of what that does and does not guarantee. - */ - -import { AsyncLocalStorage } from "node:async_hooks"; -import { parentPort, workerData } from "node:worker_threads"; -import type { - PluginBootstrapData, - PluginPush, - PluginResponse, -} from "./protocol.js"; -import { isPluginResponse } from "./protocol.js"; - -const port = parentPort; -if (!port) { - throw new Error("Plugin worker started without a parent port"); -} - -const boot = workerData as PluginBootstrapData; - -let nextRequestId = 1; -const pending = new Map< - number, - { resolve: (value: unknown) => void; reject: (error: Error) => void } ->(); - -/** Handlers the plugin registered, keyed the same way the broker pushes them. */ -const httpRoutes = new Map unknown>(); -const eventListeners = new Map void>>(); -const timers = new Map void>(); - -/** - * Tracks which user's HTTP request is currently executing, so a ctx call made - * anywhere in that request's call stack -- including deep inside plugin code - * that has no idea this exists -- can be attributed to them without adding a - * userId parameter to every ctx method. - * - * Only entered around an ctx.http.route handler invocation (see handlePush - * below). A ctx call made from a schedule.every timer or an events.on - * listener runs outside any run() call, so getStore() correctly returns - * undefined for those and the broker falls back to the plugin's owner. - */ -const requestActor = new AsyncLocalStorage<{ userId: string }>(); - -function call(method: string, ...args: unknown[]): Promise { - const id = nextRequestId++; - const callerUserId = requestActor.getStore()?.userId; - return new Promise((resolve, reject) => { - pending.set(id, { resolve, reject }); - port!.postMessage({ - id, - method, - args, - ...(callerUserId && { callerUserId }), - }); - }); -} - -function handleResponse(message: PluginResponse): void { - const waiter = pending.get(message.id); - if (!waiter) return; - pending.delete(message.id); - - if (message.ok === true) { - waiter.resolve(message.value); - return; - } - - const failure = message.error; - const error = new Error(failure.message); - if (failure.code) { - (error as Error & { code?: string }).code = failure.code; - } - waiter.reject(error); -} - -async function handlePush(push: PluginPush): Promise { - if (push.kind === "deactivate") { - // Always acknowledge, even when the plugin has no hook or its hook threw: - // the main thread is waiting before it terminates us, and a missing reply - // would just stall that until its timeout. - try { - if (pluginDeactivate) await pluginDeactivate(); - } catch { - // Nothing useful to do here; the worker is about to be terminated. - } - if (push.replyTo !== undefined) { - port!.postMessage({ id: push.replyTo, ok: true, value: null }); - } - return; - } - - if (push.kind === "event") { - const listeners = eventListeners.get(push.key); - if (!listeners) return; - for (const listener of listeners) { - try { - listener(push.payload); - } catch { - // A throwing listener must not take down the worker. - } - } - return; - } - - if (push.kind === "timer") { - const handler = timers.get(push.key); - if (handler) { - try { - handler(); - } catch { - // same - } - } - return; - } - - if (push.kind === "http") { - const handler = httpRoutes.get(push.key); - if (push.replyTo === undefined) return; - - if (!handler) { - port!.postMessage({ - id: push.replyTo, - ok: false, - error: { message: `No handler registered for route ${push.key}` }, - }); - return; - } - - const requestUserId = - (push.payload as { userId?: string | null } | undefined)?.userId ?? - undefined; - - try { - const value = await (requestUserId - ? requestActor.run({ userId: requestUserId }, () => - handler(push.payload), - ) - : handler(push.payload)); - port!.postMessage({ id: push.replyTo, ok: true, value }); - } catch (error) { - port!.postMessage({ - id: push.replyTo, - ok: false, - error: { - message: error instanceof Error ? error.message : String(error), - }, - }); - } - } -} - -port.on("message", (message: unknown) => { - if (isPluginResponse(message)) { - handleResponse(message); - return; - } - const push = message as PluginPush; - if (push && typeof push.kind === "string") { - void handlePush(push); - } -}); - -function notImplemented(name: string) { - return () => - Promise.reject( - new Error( - `ctx.${name} is not implemented in plugin SDK version 1. It is reserved and will be added in a later version.`, - ), - ); -} - -const ctx = { - pluginId: boot.pluginId, - manifest: boot.manifest, - - log: { - debug: (message: string, context?: unknown) => - call("log.debug", message, context), - info: (message: string, context?: unknown) => - call("log.info", message, context), - warn: (message: string, context?: unknown) => - call("log.warn", message, context), - error: (message: string, context?: unknown) => - call("log.error", message, context), - }, - - audit: (action: string, details?: unknown) => call("audit", action, details), - - hosts: { - list: () => call("hosts.list"), - get: (hostId: number) => call("hosts.get", hostId), - }, - - ssh: { - connect: (hostId: number) => call("ssh.connect", hostId), - exec: (handle: string, command: string, timeoutMs?: number) => - call("ssh.exec", handle, command, timeoutMs), - close: (handle: string) => call("ssh.close", handle), - }, - - storage: { - get: (key: string) => call("storage.get", key), - set: (key: string, value: unknown) => call("storage.set", key, value), - delete: (key: string) => call("storage.delete", key), - list: () => call("storage.list"), - }, - - http: { - route: ( - method: string, - routePath: string, - handler: (req: unknown) => unknown, - ) => { - const key = `${method.toUpperCase()} ${routePath}`; - httpRoutes.set(key, handler); - return call("http.route", method.toUpperCase(), routePath); - }, - }, - - events: { - emit: (topic: string, payload: unknown) => - call("events.emit", topic, payload), - on: async (topic: string, listener: (payload: unknown) => void) => { - let listeners = eventListeners.get(topic); - if (!listeners) { - listeners = new Set(); - eventListeners.set(topic, listeners); - await call("events.subscribe", topic); - } - listeners.add(listener); - return () => { - listeners!.delete(listener); - }; - }, - }, - - schedule: { - every: async (intervalMs: number, handler: () => void) => { - const id = (await call("schedule.every", intervalMs)) as string; - timers.set(id, handler); - return id; - }, - cancel: async (id: string) => { - timers.delete(id); - await call("schedule.cancel", id); - }, - }, - - secrets: { - get: notImplemented("secrets.get"), - set: notImplemented("secrets.set"), - // Cross-plugin sharing is in-process only for now. Named here rather than - // left undefined so a worker plugin that tries it gets the same explicit - // error as any other unimplemented ctx call. - offer: notImplemented("secrets.offer"), - withdraw: notImplemented("secrets.withdraw"), - getShared: notImplemented("secrets.getShared"), - }, - notify: notImplemented("notify"), - fetch: notImplemented("fetch"), -}; - -/** - * Held so a deactivate push can call it. A worker plugin is still terminated - * afterwards either way -- this only gives it a chance to close things - * cleanly first, which matters for anything holding a remote resource. - */ -let pluginDeactivate: (() => void | Promise) | null = null; - -async function start(): Promise { - const module = await import(boot.entryPath); - const activate = module.activate ?? module.default?.activate; - const deactivate = module.deactivate ?? module.default?.deactivate; - - if (typeof activate !== "function") { - throw new Error( - `Plugin ${boot.pluginId} backend entry does not export an activate(ctx) function`, - ); - } - if (typeof deactivate === "function") pluginDeactivate = deactivate; - - await activate(ctx); - port!.postMessage({ id: 0, ok: true, value: "activated" }); -} - -start().catch((error) => { - port!.postMessage({ - id: 0, - ok: false, - error: { - message: error instanceof Error ? error.message : String(error), - }, - }); -}); diff --git a/src/backend/tests/database/routes/plugin-api-routes-auth.test.ts b/src/backend/tests/database/routes/plugin-api-routes-auth.test.ts index a6ff3aea7..72e56d538 100644 --- a/src/backend/tests/database/routes/plugin-api-routes-auth.test.ts +++ b/src/backend/tests/database/routes/plugin-api-routes-auth.test.ts @@ -1,20 +1,19 @@ /** - * database.ts mounts /plugin-api behind authenticateJWT, the same way every - * other router is gated. This exercises that exact wiring (rather than - * http-bridge.test.ts's bare app, which mounts the dispatcher with no auth - * in front of it on purpose, to test the dispatcher in isolation) so a - * regression here - an unauthenticated caller reaching a plugin route - is - * caught. + * /plugin-api is mounted behind authenticateJWT in database.ts, the same way + * every other router is gated, so an unauthenticated caller can never reach + * plugin code. + * + * The dispatcher itself has no routes registered in A1: the worker HTTP + * bridge was removed with the worker tier and A4 rebuilds routing on top of + * the SDK. What still has to hold, and what this covers, is that the auth + * gate sits in front of the mount and that an unknown plugin id is a 404 + * rather than a crash. */ -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import express from "express"; import type { AddressInfo } from "node:net"; import type { Server } from "node:http"; -import { - createFixturePlugin, - type Fixture, -} from "../../plugins/fixture-plugin.js"; const state = vi.hoisted(() => ({ validToken: "valid-token", @@ -22,13 +21,6 @@ const state = vi.hoisted(() => ({ })); vi.mock("../../../utils/logger.js", () => ({ - pluginLogger: { - debug: vi.fn(), - info: vi.fn(), - warn: vi.fn(), - error: vi.fn(), - success: vi.fn(), - }, databaseLogger: { debug: vi.fn(), info: vi.fn(), @@ -38,133 +30,108 @@ vi.mock("../../../utils/logger.js", () => ({ }, })); -vi.mock("../../../utils/auth-manager.js", () => ({ - AuthManager: { - getInstance: () => ({ - createAuthMiddleware: - () => - ( - req: Record & { headers: Record }, - res: { status: (code: number) => { json: (body: unknown) => void } }, - next: () => void, - ) => { - const auth = req.headers["authorization"]; - const token = auth?.startsWith("Bearer ") ? auth.slice(7) : null; - if (token !== state.validToken) { - res.status(401).json({ error: "Missing authentication token" }); - return; - } - req.userId = state.userId; - next(); - }, - }), - }, -})); - -const { AuthManager } = await import("../../../utils/auth-manager.js"); -const { PluginBroker } = await import("../../../plugins/broker.js"); -const { PluginLoader } = await import("../../../plugins/loader.js"); -const { buildPluginRouter } = await import("../../../plugins/http-bridge.js"); const pluginApi = await import("../../../database/routes/plugin-api-routes.js"); -const WORKER_TIMEOUT = 30_000; +/** Stands in for AuthManager's middleware, with the same 401 behaviour. */ +function authenticateJWT( + req: express.Request, + res: express.Response, + next: express.NextFunction, +): void { + const header = req.headers.authorization; + const token = header?.startsWith("Bearer ") ? header.slice(7) : null; -describe("/plugin-api behind the app's real auth middleware", () => { - const fixtures: Fixture[] = []; - let loader: InstanceType | null = null; - let broker: InstanceType | null = null; - let server: Server | null = null; - let baseUrl = ""; + if (token !== state.validToken) { + res.status(401).json({ error: "Unauthorized" }); + return; + } - beforeEach(async () => { - const authenticateJWT = AuthManager.getInstance().createAuthMiddleware(); + (req as express.Request & { userId?: string }).userId = state.userId; + next(); +} - const app = express(); - // Mirrors database.ts: app.use("/plugin-api", authenticateJWT, pluginApiRoutes). - app.use("/plugin-api", authenticateJWT, pluginApi.default); +let server: Server | null = null; - server = await new Promise((resolve) => { - const created = app.listen(0, "127.0.0.1", () => resolve(created)); +async function startServer(): Promise { + const app = express(); + // The same order database.ts uses: the gate, then the dispatcher. + app.use("/plugin-api", authenticateJWT, pluginApi.default); + + return new Promise((resolve) => { + server = app.listen(0, "127.0.0.1", () => { + const { port } = server!.address() as AddressInfo; + resolve(`http://127.0.0.1:${port}`); }); - baseUrl = `http://127.0.0.1:${(server.address() as AddressInfo).port}`; - - const fixture = createFixturePlugin({ - backendSource: ` - export async function activate(ctx) { - await ctx.http.route("GET", "/whoami", async (req) => ({ - userId: req.userId ?? null, - })); - } - `, - }); - fixtures.push(fixture); - - broker = new PluginBroker({ - listHosts: async () => [], - resolveHost: async () => null, - onRoutesChanged: (runtime) => { - pluginApi.registerPluginRouter( - runtime.plugin.id, - buildPluginRouter(broker!, runtime), - ); - }, - }); - - loader = new PluginLoader({ - onWorkerReady: (plugin, worker) => broker!.attach(plugin, worker), - onWorkerGone: (plugin) => { - broker!.detach(plugin.id); - pluginApi.unregisterPluginRouter(plugin.id); - }, - }); - - await loader.load(fixture.dir); - await loader.activate("sample-plugin", "user-1"); }); +} - afterEach(async () => { - await loader?.shutdown(); - loader = null; - broker = null; - - for (const id of pluginApi.getRegisteredPluginIds()) { - pluginApi.unregisterPluginRouter(id); - } - - await new Promise((resolve) => server?.close(() => resolve())); +afterEach(async () => { + if (server) { + await new Promise((resolve) => server!.close(() => resolve(undefined))); server = null; - while (fixtures.length) fixtures.pop()!.cleanup(); + } + for (const id of pluginApi.getRegisteredPluginIds()) { + pluginApi.unregisterPluginRouter(id); + } +}); + +describe("/plugin-api authentication", () => { + it("rejects a request with no token", async () => { + const base = await startServer(); + + const response = await fetch(`${base}/plugin-api/sample-plugin/thing`); + + expect(response.status).toBe(401); }); - it( - "rejects a request with no token before it reaches the plugin", - async () => { - const res = await fetch(`${baseUrl}/plugin-api/sample-plugin/whoami`); - expect(res.status).toBe(401); - }, - WORKER_TIMEOUT, - ); + it("rejects a request with the wrong token", async () => { + const base = await startServer(); - it( - "rejects a request with an invalid token", - async () => { - const res = await fetch(`${baseUrl}/plugin-api/sample-plugin/whoami`, { - headers: { authorization: "Bearer not-the-right-token" }, - }); - expect(res.status).toBe(401); - }, - WORKER_TIMEOUT, - ); + const response = await fetch(`${base}/plugin-api/sample-plugin/thing`, { + headers: { Authorization: "Bearer nope" }, + }); - it( - "forwards the verified userId once authenticated", - async () => { - const res = await fetch(`${baseUrl}/plugin-api/sample-plugin/whoami`, { - headers: { authorization: `Bearer ${state.validToken}` }, + expect(response.status).toBe(401); + }); + + // Past the gate, an id nothing registered is a plain 404. The auth check + // runs first either way, so a caller cannot use the difference between 401 + // and 404 to find out which plugins are installed. + it("returns 404 for an unregistered plugin id when authenticated", async () => { + const base = await startServer(); + + const response = await fetch(`${base}/plugin-api/sample-plugin/thing`, { + headers: { Authorization: `Bearer ${state.validToken}` }, + }); + + expect(response.status).toBe(404); + }); + + it("dispatches to a registered router only when authenticated", async () => { + const router = express.Router(); + router.get("/thing", (req, res) => { + res.json({ + ok: true, + userId: (req as express.Request & { userId?: string }).userId, }); - expect(res.status).toBe(200); - expect(await res.json()).toEqual({ userId: state.userId }); - }, - WORKER_TIMEOUT, - ); + }); + pluginApi.registerPluginRouter("sample-plugin", router); + + const base = await startServer(); + + const unauthorized = await fetch(`${base}/plugin-api/sample-plugin/thing`); + expect(unauthorized.status).toBe(401); + + const authorized = await fetch(`${base}/plugin-api/sample-plugin/thing`, { + headers: { Authorization: `Bearer ${state.validToken}` }, + }); + + expect(authorized.status).toBe(200); + // The user id the plugin sees is the one the gate verified, never one the + // caller supplied. + expect(await authorized.json()).toEqual({ + ok: true, + userId: state.userId, + }); + }); }); diff --git a/src/backend/tests/database/routes/plugins.test.ts b/src/backend/tests/database/routes/plugins.test.ts index 3bc8bd085..01c4c0b50 100644 --- a/src/backend/tests/database/routes/plugins.test.ts +++ b/src/backend/tests/database/routes/plugins.test.ts @@ -17,12 +17,18 @@ interface PluginRow { tier: string; source: string; state: string; + lastError?: string | null; manifestJson: string; } const state = vi.hoisted(() => ({ plugins: new Map(), - grants: [] as { pluginId: string; capability: string; grantedBy: string }[], + grants: [] as { + pluginId: string; + capability: string; + grantedBy: string | null; + source?: string; + }[], // Which users have admin.plugins.manage, keyed by userId. managers: new Set(["admin-1"]), })); @@ -62,6 +68,9 @@ vi.mock("../../../utils/auth-manager.js", () => ({ vi.mock("../../../utils/permission-manager.js", () => ({ PermissionManager: { getInstance: () => ({ + // The list route asks directly rather than gating the whole mount, so + // it can return a narrower shape to a non-admin instead of a 403. + hasPermission: async (userId: string) => state.managers.has(userId), requirePermission: (_permission: string) => ( @@ -113,7 +122,8 @@ vi.mock("../../../database/repositories/factory.js", () => ({ grant: async (input: { pluginId: string; capability: string; - grantedBy: string; + grantedBy: string | null; + source?: string; }) => { state.grants.push(input); return input; @@ -140,7 +150,7 @@ function makePlugin(overrides: Partial = {}): PluginRow { source: "community", state: "enabled", manifestJson: JSON.stringify({ - permissions: ["hosts.read", "storage.own"], + capabilities: ["hosts:read", "kv:own"], }), ...overrides, }; @@ -170,11 +180,11 @@ describe("plugins route", () => { server = null; }); - it("lists a plugin's declared permissions and granted capabilities", async () => { + it("lists a plugin's declared and granted capabilities", async () => { state.plugins.set("sample-plugin", makePlugin()); state.grants.push({ pluginId: "sample-plugin", - capability: "hosts.read", + capability: "hosts:read", grantedBy: "admin-1", }); @@ -184,8 +194,8 @@ describe("plugins route", () => { expect(body).toEqual([ expect.objectContaining({ id: "sample-plugin", - permissions: ["hosts.read", "storage.own"], - grantedCapabilities: ["hosts.read"], + capabilities: ["hosts:read", "kv:own"], + grantedCapabilities: ["hosts:read"], }), ]); }); @@ -196,19 +206,19 @@ describe("plugins route", () => { const res = await fetch(`${baseUrl}/plugins/sample-plugin/grants`, { method: "POST", headers: { "content-type": "application/json" }, - body: JSON.stringify({ capability: "hosts.read" }), + body: JSON.stringify({ capability: "hosts:read" }), }); expect(res.status).toBe(200); expect(await res.json()).toEqual({ id: "sample-plugin", - capability: "hosts.read", + capability: "hosts:read", granted: true, }); expect(state.grants).toEqual([ { pluginId: "sample-plugin", - capability: "hosts.read", + capability: "hosts:read", grantedBy: "admin-1", }, ]); @@ -231,7 +241,7 @@ describe("plugins route", () => { const res = await fetch(`${baseUrl}/plugins/ghost/grants`, { method: "POST", headers: { "content-type": "application/json" }, - body: JSON.stringify({ capability: "hosts.read" }), + body: JSON.stringify({ capability: "hosts:read" }), }); expect(res.status).toBe(404); @@ -243,12 +253,12 @@ describe("plugins route", () => { await fetch(`${baseUrl}/plugins/sample-plugin/grants`, { method: "POST", headers: { "content-type": "application/json" }, - body: JSON.stringify({ capability: "hosts.read" }), + body: JSON.stringify({ capability: "hosts:read" }), }); await fetch(`${baseUrl}/plugins/sample-plugin/grants`, { method: "POST", headers: { "content-type": "application/json" }, - body: JSON.stringify({ capability: "hosts.read" }), + body: JSON.stringify({ capability: "hosts:read" }), }); expect(state.grants).toHaveLength(1); @@ -258,19 +268,19 @@ describe("plugins route", () => { state.plugins.set("sample-plugin", makePlugin()); state.grants.push({ pluginId: "sample-plugin", - capability: "hosts.read", + capability: "hosts:read", grantedBy: "admin-1", }); const res = await fetch( - `${baseUrl}/plugins/sample-plugin/grants/hosts.read`, + `${baseUrl}/plugins/sample-plugin/grants/${encodeURIComponent("hosts:read")}`, { method: "DELETE" }, ); expect(res.status).toBe(200); expect(await res.json()).toEqual({ id: "sample-plugin", - capability: "hosts.read", + capability: "hosts:read", granted: false, }); expect(state.grants).toEqual([]); @@ -280,7 +290,7 @@ describe("plugins route", () => { state.plugins.set("sample-plugin", makePlugin()); const res = await fetch( - `${baseUrl}/plugins/sample-plugin/grants/hosts.read`, + `${baseUrl}/plugins/sample-plugin/grants/${encodeURIComponent("hosts:read")}`, { method: "DELETE" }, ); @@ -288,7 +298,7 @@ describe("plugins route", () => { }); describe("admin.plugins.manage gate", () => { - it("a non-admin authenticated user cannot list plugins", async () => { + it("lets a non-admin list plugins", async () => { // GET stays open to any authenticated user on purpose: the app shell // calls it for every session to know which plugin tabs to register. state.plugins.set("sample-plugin", makePlugin()); @@ -300,6 +310,58 @@ describe("plugins route", () => { expect(res.status).toBe(200); }); + // What a plugin may do, what it has been granted and why it failed are + // operational details. The shell needs none of them to render a tab. + it("hides capabilities, grants and lastError from a non-admin", async () => { + state.plugins.set( + "sample-plugin", + makePlugin({ lastError: "port already in use" }), + ); + state.grants.push({ + pluginId: "sample-plugin", + capability: "hosts:read", + grantedBy: "admin-1", + }); + + const res = await fetch(`${baseUrl}/plugins`, { + headers: { "x-test-user-id": "regular-user" }, + }); + const [plugin] = await res.json(); + + expect(plugin).toEqual({ + id: "sample-plugin", + name: "Sample Plugin", + version: "1.0.0", + enabled: true, + state: "enabled", + contributes: null, + }); + }); + + it("shows the full record to a holder of admin.plugins.manage", async () => { + state.plugins.set( + "sample-plugin", + makePlugin({ lastError: "port already in use" }), + ); + state.grants.push({ + pluginId: "sample-plugin", + capability: "hosts:read", + grantedBy: "admin-1", + }); + + const res = await fetch(`${baseUrl}/plugins`, { + headers: { "x-test-user-id": "admin-1" }, + }); + const [plugin] = await res.json(); + + expect(plugin).toMatchObject({ + id: "sample-plugin", + capabilities: ["hosts:read", "kv:own"], + grantedCapabilities: ["hosts:read"], + lastError: "port already in use", + }); + }); + it("403s a non-admin enabling or disabling a plugin", async () => { state.plugins.set("sample-plugin", makePlugin()); @@ -325,7 +387,7 @@ describe("plugins route", () => { "content-type": "application/json", "x-test-user-id": "regular-user", }, - body: JSON.stringify({ capability: "hosts.read" }), + body: JSON.stringify({ capability: "hosts:read" }), }); expect(res.status).toBe(403); @@ -336,12 +398,12 @@ describe("plugins route", () => { state.plugins.set("sample-plugin", makePlugin()); state.grants.push({ pluginId: "sample-plugin", - capability: "hosts.read", + capability: "hosts:read", grantedBy: "admin-1", }); const res = await fetch( - `${baseUrl}/plugins/sample-plugin/grants/hosts.read`, + `${baseUrl}/plugins/sample-plugin/grants/${encodeURIComponent("hosts:read")}`, { method: "DELETE", headers: { "x-test-user-id": "regular-user" }, diff --git a/src/backend/tests/plugins/broker.test.ts b/src/backend/tests/plugins/broker.test.ts deleted file mode 100644 index 9f532098c..000000000 --- a/src/backend/tests/plugins/broker.test.ts +++ /dev/null @@ -1,620 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { createFixturePlugin, type Fixture } from "./fixture-plugin.js"; - -const state = vi.hoisted(() => ({ - grants: [] as { pluginId: string; capability: string }[], - auditEntries: [] as Record[], -})); - -vi.mock("../../utils/logger.js", () => ({ - pluginLogger: { - debug: vi.fn(), - info: vi.fn(), - warn: vi.fn(), - error: vi.fn(), - success: vi.fn(), - }, -})); - -vi.mock("../../utils/audit-logger.js", () => ({ - logAudit: vi.fn(async (params: Record) => { - state.auditEntries.push(params); - }), - getAuditUsername: vi.fn(async (userId: string) => `user:${userId}`), -})); - -vi.mock("../../database/repositories/factory.js", () => ({ - createCurrentPluginPermissionGrantRepository: () => ({ - listByPlugin: async (pluginId: string) => - state.grants.filter((g) => g.pluginId === pluginId), - }), -})); - -const { PluginBroker } = await import("../../plugins/broker.js"); -const { PluginLoader } = await import("../../plugins/loader.js"); -const { invalidatePluginPermissionCache } = - await import("../../plugins/permissions.js"); - -const WORKER_TIMEOUT = 30_000; - -/** - * A host as resolveHostById would return it: secrets in plaintext. The tests - * assert none of this reaches the worker. - */ -const SECRET = "hunter2-plaintext-secret"; -const RAW_HOST = { - id: 42, - name: "prod-box", - ip: "10.0.0.5", - port: 22, - username: "root", - folder: "Production", - tags: ["prod"], - authType: "password", - enableTerminal: true, - enableTunnel: false, - enableFileManager: true, - createdAt: "2026-01-01T00:00:00Z", - updatedAt: "2026-01-02T00:00:00Z", - // None of these may ever cross the boundary. - password: SECRET, - key: SECRET, - keyPassword: SECRET, - certPublicKey: SECRET, - sudoPassword: SECRET, - autostartPassword: SECRET, -}; - -describe("PluginBroker", () => { - const fixtures: Fixture[] = []; - let loader: InstanceType | null = null; - let broker: InstanceType | null = null; - - beforeEach(() => { - state.grants = []; - state.auditEntries = []; - storage = memoryStorage(); - invalidatePluginPermissionCache(); - }); - - afterEach(async () => { - await loader?.shutdown(); - loader = null; - broker = null; - // The bus is a module singleton, so a leftover listener would leak between - // tests. - const { pluginEvents } = await import("../../plugins/events.js"); - pluginEvents.clear(); - while (fixtures.length) fixtures.pop()!.cleanup(); - }); - - function grant(capability: string, pluginId = "sample-plugin") { - state.grants.push({ pluginId, capability }); - invalidatePluginPermissionCache(pluginId); - } - - /** - * Boots a real worker whose activate() runs `body` and reports the outcome - * back through a ctx.log call the test can read. - */ - /** In-memory stand-in for PluginStorageRepository, scoped per plugin. */ - function memoryStorage() { - const rows = new Map(); - const at = (pluginId: string, key: string) => `${pluginId}\u0000${key}`; - - return { - rows, - backend: { - get: async (pluginId: string, key: string) => - rows.get(at(pluginId, key)) ?? null, - set: async (pluginId: string, key: string, value: string) => { - rows.set(at(pluginId, key), value); - }, - delete: async (pluginId: string, key: string) => - rows.delete(at(pluginId, key)), - listKeys: async (pluginId: string) => - [...rows.keys()] - .filter((k) => k.startsWith(`${pluginId}\u0000`)) - .map((k) => k.split("\u0000")[1]), - }, - }; - } - - let storage = memoryStorage(); - - async function boot(body: string, permissions?: string[]) { - const fixture = createFixturePlugin({ - permissions: permissions ?? ["hosts.read", "storage.own"], - backendSource: ` - export async function activate(ctx) { - globalThis.__result = undefined; - try { - globalThis.__result = { ok: true, value: await (async () => { ${body} })() }; - } catch (error) { - globalThis.__result = { ok: false, error: error.message }; - } - await ctx.log.info(JSON.stringify(globalThis.__result)); - } - `, - }); - fixtures.push(fixture); - - broker = new PluginBroker({ - listHosts: async () => [RAW_HOST], - resolveHost: async (hostId) => (hostId === 42 ? RAW_HOST : null), - storage: storage.backend, - }); - - const logged: string[] = []; - const { pluginLogger } = await import("../../utils/logger.js"); - vi.mocked(pluginLogger.info).mockImplementation((message: string) => { - logged.push(message); - }); - - loader = new PluginLoader({ - onWorkerReady: (plugin, worker) => broker!.attach(plugin, worker), - onWorkerGone: (plugin) => broker!.detach(plugin.id), - }); - - await loader.load(fixture.dir); - await loader.activate("sample-plugin", "user-1"); - - const line = logged.find((entry) => entry.startsWith("{")); - return line ? JSON.parse(line) : { ok: false, error: "no result reported" }; - } - - describe("permission gate", () => { - it( - "allows a call whose capability is declared and granted", - async () => { - grant("hosts.read"); - const result = await boot(`return await ctx.hosts.get(42);`); - - expect(result.ok).toBe(true); - expect(result.value).toMatchObject({ id: 42, name: "prod-box" }); - }, - WORKER_TIMEOUT, - ); - - it( - "denies a declared capability that was never granted", - async () => { - const result = await boot(`return await ctx.hosts.get(42);`); - - expect(result.ok).toBe(false); - expect(result.error).toContain('is not granted the "hosts.read"'); - }, - WORKER_TIMEOUT, - ); - - it( - "denies a granted capability the manifest never declared", - async () => { - // Granted in the database, but absent from the manifest: widening a - // plugin's reach must require a new manifest, not just a grant. - grant("hosts.read"); - const result = await boot(`return await ctx.hosts.get(42);`, [ - "storage.own", - ]); - - expect(result.ok).toBe(false); - expect(result.error).toContain('is not granted the "hosts.read"'); - }, - WORKER_TIMEOUT, - ); - - it( - "rejects an unknown ctx method", - async () => { - const result = await boot( - `return await new Promise((resolve, reject) => { - ctx.hosts.__proto__; resolve("noop"); - });`, - ); - expect(result.ok).toBe(true); - }, - WORKER_TIMEOUT, - ); - }); - - describe("credential isolation", () => { - it( - "never sends credential material to the worker", - async () => { - grant("hosts.read"); - const result = await boot(` - const host = await ctx.hosts.get(42); - const list = await ctx.hosts.list(); - const blob = JSON.stringify({ host, list }); - if (blob.includes(${JSON.stringify(SECRET)})) { - throw new Error("SECRET LEAKED: " + blob); - } - return { keys: Object.keys(host).sort(), listLength: list.length }; - `); - - expect(result.ok).toBe(true); - expect(result.value.listLength).toBe(1); - expect(result.value.keys).not.toContain("password"); - expect(result.value.keys).not.toContain("key"); - expect(result.value.keys).not.toContain("keyPassword"); - expect(result.value.keys).not.toContain("certPublicKey"); - expect(result.value.keys).not.toContain("sudoPassword"); - expect(result.value.keys).not.toContain("autostartPassword"); - }, - WORKER_TIMEOUT, - ); - - it( - "returns null rather than distinguishing missing from forbidden", - async () => { - grant("hosts.read"); - const result = await boot(`return await ctx.hosts.get(999);`); - - expect(result.ok).toBe(true); - expect(result.value).toBeNull(); - }, - WORKER_TIMEOUT, - ); - }); - - describe("audit", () => { - it( - "writes one plugin-attributed line per permitted call", - async () => { - grant("hosts.read"); - await boot(`return await ctx.hosts.get(42);`); - - const entry = state.auditEntries.find( - (e) => e.action === "plugin_hosts_get", - ); - expect(entry).toBeDefined(); - expect(entry).toMatchObject({ - userId: "user-1", - username: "plugin:sample-plugin", - resourceType: "plugin", - resourceId: "sample-plugin", - success: true, - }); - }, - WORKER_TIMEOUT, - ); - - it( - "audits a denied call as a failure", - async () => { - await boot(`return await ctx.hosts.get(42);`); - - const entry = state.auditEntries.find( - (e) => e.action === "plugin_hosts_get", - ); - expect(entry).toMatchObject({ - username: "plugin:sample-plugin", - success: false, - }); - expect(String(entry?.errorMessage)).toContain("is not granted"); - }, - WORKER_TIMEOUT, - ); - - it( - "does not let a plugin forge the actor or smuggle bulk details", - async () => { - grant("hosts.read"); - // ctx.log takes a context object, so this exercises the summariser on - // an argument a plugin genuinely controls. - await boot(` - await ctx.log.info("hello", { username: "admin", userId: "user-999" }); - return await ctx.hosts.get(42); - `); - - const entry = state.auditEntries.find( - (e) => e.action === "plugin_hosts_get", - ); - // Attribution is broker-set regardless of what the plugin passed. - expect(entry?.username).toBe("plugin:sample-plugin"); - expect(entry?.userId).toBe("user-1"); - - // No audit entry anywhere claims the user the plugin named. - expect( - state.auditEntries.some( - (e) => e.userId === "user-999" || e.username === "admin", - ), - ).toBe(false); - }, - WORKER_TIMEOUT, - ); - - it( - "summarises rather than records a long string argument", - async () => { - grant("hosts.read"); - await boot(` - try { await ctx.hosts.get("${"x".repeat(500)}"); } catch {} - return "done"; - `); - - const entry = state.auditEntries.find( - (e) => e.action === "plugin_hosts_get", - ); - const details = String(entry?.details); - // Capped, so a plugin cannot write bulk data into the audit trail. - expect(details.length).toBeLessThan(200); - expect(details).not.toContain("x".repeat(200)); - }, - WORKER_TIMEOUT, - ); - }); - - describe("ctx.storage", () => { - it( - "round-trips a structured value", - async () => { - grant("storage.own"); - const result = await boot(` - await ctx.storage.set("prefs", { theme: "dark", count: 3 }); - const read = await ctx.storage.get("prefs"); - const keys = await ctx.storage.list(); - return { read, keys }; - `); - - expect(result.ok).toBe(true); - expect(result.value.read).toEqual({ theme: "dark", count: 3 }); - expect(result.value.keys).toEqual(["prefs"]); - }, - WORKER_TIMEOUT, - ); - - it( - "returns null for a key that was never set", - async () => { - grant("storage.own"); - const result = await boot(`return await ctx.storage.get("nope");`); - - expect(result.ok).toBe(true); - expect(result.value).toBeNull(); - }, - WORKER_TIMEOUT, - ); - - it( - "deletes a key", - async () => { - grant("storage.own"); - const result = await boot(` - await ctx.storage.set("temp", 1); - await ctx.storage.delete("temp"); - return await ctx.storage.list(); - `); - - expect(result.ok).toBe(true); - expect(result.value).toEqual([]); - }, - WORKER_TIMEOUT, - ); - - it( - "scopes writes to the calling plugin, which cannot name another scope", - async () => { - grant("storage.own"); - // A plugin only ever supplies a key; the plugin id is added by the - // broker, so there is no argument through which to reach another - // plugin's rows. - await boot(`await ctx.storage.set("owned", "value");`); - - const keys = [...storage.rows.keys()]; - expect(keys).toEqual(["sample-plugin\u0000owned"]); - }, - WORKER_TIMEOUT, - ); - - it( - "denies storage without the granted capability", - async () => { - const result = await boot(`return await ctx.storage.get("prefs");`); - - expect(result.ok).toBe(false); - expect(result.error).toContain('is not granted the "storage.own"'); - }, - WORKER_TIMEOUT, - ); - - it( - "rejects an empty key and an oversized value", - async () => { - grant("storage.own"); - const result = await boot(` - const errors = []; - try { await ctx.storage.set("", 1); } catch (e) { errors.push(e.message); } - try { - await ctx.storage.set("big", "x".repeat(300000)); - } catch (e) { errors.push(e.message); } - return errors; - `); - - expect(result.ok).toBe(true); - expect(result.value[0]).toContain("non-empty key"); - expect(result.value[1]).toContain("byte limit"); - }, - WORKER_TIMEOUT, - ); - }); - - describe("ctx.schedule", () => { - it( - "registers a timer and fires the plugin's handler", - async () => { - grant("storage.own"); - const result = await boot(` - await new Promise(async (resolve, reject) => { - const timer = setTimeout(() => reject(new Error("timer never fired")), 8000); - await ctx.schedule.every(1000, async () => { - clearTimeout(timer); - resolve(); - }); - }); - return "fired"; - `); - - expect(result.ok).toBe(true); - expect(result.value).toBe("fired"); - }, - WORKER_TIMEOUT, - ); - - it( - "rejects an interval below the floor", - async () => { - grant("storage.own"); - const result = await boot( - `return await ctx.schedule.every(5, () => {});`, - ); - - expect(result.ok).toBe(false); - expect(result.error).toContain("at least 1000ms"); - }, - WORKER_TIMEOUT, - ); - - it( - "cancel stops the timer and drops it from the runtime", - async () => { - grant("storage.own"); - await boot(` - const id = await ctx.schedule.every(1000, () => {}); - await ctx.schedule.cancel(id); - `); - - expect(broker!.get("sample-plugin")!.timers.size).toBe(0); - }, - WORKER_TIMEOUT, - ); - }); - - describe("ctx.events", () => { - it( - "pushes a subscribed topic through to the plugin's listener", - async () => { - grant("events.read"); - const { pluginEvents } = await import("../../plugins/events.js"); - - // The plugin subscribes during activate, then reports what it sees on - // the next event. boot() resolves once activate returns, so the emit - // below happens with the subscription already live. - await boot( - ` - await ctx.events.on("host.status", async (payload) => { - await ctx.log.info("EVENT:" + JSON.stringify(payload)); - }); - `, - ["events.read"], - ); - - const runtime = broker!.get("sample-plugin")!; - expect(runtime.subscriptions.has("host.status")).toBe(true); - expect(pluginEvents.listenerCount("host.status")).toBe(1); - - const pushed: unknown[] = []; - const original = runtime.worker.postMessage.bind(runtime.worker); - runtime.worker.postMessage = ((message: unknown) => { - pushed.push(message); - return original(message); - }) as typeof runtime.worker.postMessage; - - pluginEvents.emit("host.status", { hostId: 42, online: true }); - - expect(pushed).toContainEqual({ - kind: "event", - key: "host.status", - payload: { hostId: 42, online: true }, - }); - }, - WORKER_TIMEOUT, - ); - - it( - "refuses to emit outside the plugin's own namespace", - async () => { - grant("events.read"); - const result = await boot( - ` - const errors = []; - try { - await ctx.events.emit("host.status", { hostId: 1, online: false }); - } catch (e) { errors.push(e.message); } - await ctx.events.emit("plugin.sample-plugin.ready", { ok: true }); - return errors; - `, - ["events.read"], - ); - - expect(result.ok).toBe(true); - expect(result.value[0]).toContain( - 'may only publish topics beginning with "plugin.sample-plugin."', - ); - }, - WORKER_TIMEOUT, - ); - - it( - "denies subscribing without the events.read capability", - async () => { - const result = await boot( - `return await ctx.events.on("host.status", () => {});`, - ["events.read"], - ); - - expect(result.ok).toBe(false); - expect(result.error).toContain('is not granted the "events.read"'); - }, - WORKER_TIMEOUT, - ); - - it( - "drops bus subscriptions when the plugin detaches", - async () => { - grant("events.read"); - const { pluginEvents } = await import("../../plugins/events.js"); - - await boot(`await ctx.events.on("host.status", () => {});`, [ - "events.read", - ]); - - expect(pluginEvents.listenerCount("host.status")).toBe(1); - broker!.detach("sample-plugin"); - expect(pluginEvents.listenerCount("host.status")).toBe(0); - }, - WORKER_TIMEOUT, - ); - }); - - describe("detach", () => { - it( - "releases ssh handles and timers when the worker goes away", - async () => { - grant("hosts.read"); - await boot(`return await ctx.hosts.get(42);`); - - const runtime = broker!.get("sample-plugin"); - expect(runtime).toBeDefined(); - - let released = false; - runtime!.sshHandles.set("sshconn:test", { - hostId: 42, - release: () => { - released = true; - }, - }); - runtime!.timers.set( - "t1", - setInterval(() => {}, 10_000), - ); - - broker!.detach("sample-plugin"); - - expect(released).toBe(true); - expect(broker!.get("sample-plugin")).toBeUndefined(); - }, - WORKER_TIMEOUT, - ); - }); -}); diff --git a/src/backend/tests/plugins/bundled-plugin.test.ts b/src/backend/tests/plugins/bundled-plugin.test.ts index 259312eed..2f3f930c7 100644 --- a/src/backend/tests/plugins/bundled-plugin.test.ts +++ b/src/backend/tests/plugins/bundled-plugin.test.ts @@ -1,8 +1,9 @@ /** - * The real bundled ssh-terminal plugin, as it ships. + * The bundled plugins, as they ship. * - * This reads plugins/ in the repo rather than a fixture: the manifest is a - * checked-in artifact, and a typo in it would otherwise only show up at boot. + * This reads plugins/ in the repo rather than a fixture: the manifests are + * checked-in artifacts, and a typo in one would otherwise only show up when + * the server tried to boot. */ import { describe, expect, it, vi } from "vitest"; @@ -10,11 +11,7 @@ import fs from "node:fs"; import path from "node:path"; import { getBundledPluginsDir } from "../../plugins/paths.js"; import { parseManifest } from "../../plugins/manifest.js"; -import { - isFirstParty, - runsInProcess, - TRANSPORT_OWNER_CAPABILITY, -} from "../../plugins/first-party.js"; +import { isKnownCapability } from "@termix/plugin-sdk/capabilities"; vi.mock("../../utils/logger.js", () => ({ pluginLogger: { @@ -26,443 +23,125 @@ vi.mock("../../utils/logger.js", () => ({ }, })); -function readBundledManifest(pluginId: string): Record { - const file = path.join(getBundledPluginsDir(), pluginId, "manifest.json"); - return JSON.parse(fs.readFileSync(file, "utf8")); +const BUNDLED_DIR = getBundledPluginsDir(); + +function bundledIds(): string[] { + return fs + .readdirSync(BUNDLED_DIR, { withFileTypes: true }) + .filter((entry) => entry.isDirectory()) + .map((entry) => entry.name) + .filter((id) => fs.existsSync(path.join(BUNDLED_DIR, id, "manifest.json"))); } -describe("bundled ssh-terminal plugin", () => { - it("ships a directory with a manifest and both entry points", () => { - const dir = path.join(getBundledPluginsDir(), "ssh-terminal"); +function readManifest(pluginId: string): Record { + return JSON.parse( + fs.readFileSync(path.join(BUNDLED_DIR, pluginId, "manifest.json"), "utf8"), + ); +} - expect(fs.existsSync(path.join(dir, "manifest.json"))).toBe(true); - expect(fs.existsSync(path.join(dir, "backend", "index.mjs"))).toBe(true); - expect(fs.existsSync(path.join(dir, "frontend", "index.mjs"))).toBe(true); +const ids = bundledIds(); + +describe("bundled plugins", () => { + it("finds the bundled plugin directory", () => { + expect(ids.length).toBeGreaterThan(0); }); - it("has a manifest that passes the real validator", () => { - const { manifest, errors } = parseManifest( - readBundledManifest("ssh-terminal"), - ); + it.each(ids)("%s has a manifest that parses", (id) => { + const { manifest, errors } = parseManifest(readManifest(id)); expect(errors).toEqual([]); - expect(manifest?.id).toBe("ssh-terminal"); - expect(manifest?.category).toBe("Terminal"); + expect(manifest?.id).toBe(id); }); - it("declares the transport-owner capability and qualifies for the tier", () => { - const { manifest } = parseManifest(readBundledManifest("ssh-terminal")); + it.each(ids)("%s declares only catalog capabilities", (id) => { + const { manifest } = parseManifest(readManifest(id)); - expect(manifest?.permissions).toContain(TRANSPORT_OWNER_CAPABILITY); - expect(isFirstParty("ssh-terminal")).toBe(true); - expect(runsInProcess("ssh-terminal", manifest!.permissions)).toBe(true); + for (const capability of manifest?.capabilities ?? []) { + expect(isKnownCapability(capability)).toBe(true); + } }); - it("contributes the terminal tab the shell registers", () => { - const { manifest } = parseManifest(readBundledManifest("ssh-terminal")); - const tab = manifest?.contributes?.tabs?.[0]; + it.each(ids)("%s ships the entry points its manifest names", (id) => { + const { manifest } = parseManifest(readManifest(id)); + const dir = path.join(BUNDLED_DIR, id); - // The shell keys tab content off this id, so it has to stay "terminal". - expect(tab?.id).toBe("terminal"); - expect(tab?.openFrom).toContain("rail"); + expect(fs.existsSync(path.join(dir, manifest!.backend!))).toBe(true); + expect(fs.existsSync(path.join(dir, manifest!.frontend!))).toBe(true); }); - it("exports activate and deactivate from its backend entry", () => { - const entry = path.join( - getBundledPluginsDir(), - "ssh-terminal", - "backend", - "index.mjs", - ); - const source = fs.readFileSync(entry, "utf8"); + // process:transport-owner was the v1 marker for the in-process tier. There + // is one tier now, so nothing should still be asking for it. + it.each(ids)("%s does not declare a v1 permission", (id) => { + const raw = readManifest(id); - // A first-party plugin owns a listening port, so deactivate is not - // optional here the way it is for an ordinary plugin. - expect(source).toMatch(/export async function activate/); - expect(source).toMatch(/export async function deactivate/); + expect(raw.permissions).toBeUndefined(); + expect(raw.sidecars).toBeUndefined(); + expect(JSON.stringify(raw)).not.toContain("transport-owner"); }); }); -describe("bundled docker plugin", () => { - it("ships a directory with a manifest and both entry points", () => { - const dir = path.join(getBundledPluginsDir(), "docker"); - - expect(fs.existsSync(path.join(dir, "manifest.json"))).toBe(true); - expect(fs.existsSync(path.join(dir, "backend", "index.mjs"))).toBe(true); - expect(fs.existsSync(path.join(dir, "frontend", "index.mjs"))).toBe(true); +describe("declared dependencies", () => { + it("names a plugin that is actually bundled", () => { + for (const id of ids) { + const { manifest } = parseManifest(readManifest(id)); + for (const dependency of Object.keys(manifest?.dependencies ?? {})) { + expect(ids).toContain(dependency); + } + } }); - it("has a manifest that passes the real validator", () => { - const { manifest, errors } = parseManifest(readBundledManifest("docker")); + // plugins/ai/backend/tools/executor.ts imports automations' routes + // directly, so the loader has to start automations first. B18 replaces the + // import with a service call and this becomes optional. + it("records ai's dependency on automations", () => { + if (!ids.includes("ai")) return; - expect(errors).toEqual([]); - expect(manifest?.id).toBe("docker"); - expect(manifest?.category).toBe("Infrastructure"); - }); + const { manifest } = parseManifest(readManifest("ai")); - it("declares the transport-owner capability and qualifies for the tier", () => { - const { manifest } = parseManifest(readBundledManifest("docker")); - - expect(manifest?.permissions).toContain(TRANSPORT_OWNER_CAPABILITY); - expect(isFirstParty("docker")).toBe(true); - expect(runsInProcess("docker", manifest!.permissions)).toBe(true); - }); - - it("contributes the docker tab the shell registers", () => { - const { manifest } = parseManifest(readBundledManifest("docker")); - const tab = manifest?.contributes?.tabs?.[0]; - - // The shell keys tab content off this id, so it has to stay "docker". - expect(tab?.id).toBe("docker"); - expect(tab?.openFrom).toContain("host-context-menu"); - }); - - it("declares the enableDocker host capability", () => { - const { manifest } = parseManifest(readBundledManifest("docker")); - - expect(manifest?.contributes?.hostCapability?.key).toBe("enableDocker"); - }); - - it("exports activate and deactivate from its backend entry", () => { - const entry = path.join( - getBundledPluginsDir(), - "docker", - "backend", - "index.mjs", - ); - const source = fs.readFileSync(entry, "utf8"); - - expect(source).toMatch(/export async function activate/); - expect(source).toMatch(/export async function deactivate/); + expect(manifest?.dependencies?.automations).toBeTruthy(); }); }); -describe("bundled host-metrics plugin", () => { - it("ships a directory with a manifest and both entry points", () => { - const dir = path.join(getBundledPluginsDir(), "host-metrics"); +describe("plugins that own a port", () => { + // Every one of these had its server created at module scope at some point. + // The rule is that it belongs inside activate, or disable leaves the port + // held and re-enable fails. + it.each(["ssh-terminal", "docker", "host-metrics", "remote-desktop"])( + "%s creates no listener at module scope", + (id) => { + if (!ids.includes(id)) return; - expect(fs.existsSync(path.join(dir, "manifest.json"))).toBe(true); - expect(fs.existsSync(path.join(dir, "backend", "index.mjs"))).toBe(true); - expect(fs.existsSync(path.join(dir, "frontend", "index.mjs"))).toBe(true); - }); + const entry = path.join(BUNDLED_DIR, id, "backend", "index.mjs"); + const source = fs.readFileSync(entry, "utf8"); + const moduleScope = source.slice( + 0, + source.indexOf("export async function activate"), + ); - it("has a manifest that passes the real validator", () => { - const { manifest, errors } = parseManifest( - readBundledManifest("host-metrics"), - ); + expect(moduleScope).not.toMatch(/\.listen\(/); + expect(moduleScope).not.toMatch(/new WebSocketServer/); + }, + ); - expect(errors).toEqual([]); - expect(manifest?.id).toBe("host-metrics"); - expect(manifest?.category).toBe("Monitoring"); - }); + it("docker does not register a process signal handler", () => { + if (!ids.includes("docker")) return; - it("declares the transport-owner capability and qualifies for the tier", () => { - const { manifest } = parseManifest(readBundledManifest("host-metrics")); + // Core owns shutdown: gracefulShutdown calls shutdownPlugins, which runs + // every plugin's deactivate. A plugin calling process.exit() itself + // skipped the rest of that sequence. + for (const file of ["index.mjs", "console.ts"]) { + const candidate = path.join(BUNDLED_DIR, "docker", "backend", file); + if (!fs.existsSync(candidate)) continue; - expect(manifest?.permissions).toContain(TRANSPORT_OWNER_CAPABILITY); - expect(isFirstParty("host-metrics")).toBe(true); - expect(runsInProcess("host-metrics", manifest!.permissions)).toBe(true); - }); + // Comments are stripped first: the rule is worth explaining in prose + // right where it applies, and that prose names the thing it forbids. + const code = fs + .readFileSync(candidate, "utf8") + .replace(/\/\*[\s\S]*?\*\//g, "") + .replace(/^\s*\/\/.*$/gm, ""); - it("contributes the host-metrics tab the shell registers", () => { - const { manifest } = parseManifest(readBundledManifest("host-metrics")); - const tab = manifest?.contributes?.tabs?.[0]; - - // The shell keys tab content off this id, so it has to stay "host-metrics". - expect(tab?.id).toBe("host-metrics"); - expect(tab?.openFrom).toContain("host-context-menu"); - }); - - it("exports activate and deactivate from its backend entry", () => { - const entry = path.join( - getBundledPluginsDir(), - "host-metrics", - "backend", - "index.mjs", - ); - const source = fs.readFileSync(entry, "utf8"); - - expect(source).toMatch(/export async function activate/); - expect(source).toMatch(/export async function deactivate/); - }); -}); - -describe("bundled remote-desktop plugin", () => { - it("ships a directory with a manifest and both entry points", () => { - const dir = path.join(getBundledPluginsDir(), "remote-desktop"); - - expect(fs.existsSync(path.join(dir, "manifest.json"))).toBe(true); - expect(fs.existsSync(path.join(dir, "backend", "index.mjs"))).toBe(true); - expect(fs.existsSync(path.join(dir, "frontend", "index.mjs"))).toBe(true); - }); - - it("has a manifest that passes the real validator", () => { - const { manifest, errors } = parseManifest( - readBundledManifest("remote-desktop"), - ); - - expect(errors).toEqual([]); - expect(manifest?.id).toBe("remote-desktop"); - expect(manifest?.category).toBe("Terminal"); - }); - - it("declares the transport-owner capability and qualifies for the tier", () => { - const { manifest } = parseManifest(readBundledManifest("remote-desktop")); - - expect(manifest?.permissions).toContain(TRANSPORT_OWNER_CAPABILITY); - expect(isFirstParty("remote-desktop")).toBe(true); - expect(runsInProcess("remote-desktop", manifest!.permissions)).toBe(true); - }); - - it("contributes the rdp/vnc/telnet tabs the shell registers", () => { - const { manifest } = parseManifest(readBundledManifest("remote-desktop")); - const tabIds = manifest?.contributes?.tabs?.map((tab) => tab.id); - - expect(tabIds).toEqual(["rdp", "vnc", "telnet"]); - }); - - it("declares three host capabilities as an array", () => { - const { manifest } = parseManifest(readBundledManifest("remote-desktop")); - const hostCapability = manifest?.contributes?.hostCapability; - - expect(Array.isArray(hostCapability)).toBe(true); - expect( - (hostCapability as Array<{ key: string }>).map((entry) => entry.key), - ).toEqual(["enableRdp", "enableVnc", "enableTelnet"]); - }); - - it("exports activate and deactivate from its backend entry", () => { - const entry = path.join( - getBundledPluginsDir(), - "remote-desktop", - "backend", - "index.mjs", - ); - const source = fs.readFileSync(entry, "utf8"); - - expect(source).toMatch(/export async function activate/); - expect(source).toMatch(/export async function deactivate/); - }); -}); - -describe("bundled fleets plugin", () => { - it("ships a directory with a manifest and both entry points", () => { - const dir = path.join(getBundledPluginsDir(), "fleets"); - - expect(fs.existsSync(path.join(dir, "manifest.json"))).toBe(true); - expect(fs.existsSync(path.join(dir, "backend", "index.mjs"))).toBe(true); - expect(fs.existsSync(path.join(dir, "frontend", "index.mjs"))).toBe(true); - }); - - it("has a manifest that passes the real validator", () => { - const { manifest, errors } = parseManifest(readBundledManifest("fleets")); - - expect(errors).toEqual([]); - expect(manifest?.id).toBe("fleets"); - expect(manifest?.category).toBe("Infrastructure"); - }); - - it("declares the transport-owner capability and qualifies for the tier", () => { - const { manifest } = parseManifest(readBundledManifest("fleets")); - - expect(manifest?.permissions).toContain(TRANSPORT_OWNER_CAPABILITY); - expect(isFirstParty("fleets")).toBe(true); - expect(runsInProcess("fleets", manifest!.permissions)).toBe(true); - }); - - it("exports activate and deactivate from its backend entry", () => { - const entry = path.join( - getBundledPluginsDir(), - "fleets", - "backend", - "index.mjs", - ); - const source = fs.readFileSync(entry, "utf8"); - - expect(source).toMatch(/export async function activate/); - expect(source).toMatch(/export async function deactivate/); - }); -}); - -describe("bundled automations plugin", () => { - it("ships a directory with a manifest and both entry points", () => { - const dir = path.join(getBundledPluginsDir(), "automations"); - - expect(fs.existsSync(path.join(dir, "manifest.json"))).toBe(true); - expect(fs.existsSync(path.join(dir, "backend", "index.mjs"))).toBe(true); - expect(fs.existsSync(path.join(dir, "frontend", "index.mjs"))).toBe(true); - }); - - it("has a manifest that passes the real validator", () => { - const { manifest, errors } = parseManifest( - readBundledManifest("automations"), - ); - - expect(errors).toEqual([]); - expect(manifest?.id).toBe("automations"); - expect(manifest?.category).toBe("Infrastructure"); - }); - - it("declares the transport-owner capability and qualifies for the tier", () => { - const { manifest } = parseManifest(readBundledManifest("automations")); - - expect(manifest?.permissions).toContain(TRANSPORT_OWNER_CAPABILITY); - expect(isFirstParty("automations")).toBe(true); - expect(runsInProcess("automations", manifest!.permissions)).toBe(true); - }); - - it("exports activate and deactivate from its backend entry", () => { - const entry = path.join( - getBundledPluginsDir(), - "automations", - "backend", - "index.mjs", - ); - const source = fs.readFileSync(entry, "utf8"); - - expect(source).toMatch(/export async function activate/); - expect(source).toMatch(/export async function deactivate/); - }); -}); - -describe("bundled network-topology plugin", () => { - it("ships a directory with a manifest and both entry points", () => { - const dir = path.join(getBundledPluginsDir(), "network-topology"); - - expect(fs.existsSync(path.join(dir, "manifest.json"))).toBe(true); - expect(fs.existsSync(path.join(dir, "backend", "index.mjs"))).toBe(true); - expect(fs.existsSync(path.join(dir, "frontend", "index.mjs"))).toBe(true); - }); - - it("has a manifest that passes the real validator", () => { - const { manifest, errors } = parseManifest( - readBundledManifest("network-topology"), - ); - - expect(errors).toEqual([]); - expect(manifest?.id).toBe("network-topology"); - expect(manifest?.category).toBe("Infrastructure"); - }); - - it("declares the transport-owner capability and qualifies for the tier", () => { - const { manifest } = parseManifest(readBundledManifest("network-topology")); - - expect(manifest?.permissions).toContain(TRANSPORT_OWNER_CAPABILITY); - expect(isFirstParty("network-topology")).toBe(true); - expect(runsInProcess("network-topology", manifest!.permissions)).toBe(true); - }); - - it("contributes the network_graph tab the shell registers", () => { - const { manifest } = parseManifest(readBundledManifest("network-topology")); - const tab = manifest?.contributes?.tabs?.[0]; - - // The shell keys tab content off this id, so it has to stay "network_graph". - expect(tab?.id).toBe("network_graph"); - expect(tab?.openFrom).toContain("rail"); - }); - - it("exports activate and deactivate from its backend entry", () => { - const entry = path.join( - getBundledPluginsDir(), - "network-topology", - "backend", - "index.mjs", - ); - const source = fs.readFileSync(entry, "utf8"); - - expect(source).toMatch(/export async function activate/); - expect(source).toMatch(/export async function deactivate/); - }); -}); - -describe("bundled workspaces plugin", () => { - it("ships a directory with a manifest and both entry points", () => { - const dir = path.join(getBundledPluginsDir(), "workspaces"); - - expect(fs.existsSync(path.join(dir, "manifest.json"))).toBe(true); - expect(fs.existsSync(path.join(dir, "backend", "index.mjs"))).toBe(true); - expect(fs.existsSync(path.join(dir, "frontend", "index.mjs"))).toBe(true); - }); - - it("has a manifest that passes the real validator", () => { - const { manifest, errors } = parseManifest( - readBundledManifest("workspaces"), - ); - - expect(errors).toEqual([]); - expect(manifest?.id).toBe("workspaces"); - expect(manifest?.category).toBe("Productivity"); - }); - - it("declares the transport-owner capability and qualifies for the tier", () => { - const { manifest } = parseManifest(readBundledManifest("workspaces")); - - expect(manifest?.permissions).toContain(TRANSPORT_OWNER_CAPABILITY); - expect(isFirstParty("workspaces")).toBe(true); - expect(runsInProcess("workspaces", manifest!.permissions)).toBe(true); - }); - - it("exports activate and deactivate from its backend entry", () => { - const entry = path.join( - getBundledPluginsDir(), - "workspaces", - "backend", - "index.mjs", - ); - const source = fs.readFileSync(entry, "utf8"); - - expect(source).toMatch(/export async function activate/); - expect(source).toMatch(/export async function deactivate/); - }); -}); - -describe("bundled web-endpoint plugin", () => { - it("ships a directory with a manifest and both entry points", () => { - const dir = path.join(getBundledPluginsDir(), "web-endpoint"); - - expect(fs.existsSync(path.join(dir, "manifest.json"))).toBe(true); - expect(fs.existsSync(path.join(dir, "backend", "index.mjs"))).toBe(true); - expect(fs.existsSync(path.join(dir, "frontend", "index.mjs"))).toBe(true); - }); - - it("has a manifest that passes the real validator", () => { - const { manifest, errors } = parseManifest( - readBundledManifest("web-endpoint"), - ); - - expect(errors).toEqual([]); - expect(manifest?.id).toBe("web-endpoint"); - expect(manifest?.category).toBe("Networking"); - }); - - it("declares the transport-owner capability and qualifies for the tier", () => { - const { manifest } = parseManifest(readBundledManifest("web-endpoint")); - - expect(manifest?.permissions).toContain(TRANSPORT_OWNER_CAPABILITY); - expect(isFirstParty("web-endpoint")).toBe(true); - expect(runsInProcess("web-endpoint", manifest!.permissions)).toBe(true); - }); - - // The column this points at stays on core's ssh_data table, so a typo here - // would silently detach the host-editor checkbox from its storage. - it("contributes the enableWebUi host capability", () => { - const { manifest } = parseManifest(readBundledManifest("web-endpoint")); - - expect(manifest?.contributes?.hostCapability).toMatchObject({ - key: "enableWebUi", - }); - }); - - it("exports activate and deactivate from its backend entry", () => { - const entry = path.join( - getBundledPluginsDir(), - "web-endpoint", - "backend", - "index.mjs", - ); - const source = fs.readFileSync(entry, "utf8"); - - expect(source).toMatch(/export async function activate/); - expect(source).toMatch(/export async function deactivate/); + expect(code).not.toMatch(/process\.on\(\s*["']SIG/); + expect(code).not.toMatch(/process\.exit\(/); + } }); }); diff --git a/src/backend/tests/plugins/capabilities.test.ts b/src/backend/tests/plugins/capabilities.test.ts new file mode 100644 index 000000000..d2a1ff362 --- /dev/null +++ b/src/backend/tests/plugins/capabilities.test.ts @@ -0,0 +1,234 @@ +/** + * The capability gate. + * + * Two conditions, both required: the manifest declares it and it is granted. + * Either missing is a refusal, and both the refusal and the allowed call are + * audited with the plugin id and the acting user, because "who did this" + * being answerable is the point of the gate. + */ + +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const grants = new Map(); +const auditEntries: Array> = []; +const kvStore = new Map(); + +vi.mock("../../database/repositories/factory.js", () => ({ + createCurrentPluginPermissionGrantRepository: () => ({ + listByPlugin: async (pluginId: string) => + (grants.get(pluginId) ?? []).map((capability) => ({ + pluginId, + capability, + })), + }), + createCurrentPluginStorageRepository: () => ({ + get: async (pluginId: string, key: string) => + kvStore.get(`${pluginId}:${key}`) ?? null, + set: async (pluginId: string, key: string, value: string) => { + kvStore.set(`${pluginId}:${key}`, value); + }, + delete: async (pluginId: string, key: string) => + kvStore.delete(`${pluginId}:${key}`), + listKeys: async () => [...kvStore.keys()], + }), +})); + +vi.mock("../../utils/audit-logger.js", () => ({ + logAudit: async (entry: Record) => { + auditEntries.push(entry); + }, +})); + +const { createPluginContext, createPluginHandle } = + await import("../../plugins/ctx.js"); +const { invalidatePluginPermissionCache } = + await import("../../plugins/permissions.js"); +const { runAsActor } = await import("../../plugins/actor.js"); + +function manifest(capabilities: string[]) { + return { + id: "sample-plugin", + name: "Sample Plugin", + version: "1.0.0", + description: "", + author: { name: "Termix Tests" }, + license: "MIT", + category: "Productivity", + engine: { termix: ">=2.9.0", api: "1" }, + capabilities, + } as never; +} + +function context(declared: string[]) { + const value = manifest(declared); + const handle = createPluginHandle("sample-plugin", { + activate: () => {}, + }); + return createPluginContext(value, handle); +} + +beforeEach(() => { + grants.clear(); + auditEntries.length = 0; + kvStore.clear(); + invalidatePluginPermissionCache(); +}); + +describe("capability gate", () => { + it("denies a capability the manifest never declared", async () => { + // Granted in the database, but absent from the manifest. Widening a + // plugin's reach must always require a new manifest the user can see. + grants.set("sample-plugin", ["kv:own"]); + const ctx = context([]); + + await expect(ctx.kv.get("anything")).rejects.toThrow( + /not granted the "kv:own" capability/, + ); + }); + + it("denies a declared capability that has not been granted", async () => { + const ctx = context(["kv:own"]); + + await expect(ctx.kv.get("anything")).rejects.toThrow( + /not granted the "kv:own" capability/, + ); + }); + + it("allows a capability that is both declared and granted", async () => { + grants.set("sample-plugin", ["kv:own"]); + const ctx = context(["kv:own"]); + + await ctx.kv.set("colour", "blue"); + + await expect(ctx.kv.get("colour")).resolves.toBe("blue"); + }); + + it("audits an allowed call with the plugin id and the actor", async () => { + grants.set("sample-plugin", ["kv:own"]); + const ctx = context(["kv:own"]); + + await runAsActor("user-42", "request", () => ctx.kv.set("k", 1)); + + const entry = auditEntries.find((e) => e.action === "plugin_kv_set"); + expect(entry).toMatchObject({ + userId: "user-42", + username: "plugin:sample-plugin", + resourceType: "plugin", + resourceId: "sample-plugin", + success: true, + }); + }); + + // A refused call is exactly the thing an operator wants to find later. + it("audits a denied call as a failure", async () => { + const ctx = context(["kv:own"]); + + await expect( + runAsActor("user-42", "request", () => ctx.kv.get("k")), + ).rejects.toThrow(); + + const entry = auditEntries.find((e) => e.action === "plugin_kv_get"); + expect(entry).toMatchObject({ userId: "user-42", success: false }); + expect(String(entry?.errorMessage)).toMatch(/not granted/); + }); + + it("picks up a grant made after the first denial", async () => { + const ctx = context(["kv:own"]); + await expect(ctx.kv.get("k")).rejects.toThrow(); + + grants.set("sample-plugin", ["kv:own"]); + invalidatePluginPermissionCache("sample-plugin"); + + await expect(ctx.kv.get("k")).resolves.toBeNull(); + }); +}); + +describe("ctx.asUser", () => { + it("makes the named user the actor and audits the switch", async () => { + grants.set("sample-plugin", ["kv:own"]); + const ctx = context(["kv:own"]); + + let seen: string | undefined; + await ctx.asUser("user-7", async () => { + seen = ctx.currentActor(); + await ctx.kv.set("k", 1); + }); + + expect(seen).toBe("user-7"); + + expect( + auditEntries.find((e) => e.action === "plugin_as_user"), + ).toMatchObject({ resourceId: "sample-plugin", success: true }); + + expect( + auditEntries.find((e) => e.action === "plugin_kv_set"), + ).toMatchObject({ userId: "user-7" }); + }); + + it("restores the previous actor afterwards", async () => { + const ctx = context([]); + + await runAsActor("outer-user", "request", async () => { + await ctx.asUser("inner-user", async () => { + expect(ctx.currentActor()).toBe("inner-user"); + }); + expect(ctx.currentActor()).toBe("outer-user"); + }); + }); + + it("refuses an empty user id", async () => { + const ctx = context([]); + + await expect(ctx.asUser("", async () => {})).rejects.toThrow( + /without a user id/, + ); + }); +}); + +describe("ctx.events namespacing", () => { + it("refuses a core topic without events:core", () => { + const ctx = context(["kv:own"]); + + // Without this a plugin could publish host.status and drive the + // automations engine as though core had. + expect(() => ctx.events.emit("host.status", {})).toThrow( + /may only emit topics under/, + ); + }); + + it("allows the plugin's own namespace", () => { + const ctx = context(["kv:own"]); + + expect(() => + ctx.events.emit("plugin.sample-plugin.thing", {}), + ).not.toThrow(); + }); + + it("allows a core topic when events:core is declared", () => { + const ctx = context(["events:core"]); + + expect(() => ctx.events.emit("host.status", {})).not.toThrow(); + }); +}); + +describe("ctx.kv limits", () => { + beforeEach(() => { + grants.set("sample-plugin", ["kv:own"]); + }); + + it("rejects an over-long key", async () => { + const ctx = context(["kv:own"]); + + await expect(ctx.kv.set("k".repeat(129), 1)).rejects.toThrow( + /at most 128 characters/, + ); + }); + + it("rejects a value over the size cap", async () => { + const ctx = context(["kv:own"]); + + await expect(ctx.kv.set("big", "x".repeat(300_000))).rejects.toThrow( + /byte limit/, + ); + }); +}); diff --git a/src/backend/tests/plugins/credential-isolation.test.ts b/src/backend/tests/plugins/credential-isolation.test.ts deleted file mode 100644 index 8f3afd3ef..000000000 --- a/src/backend/tests/plugins/credential-isolation.test.ts +++ /dev/null @@ -1,365 +0,0 @@ -/** - * The security test for the plugin runtime. - * - * The claim under test: a plugin worker cannot obtain plaintext credential - * material through ANY ctx method. Rather than assert field-by-field, this - * plants a unique sentinel string in every secret-bearing field of the resolved - * host and then asserts that the sentinel appears in nothing the worker ever - * receives -- return values, thrown error messages, or the ctx object itself. - * - * Every message crossing the boundary is recorded, so the assertion covers the - * wire, not just what the plugin happened to look at. - */ - -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { createFixturePlugin, type Fixture } from "./fixture-plugin.js"; - -const SENTINEL = "SENT1NEL-plaintext-credential-do-not-leak"; - -const state = vi.hoisted(() => ({ - grants: [] as { pluginId: string; capability: string }[], - /** Every message the main thread sent to a worker. */ - outbound: [] as unknown[], -})); - -vi.mock("../../utils/logger.js", () => ({ - pluginLogger: { - debug: vi.fn(), - info: vi.fn(), - warn: vi.fn(), - error: vi.fn(), - success: vi.fn(), - }, -})); - -vi.mock("../../utils/audit-logger.js", () => ({ - logAudit: vi.fn(async () => {}), - getAuditUsername: vi.fn(async (userId: string) => `user:${userId}`), -})); - -vi.mock("../../database/repositories/factory.js", () => ({ - createCurrentPluginPermissionGrantRepository: () => ({ - listByPlugin: async (pluginId: string) => - state.grants.filter((g) => g.pluginId === pluginId), - }), -})); - -const { PluginBroker } = await import("../../plugins/broker.js"); -const { PluginLoader } = await import("../../plugins/loader.js"); -const { invalidatePluginPermissionCache } = - await import("../../plugins/permissions.js"); - -const WORKER_TIMEOUT = 30_000; - -/** A host exactly as resolveHostById hands it over: secrets in the clear. */ -const RESOLVED_HOST = { - id: 42, - name: "prod-box", - ip: "10.0.0.5", - port: 22, - username: "root", - folder: "Production", - tags: ["prod"], - authType: "password", - enableTerminal: true, - enableTunnel: true, - enableFileManager: true, - createdAt: "2026-01-01T00:00:00Z", - updatedAt: "2026-01-02T00:00:00Z", - password: SENTINEL, - key: SENTINEL, - keyPassword: SENTINEL, - certPublicKey: SENTINEL, - sudoPassword: SENTINEL, - autostartPassword: SENTINEL, - autostartKey: SENTINEL, - autostartKeyPassword: SENTINEL, - credentialId: 7, - vaultProfileId: 3, -}; - -function deepContains(value: unknown, needle: string): boolean { - if (typeof value === "string") return value.includes(needle); - if (value === null || typeof value !== "object") return false; - return Object.values(value as Record).some((inner) => - deepContains(inner, needle), - ); -} - -describe("plugin credential isolation", () => { - const fixtures: Fixture[] = []; - let loader: InstanceType | null = null; - let broker: InstanceType | null = null; - - beforeEach(() => { - state.grants = [ - { pluginId: "sample-plugin", capability: "hosts.read" }, - { pluginId: "sample-plugin", capability: "ssh.exec" }, - { pluginId: "sample-plugin", capability: "storage.own" }, - ]; - state.outbound = []; - invalidatePluginPermissionCache(); - }); - - afterEach(async () => { - await loader?.shutdown(); - loader = null; - broker = null; - while (fixtures.length) fixtures.pop()!.cleanup(); - }); - - /** Boots a plugin whose activate() runs `body` and reports the result. */ - async function attack(body: string, sshOpenFails = false) { - const fixture = createFixturePlugin({ - permissions: ["hosts.read", "ssh.exec", "storage.own"], - backendSource: ` - export async function activate(ctx) { - let result; - try { - result = { ok: true, value: await (async () => { ${body} })() }; - } catch (error) { - result = { ok: false, error: String(error && error.message) }; - } - await ctx.log.info(JSON.stringify(result)); - } - `, - }); - fixtures.push(fixture); - - broker = new PluginBroker({ - listHosts: async () => [RESOLVED_HOST], - resolveHost: async (hostId) => (hostId === 42 ? RESOLVED_HOST : null), - ssh: { - open: async () => { - if (sshOpenFails) { - // A failing connect must not spill the credential into the error - // message either: this mirrors ssh2 embedding config on failure. - throw new Error( - `Authentication failed for root using password ${SENTINEL}`, - ); - } - return { - release: () => {}, - exec: async () => ({ - stdout: "uptime: 4 days", - stderr: "", - code: 0, - }), - }; - }, - }, - }); - - const logged: string[] = []; - const { pluginLogger } = await import("../../utils/logger.js"); - vi.mocked(pluginLogger.info).mockImplementation((message: string) => { - logged.push(message); - }); - - loader = new PluginLoader({ - onWorkerReady: (plugin, worker) => { - // Record everything the main thread pushes at the worker. - const original = worker.postMessage.bind(worker); - worker.postMessage = ((message: unknown) => { - state.outbound.push(message); - return original(message); - }) as typeof worker.postMessage; - - broker!.attach(plugin, worker); - }, - onWorkerGone: (plugin) => broker!.detach(plugin.id), - }); - - await loader.load(fixture.dir); - await loader.activate("sample-plugin", "user-1"); - - const line = logged.find((entry) => entry.startsWith("{")); - return line ? JSON.parse(line) : { ok: false, error: "no result" }; - } - - it( - "ctx.hosts.get returns no credential material", - async () => { - const result = await attack(`return await ctx.hosts.get(42);`); - - expect(result.ok).toBe(true); - expect(deepContains(result.value, SENTINEL)).toBe(false); - for (const field of [ - "password", - "key", - "keyPassword", - "certPublicKey", - "sudoPassword", - "autostartPassword", - "autostartKey", - "autostartKeyPassword", - "credentialId", - "vaultProfileId", - ]) { - expect(Object.keys(result.value)).not.toContain(field); - } - }, - WORKER_TIMEOUT, - ); - - it( - "ctx.hosts.list returns no credential material", - async () => { - const result = await attack(`return await ctx.hosts.list();`); - - expect(result.ok).toBe(true); - expect(deepContains(result.value, SENTINEL)).toBe(false); - }, - WORKER_TIMEOUT, - ); - - it( - "ctx.ssh.connect returns an opaque handle, not a connection", - async () => { - const result = await attack(` - const handle = await ctx.ssh.connect(42); - return { - handle, - type: typeof handle, - // Anything reachable from the handle, if it were an object. - reachable: typeof handle === "object" ? Object.keys(handle) : null, - }; - `); - - expect(result.ok).toBe(true); - expect(result.value.type).toBe("string"); - expect(result.value.handle).toMatch(/^sshconn:/); - expect(result.value.handle).not.toContain(SENTINEL); - // The handle is random, so it cannot encode the host or its secrets. - expect(result.value.handle).not.toContain("10.0.0.5"); - expect(result.value.handle).not.toContain("root"); - }, - WORKER_TIMEOUT, - ); - - it( - "ctx.ssh.exec returns only stdout, stderr and code", - async () => { - const result = await attack(` - const handle = await ctx.ssh.connect(42); - return await ctx.ssh.exec(handle, "uptime"); - `); - - expect(result.ok).toBe(true); - expect(Object.keys(result.value).sort()).toEqual([ - "code", - "stderr", - "stdout", - ]); - expect(deepContains(result.value, SENTINEL)).toBe(false); - }, - WORKER_TIMEOUT, - ); - - it( - "a failing ctx.ssh.connect does not leak the credential in its error", - async () => { - const result = await attack(`return await ctx.ssh.connect(42);`, true); - - expect(result.ok).toBe(false); - expect(result.error).not.toContain(SENTINEL); - }, - WORKER_TIMEOUT, - ); - - it( - "a plugin cannot walk ctx itself to a credential", - async () => { - const result = await attack(` - const seen = new Set(); - const found = []; - (function walk(value, path, depth) { - if (depth > 6 || value === null || seen.has(value)) return; - if (typeof value === "string") { - if (value.includes(${JSON.stringify(SENTINEL)})) found.push(path); - return; - } - if (typeof value !== "object" && typeof value !== "function") return; - seen.add(value); - for (const key of Object.getOwnPropertyNames(value)) { - let inner; - try { inner = value[key]; } catch { continue; } - walk(inner, path + "." + key, depth + 1); - } - })(ctx, "ctx", 0); - return found; - `); - - expect(result.ok).toBe(true); - expect(result.value).toEqual([]); - }, - WORKER_TIMEOUT, - ); - - it( - "no message sent to the worker ever contains credential material", - async () => { - await attack(` - const host = await ctx.hosts.get(42); - const list = await ctx.hosts.list(); - const handle = await ctx.ssh.connect(42); - await ctx.ssh.exec(handle, "uptime"); - await ctx.storage.set("cache", { host, list }); - return await ctx.storage.get("cache"); - `); - - expect(state.outbound.length).toBeGreaterThan(0); - const leaking = state.outbound.filter((message) => - deepContains(message, SENTINEL), - ); - expect(leaking).toEqual([]); - }, - WORKER_TIMEOUT, - ); - - it( - "a plugin cannot exec on a handle it did not open", - async () => { - const result = await attack(` - return await ctx.ssh.exec("sshconn:00000000-0000-0000-0000-000000000000", "id"); - `); - - expect(result.ok).toBe(false); - expect(result.error).toContain("Unknown or closed SSH connection handle"); - }, - WORKER_TIMEOUT, - ); - - it( - "a closed handle stops working", - async () => { - const result = await attack(` - const handle = await ctx.ssh.connect(42); - await ctx.ssh.close(handle); - try { - await ctx.ssh.exec(handle, "id"); - return "still worked"; - } catch (error) { - return error.message; - } - `); - - expect(result.value).toContain("Unknown or closed SSH connection handle"); - }, - WORKER_TIMEOUT, - ); - - it( - "ssh.connect is denied without the granted capability", - async () => { - state.grants = [{ pluginId: "sample-plugin", capability: "hosts.read" }]; - invalidatePluginPermissionCache(); - - const result = await attack(`return await ctx.ssh.connect(42);`); - - expect(result.ok).toBe(false); - expect(result.error).toContain('is not granted the "ssh.exec"'); - }, - WORKER_TIMEOUT, - ); -}); diff --git a/src/backend/tests/plugins/disable-isolation.test.ts b/src/backend/tests/plugins/disable-isolation.test.ts index a682cdc7c..ae4262541 100644 --- a/src/backend/tests/plugins/disable-isolation.test.ts +++ b/src/backend/tests/plugins/disable-isolation.test.ts @@ -1,17 +1,18 @@ /** - * The central promise of shipping the terminal as a plugin: disabling it - * releases what it owns, and nothing else notices. + * Disabling a plugin releases what it owns. * - * The SSH connection pool deliberately stayed in core, so a worker plugin's - * ctx.ssh keeps working with the terminal disabled. If the pool had moved into - * the terminal plugin, these tests would fail, which is the point of them. + * A plugin that keeps a port after deactivate makes "disabled" a lie and + * makes re-enabling fail on a port that is still held, which is exactly the + * bug the docker console had: its WebSocket server was created at module + * scope, so it survived deactivate and the second activate got nothing. + * + * These use a real listening socket rather than a mock, because the thing + * under test is whether the OS still has the port. */ import { afterEach, describe, expect, it, vi } from "vitest"; import net from "node:net"; import { PluginLoader } from "../../plugins/loader.js"; -import { PluginBroker } from "../../plugins/broker.js"; -import { TRANSPORT_OWNER_CAPABILITY } from "../../plugins/first-party.js"; import { clearRegistry } from "../../plugins/registry.js"; import { createFixturePlugin, type Fixture } from "./fixture-plugin.js"; @@ -28,7 +29,7 @@ vi.mock("../../utils/logger.js", () => ({ vi.mock("../../plugins/permissions.js", () => ({ assertCapability: vi.fn().mockResolvedValue(undefined), hasCapability: vi.fn().mockResolvedValue(true), - PluginPermissionError: class extends Error {}, + PluginCapabilityError: class extends Error {}, invalidatePluginPermissionCache: vi.fn(), })); @@ -45,136 +46,83 @@ function isListening(port: number): Promise { }); } -/** Stands in for the terminal: a first-party plugin that owns a real port. */ -function terminalLikeSource(port: number): string { +/** + * A plugin that owns a real port, the way ssh-terminal and docker do. + * + * The server is created inside activate and closed through a disposable, not + * at module scope, which is the rule the runtime documents. + */ +function portOwningSource(port: number): string { return ` -import net from "node:net"; -let server = null; -export async function activate(ctx) { - server = net.createServer(); - await new Promise((resolve) => server.listen(${port}, "127.0.0.1", resolve)); - ctx.registry.provide("terminal.sessions", { live: true }); -} -export async function deactivate() { - if (!server) return; - await new Promise((resolve) => server.close(resolve)); - server = null; -} -`; + import net from "node:net"; + + export async function activate(ctx) { + const server = net.createServer(); + await new Promise((resolve) => server.listen(${port}, "127.0.0.1", resolve)); + ctx.disposables.add( + () => new Promise((resolve) => server.close(() => resolve())), + ); + } + `; } -describe("disabling the terminal plugin", () => { - const fixtures: Fixture[] = []; - let loader: PluginLoader | null = null; +let fixture: Fixture | null = null; +let loader: PluginLoader | null = null; - afterEach(async () => { - await loader?.shutdown(); - loader = null; - for (const fixture of fixtures.splice(0)) fixture.cleanup(); - clearRegistry(); - }); +afterEach(async () => { + await loader?.shutdown(); + loader = null; + fixture?.cleanup(); + fixture = null; + clearRegistry(); + delete process.env.TERMIX_BUNDLED_PLUGINS_DIR; + delete process.env.DATA_DIR; +}); - it("frees its port and leaves another plugin's ctx.ssh working", async () => { - const terminal = createFixturePlugin({ - id: "ssh-terminal", - permissions: [TRANSPORT_OWNER_CAPABILITY], - manifestOverrides: { category: "Terminal" }, - backendSource: terminalLikeSource(TEST_PORT), +describe("disabling a plugin releases its resources", () => { + async function start(): Promise { + fixture = createFixturePlugin({ + backendSource: portOwningSource(TEST_PORT), }); - fixtures.push(terminal); + process.env.TERMIX_BUNDLED_PLUGINS_DIR = fixture.root; + process.env.DATA_DIR = fixture.root; - const other = createFixturePlugin({ - id: "other-plugin", - permissions: ["ssh.exec"], - // Exposes an HTTP route the test invokes after the terminal is - // disabled, so the ctx.ssh round trip genuinely happens afterwards and - // travels the real worker boundary. - backendSource: ` -export async function activate(ctx) { - await ctx.http.route("GET", "/run", async () => { - const handle = await ctx.ssh.connect(1); - const result = await ctx.ssh.exec(handle, "uptime"); - await ctx.ssh.close(handle); - return result; - }); -} -`, - }); - fixtures.push(other); - - // Stands in for the core pool. The terminal plugin does not provide it, - // so disabling the terminal must not affect it. - const open = vi.fn().mockResolvedValue({ - release: vi.fn(), - exec: vi - .fn() - .mockResolvedValue({ stdout: "up 3 days", stderr: "", code: 0 }), - }); - - const broker = new PluginBroker({ - listHosts: async () => [], - resolveHost: async () => null, - ssh: { open }, - }); - - loader = new PluginLoader({ - onWorkerReady: (plugin, worker) => broker.attach(plugin, worker), - onWorkerGone: (plugin) => broker.detach(plugin.id), - }); - - await loader.load(terminal.dir); - await loader.load(other.dir); - await loader.activate("ssh-terminal"); - await loader.activate("other-plugin", "user-1"); + const instance = new PluginLoader(); + await instance.loadAll(); + await instance.activate(fixture.id); + loader = instance; + return instance; + } + it("frees the port the plugin was listening on", async () => { + const instance = await start(); expect(await isListening(TEST_PORT)).toBe(true); - await loader.deactivate("ssh-terminal"); + await instance.deactivate(fixture!.id); - // What the user sees: the terminal's port is gone. expect(await isListening(TEST_PORT)).toBe(false); - expect(loader.get("ssh-terminal")?.state).toBe("stopped"); - - // And the other plugin is untouched, still running off the core pool. - const otherPlugin = loader.get("other-plugin"); - expect(otherPlugin?.state).toBe("active"); - - // The real test: a ctx.ssh round trip completed AFTER the terminal was - // disabled, off the core pool, across the worker boundary. - const runtime = broker.get("other-plugin")!; - const result = (await broker.invokeRoute(runtime, "GET /run", { - method: "GET", - path: "/run", - params: {}, - query: {}, - body: null, - headers: {}, - userId: "user-1", - })) as { stdout: string }; - - expect(open).toHaveBeenCalled(); - expect(result.stdout).toBe("up 3 days"); }); - it("can be re-enabled after being disabled", async () => { - const terminal = createFixturePlugin({ - id: "ssh-terminal", - permissions: [TRANSPORT_OWNER_CAPABILITY], - manifestOverrides: { category: "Terminal" }, - backendSource: terminalLikeSource(TEST_PORT + 1), - }); - fixtures.push(terminal); + // The regression the docker console hit: the module stays in the ESM cache, + // so the second activate runs against it and has to be able to bind again. + it("can take the port again after being re-enabled", async () => { + const instance = await start(); - loader = new PluginLoader(); - await loader.load(terminal.dir); + await instance.deactivate(fixture!.id); + expect(await isListening(TEST_PORT)).toBe(false); - await loader.activate("ssh-terminal"); - expect(await isListening(TEST_PORT + 1)).toBe(true); + await instance.activate(fixture!.id); - await loader.deactivate("ssh-terminal"); - expect(await isListening(TEST_PORT + 1)).toBe(false); + expect(await isListening(TEST_PORT)).toBe(true); + expect(instance.get(fixture!.id)?.state).toBe("active"); + }); - await loader.activate("ssh-terminal"); - expect(await isListening(TEST_PORT + 1)).toBe(true); + it("frees the port on shutdown too", async () => { + const instance = await start(); + + await instance.shutdown(); + loader = null; + + expect(await isListening(TEST_PORT)).toBe(false); }); }); diff --git a/src/backend/tests/plugins/first-party.test.ts b/src/backend/tests/plugins/first-party.test.ts deleted file mode 100644 index b12527874..000000000 --- a/src/backend/tests/plugins/first-party.test.ts +++ /dev/null @@ -1,279 +0,0 @@ -/** - * The first-party tier is the one place in the plugin system where code runs - * without the worker boundary, so the thing most worth testing is that it - * cannot be entered by a plugin that says it should be. - */ - -import { describe, expect, it } from "vitest"; -import { - FIRST_PARTY_PLUGIN_IDS, - isFirstParty, - runsInProcess, - TRANSPORT_OWNER_CAPABILITY, -} from "../../plugins/first-party.js"; -import { - parseManifest, - SUPPORTED_PLUGIN_API_VERSION, -} from "../../plugins/manifest.js"; - -function manifest(overrides: Record = {}) { - return { - id: "sample-plugin", - name: "Sample Plugin", - version: "1.0.0", - description: "A plugin.", - author: { name: "Someone" }, - license: "MIT", - category: "Productivity", - engine: { termix: ">=2.9.0", api: SUPPORTED_PLUGIN_API_VERSION }, - capabilities: { - backend: true, - frontend: false, - electron: false, - platforms: ["linux"], - }, - permissions: [] as string[], - sidecars: [], - ...overrides, - }; -} - -describe("first-party allowlist", () => { - it("contains only ssh-terminal, docker, host-metrics, ai, proxmox, remote-desktop, fleets, automations, network-topology, tailscale, workspaces and web-endpoint", () => { - expect([...FIRST_PARTY_PLUGIN_IDS].sort()).toEqual([ - "ai", - "automations", - "docker", - "fleets", - "host-metrics", - "network-topology", - "proxmox", - "remote-desktop", - "ssh-terminal", - "tailscale", - "web-endpoint", - "workspaces", - ]); - }); - - it("recognises ssh-terminal, docker, host-metrics, ai, proxmox, remote-desktop, fleets, automations, network-topology, tailscale, workspaces and web-endpoint and nothing else", () => { - expect(isFirstParty("ssh-terminal")).toBe(true); - expect(isFirstParty("docker")).toBe(true); - expect(isFirstParty("host-metrics")).toBe(true); - expect(isFirstParty("ai")).toBe(true); - expect(isFirstParty("proxmox")).toBe(true); - expect(isFirstParty("remote-desktop")).toBe(true); - expect(isFirstParty("fleets")).toBe(true); - expect(isFirstParty("automations")).toBe(true); - expect(isFirstParty("network-topology")).toBe(true); - expect(isFirstParty("tailscale")).toBe(true); - expect(isFirstParty("workspaces")).toBe(true); - expect(isFirstParty("web-endpoint")).toBe(true); - expect(isFirstParty("ssh-terminal-pro")).toBe(false); - expect(isFirstParty("community-plugin")).toBe(false); - expect(isFirstParty("")).toBe(false); - }); -}); - -describe("runsInProcess", () => { - it("requires both the allowlist and the declared capability", () => { - expect(runsInProcess("ssh-terminal", [TRANSPORT_OWNER_CAPABILITY])).toBe( - true, - ); - expect(runsInProcess("docker", [TRANSPORT_OWNER_CAPABILITY])).toBe(true); - expect(runsInProcess("host-metrics", [TRANSPORT_OWNER_CAPABILITY])).toBe( - true, - ); - expect(runsInProcess("ai", [TRANSPORT_OWNER_CAPABILITY])).toBe(true); - expect(runsInProcess("proxmox", [TRANSPORT_OWNER_CAPABILITY])).toBe(true); - expect(runsInProcess("remote-desktop", [TRANSPORT_OWNER_CAPABILITY])).toBe( - true, - ); - expect(runsInProcess("fleets", [TRANSPORT_OWNER_CAPABILITY])).toBe(true); - expect(runsInProcess("automations", [TRANSPORT_OWNER_CAPABILITY])).toBe( - true, - ); - expect( - runsInProcess("network-topology", [TRANSPORT_OWNER_CAPABILITY]), - ).toBe(true); - expect(runsInProcess("tailscale", [TRANSPORT_OWNER_CAPABILITY])).toBe(true); - expect(runsInProcess("workspaces", [TRANSPORT_OWNER_CAPABILITY])).toBe( - true, - ); - expect(runsInProcess("web-endpoint", [TRANSPORT_OWNER_CAPABILITY])).toBe( - true, - ); - - // On the list but not asking for it: stays in a worker, so the manifest - // remains an honest description of what the plugin does. - expect(runsInProcess("ssh-terminal", ["hosts.read"])).toBe(false); - expect(runsInProcess("docker", ["hosts.read"])).toBe(false); - expect(runsInProcess("host-metrics", ["hosts.read"])).toBe(false); - expect(runsInProcess("ai", ["hosts.read"])).toBe(false); - expect(runsInProcess("proxmox", ["hosts.read"])).toBe(false); - expect(runsInProcess("remote-desktop", ["hosts.read"])).toBe(false); - expect(runsInProcess("fleets", ["hosts.read"])).toBe(false); - expect(runsInProcess("automations", ["hosts.read"])).toBe(false); - expect(runsInProcess("network-topology", ["hosts.read"])).toBe(false); - expect(runsInProcess("tailscale", ["hosts.read"])).toBe(false); - expect(runsInProcess("workspaces", ["hosts.read"])).toBe(false); - expect(runsInProcess("web-endpoint", ["hosts.read"])).toBe(false); - - // Asking for it but not on the list. - expect( - runsInProcess("community-plugin", [TRANSPORT_OWNER_CAPABILITY]), - ).toBe(false); - - expect(runsInProcess("community-plugin", ["hosts.read"])).toBe(false); - }); -}); - -describe("manifest gate on the reserved capability", () => { - it("refuses the capability for a plugin that is not first-party", () => { - const { manifest: parsed, errors } = parseManifest( - manifest({ permissions: [TRANSPORT_OWNER_CAPABILITY] }), - ); - - expect(parsed).toBeUndefined(); - expect(errors).toHaveLength(1); - expect(errors[0]).toContain(TRANSPORT_OWNER_CAPABILITY); - expect(errors[0]).toContain("reserved for first-party plugins"); - }); - - it("refuses it even when mixed in with ordinary permissions", () => { - const { manifest: parsed } = parseManifest( - manifest({ - permissions: ["hosts.read", TRANSPORT_OWNER_CAPABILITY, "storage.own"], - }), - ); - expect(parsed).toBeUndefined(); - }); - - it("allows it for ssh-terminal", () => { - const { manifest: parsed, errors } = parseManifest( - manifest({ - id: "ssh-terminal", - permissions: [TRANSPORT_OWNER_CAPABILITY], - }), - ); - - expect(errors).toEqual([]); - expect(parsed?.id).toBe("ssh-terminal"); - }); - - it("allows it for docker", () => { - const { manifest: parsed, errors } = parseManifest( - manifest({ - id: "docker", - permissions: [TRANSPORT_OWNER_CAPABILITY], - }), - ); - - expect(errors).toEqual([]); - expect(parsed?.id).toBe("docker"); - }); - - it("allows it for host-metrics", () => { - const { manifest: parsed, errors } = parseManifest( - manifest({ - id: "host-metrics", - permissions: [TRANSPORT_OWNER_CAPABILITY], - }), - ); - - expect(errors).toEqual([]); - expect(parsed?.id).toBe("host-metrics"); - }); - - it("allows it for ai", () => { - const { manifest: parsed, errors } = parseManifest( - manifest({ - id: "ai", - permissions: [TRANSPORT_OWNER_CAPABILITY], - }), - ); - - expect(errors).toEqual([]); - expect(parsed?.id).toBe("ai"); - }); - - it("allows it for proxmox", () => { - const { manifest: parsed, errors } = parseManifest( - manifest({ - id: "proxmox", - permissions: [TRANSPORT_OWNER_CAPABILITY], - }), - ); - - expect(errors).toEqual([]); - expect(parsed?.id).toBe("proxmox"); - }); - - it("allows it for automations", () => { - const { manifest: parsed, errors } = parseManifest( - manifest({ - id: "automations", - permissions: [TRANSPORT_OWNER_CAPABILITY], - }), - ); - - expect(errors).toEqual([]); - expect(parsed?.id).toBe("automations"); - }); - - it("allows it for network-topology", () => { - const { manifest: parsed, errors } = parseManifest( - manifest({ - id: "network-topology", - permissions: [TRANSPORT_OWNER_CAPABILITY], - }), - ); - - expect(errors).toEqual([]); - expect(parsed?.id).toBe("network-topology"); - }); - - it("allows it for tailscale", () => { - const { manifest: parsed, errors } = parseManifest( - manifest({ - id: "tailscale", - permissions: [TRANSPORT_OWNER_CAPABILITY], - }), - ); - - expect(errors).toEqual([]); - expect(parsed?.id).toBe("tailscale"); - }); - - it("allows it for workspaces", () => { - const { manifest: parsed, errors } = parseManifest( - manifest({ - id: "workspaces", - permissions: [TRANSPORT_OWNER_CAPABILITY], - }), - ); - - expect(errors).toEqual([]); - expect(parsed?.id).toBe("workspaces"); - }); - - it("allows it for web-endpoint", () => { - const { manifest: parsed, errors } = parseManifest( - manifest({ - id: "web-endpoint", - permissions: [TRANSPORT_OWNER_CAPABILITY], - }), - ); - - expect(errors).toEqual([]); - expect(parsed?.id).toBe("web-endpoint"); - }); - - it("still accepts an ordinary manifest that never mentions it", () => { - const { manifest: parsed, errors } = parseManifest( - manifest({ permissions: ["hosts.read"] }), - ); - expect(errors).toEqual([]); - expect(parsed?.id).toBe("sample-plugin"); - }); -}); diff --git a/src/backend/tests/plugins/fixture-plugin.ts b/src/backend/tests/plugins/fixture-plugin.ts index 075406327..ff1341b27 100644 --- a/src/backend/tests/plugins/fixture-plugin.ts +++ b/src/backend/tests/plugins/fixture-plugin.ts @@ -1,7 +1,7 @@ /** - * Builds a real unpacked plugin on disk for the loader/broker tests. + * Builds a real unpacked plugin on disk for the runtime tests. * - * The backend entry has to be a genuine .mjs file the worker can import, and + * The backend entry has to be a genuine .mjs file the loader can import, and * tsc only emits .ts, so these are written at test time rather than committed. * Same mkdtemp convention the other disk-touching backend tests use. */ @@ -13,12 +13,15 @@ import { SUPPORTED_PLUGIN_API_VERSION } from "../../plugins/manifest.js"; export interface FixtureOptions { id?: string; - permissions?: string[]; + /** Catalog capability ids the manifest declares. */ + capabilities?: string[]; /** Body of backend/index.mjs. Must export activate(ctx). */ backendSource?: string; /** Omit the backend entry file even though the manifest declares one. */ omitBackendEntry?: boolean; manifestOverrides?: Record; + /** Write into this root instead of a fresh temp dir. */ + root?: string; } export interface Fixture { @@ -30,13 +33,14 @@ export interface Fixture { const DEFAULT_BACKEND = ` export async function activate(ctx) { - ctx.__ready = true; + ctx.log.info("fixture activated"); } `; export function createFixturePlugin(options: FixtureOptions = {}): Fixture { const id = options.id ?? "sample-plugin"; - const root = fs.mkdtempSync(path.join(os.tmpdir(), "termix-plugins-")); + const root = + options.root ?? fs.mkdtempSync(path.join(os.tmpdir(), "termix-plugins-")); const dir = path.join(root, id); fs.mkdirSync(path.join(dir, "backend"), { recursive: true }); @@ -50,14 +54,8 @@ export function createFixturePlugin(options: FixtureOptions = {}): Fixture { license: "MIT", category: "Productivity", engine: { termix: ">=2.9.0", api: SUPPORTED_PLUGIN_API_VERSION }, - capabilities: { - backend: true, - frontend: false, - electron: false, - platforms: ["linux", "win32", "darwin"], - }, - permissions: options.permissions ?? ["hosts.read", "storage.own"], - sidecars: [], + capabilities: options.capabilities ?? ["hosts:read", "kv:own"], + backend: "backend/index.mjs", ...options.manifestOverrides, }; diff --git a/src/backend/tests/plugins/http-bridge.test.ts b/src/backend/tests/plugins/http-bridge.test.ts deleted file mode 100644 index 4aa7f2a31..000000000 --- a/src/backend/tests/plugins/http-bridge.test.ts +++ /dev/null @@ -1,480 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import express from "express"; -import type { AddressInfo } from "node:net"; -import type { Server } from "node:http"; -import { createFixturePlugin, type Fixture } from "./fixture-plugin.js"; - -const state = vi.hoisted(() => ({ - grants: [] as { pluginId: string; capability: string }[], -})); - -vi.mock("../../utils/logger.js", () => ({ - pluginLogger: { - debug: vi.fn(), - info: vi.fn(), - warn: vi.fn(), - error: vi.fn(), - success: vi.fn(), - }, - databaseLogger: { - debug: vi.fn(), - info: vi.fn(), - warn: vi.fn(), - error: vi.fn(), - success: vi.fn(), - }, -})); - -vi.mock("../../utils/audit-logger.js", () => ({ - logAudit: vi.fn(async () => {}), - getAuditUsername: vi.fn(async (userId: string) => `user:${userId}`), -})); - -vi.mock("../../database/repositories/factory.js", () => ({ - createCurrentPluginPermissionGrantRepository: () => ({ - listByPlugin: async (pluginId: string) => - state.grants.filter((g) => g.pluginId === pluginId), - }), -})); - -const { PluginBroker } = await import("../../plugins/broker.js"); -const { PluginLoader } = await import("../../plugins/loader.js"); -const { buildPluginRouter } = await import("../../plugins/http-bridge.js"); -const pluginApi = await import("../../database/routes/plugin-api-routes.js"); -const { invalidatePluginPermissionCache } = - await import("../../plugins/permissions.js"); - -const WORKER_TIMEOUT = 30_000; - -describe("plugin HTTP bridge", () => { - const fixtures: Fixture[] = []; - let loader: InstanceType | null = null; - let broker: InstanceType | null = null; - let server: Server | null = null; - let baseUrl = ""; - - beforeEach(async () => { - state.grants = []; - - const app = express(); - app.use("/plugin-api", pluginApi.default); - - server = await new Promise((resolve) => { - const created = app.listen(0, "127.0.0.1", () => resolve(created)); - }); - baseUrl = `http://127.0.0.1:${(server.address() as AddressInfo).port}`; - }); - - afterEach(async () => { - await loader?.shutdown(); - loader = null; - broker = null; - - for (const id of pluginApi.getRegisteredPluginIds()) { - pluginApi.unregisterPluginRouter(id); - } - - await new Promise((resolve) => server?.close(() => resolve())); - server = null; - while (fixtures.length) fixtures.pop()!.cleanup(); - }); - - async function activate(backendSource: string) { - const fixture = createFixturePlugin({ backendSource }); - fixtures.push(fixture); - - broker = new PluginBroker({ - listHosts: async () => [], - resolveHost: async () => null, - onRoutesChanged: (runtime) => { - pluginApi.registerPluginRouter( - runtime.plugin.id, - buildPluginRouter(broker!, runtime), - ); - }, - }); - - loader = new PluginLoader({ - onWorkerReady: (plugin, worker) => broker!.attach(plugin, worker), - onWorkerGone: (plugin) => { - broker!.detach(plugin.id); - pluginApi.unregisterPluginRouter(plugin.id); - }, - }); - - await loader.load(fixture.dir); - await loader.activate("sample-plugin", "user-1"); - } - - it("404s for a plugin that is not running", async () => { - const res = await fetch(`${baseUrl}/plugin-api/ghost/anything`); - expect(res.status).toBe(404); - expect(await res.json()).toEqual({ - error: "Plugin not installed or not running", - }); - }); - - it( - "dispatches a GET to an active plugin's handler", - async () => { - await activate(` - export async function activate(ctx) { - await ctx.http.route("GET", "/ping", async () => ({ pong: true })); - } - `); - - const res = await fetch(`${baseUrl}/plugin-api/sample-plugin/ping`); - expect(res.status).toBe(200); - expect(await res.json()).toEqual({ pong: true }); - }, - WORKER_TIMEOUT, - ); - - it( - "passes the body, query and params through to the handler", - async () => { - await activate(` - export async function activate(ctx) { - await ctx.http.route("POST", "/echo/:id", async (req) => ({ - id: req.params.id, - q: req.query.q, - body: req.body, - method: req.method, - })); - } - `); - - const res = await fetch( - `${baseUrl}/plugin-api/sample-plugin/echo/7?q=hello`, - { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ name: "termix" }), - }, - ); - - expect(await res.json()).toEqual({ - id: "7", - q: "hello", - body: { name: "termix" }, - method: "POST", - }); - }, - WORKER_TIMEOUT, - ); - - it( - "honours an explicit status/body envelope", - async () => { - await activate(` - export async function activate(ctx) { - await ctx.http.route("GET", "/missing", async () => ({ - status: 404, - body: { error: "nope" }, - })); - } - `); - - const res = await fetch(`${baseUrl}/plugin-api/sample-plugin/missing`); - expect(res.status).toBe(404); - expect(await res.json()).toEqual({ error: "nope" }); - }, - WORKER_TIMEOUT, - ); - - it( - "treats a payload with a body field as the body, not an envelope", - async () => { - await activate(` - export async function activate(ctx) { - await ctx.http.route("GET", "/doc", async () => ({ - body: "the article text", - title: "A post", - })); - } - `); - - const res = await fetch(`${baseUrl}/plugin-api/sample-plugin/doc`); - expect(res.status).toBe(200); - expect(await res.json()).toEqual({ - body: "the article text", - title: "A post", - }); - }, - WORKER_TIMEOUT, - ); - - it( - "returns 502 when the plugin handler throws", - async () => { - await activate(` - export async function activate(ctx) { - await ctx.http.route("GET", "/boom", async () => { - throw new Error("handler exploded"); - }); - } - `); - - const res = await fetch(`${baseUrl}/plugin-api/sample-plugin/boom`); - expect(res.status).toBe(502); - expect((await res.json()).detail).toContain("handler exploded"); - }, - WORKER_TIMEOUT, - ); - - it( - "does not forward authorization headers or cookies to the plugin", - async () => { - await activate(` - export async function activate(ctx) { - await ctx.http.route("GET", "/headers", async (req) => ({ - seen: Object.keys(req.headers).sort(), - })); - } - `); - - const res = await fetch(`${baseUrl}/plugin-api/sample-plugin/headers`, { - headers: { - authorization: "Bearer super-secret-token", - cookie: "session=secret", - "content-type": "application/json", - }, - }); - - const seen = (await res.json()).seen as string[]; - expect(seen).not.toContain("authorization"); - expect(seen).not.toContain("cookie"); - expect(seen).toContain("content-type"); - }, - WORKER_TIMEOUT, - ); - - it( - "rejects an unsupported method and a path without a leading slash", - async () => { - const fixture = createFixturePlugin({ - backendSource: ` - export async function activate(ctx) { - const errors = []; - try { - await ctx.http.route("TRACE", "/x", () => null); - } catch (e) { errors.push(e.message); } - try { - await ctx.http.route("GET", "no-slash", () => null); - } catch (e) { errors.push(e.message); } - if (errors.length !== 2) throw new Error("expected two rejections"); - await ctx.http.route("GET", "/ok", async () => ({ errors })); - } - `, - }); - fixtures.push(fixture); - - broker = new PluginBroker({ - listHosts: async () => [], - resolveHost: async () => null, - onRoutesChanged: (runtime) => { - pluginApi.registerPluginRouter( - runtime.plugin.id, - buildPluginRouter(broker!, runtime), - ); - }, - }); - loader = new PluginLoader({ - onWorkerReady: (plugin, worker) => broker!.attach(plugin, worker), - onWorkerGone: (plugin) => broker!.detach(plugin.id), - }); - - await loader.load(fixture.dir); - await loader.activate("sample-plugin", "user-1"); - - const res = await fetch(`${baseUrl}/plugin-api/sample-plugin/ok`); - const errors = (await res.json()).errors as string[]; - expect(errors[0]).toContain("does not support the TRACE method"); - expect(errors[1]).toContain('must start with "/"'); - }, - WORKER_TIMEOUT, - ); - - it( - "404s again once the plugin is deactivated", - async () => { - await activate(` - export async function activate(ctx) { - await ctx.http.route("GET", "/ping", async () => ({ pong: true })); - } - `); - - expect( - (await fetch(`${baseUrl}/plugin-api/sample-plugin/ping`)).status, - ).toBe(200); - - await loader!.deactivate("sample-plugin"); - pluginApi.unregisterPluginRouter("sample-plugin"); - - const res = await fetch(`${baseUrl}/plugin-api/sample-plugin/ping`); - expect(res.status).toBe(404); - }, - WORKER_TIMEOUT, - ); - - describe("per-request actor identity", () => { - /** - * Exercises the real worker end to end: the fixture's activate() calls - * ctx.hosts.list() from inside a genuine ctx.http.route handler, so the - * AsyncLocalStorage context worker-bootstrap.ts enters around that - * invocation is the thing under test, not a mock of it. Two concurrent - * requests as two different users must each see only their own hosts, - * even though both are in flight on the very same worker at once. - */ - async function activateWithHostsRoute() { - const fixture = createFixturePlugin({ - backendSource: ` - export async function activate(ctx) { - await ctx.http.route("GET", "/my-hosts", async () => { - const hosts = await ctx.hosts.list(); - return { hosts }; - }); - } - `, - }); - fixtures.push(fixture); - - const hostsByUser: Record = { - alice: [{ id: 1, name: "alice-host" }], - bob: [{ id: 2, name: "bob-host" }], - }; - - broker = new PluginBroker({ - listHosts: async (userId) => hostsByUser[userId] ?? [], - resolveHost: async () => null, - onRoutesChanged: (runtime) => { - pluginApi.registerPluginRouter( - runtime.plugin.id, - buildPluginRouter(broker!, runtime), - ); - }, - }); - - loader = new PluginLoader({ - onWorkerReady: (plugin, worker) => broker!.attach(plugin, worker), - onWorkerGone: (plugin) => { - broker!.detach(plugin.id); - pluginApi.unregisterPluginRouter(plugin.id); - }, - }); - - state.grants.push({ - pluginId: "sample-plugin", - capability: "hosts.read", - }); - invalidatePluginPermissionCache("sample-plugin"); - - await loader.load(fixture.dir); - // Activated under "install-owner", a third identity distinct from - // alice and bob, so a test that accidentally fell back to ownerUserId - // instead of the per-request actor would show up as a clear mismatch - // rather than an accidental pass. - await loader.activate("sample-plugin", "install-owner"); - } - - /** - * Mimics authenticateJWT setting req.userId ahead of the dispatcher, the - * way database.ts really wires it (see plugin-api-routes-auth.test.ts for - * that wiring itself). This file's own app has no auth in front of - * pluginApi.default on purpose, to test the dispatcher in isolation, so - * the per-user identity is injected the same minimal way here. - */ - function fetchAsUser(path: string, userId: string) { - return fetch(`${baseUrl}${path}`, { - headers: { "x-test-user-id": userId }, - }); - } - - it( - "two concurrent requests as different users each see only their own hosts", - async () => { - await activateWithHostsRoute(); - - // Reopen the server with a stand-in auth middleware that reads the - // test header, since the beforeEach server has none. - await new Promise((resolve) => server?.close(() => resolve())); - const app = express(); - app.use((req, _res, next) => { - const userId = req.headers["x-test-user-id"]; - if (typeof userId === "string") { - (req as typeof req & { userId?: string }).userId = userId; - } - next(); - }); - app.use("/plugin-api", pluginApi.default); - server = await new Promise((resolve) => { - const created = app.listen(0, "127.0.0.1", () => resolve(created)); - }); - baseUrl = `http://127.0.0.1:${(server.address() as AddressInfo).port}`; - - const [aliceRes, bobRes] = await Promise.all([ - fetchAsUser("/plugin-api/sample-plugin/my-hosts", "alice"), - fetchAsUser("/plugin-api/sample-plugin/my-hosts", "bob"), - ]); - - expect(await aliceRes.json()).toMatchObject({ - hosts: [{ id: 1, name: "alice-host" }], - }); - expect(await bobRes.json()).toMatchObject({ - hosts: [{ id: 2, name: "bob-host" }], - }); - }, - WORKER_TIMEOUT, - ); - - it( - "falls back to the install owner when a call has no inbound request", - async () => { - const fixture = createFixturePlugin({ - backendSource: ` - export async function activate(ctx) { - const hosts = await ctx.hosts.list(); - await ctx.log.info(JSON.stringify({ hosts })); - } - `, - }); - fixtures.push(fixture); - - const hostsByUser: Record = { - "install-owner": [{ id: 9, name: "owner-host" }], - }; - - broker = new PluginBroker({ - listHosts: async (userId) => hostsByUser[userId] ?? [], - resolveHost: async () => null, - }); - - const logged: string[] = []; - const { pluginLogger } = await import("../../utils/logger.js"); - vi.mocked(pluginLogger.info).mockImplementation((message: string) => { - logged.push(message); - }); - - loader = new PluginLoader({ - onWorkerReady: (plugin, worker) => broker!.attach(plugin, worker), - onWorkerGone: (plugin) => broker!.detach(plugin.id), - }); - - state.grants.push({ - pluginId: "sample-plugin", - capability: "hosts.read", - }); - invalidatePluginPermissionCache("sample-plugin"); - - await loader.load(fixture.dir); - await loader.activate("sample-plugin", "install-owner"); - - const line = logged.find((entry) => entry.startsWith("{")); - expect(line && JSON.parse(line)).toMatchObject({ - hosts: [{ id: 9, name: "owner-host" }], - }); - }, - WORKER_TIMEOUT, - ); - }); -}); diff --git a/src/backend/tests/plugins/in-process-loader.test.ts b/src/backend/tests/plugins/in-process-loader.test.ts deleted file mode 100644 index 3b5d69ed0..000000000 --- a/src/backend/tests/plugins/in-process-loader.test.ts +++ /dev/null @@ -1,200 +0,0 @@ -/** - * The in-process branch of the loader: a first-party plugin runs on the main - * thread, with no worker, and its deactivate() actually gets awaited. - * - * These use the real ssh-terminal id because the allowlist is hardcoded and - * that is the point -- there is deliberately no way to fake membership. - */ - -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import fs from "node:fs"; -import path from "node:path"; -import { PluginLoader } from "../../plugins/loader.js"; -import { TRANSPORT_OWNER_CAPABILITY } from "../../plugins/first-party.js"; -import { clearRegistry, consume } from "../../plugins/registry.js"; -import { pluginEvents } from "../../plugins/events.js"; -import { createFixturePlugin, type Fixture } from "./fixture-plugin.js"; - -vi.mock("../../utils/logger.js", () => ({ - pluginLogger: { - debug: vi.fn(), - info: vi.fn(), - warn: vi.fn(), - error: vi.fn(), - success: vi.fn(), - }, -})); - -const FIRST_PARTY_OPTIONS = { - id: "ssh-terminal", - permissions: [TRANSPORT_OWNER_CAPABILITY], - manifestOverrides: { category: "Terminal" }, -}; - -/** - * Writes a marker file rather than using a module-level variable: the plugin - * is imported by URL into the same process, but ESM caching means a second - * fixture at a different path is a different module instance. - */ -function backendWritingMarker(markerPath: string, extra = ""): string { - const literal = JSON.stringify(markerPath); - return ` -import fs from "node:fs"; -export async function activate(ctx) { - fs.writeFileSync(${literal}, "activated"); - ${extra} -} -export async function deactivate() { - fs.appendFileSync(${literal}, ":deactivated"); -} -`; -} - -describe("in-process first-party plugins", () => { - let fixture: Fixture | null = null; - let loader: PluginLoader | null = null; - - beforeEach(() => { - clearRegistry(); - pluginEvents.clear(); - }); - - afterEach(async () => { - await loader?.shutdown(); - loader = null; - fixture?.cleanup(); - fixture = null; - clearRegistry(); - pluginEvents.clear(); - }); - - it("activates on the main thread with no worker and no owner", async () => { - fixture = createFixturePlugin(FIRST_PARTY_OPTIONS); - const marker = path.join(fixture.root, "marker.txt"); - fs.writeFileSync( - path.join(fixture.dir, "backend", "index.mjs"), - backendWritingMarker(marker), - ); - - const onWorkerReady = vi.fn(); - loader = new PluginLoader({ onWorkerReady }); - - const plugin = await loader.load(fixture.dir); - // No ownerUserId: an in-process plugin never acts as a user. - await loader.activate(plugin.id); - - expect(plugin.state).toBe("active"); - expect(plugin.worker).toBeNull(); - expect(plugin.inProcess).not.toBeNull(); - expect(plugin.ownerUserId).toBeNull(); - // The broker attaches via this hook; an in-process plugin bypasses it. - expect(onWorkerReady).not.toHaveBeenCalled(); - expect(fs.readFileSync(marker, "utf8")).toBe("activated"); - }); - - it("awaits deactivate() on disable and clears the handle", async () => { - fixture = createFixturePlugin(FIRST_PARTY_OPTIONS); - const marker = path.join(fixture.root, "marker.txt"); - fs.writeFileSync( - path.join(fixture.dir, "backend", "index.mjs"), - backendWritingMarker(marker), - ); - - loader = new PluginLoader(); - const plugin = await loader.load(fixture.dir); - await loader.activate(plugin.id); - await loader.deactivate(plugin.id); - - expect(plugin.state).toBe("stopped"); - expect(plugin.inProcess).toBeNull(); - expect(fs.readFileSync(marker, "utf8")).toBe("activated:deactivated"); - }); - - it("revokes registry entries the plugin provided", async () => { - fixture = createFixturePlugin(FIRST_PARTY_OPTIONS); - const marker = path.join(fixture.root, "marker.txt"); - fs.writeFileSync( - path.join(fixture.dir, "backend", "index.mjs"), - backendWritingMarker( - marker, - `ctx.registry.provide("terminal.sessions", { live: true });`, - ), - ); - - loader = new PluginLoader(); - const plugin = await loader.load(fixture.dir); - await loader.activate(plugin.id); - - expect(consume("terminal.sessions")).toEqual({ live: true }); - - await loader.deactivate(plugin.id); - // Disabling the plugin must take its services with it, or a consumer - // would keep resolving a dead provider. - expect(consume("terminal.sessions")).toBeUndefined(); - }); - - it("detaches event listeners the plugin registered", async () => { - fixture = createFixturePlugin(FIRST_PARTY_OPTIONS); - const marker = path.join(fixture.root, "marker.txt"); - fs.writeFileSync( - path.join(fixture.dir, "backend", "index.mjs"), - backendWritingMarker(marker, `ctx.events.on("host.status", () => {});`), - ); - - loader = new PluginLoader(); - const plugin = await loader.load(fixture.dir); - await loader.activate(plugin.id); - - expect(pluginEvents.listenerCount("host.status")).toBe(1); - - await loader.deactivate(plugin.id); - expect(pluginEvents.listenerCount("host.status")).toBe(0); - }); - - it("reports an activation failure instead of leaving a half-live plugin", async () => { - fixture = createFixturePlugin(FIRST_PARTY_OPTIONS); - fs.writeFileSync( - path.join(fixture.dir, "backend", "index.mjs"), - `export async function activate() { throw new Error("boom"); }`, - ); - - loader = new PluginLoader(); - const plugin = await loader.load(fixture.dir); - - await expect(loader.activate(plugin.id)).rejects.toThrow("boom"); - expect(plugin.state).toBe("crashed"); - expect(plugin.inProcess).toBeNull(); - expect(plugin.lastError).toContain("boom"); - }); - - it("rejects an entry with no activate export", async () => { - fixture = createFixturePlugin(FIRST_PARTY_OPTIONS); - fs.writeFileSync( - path.join(fixture.dir, "backend", "index.mjs"), - `export const nothing = true;`, - ); - - loader = new PluginLoader(); - const plugin = await loader.load(fixture.dir); - - await expect(loader.activate(plugin.id)).rejects.toThrow( - /does not export an activate/, - ); - }); - - it("runs a first-party plugin in a worker when it does not ask for the tier", async () => { - fixture = createFixturePlugin({ - id: "ssh-terminal", - permissions: ["hosts.read"], - manifestOverrides: { category: "Terminal" }, - }); - - loader = new PluginLoader(); - const plugin = await loader.load(fixture.dir); - await loader.activate(plugin.id, "user-1"); - - expect(plugin.state).toBe("active"); - expect(plugin.worker).not.toBeNull(); - expect(plugin.inProcess).toBeNull(); - }); -}); diff --git a/src/backend/tests/plugins/loader.test.ts b/src/backend/tests/plugins/loader.test.ts index bb5c5f47c..d5210f143 100644 --- a/src/backend/tests/plugins/loader.test.ts +++ b/src/backend/tests/plugins/loader.test.ts @@ -1,350 +1,499 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { PluginLoader, MAX_RESTART_ATTEMPTS } from "../../plugins/loader.js"; -import { createFixturePlugin, type Fixture } from "./fixture-plugin.js"; +/** + * The single-tier loader: discovery, dependency order and lifecycle. + * + * These drive the real PluginLoader against real plugin directories written + * to a temp dir, because the things worth testing here are exactly the ones a + * mock would paper over: does a manifest on disk parse, does an id collision + * get refused, does a plugin's own deactivate actually run. + */ -vi.mock("../../utils/logger.js", () => ({ - pluginLogger: { - debug: vi.fn(), - info: vi.fn(), - warn: vi.fn(), - error: vi.fn(), - success: vi.fn(), - }, +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { PluginLoader } from "../../plugins/loader.js"; +import { createFixturePlugin } from "./fixture-plugin.js"; + +vi.mock("../../database/repositories/factory.js", () => ({ + createCurrentPluginStorageRepository: () => ({ + get: async () => null, + set: async () => {}, + delete: async () => false, + listKeys: async () => [], + }), })); -// Worker spawn plus crash backoff does not fit the 5s backend default. -const WORKER_TIMEOUT = 30_000; +const cleanups: Array<() => void> = []; -describe("PluginLoader", () => { - const fixtures: Fixture[] = []; - let loader: PluginLoader | null = null; +function tempRoot(prefix: string): string { + const root = fs.mkdtempSync(path.join(os.tmpdir(), prefix)); + cleanups.push(() => fs.rmSync(root, { recursive: true, force: true })); + return root; +} - function fixture(...args: Parameters) { - const created = createFixturePlugin(...args); - fixtures.push(created); - return created; - } +/** Points the loader at `bundled` and `user` as its two discovery roots. */ +function setRoots(bundled: string, user: string): void { + process.env.TERMIX_BUNDLED_PLUGINS_DIR = bundled; + process.env.DATA_DIR = user; +} - afterEach(async () => { - await loader?.shutdown(); - loader = null; - while (fixtures.length) fixtures.pop()!.cleanup(); +beforeEach(() => { + vi.unstubAllEnvs(); +}); + +afterEach(() => { + delete process.env.TERMIX_BUNDLED_PLUGINS_DIR; + delete process.env.DATA_DIR; + while (cleanups.length) cleanups.pop()?.(); +}); + +describe("PluginLoader discovery", () => { + it("loads a valid plugin directory", async () => { + const fixture = createFixturePlugin(); + cleanups.push(fixture.cleanup); + + const loader = new PluginLoader(); + const plugin = await loader.load(fixture.dir, "bundled"); + + expect(plugin.id).toBe("sample-plugin"); + expect(plugin.state).toBe("loaded"); + expect(plugin.manifest.capabilities).toEqual(["hosts:read", "kv:own"]); }); - describe("load", () => { - it("loads a valid plugin directory", async () => { - const plugin = fixture(); - loader = new PluginLoader(); - - const loaded = await loader.load(plugin.dir); - - expect(loaded.id).toBe("sample-plugin"); - expect(loaded.state).toBe("loaded"); - expect(loaded.worker).toBeNull(); - expect(loader.get("sample-plugin")).toBe(loaded); + it("rejects a directory whose name does not match the manifest id", async () => { + const fixture = createFixturePlugin({ + manifestOverrides: { id: "something-else" }, }); + cleanups.push(fixture.cleanup); - it("rejects a manifest that fails validation", async () => { - const plugin = fixture({ manifestOverrides: { version: "nope" } }); - loader = new PluginLoader(); - - await expect(loader.load(plugin.dir)).rejects.toThrow( - /Invalid plugin manifest/, - ); - }); - - it("rejects a directory whose name does not match the manifest id", async () => { - const plugin = fixture({ manifestOverrides: { id: "other-id" } }); - loader = new PluginLoader(); - - await expect(loader.load(plugin.dir)).rejects.toThrow( - /does not match manifest id/, - ); - }); - - it("rejects a declared backend with no entry file", async () => { - const plugin = fixture({ omitBackendEntry: true }); - loader = new PluginLoader(); - - await expect(loader.load(plugin.dir)).rejects.toThrow( - /declares a backend but/, - ); - }); - - it("rejects an unreadable manifest", async () => { - loader = new PluginLoader(); - await expect(loader.load("/nonexistent/plugin-dir")).rejects.toThrow( - /Could not read plugin manifest/, - ); - }); + const loader = new PluginLoader(); + await expect(loader.load(fixture.dir, "bundled")).rejects.toThrow( + /does not match manifest id/, + ); }); - describe("activate and deactivate", () => { - it( - "activates a plugin and runs its activate(ctx)", - async () => { - const plugin = fixture(); - loader = new PluginLoader(); - await loader.load(plugin.dir); + it("rejects a manifest whose backend entry is missing", async () => { + const fixture = createFixturePlugin({ omitBackendEntry: true }); + cleanups.push(fixture.cleanup); - await loader.activate("sample-plugin", "user-1"); - - const loaded = loader.get("sample-plugin")!; - expect(loaded.state).toBe("active"); - expect(loaded.worker).not.toBeNull(); - expect(loaded.ownerUserId).toBe("user-1"); - }, - WORKER_TIMEOUT, + const loader = new PluginLoader(); + await expect(loader.load(fixture.dir, "bundled")).rejects.toThrow( + /backend entry .* is missing/, ); - - it( - "surfaces an error thrown inside activate(ctx)", - async () => { - const plugin = fixture({ - backendSource: ` - export async function activate() { - throw new Error("boom from plugin"); - } - `, - }); - loader = new PluginLoader(); - await loader.load(plugin.dir); - - await expect( - loader.activate("sample-plugin", "user-1"), - ).rejects.toThrow(/boom from plugin/); - }, - WORKER_TIMEOUT, - ); - - it( - "rejects a backend entry with no activate export", - async () => { - const plugin = fixture({ - backendSource: `export const nothing = true;`, - }); - loader = new PluginLoader(); - await loader.load(plugin.dir); - - await expect( - loader.activate("sample-plugin", "user-1"), - ).rejects.toThrow(/does not export an activate\(ctx\) function/); - }, - WORKER_TIMEOUT, - ); - - it( - "deactivate terminates the worker and does not count as a crash", - async () => { - const plugin = fixture(); - loader = new PluginLoader(); - await loader.load(plugin.dir); - await loader.activate("sample-plugin", "user-1"); - - await loader.deactivate("sample-plugin"); - - const loaded = loader.get("sample-plugin")!; - expect(loaded.state).toBe("stopped"); - expect(loaded.worker).toBeNull(); - expect(loaded.restartAttempts).toBe(0); - }, - WORKER_TIMEOUT, - ); - - it( - "does not leak the server's environment into the worker", - async () => { - process.env.TERMIX_TEST_SECRET = "super-secret-value"; - - const plugin = fixture({ - backendSource: ` - export async function activate() { - if (process.env.TERMIX_TEST_SECRET) { - throw new Error("leaked:" + process.env.TERMIX_TEST_SECRET); - } - } - `, - }); - loader = new PluginLoader(); - await loader.load(plugin.dir); - - await loader.activate("sample-plugin", "user-1"); - expect(loader.get("sample-plugin")!.state).toBe("active"); - - delete process.env.TERMIX_TEST_SECRET; - }, - WORKER_TIMEOUT, - ); - - it("deactivating a never-started plugin is a no-op", async () => { - const plugin = fixture(); - loader = new PluginLoader(); - await loader.load(plugin.dir); - - await loader.deactivate("sample-plugin"); - expect(loader.get("sample-plugin")!.state).toBe("stopped"); - }); - - it("activating an unknown plugin throws", async () => { - loader = new PluginLoader(); - await expect(loader.activate("ghost", "user-1")).rejects.toThrow( - /Plugin ghost is not loaded/, - ); - }); }); - describe("crash restart", () => { - it( - "restarts a crashed plugin, then disables it after the retry budget", - async () => { - // Exits on a timer so activation succeeds first, then the worker dies. - const plugin = fixture({ - backendSource: ` - export async function activate() { - setTimeout(() => process.exit(1), 10); - } - `, - }); + it("skips an invalid directory and loads the rest", async () => { + const bundled = tempRoot("termix-bundled-"); + const user = tempRoot("termix-data-"); - loader = new PluginLoader({ restartBackoffMs: [10, 10, 10] }); - await loader.load(plugin.dir); - await loader.activate("sample-plugin", "user-1"); + createFixturePlugin({ id: "good-plugin", root: bundled }); + const broken = path.join(bundled, "broken-plugin"); + fs.mkdirSync(broken, { recursive: true }); + fs.writeFileSync(path.join(broken, "manifest.json"), "{ not json"); - const loaded = loader.get("sample-plugin")!; - await vi.waitFor( - () => { - expect(loaded.state).toBe("disabled"); - }, - { timeout: 25_000, interval: 50 }, - ); + setRoots(bundled, user); - expect(loaded.restartAttempts).toBeGreaterThan(MAX_RESTART_ATTEMPTS); - expect(loaded.worker).toBeNull(); - }, - WORKER_TIMEOUT, - ); + const loader = new PluginLoader(); + const loaded = await loader.loadAll(); - it( - "retry clears the crash counter and starts the plugin again", - async () => { - const plugin = fixture(); - loader = new PluginLoader(); - await loader.load(plugin.dir); - await loader.activate("sample-plugin", "user-1"); - - const loaded = loader.get("sample-plugin")!; - // Simulate having been auto-disabled. - await loader.deactivate("sample-plugin"); - loaded.state = "disabled"; - loaded.restartAttempts = MAX_RESTART_ATTEMPTS + 1; - - await loader.retry("sample-plugin"); - - expect(loaded.state).toBe("active"); - // retry() clears the counter up front, before the stability window. - expect(loaded.restartAttempts).toBe(0); - }, - WORKER_TIMEOUT, - ); - - it( - "clears the crash counter once a plugin stays up past the stability window", - async () => { - const plugin = fixture(); - loader = new PluginLoader({ stabilityWindowMs: 50 }); - await loader.load(plugin.dir); - - const loaded = loader.get("sample-plugin")!; - loaded.restartAttempts = 2; - await loader.activate("sample-plugin", "user-1"); - - await vi.waitFor( - () => { - expect(loaded.restartAttempts).toBe(0); - }, - { timeout: 5_000, interval: 25 }, - ); - }, - WORKER_TIMEOUT, - ); - - it("retry on a plugin that never activated throws", async () => { - const plugin = fixture(); - loader = new PluginLoader(); - await loader.load(plugin.dir); - - await expect(loader.retry("sample-plugin")).rejects.toThrow( - /never been activated/, - ); - }); + expect(loaded.map((plugin) => plugin.id)).toEqual(["good-plugin"]); }); - describe("loadAll", () => { - // loadAll scans bundled plugins as well as user-installed ones, and the - // repo really does ship one now. These tests are about the user directory, - // so they point the bundled dir somewhere empty. - let previousBundled: string | undefined; + // A plugin dropped into the data directory must never be able to take over + // an id the install already ships, or "ssh-terminal" becomes whatever the + // last person to write that folder wanted it to be. + it("refuses a user plugin that shadows a bundled id", async () => { + const bundled = tempRoot("termix-bundled-"); + const user = tempRoot("termix-data-"); + const userPlugins = path.join(user, "plugins"); + fs.mkdirSync(userPlugins, { recursive: true }); - beforeEach(() => { - previousBundled = process.env.TERMIX_BUNDLED_PLUGINS_DIR; - process.env.TERMIX_BUNDLED_PLUGINS_DIR = "/nonexistent/bundled-plugins"; + createFixturePlugin({ id: "ssh-terminal", root: bundled }); + createFixturePlugin({ + id: "ssh-terminal", + root: userPlugins, + manifestOverrides: { name: "Impostor" }, }); - afterEach(() => { - if (previousBundled === undefined) { - delete process.env.TERMIX_BUNDLED_PLUGINS_DIR; - } else { - process.env.TERMIX_BUNDLED_PLUGINS_DIR = previousBundled; - } - }); + setRoots(bundled, user); - it("returns an empty list when the plugins dir does not exist", async () => { - const previous = process.env.DATA_DIR; - process.env.DATA_DIR = "/nonexistent/data-dir"; - loader = new PluginLoader(); + const loader = new PluginLoader(); + const loaded = await loader.loadAll(); - expect(await loader.loadAll()).toEqual([]); - - if (previous === undefined) delete process.env.DATA_DIR; - else process.env.DATA_DIR = previous; - }); - - it("skips invalid directories and loads the rest", async () => { - const good = fixture({ id: "good-plugin" }); - // Share the same root so both sit under one plugins dir. - const bad = createFixturePlugin({ - id: "bad-plugin", - manifestOverrides: { version: "nope" }, - }); - fixtures.push(bad); - - const fs = await import("node:fs"); - const path = await import("node:path"); - fs.cpSync(bad.dir, path.join(good.root, "bad-plugin"), { - recursive: true, - }); - - const previous = process.env.DATA_DIR; - // getPluginsDir() appends "plugins", so point DATA_DIR at the parent. - const dataDir = path.join(good.root, ".."); - const pluginsDir = path.join(dataDir, "plugins"); - fs.mkdirSync(pluginsDir, { recursive: true }); - fs.cpSync(good.dir, path.join(pluginsDir, "good-plugin"), { - recursive: true, - }); - fs.cpSync(bad.dir, path.join(pluginsDir, "bad-plugin"), { - recursive: true, - }); - process.env.DATA_DIR = dataDir; - - loader = new PluginLoader(); - const loaded = await loader.loadAll(); - - expect(loaded.map((p) => p.id)).toEqual(["good-plugin"]); - - fs.rmSync(pluginsDir, { recursive: true, force: true }); - if (previous === undefined) delete process.env.DATA_DIR; - else process.env.DATA_DIR = previous; + expect(loaded).toHaveLength(1); + expect(loaded[0].source).toBe("bundled"); + expect(loaded[0].manifest.name).toBe("Sample Plugin"); + }); +}); + +describe("PluginLoader dependency resolution", () => { + async function loadAll( + specs: Array<{ id: string; manifestOverrides?: Record }>, + ): Promise { + const bundled = tempRoot("termix-bundled-"); + const user = tempRoot("termix-data-"); + for (const spec of specs) { + createFixturePlugin({ ...spec, root: bundled }); + } + setRoots(bundled, user); + + const loader = new PluginLoader(); + await loader.loadAll(); + return loader; + } + + it("orders a dependency before its dependent", async () => { + const loader = await loadAll([ + { id: "alpha", manifestOverrides: { dependencies: { beta: "^1.0.0" } } }, + { id: "beta" }, + ]); + + const { order, blocked, cycles } = loader.resolveOrder(["alpha", "beta"]); + + expect(cycles.size).toBe(0); + expect(blocked.size).toBe(0); + expect(order.indexOf("beta")).toBeLessThan(order.indexOf("alpha")); + }); + + it("blocks a plugin whose hard dependency is not installed", async () => { + const loader = await loadAll([ + { + id: "alpha", + manifestOverrides: { dependencies: { missing: "^1.0.0" } }, + }, + ]); + + const { order, blocked } = loader.resolveOrder(["alpha"]); + + expect(order).toEqual([]); + expect(blocked.get("alpha")).toMatch(/not installed/); + }); + + it("blocks a plugin whose dependency is present at the wrong version", async () => { + const loader = await loadAll([ + { id: "alpha", manifestOverrides: { dependencies: { beta: "^2.0.0" } } }, + { id: "beta" }, + ]); + + const { blocked } = loader.resolveOrder(["alpha", "beta"]); + + expect(blocked.get("alpha")).toMatch(/1\.0\.0 is installed/); + }); + + it("blocks a plugin whose dependency is installed but disabled", async () => { + const loader = await loadAll([ + { id: "alpha", manifestOverrides: { dependencies: { beta: "^1.0.0" } } }, + { id: "beta" }, + ]); + + const { blocked } = loader.resolveOrder(["alpha"]); + + expect(blocked.get("alpha")).toMatch(/not enabled/); + }); + + // Nothing to wait for, so this is a failure rather than a block. + it("fails every plugin in a dependency cycle", async () => { + const loader = await loadAll([ + { id: "alpha", manifestOverrides: { dependencies: { beta: "^1.0.0" } } }, + { id: "beta", manifestOverrides: { dependencies: { alpha: "^1.0.0" } } }, + ]); + + const { order, cycles } = loader.resolveOrder(["alpha", "beta"]); + + expect(order).toEqual([]); + expect(cycles.get("alpha")).toMatch(/cycle/); + expect(cycles.get("beta")).toMatch(/cycle/); + }); + + it("activates in dependency order and reports blocked plugins", async () => { + const marker = path.join(tempRoot("termix-order-"), "order.txt"); + const record = (id: string) => ` + import fs from "node:fs"; + export async function activate() { + fs.appendFileSync(${JSON.stringify(marker)}, "${id}\\n"); + } + `; + + const bundled = tempRoot("termix-bundled-"); + const user = tempRoot("termix-data-"); + createFixturePlugin({ + id: "alpha", + root: bundled, + backendSource: record("alpha"), + manifestOverrides: { dependencies: { beta: "^1.0.0" } }, + }); + createFixturePlugin({ + id: "beta", + root: bundled, + backendSource: record("beta"), + }); + createFixturePlugin({ + id: "gamma", + root: bundled, + manifestOverrides: { dependencies: { nowhere: "^1.0.0" } }, + }); + setRoots(bundled, user); + + const loader = new PluginLoader(); + await loader.loadAll(); + const result = await loader.activateAll(["alpha", "beta", "gamma"]); + + expect(result.activated).toEqual(["beta", "alpha"]); + expect(fs.readFileSync(marker, "utf8").trim().split("\n")).toEqual([ + "beta", + "alpha", + ]); + expect(loader.get("gamma")?.state).toBe("blocked"); + expect(loader.get("gamma")?.lastError).toMatch(/not installed/); + }); +}); + +describe("PluginLoader lifecycle", () => { + async function single( + backendSource: string, + id = "sample-plugin", + ): Promise { + const bundled = tempRoot("termix-bundled-"); + const user = tempRoot("termix-data-"); + createFixturePlugin({ id, root: bundled, backendSource }); + setRoots(bundled, user); + + const loader = new PluginLoader(); + await loader.loadAll(); + return loader; + } + + it("runs activate and then deactivate", async () => { + const marker = path.join(tempRoot("termix-life-"), "life.txt"); + const loader = await single(` + import fs from "node:fs"; + export async function activate() { + fs.appendFileSync(${JSON.stringify(marker)}, "up\\n"); + } + export async function deactivate() { + fs.appendFileSync(${JSON.stringify(marker)}, "down\\n"); + } + `); + + await loader.activate("sample-plugin"); + expect(loader.get("sample-plugin")?.state).toBe("active"); + + await loader.deactivate("sample-plugin"); + expect(loader.get("sample-plugin")?.state).toBe("stopped"); + expect(fs.readFileSync(marker, "utf8")).toBe("up\ndown\n"); + }); + + it("marks a plugin failed when activate throws", async () => { + const loader = await single(` + export async function activate() { + throw new Error("no good"); + } + `); + + await expect(loader.activate("sample-plugin")).rejects.toThrow("no good"); + expect(loader.get("sample-plugin")?.state).toBe("failed"); + expect(loader.get("sample-plugin")?.lastError).toBe("no good"); + }); + + it("rejects a backend entry with no activate export", async () => { + const loader = await single(`export const nothing = true;`); + + await expect(loader.activate("sample-plugin")).rejects.toThrow( + /does not export an activate/, + ); + }); + + // The whole point of the bag: cleanup cannot be conditional on the plugin + // behaving well on the way out. + it("disposes everything even when deactivate throws", async () => { + const marker = path.join(tempRoot("termix-dispose-"), "disposed.txt"); + const loader = await single(` + import fs from "node:fs"; + export async function activate(ctx) { + ctx.disposables.add(() => { + fs.appendFileSync(${JSON.stringify(marker)}, "disposed\\n"); + }); + } + export async function deactivate() { + throw new Error("deactivate blew up"); + } + `); + + await loader.activate("sample-plugin"); + await loader.deactivate("sample-plugin"); + + expect(loader.get("sample-plugin")?.state).toBe("stopped"); + expect(fs.readFileSync(marker, "utf8")).toBe("disposed\n"); + }); + + // ESM caches the module, so the second activate runs against the same + // module object the first one did. Anything created at module scope would + // already be torn down by then. + it("supports enable, disable, enable without a restart", async () => { + const marker = path.join(tempRoot("termix-cycle-"), "cycle.txt"); + const loader = await single(` + import fs from "node:fs"; + let timer = null; + let server = null; + + export async function activate(ctx) { + server = { closed: false }; + timer = setInterval(() => {}, 1000); + ctx.disposables.add(() => { + clearInterval(timer); + server.closed = true; + fs.appendFileSync(${JSON.stringify(marker)}, "closed\\n"); + }); + fs.appendFileSync(${JSON.stringify(marker)}, "started\\n"); + } + `); + + await loader.activate("sample-plugin"); + await loader.deactivate("sample-plugin"); + await loader.activate("sample-plugin"); + + expect(loader.get("sample-plugin")?.state).toBe("active"); + expect(fs.readFileSync(marker, "utf8")).toBe("started\nclosed\nstarted\n"); + + await loader.deactivate("sample-plugin"); + }); + + it("disposes registrations made before activate threw", async () => { + const marker = path.join(tempRoot("termix-partial-"), "partial.txt"); + const loader = await single(` + import fs from "node:fs"; + export async function activate(ctx) { + ctx.disposables.add(() => { + fs.appendFileSync(${JSON.stringify(marker)}, "cleaned\\n"); + }); + throw new Error("halfway"); + } + `); + + await expect(loader.activate("sample-plugin")).rejects.toThrow("halfway"); + expect(fs.readFileSync(marker, "utf8")).toBe("cleaned\n"); + }); + + it("deactivating a plugin that never started is a no-op", async () => { + const loader = await single(`export async function activate() {}`); + + await loader.deactivate("sample-plugin"); + expect(loader.get("sample-plugin")?.state).toBe("stopped"); + }); + + it("shuts plugins down in reverse activation order", async () => { + const marker = path.join(tempRoot("termix-shutdown-"), "shutdown.txt"); + const record = (id: string) => ` + import fs from "node:fs"; + export async function activate() {} + export async function deactivate() { + fs.appendFileSync(${JSON.stringify(marker)}, "${id}\\n"); + } + `; + + const bundled = tempRoot("termix-bundled-"); + const user = tempRoot("termix-data-"); + createFixturePlugin({ + id: "alpha", + root: bundled, + backendSource: record("alpha"), + manifestOverrides: { dependencies: { beta: "^1.0.0" } }, + }); + createFixturePlugin({ + id: "beta", + root: bundled, + backendSource: record("beta"), + }); + setRoots(bundled, user); + + const loader = new PluginLoader(); + await loader.loadAll(); + await loader.activateAll(["alpha", "beta"]); + await loader.shutdown(); + + // beta started first, so it stops last: a dependency outlives its + // dependents. + expect(fs.readFileSync(marker, "utf8").trim().split("\n")).toEqual([ + "alpha", + "beta", + ]); + }); +}); + +describe("PluginLoader error budget", () => { + async function failing(threshold: number): Promise { + const bundled = tempRoot("termix-bundled-"); + const user = tempRoot("termix-data-"); + createFixturePlugin({ + root: bundled, + backendSource: `export async function activate() {}`, + }); + setRoots(bundled, user); + + const loader = new PluginLoader({ + errorThreshold: threshold, + errorWindowMs: 60_000, }); + await loader.loadAll(); + await loader.activate("sample-plugin"); + return loader; + } + + it("marks a plugin failed once it trips the threshold", async () => { + const loader = await failing(3); + + await loader.reportError("sample-plugin", new Error("one")); + await loader.reportError("sample-plugin", new Error("two")); + expect(loader.get("sample-plugin")?.state).toBe("active"); + + await loader.reportError("sample-plugin", new Error("three")); + + expect(loader.get("sample-plugin")?.state).toBe("failed"); + expect(loader.get("sample-plugin")?.lastError).toBe("three"); + }); + + it("forgets errors that fall outside the window", async () => { + const bundled = tempRoot("termix-bundled-"); + const user = tempRoot("termix-data-"); + createFixturePlugin({ + root: bundled, + backendSource: `export async function activate() {}`, + }); + setRoots(bundled, user); + + const loader = new PluginLoader({ errorThreshold: 2, errorWindowMs: 10 }); + await loader.loadAll(); + await loader.activate("sample-plugin"); + + await loader.reportError("sample-plugin", new Error("one")); + await new Promise((resolve) => setTimeout(resolve, 25)); + await loader.reportError("sample-plugin", new Error("two")); + + expect(loader.get("sample-plugin")?.state).toBe("active"); + }); + + it("wrap() counts a throwing callback instead of letting it escape", async () => { + const loader = await failing(2); + + const wrapped = loader.wrap("sample-plugin", () => { + throw new Error("callback failed"); + }); + + await expect(wrapped()).resolves.toBeUndefined(); + await wrapped(); + + expect(loader.get("sample-plugin")?.state).toBe("failed"); + }); + + it("retry clears the budget and starts the plugin again", async () => { + const loader = await failing(1); + + await loader.reportError("sample-plugin", new Error("down")); + expect(loader.get("sample-plugin")?.state).toBe("failed"); + + await loader.retry("sample-plugin"); + + expect(loader.get("sample-plugin")?.state).toBe("active"); + expect(loader.get("sample-plugin")?.lastError).toBeNull(); }); }); diff --git a/src/backend/tests/plugins/manifest.test.ts b/src/backend/tests/plugins/manifest.test.ts index f816dd1f9..a54a0319b 100644 --- a/src/backend/tests/plugins/manifest.test.ts +++ b/src/backend/tests/plugins/manifest.test.ts @@ -1,3 +1,11 @@ +/** + * Manifest v2 validation. + * + * The rules live in the SDK so the server, the authoring script and plugin + * authors share one implementation; these tests drive it through the server's + * re-export, which is the path the loader actually takes. + */ + import { describe, expect, it } from "vitest"; import { parseManifest, @@ -5,484 +13,432 @@ import { SUPPORTED_PLUGIN_API_VERSION, } from "../../plugins/manifest.js"; -function validManifest(overrides: Record = {}) { +function base(overrides: Record = {}) { return { id: "sample-plugin", name: "Sample Plugin", version: "1.0.0", - description: "A sample plugin.", - author: { name: "Jane Doe" }, + description: "A fixture.", + author: { name: "Termix Tests" }, license: "MIT", category: "Productivity", engine: { termix: ">=2.9.0", api: SUPPORTED_PLUGIN_API_VERSION }, - capabilities: { - backend: true, - frontend: false, - electron: false, - platforms: ["linux"], - }, - permissions: ["hosts.read", "storage.own"], - sidecars: [], + capabilities: ["kv:own"], ...overrides, }; } -describe("plugin manifest validation", () => { - it("accepts a valid manifest", () => { - expect(validateManifest(validManifest())).toEqual([]); +describe("manifest v2 validation", () => { + it("accepts a minimal valid manifest", () => { + expect(validateManifest(base())).toEqual([]); }); - it("reports every missing required field", () => { - const errors = validateManifest({ id: "x" }); - expect(errors).toContain('Missing required field: "description"'); - expect(errors).toContain('Missing required field: "engine"'); - expect(errors).toContain('Missing required field: "sidecars"'); + it("reports every missing required field at once", () => { + const errors = validateManifest({ id: "sample-plugin" }); + + expect(errors.some((e) => e.includes('"name"'))).toBe(true); + expect(errors.some((e) => e.includes('"version"'))).toBe(true); + expect(errors.some((e) => e.includes('"capabilities"'))).toBe(true); }); - it("rejects a bad id pattern", () => { - const errors = validateManifest(validManifest({ id: "Bad_Id" })); - expect(errors.some((e) => e.startsWith('Field "id" must match'))).toBe( - true, + it("rejects an id that is not a lowercase slug", () => { + expect(validateManifest(base({ id: "Sample_Plugin" })).join()).toMatch( + /"id" must match/, + ); + }); + + it("rejects an id that starts with a digit", () => { + expect(validateManifest(base({ id: "1plugin" })).join()).toMatch( + /"id" must match/, ); }); it("rejects a non-semver version", () => { - const errors = validateManifest(validManifest({ version: "1.0" })); - expect(errors).toContain( - 'Field "version" must be valid semver, got: "1.0"', + expect(validateManifest(base({ version: "1.0" })).join()).toMatch( + /valid semver/, ); }); - describe("provides / requires", () => { - const permissionGroup = { - group: "testplugin", - permissions: ["testplugin.greet.use"], - }; - - it("accepts a declared service and requirement", () => { - const errors = validateManifest( - validManifest({ - provides: [ - { - service: "testplugin.greet", - version: "1.0.0", - permission: "testplugin.greet.use", - }, - ], - requires: [ - { service: "other.thing", versionRange: "^1.0.0", optional: true }, - ], - contributes: { permissionGroup }, - }), - ); - expect(errors).toEqual([]); - }); - - it("rejects a service name that is not dotted", () => { - const errors = validateManifest( - validManifest({ - provides: [{ service: "greet", version: "1.0.0", permission: "x.y" }], - }), - ); - expect(errors.some((e) => e.includes("provides[0].service"))).toBe(true); - }); - - it("rejects a non-semver service version", () => { - const errors = validateManifest( - validManifest({ - provides: [{ service: "a.b", version: "1.0", permission: "x.y" }], - }), - ); - expect(errors.some((e) => e.includes("provides[0].version"))).toBe(true); - }); - - it("rejects a duplicate provided service", () => { - const errors = validateManifest( - validManifest({ - provides: [ - { service: "a.b", version: "1.0.0", permission: "x.y" }, - { service: "a.b", version: "2.0.0", permission: "x.y" }, - ], - }), - ); - expect(errors.some((e) => e.includes("duplicate"))).toBe(true); - }); - - it("rejects an invalid semver range", () => { - const errors = validateManifest( - validManifest({ - requires: [{ service: "a.b", versionRange: "not a range" }], - }), - ); - expect(errors.some((e) => e.includes("versionRange"))).toBe(true); - }); - - it("rejects a non-boolean optional", () => { - const errors = validateManifest( - validManifest({ - requires: [ - { service: "a.b", versionRange: "^1.0.0", optional: "yes" }, - ], - }), - ); - expect(errors.some((e) => e.includes("optional"))).toBe(true); - }); - - it("refuses a service gated by a permission the group never declares", () => { - // Otherwise every call through it would deny against a permission no - // admin could ever grant. - const { manifest, errors } = parseManifest( - validManifest({ - provides: [ - { - service: "testplugin.greet", - version: "1.0.0", - permission: "testplugin.undeclared", - }, - ], - contributes: { permissionGroup }, - }), - ); - - expect(manifest).toBeUndefined(); - expect(errors[0]).toContain("testplugin.undeclared"); - expect(errors[0]).toContain("permissionGroup"); - }); - }); - - describe("providesSecret / requiresSecret", () => { - const permissionGroup = { - group: "testplugin", - permissions: ["testplugin.secrets.share"], - }; - - it("accepts a well-formed pair", () => { - expect( - validateManifest( - validManifest({ - providesSecret: [ - { key: "api-key", permission: "testplugin.secrets.share" }, - ], - requiresSecret: [ - { plugin: "ai-assistant", key: "api-key", optional: true }, - ], - contributes: { permissionGroup }, - }), - ), - ).toEqual([]); - }); - - it("rejects a dotted secret key", () => { - // The owning plugin is already its own field, so a dotted key would only - // encode the owner twice and invite the two disagreeing. - const errors = validateManifest( - validManifest({ - providesSecret: [ - { key: "ai.api-key", permission: "testplugin.secrets.share" }, - ], - }), - ); - expect(errors.some((e) => e.includes("providesSecret[0].key"))).toBe( - true, - ); - }); - - it("rejects a duplicate offer of the same key", () => { - const errors = validateManifest( - validManifest({ - providesSecret: [ - { key: "api-key", permission: "testplugin.secrets.share" }, - { key: "api-key", permission: "testplugin.secrets.share" }, - ], - }), - ); - expect(errors.some((e) => e.includes("duplicate"))).toBe(true); - }); - - it("requires a permission on an offer", () => { - const errors = validateManifest( - validManifest({ providesSecret: [{ key: "api-key" }] }), - ); - expect( - errors.some((e) => e.includes("providesSecret[0].permission")), - ).toBe(true); - }); - - it("rejects a reference with a bad plugin id", () => { - const errors = validateManifest( - validManifest({ - requiresSecret: [{ plugin: "Bad_Id", key: "api-key" }], - }), - ); - expect(errors.some((e) => e.includes("requiresSecret[0].plugin"))).toBe( - true, - ); - }); - - it("rejects a duplicate reference to the same plugin and key", () => { - const errors = validateManifest( - validManifest({ - requiresSecret: [ - { plugin: "ai-assistant", key: "api-key" }, - { plugin: "ai-assistant", key: "api-key" }, - ], - }), - ); - expect(errors.some((e) => e.includes("duplicate"))).toBe(true); - }); - - it("refuses a shared secret gated by a permission the group never declares", () => { - // Same rule as a service: sharing rides on an ordinary role permission, - // so it has to be one an admin can actually grant. - const { manifest, errors } = parseManifest( - validManifest({ - providesSecret: [ - { key: "api-key", permission: "testplugin.undeclared" }, - ], - contributes: { permissionGroup }, - }), - ); - - expect(manifest).toBeUndefined(); - expect(errors[0]).toContain("testplugin.undeclared"); - expect(errors[0]).toContain("permissionGroup"); - }); - - it("refuses a plugin borrowing from itself", () => { - const { manifest, errors } = parseManifest( - validManifest({ - requiresSecret: [{ plugin: "sample-plugin", key: "api-key" }], - }), - ); - - expect(manifest).toBeUndefined(); - expect(errors[0]).toContain("names this plugin itself"); - }); - }); - - it("rejects an unknown permission", () => { - const errors = validateManifest( - validManifest({ permissions: ["credentials.read"] }), - ); - expect( - errors.some((e) => e.includes('Unknown permission: "credentials.read"')), - ).toBe(true); - }); - it("rejects an unknown category", () => { - const errors = validateManifest(validManifest({ category: "Games" })); + expect(validateManifest(base({ category: "Nonsense" })).join()).toMatch( + /must be one of/, + ); + }); + + // Capabilities were dotted in v1. They are colon-separated now so they can + // never be confused with the dotted RBAC permissions. + it("rejects a dotted capability name", () => { expect( - errors.some((e) => e.startsWith('Field "category" must be one of')), - ).toBe(true); + validateManifest(base({ capabilities: ["hosts.read"] })).join(), + ).toMatch(/not a known capability/); }); - it("rejects a non-object manifest", () => { - expect(validateManifest(null)).toEqual(["Manifest must be a JSON object"]); - expect(validateManifest([])).toEqual(["Manifest must be a JSON object"]); + it("rejects a capability outside the catalog", () => { + expect( + validateManifest(base({ capabilities: ["hosts:obliterate"] })).join(), + ).toMatch(/not a known capability/); }); - it("validates contributes sub-shapes", () => { + it("rejects a duplicated capability", () => { + expect( + validateManifest(base({ capabilities: ["kv:own", "kv:own"] })).join(), + ).toMatch(/duplicates/); + }); + + it("refuses a manifest targeting another SDK api version", () => { + const { manifest, errors } = parseManifest( + base({ engine: { termix: ">=2.9.0", api: "2" } }), + ); + + expect(manifest).toBeUndefined(); + expect(errors.join()).toMatch(/SDK API version/); + }); + + it("fills in the entry point defaults", () => { + const { manifest } = parseManifest(base()); + + expect(manifest?.backend).toBe("dist/backend.js"); + expect(manifest?.frontend).toBe("dist/frontend.js"); + expect(manifest?.locales).toBe("locales"); + }); +}); + +// v1 accepted unknown fields silently, so a typo'd "contribute" validated +// clean and was then dropped. A manifest has to mean what it says. +describe("unknown field rejection", () => { + it("rejects an unknown top-level field", () => { + expect(validateManifest(base({ sudoEverything: true })).join()).toMatch( + /Unknown field "sudoEverything"/, + ); + }); + + it("rejects a near-miss of a real field name", () => { + expect(validateManifest(base({ contribute: {} })).join()).toMatch( + /Unknown field "contribute"/, + ); + }); + + it("rejects an unknown field inside contributes", () => { const errors = validateManifest( - validManifest({ + base({ contributes: { dashboardCards: [] } }), + ); + + expect(errors.join()).toMatch(/Unknown field "dashboardCards"/); + }); + + it("rejects an unknown field inside a tab", () => { + const errors = validateManifest( + base({ contributes: { - tabs: [{ id: "t", titleKey: "k", icon: "Box", openFrom: ["nope"] }], - apiPrefix: "Bad Prefix", + tabs: [ + { + id: "t", + titleKey: "k", + icon: "i", + openFrom: ["rail"], + surprise: 1, + }, + ], }, }), ); - expect( - errors.some((e) => e.includes('openFrom has unknown value: "nope"')), - ).toBe(true); - expect( - errors.some((e) => e.includes('"contributes.apiPrefix" must match')), - ).toBe(true); + + expect(errors.join()).toMatch(/Unknown field "surprise"/); }); - describe("actions / actionSlots", () => { - function withActions(contributes: Record) { - return validateManifest(validManifest({ contributes })); - } + it("rejects an unknown field inside author and engine", () => { + expect( + validateManifest(base({ author: { name: "x", role: "admin" } })).join(), + ).toMatch(/Unknown field "role"/); - it("accepts a valid actions and actionSlots pair", () => { - const errors = withActions({ - actions: [ - { - id: "ai.openWithContext", - titleKey: "ai.assistant", - handler: "openWithContext", - icon: "Bot", - slot: "terminal.toolbar", - kind: "button", - }, - ], - actionSlots: [{ id: "terminal.toolbar", accepts: ["button"] }], - }); - expect(errors).toEqual([]); - }); - - it("allows a camelCase segment in an action id", () => { - const errors = withActions({ - actions: [ - { - id: "ai.openWithContext", - titleKey: "k", - handler: "openWithContext", - }, - ], - }); - expect(errors).toEqual([]); - }); - - it("rejects an action id that is not dotted", () => { - const errors = withActions({ - actions: [{ id: "notdotted", titleKey: "k", handler: "h" }], - }); - expect(errors.some((e) => e.includes("actions[0].id must match"))).toBe( - true, - ); - }); - - it("rejects a duplicate action id", () => { - const errors = withActions({ - actions: [ - { id: "a.one", titleKey: "k", handler: "h" }, - { id: "a.one", titleKey: "k", handler: "h" }, - ], - }); - expect( - errors.some((e) => e.includes('actions[1].id is a duplicate: "a.one"')), - ).toBe(true); - }); - - it("requires titleKey and a valid handler", () => { - const errors = withActions({ - actions: [{ id: "a.one", handler: "not a identifier" }], - }); - expect(errors.some((e) => e.includes("actions[0].titleKey"))).toBe(true); - expect(errors.some((e) => e.includes("actions[0].handler"))).toBe(true); - }); - - it("rejects an unknown contribution kind", () => { - const errors = withActions({ - actions: [ - { id: "a.one", titleKey: "k", handler: "h", kind: "dropdown" }, - ], - }); - expect( - errors.some((e) => e.includes("actions[0].kind must be one of")), - ).toBe(true); - }); - - it("rejects an unknown value in a slot's accepts", () => { - const errors = withActions({ - actionSlots: [{ id: "terminal.toolbar", accepts: ["menu"] }], - }); - expect( - errors.some((e) => - e.includes('actionSlots[0].accepts has unknown value: "menu"'), - ), - ).toBe(true); - }); - - it("rejects an empty accepts array", () => { - const errors = withActions({ - actionSlots: [{ id: "terminal.toolbar", accepts: [] }], - }); - expect( - errors.some((e) => - e.includes("actionSlots[0].accepts must be a non-empty array"), - ), - ).toBe(true); - }); - - it("rejects a duplicate slot id", () => { - const errors = withActions({ - actionSlots: [ - { id: "terminal.toolbar", accepts: ["button"] }, - { id: "terminal.toolbar", accepts: ["button"] }, - ], - }); - expect( - errors.some((e) => e.includes("actionSlots[1].id is a duplicate")), - ).toBe(true); - }); + expect( + validateManifest( + base({ engine: { termix: ">=2.9.0", api: "1", unsafe: true } }), + ).join(), + ).toMatch(/Unknown field "unsafe"/); }); - describe("parseManifest", () => { - it("refuses an action gated by an undeclared permission", () => { - const { manifest, errors } = parseManifest( - validManifest({ - contributes: { - permissionGroup: { group: "ai", permissions: ["ai.use"] }, - actions: [ - { - id: "ai.openWithContext", - titleKey: "k", - handler: "h", - permission: "ai.services.use", - }, - ], + it("rejects an unknown field inside a provides entry", () => { + const errors = validateManifest( + base({ + provides: [ + { + service: "sample.thing", + version: "1.0.0", + permission: "sample-plugin.use", + elevated: true, }, - }), - ); - expect(manifest).toBeUndefined(); - expect(errors[0]).toContain( - 'Action "ai.openWithContext" is gated by "ai.services.use"', - ); - }); - - it("allows an action gated by a declared permission", () => { - const { manifest, errors } = parseManifest( - validManifest({ - contributes: { - permissionGroup: { - group: "ai", - permissions: ["ai.services.use"], - }, - actions: [ - { - id: "ai.openWithContext", - titleKey: "k", - handler: "h", - permission: "ai.services.use", - }, - ], + ], + contributes: { + permissionGroup: { + group: "sample-plugin", + permissions: ["sample-plugin.use"], }, - }), - ); - expect(errors).toEqual([]); - expect(manifest?.contributes?.actions?.[0].id).toBe("ai.openWithContext"); - }); + }, + }), + ); - it("allows an ungated action with no permissionGroup at all", () => { - const { errors } = parseManifest( - validManifest({ - contributes: { - actions: [{ id: "a.one", titleKey: "k", handler: "h" }], - }, - }), - ); - expect(errors).toEqual([]); - }); - - it("narrows a valid manifest", () => { - const { manifest, errors } = parseManifest(validManifest()); - expect(errors).toEqual([]); - expect(manifest?.id).toBe("sample-plugin"); - }); - - it("refuses a manifest targeting a different SDK major", () => { - const { manifest, errors } = parseManifest( - validManifest({ engine: { termix: ">=2.9.0", api: "99" } }), - ); - expect(manifest).toBeUndefined(); - expect(errors[0]).toContain('targets SDK api version "99"'); - }); - - it("does not narrow when validation failed", () => { - const { manifest, errors } = parseManifest({ id: "x" }); - expect(manifest).toBeUndefined(); - expect(errors.length).toBeGreaterThan(0); - }); + expect(errors.join()).toMatch(/Unknown field "elevated"/); + }); +}); + +describe("dependencies", () => { + it("accepts a well-formed dependency map", () => { + expect( + validateManifest(base({ dependencies: { automations: "^1.0.0" } })), + ).toEqual([]); + }); + + it("rejects a range that is not valid semver", () => { + expect( + validateManifest( + base({ dependencies: { automations: "latest!" } }), + ).join(), + ).toMatch(/valid semver range/); + }); + + it("refuses a plugin depending on itself", () => { + const { errors } = parseManifest( + base({ dependencies: { "sample-plugin": "^1.0.0" } }), + ); + + expect(errors.join()).toMatch(/points at this plugin/); + }); + + it("refuses the same plugin in both dependency maps", () => { + const { errors } = parseManifest( + base({ + dependencies: { automations: "^1.0.0" }, + optionalDependencies: { automations: "^1.0.0" }, + }), + ); + + expect(errors.join()).toMatch(/both dependencies and optionalDependencies/); + }); +}); + +describe("permission groups", () => { + it("accepts a group and its declared permissions", () => { + expect( + validateManifest( + base({ + contributes: { + permissionGroup: { + group: "sample-plugin", + permissions: ["sample-plugin.use"], + }, + }, + }), + ), + ).toEqual([]); + }); + + // Without this a manifest could hand any role admin.* at boot. + it("refuses a role default outside the plugin's own permissions", () => { + const errors = validateManifest( + base({ + contributes: { + permissionGroup: { + group: "sample-plugin", + permissions: ["sample-plugin.use"], + defaultForRole: { user: ["admin.users.manage"] }, + }, + }, + }), + ); + + expect(errors.join()).toMatch(/does not declare/); + }); + + it("accepts a role default the plugin does declare", () => { + expect( + validateManifest( + base({ + contributes: { + permissionGroup: { + group: "sample-plugin", + permissions: ["sample-plugin.use"], + defaultForRole: { admin: ["sample-plugin.use"] }, + }, + }, + }), + ), + ).toEqual([]); + }); +}); + +describe("services, secrets and actions", () => { + it("refuses a service gated by a permission the group never declares", () => { + const { errors } = parseManifest( + base({ + provides: [ + { + service: "sample.thing", + version: "1.0.0", + permission: "sample-plugin.missing", + }, + ], + contributes: { + permissionGroup: { + group: "sample-plugin", + permissions: ["sample-plugin.use"], + }, + }, + }), + ); + + expect(errors.join()).toMatch(/not declared in contributes/); + }); + + it("refuses a shared secret gated by an undeclared permission", () => { + const { errors } = parseManifest( + base({ + providesSecret: [ + { key: "api-key", permission: "sample-plugin.missing" }, + ], + contributes: { + permissionGroup: { + group: "sample-plugin", + permissions: ["sample-plugin.use"], + }, + }, + }), + ); + + expect(errors.join()).toMatch(/not declared in contributes/); + }); + + it("refuses a plugin borrowing a secret from itself", () => { + const { errors } = parseManifest( + base({ requiresSecret: [{ plugin: "sample-plugin", key: "api-key" }] }), + ); + + expect(errors.join()).toMatch(/points at this plugin/); + }); + + it("refuses an action gated by an undeclared permission", () => { + const { errors } = parseManifest( + base({ + contributes: { + permissionGroup: { + group: "sample-plugin", + permissions: ["sample-plugin.use"], + }, + actions: [ + { + id: "sample.open", + titleKey: "k", + handler: "open", + permission: "sample-plugin.missing", + }, + ], + }, + }), + ); + + expect(errors.join()).toMatch(/not declared in contributes/); + }); + + it("allows a camelCase segment in an action id", () => { + expect( + validateManifest( + base({ + contributes: { + actions: [ + { id: "ai.openWithContext", titleKey: "k", handler: "open" }, + ], + }, + }), + ), + ).toEqual([]); + }); + + it("rejects an action id that is not dotted", () => { + expect( + validateManifest( + base({ + contributes: { + actions: [{ id: "open", titleKey: "k", handler: "open" }], + }, + }), + ).join(), + ).toMatch(/dotted action id/); + }); + + it("rejects a handler that is not an identifier", () => { + expect( + validateManifest( + base({ + contributes: { + actions: [ + { id: "sample.open", titleKey: "k", handler: "not valid!" }, + ], + }, + }), + ).join(), + ).toMatch(/JavaScript identifier/); + }); +}); + +describe("tabs and host capabilities", () => { + it("rejects an unknown openFrom value", () => { + expect( + validateManifest( + base({ + contributes: { + tabs: [ + { id: "t", titleKey: "k", icon: "i", openFrom: ["telepathy"] }, + ], + }, + }), + ).join(), + ).toMatch(/openFrom\[0\] must be one of/); + }); + + it("rejects duplicate tab ids", () => { + expect( + validateManifest( + base({ + contributes: { + tabs: [ + { id: "t", titleKey: "k", icon: "i", openFrom: ["rail"] }, + { id: "t", titleKey: "k2", icon: "i", openFrom: ["rail"] }, + ], + }, + }), + ).join(), + ).toMatch(/duplicates/); + }); + + it("accepts hostCapability as a single object or an array", () => { + const one = { + key: "enableThing", + labelKey: "k", + editorTab: "general", + }; + + expect( + validateManifest(base({ contributes: { hostCapability: one } })), + ).toEqual([]); + + expect( + validateManifest( + base({ + contributes: { + hostCapability: [one, { ...one, key: "enableOther" }], + }, + }), + ), + ).toEqual([]); + }); + + it("rejects duplicate hostCapability keys", () => { + const one = { key: "enableThing", labelKey: "k", editorTab: "general" }; + + expect( + validateManifest( + base({ contributes: { hostCapability: [one, one] } }), + ).join(), + ).toMatch(/duplicates/); }); }); diff --git a/src/backend/tests/plugins/secret-contract.test.ts b/src/backend/tests/plugins/secret-contract.test.ts index 6eda86e18..bd5bf6702 100644 --- a/src/backend/tests/plugins/secret-contract.test.ts +++ b/src/backend/tests/plugins/secret-contract.test.ts @@ -2,11 +2,9 @@ * Two real plugins on disk, one offering a secret and one borrowing it, driven * through the actual PluginLoader. * - * Same borrowed-id trick as service-contract.test.ts: the in-process allowlist - * is hardcoded and deliberately unfakeable, so the fixtures use real - * first-party ids to reach that tier. The secret ("api-key") and the - * permission gating it ("testplugin.secrets.share") are fixture-owned, so - * nothing about the shipped ssh-terminal or docker plugins is under test. + * The secret ("api-key") and the permission gating it + * ("testplugin.secrets.share") are fixture-owned, so nothing about the + * shipped plugins is under test even though the fixtures borrow their ids. * * What the unit test cannot show, and this does: that the manifest declaration * is enforced by the real ctx, and that a genuine uninstall through @@ -53,8 +51,6 @@ vi.mock("../../utils/permission-manager.js", () => ({ })); const { PluginLoader } = await import("../../plugins/loader.js"); -const { TRANSPORT_OWNER_CAPABILITY } = - await import("../../plugins/first-party.js"); const { clearSecretRegistry, getSecretRegistration, @@ -107,7 +103,7 @@ export async function activate(ctx) { function providerFixture(): Fixture { return createFixturePlugin({ id: PROVIDER_ID, - permissions: [TRANSPORT_OWNER_CAPABILITY], + capabilities: ["kv:own"], manifestOverrides: { category: "Terminal", providesSecret: [{ key: KEY, permission: PERMISSION }], @@ -125,7 +121,7 @@ function consumerFixture( ): Fixture { return createFixturePlugin({ id: CONSUMER_ID, - permissions: [TRANSPORT_OWNER_CAPABILITY], + capabilities: ["kv:own"], backendSource: CONSUMER_SOURCE, manifestOverrides: { category: "Infrastructure", requiresSecret }, }); @@ -286,7 +282,7 @@ describe("cross-plugin secret references", () => { it("refuses to offer a secret the provider's manifest never declared", async () => { provider = createFixturePlugin({ id: PROVIDER_ID, - permissions: [TRANSPORT_OWNER_CAPABILITY], + capabilities: ["kv:own"], backendSource: ` export async function activate(ctx) { ctx.secrets.offer("undeclared-key", () => "nope"); diff --git a/src/backend/tests/plugins/service-contract.test.ts b/src/backend/tests/plugins/service-contract.test.ts index 3f24bffda..1eb2f3a94 100644 --- a/src/backend/tests/plugins/service-contract.test.ts +++ b/src/backend/tests/plugins/service-contract.test.ts @@ -2,12 +2,9 @@ * Two real plugins on disk, one providing a service and one requiring it, * driven through the actual PluginLoader. * - * The ids are the real first-party ones because the in-process allowlist is - * hardcoded and deliberately unfakeable -- the same thing in-process-loader - * does. The service ("testplugin.greet") and the permission gating it + * The service ("testplugin.greet") and the permission gating it * ("testplugin.greet.use") are fixture-owned, so nothing about the shipped - * ssh-terminal or docker plugins is under test here; only their ids are - * borrowed to reach the in-process tier. + * plugins is under test here even though the fixtures borrow their ids. */ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; @@ -51,8 +48,6 @@ vi.mock("../../utils/permission-manager.js", () => ({ })); const { PluginLoader } = await import("../../plugins/loader.js"); -const { TRANSPORT_OWNER_CAPABILITY } = - await import("../../plugins/first-party.js"); const { clearServiceRegistry, getRegistration, @@ -99,7 +94,7 @@ export async function activate(ctx) { function providerFixture(): Fixture { return createFixturePlugin({ id: "ssh-terminal", - permissions: [TRANSPORT_OWNER_CAPABILITY], + capabilities: ["kv:own"], manifestOverrides: { category: "Terminal", provides: [ @@ -119,7 +114,7 @@ function consumerFixture( ): Fixture { return createFixturePlugin({ id: "docker", - permissions: [TRANSPORT_OWNER_CAPABILITY], + capabilities: ["kv:own"], backendSource: CONSUMER_SOURCE, manifestOverrides: { category: "Infrastructure", requires }, }); @@ -212,7 +207,7 @@ describe("plugin service contracts", () => { await expect(loader.activate(plugin.id)).rejects.toThrow( /no active plugin provides|provides 1\.2\.0/, ); - expect(plugin.state).toBe("crashed"); + expect(plugin.state).toBe("failed"); }); it("activates anyway when an unsatisfied requirement is optional", async () => { @@ -323,7 +318,7 @@ describe("plugin service contracts", () => { it("refuses to provide a service the manifest never declared", async () => { provider = createFixturePlugin({ id: "ssh-terminal", - permissions: [TRANSPORT_OWNER_CAPABILITY], + capabilities: ["kv:own"], backendSource: ` export async function activate(ctx) { ctx.services.provide("testplugin.undeclared", { hello: async () => "hi" }); diff --git a/src/backend/tests/plugins/shutdown.test.ts b/src/backend/tests/plugins/shutdown.test.ts new file mode 100644 index 000000000..82c84d1e6 --- /dev/null +++ b/src/backend/tests/plugins/shutdown.test.ts @@ -0,0 +1,146 @@ +/** + * Core owns shutdown. + * + * gracefulShutdown in starter.ts calls shutdownPlugins(), which runs every + * plugin's deactivate and disposes what it registered. A plugin does not need + * its own process signal handler, and must not have one: the docker console + * used to install a SIGTERM listener that called process.exit(0), which + * skipped the rest of the sequence including other plugins and the database + * flush. + */ + +import { afterEach, describe, expect, it, vi } from "vitest"; +import fs from "node:fs"; +import net from "node:net"; +import os from "node:os"; +import path from "node:path"; +import { PluginLoader } from "../../plugins/loader.js"; +import { createFixturePlugin } from "./fixture-plugin.js"; + +vi.mock("../../utils/logger.js", () => ({ + pluginLogger: { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + success: vi.fn(), + }, +})); + +vi.mock("../../plugins/permissions.js", () => ({ + assertCapability: vi.fn().mockResolvedValue(undefined), + hasCapability: vi.fn().mockResolvedValue(true), + PluginCapabilityError: class extends Error {}, + invalidatePluginPermissionCache: vi.fn(), +})); + +const CONSOLE_PORT = 39109; +const cleanups: Array<() => void> = []; + +function tempRoot(prefix: string): string { + const root = fs.mkdtempSync(path.join(os.tmpdir(), prefix)); + cleanups.push(() => fs.rmSync(root, { recursive: true, force: true })); + return root; +} + +function isListening(port: number): Promise { + return new Promise((resolve) => { + const socket = net.connect({ port, host: "127.0.0.1" }); + socket.once("connect", () => { + socket.destroy(); + resolve(true); + }); + socket.once("error", () => resolve(false)); + }); +} + +afterEach(() => { + delete process.env.TERMIX_BUNDLED_PLUGINS_DIR; + delete process.env.DATA_DIR; + while (cleanups.length) cleanups.pop()?.(); +}); + +/** + * Shaped like the docker plugin: a REST server and a console WebSocket + * server, both started inside activate and closed through disposables. + */ +const DOCKER_LIKE = ` + import net from "node:net"; + + export async function activate(ctx) { + const console = net.createServer(); + await new Promise((resolve) => + console.listen(${CONSOLE_PORT}, "127.0.0.1", resolve), + ); + ctx.disposables.add( + () => new Promise((resolve) => console.close(() => resolve())), + ); + } +`; + +describe("shutdownPlugins closes what plugins own", () => { + it("closes a console-style WebSocket server on shutdown", async () => { + const bundled = tempRoot("termix-bundled-"); + const user = tempRoot("termix-data-"); + createFixturePlugin({ + id: "docker", + root: bundled, + backendSource: DOCKER_LIKE, + }); + process.env.TERMIX_BUNDLED_PLUGINS_DIR = bundled; + process.env.DATA_DIR = user; + + const loader = new PluginLoader(); + await loader.loadAll(); + await loader.activate("docker"); + + expect(await isListening(CONSOLE_PORT)).toBe(true); + + await loader.shutdown(); + + expect(await isListening(CONSOLE_PORT)).toBe(false); + }); + + // One plugin failing to shut down cleanly must not strand the others, + // which is the other half of why core drives this rather than each plugin. + it("keeps going when one plugin's deactivate throws", async () => { + const marker = path.join(tempRoot("termix-marker-"), "stopped.txt"); + const bundled = tempRoot("termix-bundled-"); + const user = tempRoot("termix-data-"); + + createFixturePlugin({ + id: "alpha", + root: bundled, + backendSource: ` + export async function activate() {} + export async function deactivate() { + throw new Error("alpha refuses to stop"); + } + `, + }); + createFixturePlugin({ + id: "beta", + root: bundled, + backendSource: ` + import fs from "node:fs"; + export async function activate() {} + export async function deactivate() { + fs.appendFileSync(${JSON.stringify(marker)}, "beta stopped\\n"); + } + `, + }); + + process.env.TERMIX_BUNDLED_PLUGINS_DIR = bundled; + process.env.DATA_DIR = user; + + const loader = new PluginLoader(); + await loader.loadAll(); + await loader.activateAll(["alpha", "beta"]); + + await loader.shutdown(); + + expect(loader.get("alpha")?.state).toBe("stopped"); + expect(loader.get("beta")?.state).toBe("stopped"); + expect(fs.readFileSync(marker, "utf8")).toBe("beta stopped\n"); + }); +}); diff --git a/src/backend/tests/plugins/worker-deactivate.test.ts b/src/backend/tests/plugins/worker-deactivate.test.ts deleted file mode 100644 index 541de0e61..000000000 --- a/src/backend/tests/plugins/worker-deactivate.test.ts +++ /dev/null @@ -1,121 +0,0 @@ -/** - * Worker plugins gained an optional deactivate() hook. It is best-effort by - * design: a plugin that ignores it or hangs must not be able to block a - * disable, it just loses its cleanup. - */ - -import { afterEach, describe, expect, it, vi } from "vitest"; -import fs from "node:fs"; -import path from "node:path"; -import { PluginLoader } from "../../plugins/loader.js"; -import { PluginBroker } from "../../plugins/broker.js"; -import { createFixturePlugin, type Fixture } from "./fixture-plugin.js"; - -vi.mock("../../utils/logger.js", () => ({ - pluginLogger: { - debug: vi.fn(), - info: vi.fn(), - warn: vi.fn(), - error: vi.fn(), - success: vi.fn(), - }, -})); - -function buildLoader(broker: PluginBroker): PluginLoader { - return new PluginLoader({ - onWorkerReady: (plugin, worker) => broker.attach(plugin, worker), - onWorkerGone: (plugin) => broker.detach(plugin.id), - onBeforeTerminate: async (plugin) => { - const runtime = broker.get(plugin.id); - if (runtime) await broker.requestDeactivate(runtime, 2000); - }, - }); -} - -function newBroker(): PluginBroker { - return new PluginBroker({ - listHosts: async () => [], - resolveHost: async () => null, - }); -} - -describe("worker deactivate hook", () => { - let fixture: Fixture | null = null; - let loader: PluginLoader | null = null; - - afterEach(async () => { - await loader?.shutdown(); - loader = null; - fixture?.cleanup(); - fixture = null; - }); - - it("runs the plugin's deactivate() before the worker is terminated", async () => { - fixture = createFixturePlugin(); - const marker = path.join(fixture.root, "marker.txt"); - - fs.writeFileSync( - path.join(fixture.dir, "backend", "index.mjs"), - ` -import fs from "node:fs"; -export async function activate() {} -export async function deactivate() { - fs.writeFileSync(${JSON.stringify(marker)}, "cleaned"); -} -`, - ); - - loader = buildLoader(newBroker()); - const plugin = await loader.load(fixture.dir); - await loader.activate(plugin.id, "user-1"); - await loader.deactivate(plugin.id); - - expect(plugin.state).toBe("stopped"); - expect(fs.readFileSync(marker, "utf8")).toBe("cleaned"); - }); - - it("still stops a plugin that exports no deactivate", async () => { - fixture = createFixturePlugin(); - loader = buildLoader(newBroker()); - - const plugin = await loader.load(fixture.dir); - await loader.activate(plugin.id, "user-1"); - await loader.deactivate(plugin.id); - - expect(plugin.state).toBe("stopped"); - expect(plugin.worker).toBeNull(); - }); - - it("does not let a hanging deactivate block the disable", async () => { - fixture = createFixturePlugin(); - fs.writeFileSync( - path.join(fixture.dir, "backend", "index.mjs"), - ` -export async function activate() {} -export function deactivate() { - return new Promise(() => {}); -} -`, - ); - - const broker = newBroker(); - loader = new PluginLoader({ - onWorkerReady: (plugin, worker) => broker.attach(plugin, worker), - onWorkerGone: (plugin) => broker.detach(plugin.id), - onBeforeTerminate: async (plugin) => { - const runtime = broker.get(plugin.id); - // Short timeout so the test does not wait out the real one. - if (runtime) await broker.requestDeactivate(runtime, 150); - }, - }); - - const plugin = await loader.load(fixture.dir); - await loader.activate(plugin.id, "user-1"); - - const started = Date.now(); - await loader.deactivate(plugin.id); - - expect(plugin.state).toBe("stopped"); - expect(Date.now() - started).toBeLessThan(2000); - }); -}); diff --git a/src/ui/api/plugins-api.ts b/src/ui/api/plugins-api.ts index 97c5f0a4e..5d7dd6637 100644 --- a/src/ui/api/plugins-api.ts +++ b/src/ui/api/plugins-api.ts @@ -11,20 +11,30 @@ export interface PluginContributions { tabs?: PluginTabContribution[]; } +/** + * What GET /plugins returns. + * + * The admin-only fields are absent for a caller without + * admin.plugins.manage: what a plugin may do, what it has been granted and + * why it failed are operational details the shell does not need. + */ export interface PluginSummary { id: string; name: string; version: string; - tier: string; - source: string; enabled: boolean; - runtimeState: string; - lastError: string | null; + /** enabled | disabled | blocked | failed, or the loader's live state. */ + state: string; contributes: PluginContributions | null; - /** Capabilities this plugin's manifest declares it may ask for. */ - permissions: string[]; - /** The subset of `permissions` an admin has actually granted. */ - grantedCapabilities: string[]; + + tier?: string; + source?: string; + /** Capabilities this plugin's manifest declares. Admins only. */ + capabilities?: string[]; + /** The subset of `capabilities` actually granted. Admins only. */ + grantedCapabilities?: string[]; + /** Why the plugin is blocked or failed. Admins only. */ + lastError?: string | null; } export async function getPlugins(): Promise { diff --git a/src/ui/locales/en.json b/src/ui/locales/en.json index 547340c2b..98ee87958 100644 --- a/src/ui/locales/en.json +++ b/src/ui/locales/en.json @@ -3988,31 +3988,7 @@ "pluginPermissionGranted": "Granted", "pluginPermissionNotGranted": "Not granted", "pluginGrantFailed": "Failed to grant the capability", - "pluginRevokeFailed": "Failed to revoke the capability", - "pluginPermissionHostsRead": "Read hosts", - "pluginPermissionHostsReadDesc": "See the list of your hosts and their non-secret details.", - "pluginPermissionHostsWrite": "Modify hosts", - "pluginPermissionHostsWriteDesc": "Create, edit or delete hosts on your behalf.", - "pluginPermissionCredentialsUse": "Use stored credentials", - "pluginPermissionCredentialsUseDesc": "Connect using a saved credential without you retyping it.", - "pluginPermissionSshExec": "Run commands over SSH", - "pluginPermissionSshExecDesc": "Open a connection to a host and run commands on it.", - "pluginPermissionSshSftp": "Transfer files over SFTP", - "pluginPermissionSshSftpDesc": "Read or write files on a host over SFTP.", - "pluginPermissionStorageOwn": "Store its own data", - "pluginPermissionStorageOwnDesc": "Save and read back its own settings and data.", - "pluginPermissionStorageSecrets": "Store secrets", - "pluginPermissionStorageSecretsDesc": "Save and read back sensitive values it manages itself.", - "pluginPermissionNetworkOutbound": "Make outbound network requests", - "pluginPermissionNetworkOutboundDesc": "Contact services outside this server.", - "pluginPermissionEventsRead": "Subscribe to server events", - "pluginPermissionEventsReadDesc": "React to things happening elsewhere in Termix.", - "pluginPermissionNotifySend": "Send notifications", - "pluginPermissionNotifySendDesc": "Deliver a notification through your configured channels.", - "pluginPermissionUsersRead": "Read user accounts", - "pluginPermissionUsersReadDesc": "See basic details about accounts on this server.", - "pluginPermissionProcessSidecar": "Run a background process", - "pluginPermissionProcessSidecarDesc": "Start and manage its own helper process." + "pluginRevokeFailed": "Failed to revoke the capability" }, "newUi": { "sidebar": { @@ -5136,5 +5112,93 @@ "isolationUnavailable": "This browser cannot isolate web endpoints from your Termix session. Use a browser with credentialless iframe support.", "isolationNotice": "Embedded pages use an isolated session. For pages requiring storage or popup sign-in, use an isolated window in the desktop app.", "openIsolatedWindow": "Open isolated window" + }, + "plugins": { + "capabilities": { + "credentials:read": { + "title": "See your passwords and keys", + "consequence": "It can read the stored secret for any host you can reach." + }, + "ssh:connect": { + "title": "Run commands on your servers", + "consequence": "Any command, on hosts you connect it to, with your access." + }, + "process:spawn": { + "title": "Run programs on this machine", + "consequence": "Programs run on the Termix server itself, not on your hosts." + }, + "users:write": { + "title": "Create and change user accounts", + "consequence": "It can add users and change their roles." + }, + "auth:provide": { + "title": "Handle sign in", + "consequence": "It can add a login method or a second factor." + }, + "system:tls": { + "title": "Manage certificates", + "consequence": "It can request and replace the server certificate." + }, + "hosts:write": { + "title": "Create and change hosts", + "consequence": "It can add hosts and edit the ones you already have." + }, + "credentials:use": { + "title": "Connect to your servers", + "consequence": "It cannot see your passwords or keys." + }, + "network:outbound": { + "title": "Reach the internet", + "consequence": "It can send requests to outside services." + }, + "network:serve": { + "title": "Accept connections", + "consequence": "It opens a port on the Termix server." + }, + "users:read": { + "title": "See usernames and roles", + "consequence": "Never password hashes, never two factor state." + }, + "events:core": { + "title": "Watch everything happening", + "consequence": "Including activity from other users, not just yours." + }, + "notify:send": { + "title": "Send notifications", + "consequence": "Through the channels you have already set up." + }, + "audit:read": { + "title": "Read the audit log", + "consequence": "It can see who did what and when." + }, + "desktop:window": { + "title": "Open desktop windows", + "consequence": "Only in the Termix desktop app." + }, + "hosts:read": { + "title": "See your host list", + "consequence": "Names and addresses for hosts you can already see." + }, + "db:own": { + "title": "Store its own data", + "consequence": "Kept in its own tables, separate from other plugins." + }, + "kv:own": { + "title": "Remember small settings", + "consequence": "Kept separate from other plugins." + }, + "secrets:own": { + "title": "Store its own secrets", + "consequence": "Encrypted by Termix. The plugin never holds the key." + }, + "settings:read-core": { + "title": "Read Termix settings", + "consequence": "Server settings, not your hosts or credentials." + }, + "ui:surface": { + "title": "Add its own screens", + "consequence": "Tabs, panels and settings you can hide later." + } + } } } diff --git a/src/ui/sidebar/AdminPluginsSection.tsx b/src/ui/sidebar/AdminPluginsSection.tsx index f94317d27..9bc8ae3ae 100644 --- a/src/ui/sidebar/AdminPluginsSection.tsx +++ b/src/ui/sidebar/AdminPluginsSection.tsx @@ -81,18 +81,16 @@ export function AdminPluginsSection({ key={plugin.id} label={plugin.name} badge={ - plugin.tier === "first-party" - ? t("admin.pluginBuiltIn") - : undefined + plugin.tier === "bundled" ? t("admin.pluginBuiltIn") : undefined } description={ plugin.lastError ? plugin.lastError - : `v${plugin.version} · ${plugin.runtimeState}` + : `v${plugin.version} · ${plugin.state}` } >
- {plugin.permissions.length > 0 && ( + {(plugin.capabilities?.length ?? 0) > 0 && (