From d25af0e307954d66114ebd2b80d8103d0e9f820d Mon Sep 17 00:00:00 2001 From: Jeremy Anderson Date: Fri, 11 Sep 2026 23:03:43 -0400 Subject: [PATCH] SysDeck 4.1 - Standalone Edition: consolidates the day-to-day work of a Linux operations team in a single webui --- BLOG.md | 322 ++ Makefile | 49 +- QA.md | 132 + QUICKSTART.md | 270 +- README.md | 124 +- THIRD_PARTY.md | 35 +- bridge/__init__.py | 2 +- bridge/__pycache__/__init__.cpython-312.pyc | Bin 0 -> 4121 bytes bridge/__pycache__/auth.cpython-312.pyc | Bin 0 -> 12552 bytes bridge/__pycache__/benchmark.cpython-312.pyc | Bin 0 -> 8300 bytes bridge/__pycache__/builder.cpython-312.pyc | Bin 0 -> 79182 bytes bridge/__pycache__/containers.cpython-312.pyc | Bin 0 -> 3526 bytes bridge/__pycache__/db.cpython-312.pyc | Bin 0 -> 20916 bytes bridge/__pycache__/fester.cpython-312.pyc | Bin 0 -> 13204 bytes bridge/__pycache__/firewall.cpython-312.pyc | Bin 0 -> 98864 bytes bridge/__pycache__/firmware.cpython-312.pyc | Bin 0 -> 3915 bytes bridge/__pycache__/fleet.cpython-312.pyc | Bin 0 -> 5367 bytes bridge/__pycache__/glances.cpython-312.pyc | Bin 0 -> 12478 bytes bridge/__pycache__/grafana.cpython-312.pyc | Bin 0 -> 22646 bytes bridge/__pycache__/hwalert.cpython-312.pyc | Bin 0 -> 27651 bytes bridge/__pycache__/integrity.cpython-312.pyc | Bin 0 -> 2933 bytes bridge/__pycache__/jellyfin.cpython-312.pyc | Bin 0 -> 14436 bytes bridge/__pycache__/kata.cpython-312.pyc | Bin 0 -> 27458 bytes bridge/__pycache__/klanker.cpython-312.pyc | Bin 0 -> 24435 bytes bridge/__pycache__/mesh.cpython-312.pyc | Bin 0 -> 3202 bytes bridge/__pycache__/mining.cpython-312.pyc | Bin 0 -> 20888 bytes bridge/__pycache__/modules3p.cpython-312.pyc | Bin 0 -> 23888 bytes bridge/__pycache__/netsec.cpython-312.pyc | Bin 0 -> 18257 bytes bridge/__pycache__/packages.cpython-312.pyc | Bin 0 -> 25739 bytes bridge/__pycache__/photos.cpython-312.pyc | Bin 0 -> 14413 bytes bridge/__pycache__/policy.cpython-312.pyc | Bin 0 -> 64794 bytes bridge/__pycache__/prometheus.cpython-312.pyc | Bin 0 -> 25919 bytes bridge/__pycache__/remotefs.cpython-312.pyc | Bin 0 -> 17445 bytes bridge/__pycache__/sensors.cpython-312.pyc | Bin 0 -> 5167 bytes bridge/__pycache__/themes.cpython-312.pyc | Bin 0 -> 24146 bytes bridge/__pycache__/vault.cpython-312.pyc | Bin 0 -> 3077 bytes bridge/auth.py | 21 + bridge/benchmark.py | 8 + bridge/builder.py | 76 +- bridge/db.py | 41 +- bridge/firewall.py | 6 +- bridge/hwalert.py | 47 +- bridge/klanker.py | 571 +++ .../__pycache__/__init__.cpython-312.pyc | Bin 0 -> 372 bytes bridge/packages.py | 49 +- bridge/policy.py | 45 +- bridge/themes.py | 13 + compat/compat-manifest.json | 2 +- klanker-gate/.dockerignore | 11 + klanker-gate/.editorconfig | 9 + klanker-gate/.env.example | 328 ++ klanker-gate/.env.example.dev | 71 + klanker-gate/.gitattributes | 21 + klanker-gate/.github/CODEOWNERS | 75 + .../.github/ISSUE_TEMPLATE/bug_report.yml | 129 + .../.github/ISSUE_TEMPLATE/config.yml | 1 + .../.github/ISSUE_TEMPLATE/docs_issue.yml | 43 + .../ISSUE_TEMPLATE/feature_request.yml | 67 + klanker-gate/.github/pull_request_template.md | 73 + klanker-gate/.gitignore | 49 + klanker-gate/AGENTS.md | 183 + klanker-gate/ATTRIBUTION.md | 41 + klanker-gate/CHANGELOG.md | 34 + klanker-gate/CLAUDE.md | 251 ++ klanker-gate/CODE_OF_CONDUCT.md | 129 + klanker-gate/CONDUCT.md | 71 + klanker-gate/CONTRIBUTING.md | 82 + klanker-gate/Dockerfile | 57 + klanker-gate/LICENSE | 201 + klanker-gate/README.md | 84 + klanker-gate/SECURITY.md | 69 + klanker-gate/TODO.md | 313 ++ klanker-gate/apps/control-ui/CONVENTIONS.md | 210 + klanker-gate/apps/control-ui/deno.jsonc | 15 + klanker-gate/apps/control-ui/index.html | 28 + klanker-gate/apps/control-ui/package.json | 28 + .../apps/control-ui/src/App.nav.test.tsx | 49 + .../apps/control-ui/src/App.rebuild.test.tsx | 98 + klanker-gate/apps/control-ui/src/App.test.tsx | 164 + klanker-gate/apps/control-ui/src/App.tsx | 337 ++ klanker-gate/apps/control-ui/src/api.ts | 1102 +++++ .../catalog/ProviderModelsDialog.tsx | 232 ++ .../src/components/dashboard/ChartCard.tsx | 166 + .../src/components/dashboard/adapters.ts | 260 ++ .../src/components/logs/ColumnPicker.tsx | 99 + .../src/components/logs/LogsAnalytics.tsx | 164 + .../src/components/logs/LogsFacetRail.tsx | 221 + .../src/components/logs/LogsTable.tsx | 268 ++ .../src/components/logs/logs-model.ts | 257 ++ .../providers/AddCustomProviderForm.tsx | 187 + .../providers/AddProviderDialog.tsx | 113 + .../components/providers/AddProviderForm.tsx | 333 ++ .../providers/ProviderConfigPanel.tsx | 929 +++++ .../components/providers/SecretReenter.tsx | 134 + .../src/components/providers/constants.ts | 343 ++ .../src/components/settings/CacheOpsPanel.tsx | 137 + .../src/components/settings/CachingPanel.tsx | 370 ++ .../settings/CodeModeVfsPreview.tsx | 159 + .../settings/CompatibilityPanel.tsx | 115 + .../src/components/settings/ConfigPanel.tsx | 431 ++ .../src/components/settings/McpPanel.tsx | 228 + .../components/settings/PerformancePanel.tsx | 88 + .../src/components/settings/SecurityPanel.tsx | 280 ++ .../src/components/settings/helpers.tsx | 289 ++ .../src/components/shell/AdminTokenDialog.tsx | 81 + .../components/shell/CommandPalette.test.tsx | 84 + .../src/components/shell/CommandPalette.tsx | 188 + .../src/components/shell/Sidebar.tsx | 326 ++ .../control-ui/src/components/ui/badge.tsx | 44 + .../control-ui/src/components/ui/banner.tsx | 54 + .../control-ui/src/components/ui/button.tsx | 75 + .../control-ui/src/components/ui/card.tsx | 47 + .../control-ui/src/components/ui/chart.tsx | 307 ++ .../control-ui/src/components/ui/checkbox.tsx | 19 + .../src/components/ui/collapsible.test.tsx | 39 + .../src/components/ui/collapsible.tsx | 90 + .../src/components/ui/combobox.test.tsx | 65 + .../control-ui/src/components/ui/combobox.tsx | 227 + .../src/components/ui/copy-button.tsx | 51 + .../src/components/ui/data-table.test.tsx | 106 + .../src/components/ui/data-table.tsx | 279 ++ .../control-ui/src/components/ui/dialog.tsx | 139 + .../src/components/ui/dropdown-menu.test.tsx | 56 + .../src/components/ui/dropdown-menu.tsx | 271 ++ .../src/components/ui/empty-state.tsx | 40 + .../src/components/ui/export-button.test.tsx | 47 + .../src/components/ui/export-button.tsx | 55 + .../src/components/ui/facet-rail.test.tsx | 43 + .../src/components/ui/facet-rail.tsx | 170 + .../control-ui/src/components/ui/input.tsx | 37 + .../src/components/ui/key-value-rows.test.tsx | 35 + .../src/components/ui/key-value-rows.tsx | 114 + .../control-ui/src/components/ui/label.tsx | 54 + .../src/components/ui/masked-secret.test.tsx | 43 + .../src/components/ui/masked-secret.tsx | 137 + .../src/components/ui/nav-tabs.test.tsx | 62 + .../control-ui/src/components/ui/nav-tabs.tsx | 100 + .../src/components/ui/number-field.test.tsx | 35 + .../src/components/ui/number-field.tsx | 94 + .../src/components/ui/page-header.tsx | 43 + .../src/components/ui/pem-textarea.test.tsx | 40 + .../src/components/ui/pem-textarea.tsx | 85 + .../src/components/ui/provider-icon.test.tsx | 53 + .../src/components/ui/provider-icon.tsx | 182 + .../src/components/ui/provider-logos.tsx | 601 +++ .../components/ui/segmented-select.test.tsx | 47 + .../src/components/ui/segmented-select.tsx | 92 + .../control-ui/src/components/ui/select.tsx | 32 + .../control-ui/src/components/ui/sheet.tsx | 76 + .../control-ui/src/components/ui/skeleton.tsx | 58 + .../control-ui/src/components/ui/spinner.tsx | 15 + .../src/components/ui/stat-tile.tsx | 30 + .../control-ui/src/components/ui/switch.tsx | 45 + .../control-ui/src/components/ui/table.tsx | 102 + .../control-ui/src/components/ui/tabs.tsx | 74 + .../src/components/ui/tag-input.tsx | 67 + .../components/ui/time-range-picker.test.tsx | 25 + .../src/components/ui/time-range-picker.tsx | 185 + .../control-ui/src/components/ui/toast.tsx | 147 + .../components/ui/toggle-grid-item.test.tsx | 37 + .../src/components/ui/toggle-grid-item.tsx | 84 + .../components/ui/two-pane.overflow.test.tsx | 51 + .../src/components/ui/two-pane.test.tsx | 47 + .../control-ui/src/components/ui/two-pane.tsx | 212 + .../control-ui/src/components/ui/use-modal.ts | 78 + klanker-gate/apps/control-ui/src/index.css | 154 + .../apps/control-ui/src/lib/analytics.test.ts | 270 ++ .../apps/control-ui/src/lib/analytics.ts | 209 + .../apps/control-ui/src/lib/csv.test.ts | 48 + klanker-gate/apps/control-ui/src/lib/csv.ts | 62 + .../apps/control-ui/src/lib/currency.ts | 72 + .../apps/control-ui/src/lib/governance.tsx | 346 ++ .../apps/control-ui/src/lib/nav.test.ts | 36 + klanker-gate/apps/control-ui/src/lib/nav.ts | 30 + .../apps/control-ui/src/lib/table.test.ts | 72 + klanker-gate/apps/control-ui/src/lib/table.ts | 76 + klanker-gate/apps/control-ui/src/lib/utils.ts | 38 + klanker-gate/apps/control-ui/src/main.tsx | 10 + .../apps/control-ui/src/styles/tokens.css | 339 ++ .../apps/control-ui/src/test/setup.ts | 29 + .../control-ui/src/views/CustomersView.tsx | 340 ++ .../src/views/DashboardView.test.tsx | 209 + .../control-ui/src/views/DashboardView.tsx | 604 +++ .../control-ui/src/views/ExtensionsView.tsx | 726 ++++ .../control-ui/src/views/LogsView.test.tsx | 224 + .../apps/control-ui/src/views/LogsView.tsx | 409 ++ .../src/views/ModelCatalogView.test.tsx | 212 + .../control-ui/src/views/ModelCatalogView.tsx | 327 ++ .../apps/control-ui/src/views/PricingView.tsx | 322 ++ .../src/views/ProvidersView.test.tsx | 276 ++ .../control-ui/src/views/ProvidersView.tsx | 774 ++++ .../src/views/SettingsView.test.tsx | 379 ++ .../control-ui/src/views/SettingsView.tsx | 199 + .../control-ui/src/views/StatusView.test.tsx | 216 + .../apps/control-ui/src/views/StatusView.tsx | 614 +++ .../apps/control-ui/src/views/TeamsView.tsx | 388 ++ .../src/views/VirtualKeysView.test.tsx | 255 ++ .../control-ui/src/views/VirtualKeysView.tsx | 986 +++++ .../apps/control-ui/src/vite-env.d.ts | 1 + klanker-gate/apps/control-ui/tsconfig.json | 27 + .../apps/control-ui/tsconfig.node.json | 10 + klanker-gate/apps/control-ui/vite.config.ts | 21 + klanker-gate/apps/gateway/cluster.ts | 195 + klanker-gate/apps/gateway/cluster_test.ts | 108 + klanker-gate/apps/gateway/context.ts | 887 ++++ klanker-gate/apps/gateway/context_test.ts | 107 + klanker-gate/apps/gateway/main.ts | 228 + klanker-gate/apps/gateway/main_test.ts | 134 + klanker-gate/apps/gateway/routes/admin.ts | 402 ++ .../apps/gateway/routes/admin_test.ts | 277 ++ klanker-gate/apps/gateway/routes/advanced.ts | 694 ++++ .../apps/gateway/routes/advanced_test.ts | 138 + klanker-gate/apps/gateway/routes/analytics.ts | 45 + .../apps/gateway/routes/azure_ingress.ts | 146 + klanker-gate/apps/gateway/routes/catalog.ts | 72 + .../apps/gateway/routes/catalog_test.ts | 167 + klanker-gate/apps/gateway/routes/codemode.ts | 132 + klanker-gate/apps/gateway/routes/compat.ts | 349 ++ .../apps/gateway/routes/compat_families.ts | 812 ++++ .../apps/gateway/routes/extensions.ts | 154 + .../apps/gateway/routes/governance.ts | 1022 +++++ .../apps/gateway/routes/governance_test.ts | 84 + klanker-gate/apps/gateway/routes/helpers.ts | 247 ++ .../apps/gateway/routes/helpers_test.ts | 346 ++ klanker-gate/apps/gateway/routes/inference.ts | 770 ++++ klanker-gate/apps/gateway/routes/logs.ts | 146 + klanker-gate/apps/gateway/routes/logs_test.ts | 188 + klanker-gate/apps/gateway/routes/mcpserver.ts | 139 + .../apps/gateway/routes/openrouter_ingress.ts | 197 + .../apps/gateway/routes/origin-guard.ts | 158 + klanker-gate/apps/gateway/routes/runtime.ts | 158 + klanker-gate/apps/gateway/routes/settings.ts | 280 ++ .../apps/gateway/routes/settings_store.ts | 48 + klanker-gate/apps/gateway/routes/telemetry.ts | 444 ++ .../apps/gateway/routes/telemetry_test.ts | 243 ++ klanker-gate/arch/INSTALL-ARCH.md | 227 + klanker-gate/arch/PKGBUILD | 95 + klanker-gate/arch/README.md | 18 + klanker-gate/arch/SECURITY-UPSTREAM.md | 206 + klanker-gate/arch/env.example | 46 + klanker-gate/arch/klanker-gate.service | 68 + klanker-gate/arch/run.sh | 50 + klanker-gate/arch/sysusers.conf | 3 + klanker-gate/arch/tmpfiles.conf | 3 + klanker-gate/deno.jsonc | 73 + klanker-gate/deno.lock | 1273 ++++++ klanker-gate/deploy/docker-entrypoint.sh | 36 + klanker-gate/deploy/observability/README.md | 278 ++ .../dashboards/frosty-gateway.json | 1174 ++++++ .../dashboards/otel-collector.json | 1656 ++++++++ .../deploy/observability/grafana-observer.sh | 64 + .../observability/otel-collector-config.yaml | 88 + .../deploy/observability/prometheus.yml | 44 + .../provisioning/dashboards/dashboards.yml | 18 + .../provisioning/datasources/datasource.yml | 58 + klanker-gate/deploy/observability/tempo.yaml | 76 + .../deploy/vector-stores/docker-compose.yml | 83 + klanker-gate/docker-compose.yml | 385 ++ .../docs/assets/diagrams/data-flow.svg | 334 ++ .../docs/assets/diagrams/logic-flow.svg | 358 ++ .../docs/assets/diagrams/resource-flow.svg | 347 ++ klanker-gate/docs/benchmark-report.md | 364 ++ .../docs/concepts/architectural-overview.md | 159 + .../functionality-and-capabilities.md | 304 ++ klanker-gate/docs/concepts/security-model.md | 200 + .../fixtures/chat_completion_req.json | 11 + .../fixtures/chat_completion_res.json | 21 + klanker-gate/docs/design/DESIGN.md | 509 +++ klanker-gate/docs/design/design-system.html | 2260 ++++++++++ klanker-gate/docs/design/tokens.css | 331 ++ klanker-gate/docs/design/ui-design.md | 1372 ++++++ .../docs/getting-started/01-installation.md | 117 + .../docs/getting-started/02-configuration.md | 153 + .../getting-started/03-local-development.md | 177 + .../docs/guides/deploying-to-production.md | 119 + .../docs/guides/development-planning.md | 104 + klanker-gate/docs/guides/run-tests.md | 98 + .../docs/guides/setting-up-monitoring.md | 88 + klanker-gate/docs/index.md | 53 + klanker-gate/docs/reference/api-endpoints.md | 182 + .../docs/reference/commands-scripts.md | 102 + klanker-gate/docs/reference/data-model.md | 193 + klanker-gate/docs/reference/dependencies.md | 98 + .../docs/reference/docker-reference.md | 144 + .../docs/reference/environment-variables.md | 320 ++ klanker-gate/docs/reference/sbom.md | 292 ++ .../docs/reference/sbom/sbom.cyclonedx.json | 3660 +++++++++++++++++ klanker-gate/packages/cache/.gitkeep | 0 .../packages/cache/src/invalidation.ts | 155 + .../packages/cache/src/invalidation_test.ts | 205 + klanker-gate/packages/cache/src/pg_cache.ts | 220 + klanker-gate/packages/cache/src/pgvector.ts | 84 + klanker-gate/packages/cache/src/semantic.ts | 762 ++++ .../packages/cache/src/semantic_bench.ts | 121 + .../packages/cache/src/semantic_test.ts | 474 +++ klanker-gate/packages/cache/src/store.ts | 40 + .../packages/cache/src/store_bench.ts | 42 + .../packages/cache/src/stores_test.ts | 50 + .../packages/cache/src/two_tier_test.ts | 221 + klanker-gate/packages/cache/src/vector.ts | 73 + .../packages/cache/src/vector_test.ts | 146 + klanker-gate/packages/config/.gitkeep | 0 klanker-gate/packages/config/src/crypto.ts | 599 +++ .../packages/config/src/crypto_test.ts | 348 ++ klanker-gate/packages/config/src/env.ts | 206 + klanker-gate/packages/config/src/env_test.ts | 135 + klanker-gate/packages/config/src/pg.ts | 171 + klanker-gate/packages/config/src/pg_test.ts | 161 + klanker-gate/packages/config/src/pg_types.ts | 27 + klanker-gate/packages/config/src/service.ts | 457 ++ .../config/src/service_crypto_test.ts | 346 ++ klanker-gate/packages/config/src/store.ts | 126 + .../packages/config/src/store_bench.ts | 56 + .../packages/config/src/store_contract.ts | 301 ++ .../packages/config/src/store_memory.ts | 176 + .../packages/config/src/store_memory_test.ts | 66 + .../packages/config/src/store_postgres.ts | 311 ++ .../packages/config/src/store_test.ts | 17 + klanker-gate/packages/contracts/.gitkeep | 0 .../packages/contracts/src/audio.test.ts | 99 + klanker-gate/packages/contracts/src/audio.ts | 64 + klanker-gate/packages/contracts/src/compat.ts | 55 + klanker-gate/packages/contracts/src/config.ts | 423 ++ .../packages/contracts/src/config_test.ts | 221 + .../packages/contracts/src/file_batch.test.ts | 148 + .../packages/contracts/src/file_batch.ts | 118 + klanker-gate/packages/contracts/src/genai.ts | 53 + .../packages/contracts/src/image.test.ts | 116 + klanker-gate/packages/contracts/src/image.ts | 56 + .../packages/contracts/src/internal.ts | 47 + .../packages/contracts/src/limits.test.ts | 106 + klanker-gate/packages/contracts/src/limits.ts | 70 + klanker-gate/packages/contracts/src/mcp.ts | 24 + klanker-gate/packages/contracts/src/mod.ts | 19 + .../packages/contracts/src/pricing.test.ts | 238 ++ .../packages/contracts/src/pricing.ts | 118 + .../contracts/src/provider-registry.ts | 294 ++ .../packages/contracts/src/responses.test.ts | 219 + .../packages/contracts/src/responses.ts | 241 ++ .../packages/contracts/src/schemas.test.ts | 220 + .../packages/contracts/src/schemas.ts | 279 ++ .../packages/contracts/src/settings.ts | 148 + klanker-gate/packages/core/.gitkeep | 0 klanker-gate/packages/core/src/accumulate.ts | 331 ++ .../packages/core/src/accumulate_bench.ts | 122 + .../packages/core/src/accumulate_test.ts | 295 ++ klanker-gate/packages/core/src/errors.ts | 38 + klanker-gate/packages/core/src/middleware.ts | 136 + klanker-gate/packages/core/src/mod.ts | 9 + .../packages/core/src/orchestrator.ts | 222 + .../packages/core/src/orchestrator_test.ts | 267 ++ .../packages/core/src/responses_loop.ts | 184 + klanker-gate/packages/core/src/router.ts | 66 + klanker-gate/packages/core/src/router_test.ts | 75 + klanker-gate/packages/core/src/sse.ts | 29 + klanker-gate/packages/core/src/stream.ts | 153 + klanker-gate/packages/core/src/stream_test.ts | 135 + klanker-gate/packages/core/src/translate.ts | 865 ++++ .../packages/core/src/translate_bench.ts | 184 + .../packages/core/src/translate_test.ts | 308 ++ klanker-gate/packages/governance/.gitkeep | 0 .../packages/governance/src/budget_epochs.ts | 259 ++ .../packages/governance/src/hierarchy.ts | 298 ++ .../packages/governance/src/pricing.ts | 287 ++ .../packages/governance/src/pricing_sync.ts | 264 ++ .../governance/src/pricing_sync_test.ts | 217 + .../packages/governance/src/pricing_test.ts | 256 ++ .../governance/src/provider_budgets.ts | 277 ++ .../governance/src/provider_budgets_test.ts | 73 + .../packages/governance/src/rate_limit.ts | 58 + .../governance/src/rate_limit_test.ts | 30 + .../governance/src/shared_rate_limit.ts | 98 + .../governance/src/shared_rate_limit_test.ts | 177 + .../packages/governance/src/virtual_keys.ts | 398 ++ .../governance/src/virtual_keys_bench.ts | 102 + .../governance/src/virtual_keys_test.ts | 131 + klanker-gate/packages/mcp/.gitkeep | 0 klanker-gate/packages/mcp/src/client.ts | 658 +++ .../packages/mcp/src/codemode/broker.ts | 182 + .../packages/mcp/src/codemode/broker_test.ts | 213 + .../packages/mcp/src/codemode/codegen.ts | 426 ++ .../packages/mcp/src/codemode/executor.ts | 470 +++ .../mcp/src/codemode/executor_test.ts | 159 + .../packages/mcp/src/codemode/flag.ts | 113 + .../packages/mcp/src/codemode/future_test.ts | 808 ++++ .../src/codemode/inference_codemode_test.ts | 583 +++ .../packages/mcp/src/codemode/probe.ts | 29 + .../packages/mcp/src/codemode/route_test.ts | 264 ++ .../packages/mcp/src/codemode/sanitize.ts | 88 + .../mcp/src/codemode/sanitize_test.ts | 34 + klanker-gate/packages/mcp/src/codemode/vfs.ts | 76 + .../packages/mcp/src/codemode/vfs_test.ts | 362 ++ .../packages/mcp/src/codemode/worker.ts | 202 + klanker-gate/packages/mcp/src/monitor.ts | 133 + klanker-gate/packages/mcp/src/monitor_test.ts | 97 + klanker-gate/packages/mcp/src/registry.ts | 264 ++ .../packages/mcp/src/registry_test.ts | 321 ++ klanker-gate/packages/plugins/.gitkeep | 0 .../packages/plugins/src/jsonparser.ts | 188 + .../packages/plugins/src/jsonparser_test.ts | 173 + .../packages/plugins/src/lifecycle.ts | 253 ++ .../packages/plugins/src/lifecycle_test.ts | 238 ++ klanker-gate/packages/plugins/src/mocker.ts | 625 +++ .../packages/plugins/src/mocker_test.ts | 441 ++ klanker-gate/packages/providers/.gitkeep | 0 .../packages/providers/src/anthropic.ts | 860 ++++ .../providers/src/anthropic_advanced.ts | 240 ++ .../providers/src/anthropic_advanced_test.ts | 190 + .../packages/providers/src/anthropic_test.ts | 690 ++++ klanker-gate/packages/providers/src/audio.ts | 93 + .../packages/providers/src/audio_test.ts | 123 + .../packages/providers/src/aws_credentials.ts | 480 +++ .../providers/src/aws_credentials_test.ts | 288 ++ klanker-gate/packages/providers/src/azure.ts | 160 + .../packages/providers/src/bedrock.ts | 1346 ++++++ .../providers/src/bedrock_advanced.ts | 242 ++ .../providers/src/bedrock_advanced_test.ts | 207 + .../packages/providers/src/bedrock_test.ts | 1190 ++++++ klanker-gate/packages/providers/src/client.ts | 657 +++ .../providers/src/client_hardening_test.ts | 478 +++ klanker-gate/packages/providers/src/cohere.ts | 413 ++ .../packages/providers/src/cohere_test.ts | 240 ++ .../providers/src/config_honoring_test.ts | 211 + .../packages/providers/src/elevenlabs.ts | 132 + .../packages/providers/src/elevenlabs_test.ts | 144 + .../packages/providers/src/eventstream.ts | 273 ++ .../providers/src/eventstream_test.ts | 142 + .../packages/providers/src/fallback.ts | 71 + .../packages/providers/src/fallback_test.ts | 86 + .../packages/providers/src/gcp_credentials.ts | 99 + klanker-gate/packages/providers/src/gemini.ts | 750 ++++ .../packages/providers/src/gemini_advanced.ts | 365 ++ .../providers/src/gemini_advanced_test.ts | 284 ++ .../packages/providers/src/gemini_test.ts | 967 +++++ .../packages/providers/src/huggingface.ts | 595 +++ .../providers/src/huggingface_test.ts | 866 ++++ klanker-gate/packages/providers/src/imagen.ts | 74 + .../packages/providers/src/manager.ts | 540 +++ .../packages/providers/src/manager_test.ts | 412 ++ .../packages/providers/src/mistral_test.ts | 64 + klanker-gate/packages/providers/src/mod.ts | 18 + klanker-gate/packages/providers/src/openai.ts | 206 + .../packages/providers/src/openai_compat.ts | 23 + .../packages/providers/src/providers_test.ts | 173 + klanker-gate/packages/providers/src/s3.ts | 401 ++ .../packages/providers/src/s3_test.ts | 352 ++ klanker-gate/packages/providers/src/scope.ts | 25 + klanker-gate/packages/providers/src/sigv4.ts | 179 + .../packages/providers/src/sigv4_test.ts | 148 + klanker-gate/packages/providers/src/types.ts | 82 + klanker-gate/packages/providers/src/vertex.ts | 367 ++ .../packages/providers/src/vertex_test.ts | 434 ++ .../providers/src/wave4_fixes_test.ts | 88 + klanker-gate/packages/telemetry/.gitkeep | 0 .../packages/telemetry/src/concurrency.ts | 245 ++ .../telemetry/src/concurrency_test.ts | 237 ++ klanker-gate/packages/telemetry/src/logbus.ts | 292 ++ .../packages/telemetry/src/logbus_test.ts | 250 ++ .../packages/telemetry/src/logenrich.ts | 163 + .../packages/telemetry/src/logenrich_test.ts | 147 + .../packages/telemetry/src/logstore.ts | 329 ++ .../packages/telemetry/src/logstore_test.ts | 305 ++ .../packages/telemetry/src/metrics.ts | 401 ++ .../packages/telemetry/src/metrics_test.ts | 208 + klanker-gate/packages/telemetry/src/otel.ts | 141 + .../packages/telemetry/src/otel_test.ts | 82 + .../telemetry/src/span_cardinality.ts | 103 + .../telemetry/src/span_cardinality_test.ts | 92 + klanker-gate/packages/telemetry/src/trace.ts | 137 + .../packages/telemetry/src/trace_test.ts | 128 + klanker-gate/packages/telemetry/src/usage.ts | 483 +++ .../packages/telemetry/src/usage_test.ts | 839 ++++ .../packages/telemetry/src/usagestore.ts | 331 ++ .../packages/telemetry/src/usagestore_test.ts | 166 + klanker-gate/packages/testing/.gitkeep | 0 .../packages/testing/src/mock_provider.ts | 128 + klanker-gate/packages/testing/src/mod.ts | 1 + klanker-gate/permissions.md | 225 + .../scripts/codemode_launch_matrix.sh | 38 + .../scripts/codemode_worker_options_probe.ts | 65 + klanker-gate/scripts/full_suite.ts | 227 + klanker-gate/scripts/generate_sbom.ts | 597 +++ klanker-gate/scripts/load-bench.ts | 203 + klanker-gate/scripts/migrate_kv_to_pg.ts | 206 + klanker-gate/tests/browser/.gitignore | 3 + klanker-gate/tests/browser/gateway.spec.ts | 77 + klanker-gate/tests/browser/package-lock.json | 78 + klanker-gate/tests/browser/package.json | 12 + .../tests/browser/playwright.config.ts | 18 + .../contract/advanced_apis_extended_test.ts | 218 + .../tests/contract/advanced_apis_test.ts | 475 +++ .../tests/contract/golden_chat_test.ts | 234 ++ .../tests/contract/responses_agent_test.ts | 282 ++ .../tests/contract/settings_contract_test.ts | 264 ++ .../tests/contract/streaming_surfaces_test.ts | 279 ++ klanker-gate/tests/e2e/gateway_test.ts | 214 + klanker-gate/tests/e2e/ui_serving_test.ts | 111 + .../tests/integration/admin_api_test.ts | 370 ++ .../integration/anthropic_ingress_test.ts | 875 ++++ .../tests/integration/azure_ingress_test.ts | 371 ++ .../tests/integration/azure_longtail_test.ts | 226 + .../budget_reset_schedules_test.ts | 207 + klanker-gate/tests/integration/cache_test.ts | 320 ++ .../tests/integration/compat_prefixes_test.ts | 460 +++ .../integration/config_persistence_test.ts | 126 + .../integration/config_propagation_test.ts | 296 ++ .../tests/integration/dialect_billing_test.ts | 429 ++ .../tests/integration/durable_budgets_test.ts | 71 + .../tests/integration/governance_api_test.ts | 530 +++ .../tests/integration/log_enrichment_test.ts | 299 ++ .../tests/integration/mcp_autoinject_test.ts | 253 ++ .../tests/integration/mcp_hardening_test.ts | 218 + .../tests/integration/mcp_plugins_test.ts | 421 ++ .../tests/integration/mcp_transports_test.ts | 197 + .../tests/integration/mcp_wave3_test.ts | 288 ++ .../tests/integration/media_billing_test.ts | 1303 ++++++ .../tests/integration/observability_test.ts | 255 ++ .../integration/openrouter_ingress_test.ts | 502 +++ .../tests/integration/origin_guard_test.ts | 437 ++ .../integration/pricing_hierarchy_test.ts | 287 ++ .../integration/provider_breadth_test.ts | 438 ++ .../tests/integration/runtime_api_test.ts | 251 ++ .../tests/integration/serving_headers_test.ts | 495 +++ .../tests/integration/settings_api_test.ts | 178 + .../integration/shared_token_window_test.ts | 162 + .../tests/integration/telemetry_test.ts | 327 ++ .../tests/integration/tool_security_test.ts | 130 + .../tests/integration/wave4_fixes_test.ts | 298 ++ .../tests/live/postgres_state_live_test.ts | 292 ++ .../tests/live/vector_stores_live_test.ts | 104 + packaging/PKGBUILD | 2 +- packaging/debian/changelog | 73 + packaging/setup.py | 2 +- packaging/sysdeck.metainfo.xml | 37 + packaging/sysdeck.spec | 34 +- plugins/sysdeck-auth/auth.js | 62 +- plugins/sysdeck-auth/index.html | 1 + plugins/sysdeck-auth/manifest.json | 2 +- plugins/sysdeck-benchmark/benchmark.js | 39 +- plugins/sysdeck-benchmark/index.html | 1 + plugins/sysdeck-benchmark/manifest.json | 2 +- plugins/sysdeck-builder/index.html | 1 + plugins/sysdeck-builder/manifest.json | 2 +- plugins/sysdeck-containers/index.html | 1 + plugins/sysdeck-containers/manifest.json | 2 +- plugins/sysdeck-db/index.html | 1 + plugins/sysdeck-db/manifest.json | 2 +- plugins/sysdeck-fester/index.html | 1 + plugins/sysdeck-fester/manifest.json | 2 +- plugins/sysdeck-firewall/index.html | 1 + plugins/sysdeck-firewall/manifest.json | 2 +- plugins/sysdeck-firmware/firmware.js | 20 +- plugins/sysdeck-firmware/index.html | 1 + plugins/sysdeck-firmware/manifest.json | 2 +- plugins/sysdeck-fleet/index.html | 1 + plugins/sysdeck-fleet/manifest.json | 2 +- plugins/sysdeck-glances/glances.js | 2 +- plugins/sysdeck-glances/index.html | 1 + plugins/sysdeck-glances/manifest.json | 2 +- plugins/sysdeck-integrity/index.html | 1 + plugins/sysdeck-integrity/manifest.json | 2 +- plugins/sysdeck-jellyfin/index.html | 1 + plugins/sysdeck-jellyfin/jellyfin.js | 4 +- plugins/sysdeck-jellyfin/manifest.json | 2 +- plugins/sysdeck-kata/index.html | 1 + plugins/sysdeck-kata/manifest.json | 2 +- plugins/sysdeck-klanker/index.html | 70 + plugins/sysdeck-klanker/klanker.js | 878 ++++ plugins/sysdeck-klanker/manifest.json | 35 + plugins/sysdeck-mesh/index.html | 1 + plugins/sysdeck-mesh/manifest.json | 2 +- plugins/sysdeck-mesh/mesh.js | 25 +- plugins/sysdeck-mining/index.html | 1 + plugins/sysdeck-mining/manifest.json | 2 +- plugins/sysdeck-modules/index.html | 1 + plugins/sysdeck-modules/manifest.json | 2 +- plugins/sysdeck-monitoring/index.html | 1 + plugins/sysdeck-monitoring/manifest.json | 2 +- plugins/sysdeck-monitoring/monitoring.js | 4 +- plugins/sysdeck-netsec/index.html | 1 + plugins/sysdeck-netsec/manifest.json | 2 +- plugins/sysdeck-packages/index.html | 1 + plugins/sysdeck-packages/manifest.json | 2 +- plugins/sysdeck-packages/packages.js | 36 +- plugins/sysdeck-photos/index.html | 1 + plugins/sysdeck-photos/manifest.json | 2 +- plugins/sysdeck-photos/photos.js | 6 +- plugins/sysdeck-policy/index.html | 1 + plugins/sysdeck-policy/manifest.json | 2 +- plugins/sysdeck-remotefs/index.html | 1 + plugins/sysdeck-remotefs/manifest.json | 2 +- plugins/sysdeck-remotefs/remotefs.js | 4 +- plugins/sysdeck-sensors/index.html | 1 + plugins/sysdeck-sensors/manifest.json | 2 +- plugins/sysdeck-sensors/sensors.js | 36 +- plugins/sysdeck-services/index.html | 1 + plugins/sysdeck-services/manifest.json | 2 +- plugins/sysdeck-themes/index.html | 1 + plugins/sysdeck-themes/manifest.json | 2 +- plugins/sysdeck-vault/index.html | 1 + plugins/sysdeck-vault/manifest.json | 2 +- plugins/sysdeck-vault/vault.js | 23 +- scripts/generate-plugins.py | 25 + shared/branding.css | 149 + shared/bridge.js | 29 + shared/sysdeck-web.css | 348 ++ tests/test_bridge_parsers.py | 4 +- web/.env | 15 + web/README.md | 288 +- web/dev.log | 274 ++ web/eslint.config.mjs | 2 +- web/mini-services/fester/api.ts | 7 +- web/mini-services/fester/fester.db | Bin 0 -> 73728 bytes web/mini-services/fester/fester.db-shm | Bin 0 -> 32768 bytes web/mini-services/fester/fester.db-wal | Bin 0 -> 4120032 bytes web/mini-services/fester/index.ts | 99 + web/next-env.d.ts | 6 + web/package.json | 6 +- web/prisma/schema.prisma | 59 + web/scripts/make-master-tarball.sh | 457 +- web/scripts/manage-users.mjs | 187 + web/scripts/pam-auth.py | 226 + web/src/app/api/auth/login/route.ts | 190 + web/src/app/api/auth/logout/route.ts | 24 + web/src/app/api/auth/session/route.ts | 45 + web/src/app/api/bridge/route.ts | 78 +- web/src/app/api/fester/route.ts | 16 +- web/src/app/api/release/route.ts | 38 +- web/src/app/api/route.ts | 22 +- web/src/app/globals.css | 82 + web/src/app/layout.tsx | 4 +- web/src/app/page.tsx | 397 +- web/src/components/sysdeck/login-screen.tsx | 285 ++ web/src/components/sysdeck/panels-map.tsx | 3 + .../sysdeck/panels/cockpitModulesPanel.tsx | 335 ++ .../sysdeck/panels/glancesPanel.tsx | 2 +- .../sysdeck/panels/klankerPanel.tsx | 595 +++ .../sysdeck/panels/overviewPanel.tsx | 41 +- .../sysdeck/panels/packagesPanel.tsx | 6 +- .../sysdeck/panels/runbookPanel.tsx | 389 ++ .../sysdeck/panels/servicesPanel.tsx | 2 +- web/src/components/sysdeck/shell.tsx | 774 ++++ web/src/lib/sysdeck/bridge/cockpitmodules.ts | 277 ++ web/src/lib/sysdeck/bridge/index.ts | 6 + web/src/lib/sysdeck/bridge/klanker.ts | 827 ++++ web/src/lib/sysdeck/bridge/overview.ts | 9 +- web/src/lib/sysdeck/bridge/shared.ts | 11 +- web/src/lib/sysdeck/bridge/shell.ts | 80 + web/src/lib/sysdeck/client.ts | 16 + web/src/lib/sysdeck/pam.ts | 124 + web/src/lib/sysdeck/registry.ts | 7 +- web/src/lib/sysdeck/session.ts | 207 + web/src/lib/sysdeck/types.ts | 27 + web/src/lib/sysdeck/users.ts | 263 ++ web/tsconfig.tsbuildinfo | 1 + worklog.md | 49 + 656 files changed, 123932 insertions(+), 565 deletions(-) create mode 100644 bridge/__pycache__/__init__.cpython-312.pyc create mode 100644 bridge/__pycache__/auth.cpython-312.pyc create mode 100644 bridge/__pycache__/benchmark.cpython-312.pyc create mode 100644 bridge/__pycache__/builder.cpython-312.pyc create mode 100644 bridge/__pycache__/containers.cpython-312.pyc create mode 100644 bridge/__pycache__/db.cpython-312.pyc create mode 100644 bridge/__pycache__/fester.cpython-312.pyc create mode 100644 bridge/__pycache__/firewall.cpython-312.pyc create mode 100644 bridge/__pycache__/firmware.cpython-312.pyc create mode 100644 bridge/__pycache__/fleet.cpython-312.pyc create mode 100644 bridge/__pycache__/glances.cpython-312.pyc create mode 100644 bridge/__pycache__/grafana.cpython-312.pyc create mode 100644 bridge/__pycache__/hwalert.cpython-312.pyc create mode 100644 bridge/__pycache__/integrity.cpython-312.pyc create mode 100644 bridge/__pycache__/jellyfin.cpython-312.pyc create mode 100644 bridge/__pycache__/kata.cpython-312.pyc create mode 100644 bridge/__pycache__/klanker.cpython-312.pyc create mode 100644 bridge/__pycache__/mesh.cpython-312.pyc create mode 100644 bridge/__pycache__/mining.cpython-312.pyc create mode 100644 bridge/__pycache__/modules3p.cpython-312.pyc create mode 100644 bridge/__pycache__/netsec.cpython-312.pyc create mode 100644 bridge/__pycache__/packages.cpython-312.pyc create mode 100644 bridge/__pycache__/photos.cpython-312.pyc create mode 100644 bridge/__pycache__/policy.cpython-312.pyc create mode 100644 bridge/__pycache__/prometheus.cpython-312.pyc create mode 100644 bridge/__pycache__/remotefs.cpython-312.pyc create mode 100644 bridge/__pycache__/sensors.cpython-312.pyc create mode 100644 bridge/__pycache__/themes.cpython-312.pyc create mode 100644 bridge/__pycache__/vault.cpython-312.pyc create mode 100644 bridge/klanker.py create mode 100644 bridge/modules/__pycache__/__init__.cpython-312.pyc create mode 100755 klanker-gate/.dockerignore create mode 100755 klanker-gate/.editorconfig create mode 100755 klanker-gate/.env.example create mode 100755 klanker-gate/.env.example.dev create mode 100755 klanker-gate/.gitattributes create mode 100755 klanker-gate/.github/CODEOWNERS create mode 100755 klanker-gate/.github/ISSUE_TEMPLATE/bug_report.yml create mode 100755 klanker-gate/.github/ISSUE_TEMPLATE/config.yml create mode 100755 klanker-gate/.github/ISSUE_TEMPLATE/docs_issue.yml create mode 100755 klanker-gate/.github/ISSUE_TEMPLATE/feature_request.yml create mode 100755 klanker-gate/.github/pull_request_template.md create mode 100755 klanker-gate/.gitignore create mode 100755 klanker-gate/AGENTS.md create mode 100644 klanker-gate/ATTRIBUTION.md create mode 100755 klanker-gate/CHANGELOG.md create mode 100755 klanker-gate/CLAUDE.md create mode 100755 klanker-gate/CODE_OF_CONDUCT.md create mode 100755 klanker-gate/CONDUCT.md create mode 100755 klanker-gate/CONTRIBUTING.md create mode 100755 klanker-gate/Dockerfile create mode 100755 klanker-gate/LICENSE create mode 100755 klanker-gate/README.md create mode 100755 klanker-gate/SECURITY.md create mode 100755 klanker-gate/TODO.md create mode 100755 klanker-gate/apps/control-ui/CONVENTIONS.md create mode 100755 klanker-gate/apps/control-ui/deno.jsonc create mode 100755 klanker-gate/apps/control-ui/index.html create mode 100755 klanker-gate/apps/control-ui/package.json create mode 100755 klanker-gate/apps/control-ui/src/App.nav.test.tsx create mode 100755 klanker-gate/apps/control-ui/src/App.rebuild.test.tsx create mode 100755 klanker-gate/apps/control-ui/src/App.test.tsx create mode 100755 klanker-gate/apps/control-ui/src/App.tsx create mode 100755 klanker-gate/apps/control-ui/src/api.ts create mode 100755 klanker-gate/apps/control-ui/src/components/catalog/ProviderModelsDialog.tsx create mode 100755 klanker-gate/apps/control-ui/src/components/dashboard/ChartCard.tsx create mode 100755 klanker-gate/apps/control-ui/src/components/dashboard/adapters.ts create mode 100755 klanker-gate/apps/control-ui/src/components/logs/ColumnPicker.tsx create mode 100755 klanker-gate/apps/control-ui/src/components/logs/LogsAnalytics.tsx create mode 100755 klanker-gate/apps/control-ui/src/components/logs/LogsFacetRail.tsx create mode 100755 klanker-gate/apps/control-ui/src/components/logs/LogsTable.tsx create mode 100755 klanker-gate/apps/control-ui/src/components/logs/logs-model.ts create mode 100755 klanker-gate/apps/control-ui/src/components/providers/AddCustomProviderForm.tsx create mode 100755 klanker-gate/apps/control-ui/src/components/providers/AddProviderDialog.tsx create mode 100755 klanker-gate/apps/control-ui/src/components/providers/AddProviderForm.tsx create mode 100755 klanker-gate/apps/control-ui/src/components/providers/ProviderConfigPanel.tsx create mode 100755 klanker-gate/apps/control-ui/src/components/providers/SecretReenter.tsx create mode 100755 klanker-gate/apps/control-ui/src/components/providers/constants.ts create mode 100755 klanker-gate/apps/control-ui/src/components/settings/CacheOpsPanel.tsx create mode 100755 klanker-gate/apps/control-ui/src/components/settings/CachingPanel.tsx create mode 100755 klanker-gate/apps/control-ui/src/components/settings/CodeModeVfsPreview.tsx create mode 100755 klanker-gate/apps/control-ui/src/components/settings/CompatibilityPanel.tsx create mode 100755 klanker-gate/apps/control-ui/src/components/settings/ConfigPanel.tsx create mode 100755 klanker-gate/apps/control-ui/src/components/settings/McpPanel.tsx create mode 100755 klanker-gate/apps/control-ui/src/components/settings/PerformancePanel.tsx create mode 100755 klanker-gate/apps/control-ui/src/components/settings/SecurityPanel.tsx create mode 100755 klanker-gate/apps/control-ui/src/components/settings/helpers.tsx create mode 100755 klanker-gate/apps/control-ui/src/components/shell/AdminTokenDialog.tsx create mode 100755 klanker-gate/apps/control-ui/src/components/shell/CommandPalette.test.tsx create mode 100755 klanker-gate/apps/control-ui/src/components/shell/CommandPalette.tsx create mode 100755 klanker-gate/apps/control-ui/src/components/shell/Sidebar.tsx create mode 100755 klanker-gate/apps/control-ui/src/components/ui/badge.tsx create mode 100755 klanker-gate/apps/control-ui/src/components/ui/banner.tsx create mode 100755 klanker-gate/apps/control-ui/src/components/ui/button.tsx create mode 100755 klanker-gate/apps/control-ui/src/components/ui/card.tsx create mode 100755 klanker-gate/apps/control-ui/src/components/ui/chart.tsx create mode 100755 klanker-gate/apps/control-ui/src/components/ui/checkbox.tsx create mode 100755 klanker-gate/apps/control-ui/src/components/ui/collapsible.test.tsx create mode 100755 klanker-gate/apps/control-ui/src/components/ui/collapsible.tsx create mode 100755 klanker-gate/apps/control-ui/src/components/ui/combobox.test.tsx create mode 100755 klanker-gate/apps/control-ui/src/components/ui/combobox.tsx create mode 100755 klanker-gate/apps/control-ui/src/components/ui/copy-button.tsx create mode 100755 klanker-gate/apps/control-ui/src/components/ui/data-table.test.tsx create mode 100755 klanker-gate/apps/control-ui/src/components/ui/data-table.tsx create mode 100755 klanker-gate/apps/control-ui/src/components/ui/dialog.tsx create mode 100755 klanker-gate/apps/control-ui/src/components/ui/dropdown-menu.test.tsx create mode 100755 klanker-gate/apps/control-ui/src/components/ui/dropdown-menu.tsx create mode 100755 klanker-gate/apps/control-ui/src/components/ui/empty-state.tsx create mode 100755 klanker-gate/apps/control-ui/src/components/ui/export-button.test.tsx create mode 100755 klanker-gate/apps/control-ui/src/components/ui/export-button.tsx create mode 100755 klanker-gate/apps/control-ui/src/components/ui/facet-rail.test.tsx create mode 100755 klanker-gate/apps/control-ui/src/components/ui/facet-rail.tsx create mode 100755 klanker-gate/apps/control-ui/src/components/ui/input.tsx create mode 100755 klanker-gate/apps/control-ui/src/components/ui/key-value-rows.test.tsx create mode 100755 klanker-gate/apps/control-ui/src/components/ui/key-value-rows.tsx create mode 100755 klanker-gate/apps/control-ui/src/components/ui/label.tsx create mode 100755 klanker-gate/apps/control-ui/src/components/ui/masked-secret.test.tsx create mode 100755 klanker-gate/apps/control-ui/src/components/ui/masked-secret.tsx create mode 100755 klanker-gate/apps/control-ui/src/components/ui/nav-tabs.test.tsx create mode 100755 klanker-gate/apps/control-ui/src/components/ui/nav-tabs.tsx create mode 100755 klanker-gate/apps/control-ui/src/components/ui/number-field.test.tsx create mode 100755 klanker-gate/apps/control-ui/src/components/ui/number-field.tsx create mode 100755 klanker-gate/apps/control-ui/src/components/ui/page-header.tsx create mode 100755 klanker-gate/apps/control-ui/src/components/ui/pem-textarea.test.tsx create mode 100755 klanker-gate/apps/control-ui/src/components/ui/pem-textarea.tsx create mode 100755 klanker-gate/apps/control-ui/src/components/ui/provider-icon.test.tsx create mode 100755 klanker-gate/apps/control-ui/src/components/ui/provider-icon.tsx create mode 100755 klanker-gate/apps/control-ui/src/components/ui/provider-logos.tsx create mode 100755 klanker-gate/apps/control-ui/src/components/ui/segmented-select.test.tsx create mode 100755 klanker-gate/apps/control-ui/src/components/ui/segmented-select.tsx create mode 100755 klanker-gate/apps/control-ui/src/components/ui/select.tsx create mode 100755 klanker-gate/apps/control-ui/src/components/ui/sheet.tsx create mode 100755 klanker-gate/apps/control-ui/src/components/ui/skeleton.tsx create mode 100755 klanker-gate/apps/control-ui/src/components/ui/spinner.tsx create mode 100755 klanker-gate/apps/control-ui/src/components/ui/stat-tile.tsx create mode 100755 klanker-gate/apps/control-ui/src/components/ui/switch.tsx create mode 100755 klanker-gate/apps/control-ui/src/components/ui/table.tsx create mode 100755 klanker-gate/apps/control-ui/src/components/ui/tabs.tsx create mode 100755 klanker-gate/apps/control-ui/src/components/ui/tag-input.tsx create mode 100755 klanker-gate/apps/control-ui/src/components/ui/time-range-picker.test.tsx create mode 100755 klanker-gate/apps/control-ui/src/components/ui/time-range-picker.tsx create mode 100755 klanker-gate/apps/control-ui/src/components/ui/toast.tsx create mode 100755 klanker-gate/apps/control-ui/src/components/ui/toggle-grid-item.test.tsx create mode 100755 klanker-gate/apps/control-ui/src/components/ui/toggle-grid-item.tsx create mode 100755 klanker-gate/apps/control-ui/src/components/ui/two-pane.overflow.test.tsx create mode 100755 klanker-gate/apps/control-ui/src/components/ui/two-pane.test.tsx create mode 100755 klanker-gate/apps/control-ui/src/components/ui/two-pane.tsx create mode 100755 klanker-gate/apps/control-ui/src/components/ui/use-modal.ts create mode 100755 klanker-gate/apps/control-ui/src/index.css create mode 100755 klanker-gate/apps/control-ui/src/lib/analytics.test.ts create mode 100755 klanker-gate/apps/control-ui/src/lib/analytics.ts create mode 100755 klanker-gate/apps/control-ui/src/lib/csv.test.ts create mode 100755 klanker-gate/apps/control-ui/src/lib/csv.ts create mode 100755 klanker-gate/apps/control-ui/src/lib/currency.ts create mode 100755 klanker-gate/apps/control-ui/src/lib/governance.tsx create mode 100755 klanker-gate/apps/control-ui/src/lib/nav.test.ts create mode 100755 klanker-gate/apps/control-ui/src/lib/nav.ts create mode 100755 klanker-gate/apps/control-ui/src/lib/table.test.ts create mode 100755 klanker-gate/apps/control-ui/src/lib/table.ts create mode 100755 klanker-gate/apps/control-ui/src/lib/utils.ts create mode 100755 klanker-gate/apps/control-ui/src/main.tsx create mode 100755 klanker-gate/apps/control-ui/src/styles/tokens.css create mode 100755 klanker-gate/apps/control-ui/src/test/setup.ts create mode 100755 klanker-gate/apps/control-ui/src/views/CustomersView.tsx create mode 100755 klanker-gate/apps/control-ui/src/views/DashboardView.test.tsx create mode 100755 klanker-gate/apps/control-ui/src/views/DashboardView.tsx create mode 100755 klanker-gate/apps/control-ui/src/views/ExtensionsView.tsx create mode 100755 klanker-gate/apps/control-ui/src/views/LogsView.test.tsx create mode 100755 klanker-gate/apps/control-ui/src/views/LogsView.tsx create mode 100755 klanker-gate/apps/control-ui/src/views/ModelCatalogView.test.tsx create mode 100755 klanker-gate/apps/control-ui/src/views/ModelCatalogView.tsx create mode 100755 klanker-gate/apps/control-ui/src/views/PricingView.tsx create mode 100755 klanker-gate/apps/control-ui/src/views/ProvidersView.test.tsx create mode 100755 klanker-gate/apps/control-ui/src/views/ProvidersView.tsx create mode 100755 klanker-gate/apps/control-ui/src/views/SettingsView.test.tsx create mode 100755 klanker-gate/apps/control-ui/src/views/SettingsView.tsx create mode 100755 klanker-gate/apps/control-ui/src/views/StatusView.test.tsx create mode 100755 klanker-gate/apps/control-ui/src/views/StatusView.tsx create mode 100755 klanker-gate/apps/control-ui/src/views/TeamsView.tsx create mode 100755 klanker-gate/apps/control-ui/src/views/VirtualKeysView.test.tsx create mode 100755 klanker-gate/apps/control-ui/src/views/VirtualKeysView.tsx create mode 100755 klanker-gate/apps/control-ui/src/vite-env.d.ts create mode 100755 klanker-gate/apps/control-ui/tsconfig.json create mode 100755 klanker-gate/apps/control-ui/tsconfig.node.json create mode 100755 klanker-gate/apps/control-ui/vite.config.ts create mode 100755 klanker-gate/apps/gateway/cluster.ts create mode 100755 klanker-gate/apps/gateway/cluster_test.ts create mode 100755 klanker-gate/apps/gateway/context.ts create mode 100755 klanker-gate/apps/gateway/context_test.ts create mode 100755 klanker-gate/apps/gateway/main.ts create mode 100755 klanker-gate/apps/gateway/main_test.ts create mode 100755 klanker-gate/apps/gateway/routes/admin.ts create mode 100755 klanker-gate/apps/gateway/routes/admin_test.ts create mode 100755 klanker-gate/apps/gateway/routes/advanced.ts create mode 100755 klanker-gate/apps/gateway/routes/advanced_test.ts create mode 100755 klanker-gate/apps/gateway/routes/analytics.ts create mode 100755 klanker-gate/apps/gateway/routes/azure_ingress.ts create mode 100755 klanker-gate/apps/gateway/routes/catalog.ts create mode 100755 klanker-gate/apps/gateway/routes/catalog_test.ts create mode 100755 klanker-gate/apps/gateway/routes/codemode.ts create mode 100755 klanker-gate/apps/gateway/routes/compat.ts create mode 100755 klanker-gate/apps/gateway/routes/compat_families.ts create mode 100755 klanker-gate/apps/gateway/routes/extensions.ts create mode 100755 klanker-gate/apps/gateway/routes/governance.ts create mode 100755 klanker-gate/apps/gateway/routes/governance_test.ts create mode 100755 klanker-gate/apps/gateway/routes/helpers.ts create mode 100755 klanker-gate/apps/gateway/routes/helpers_test.ts create mode 100755 klanker-gate/apps/gateway/routes/inference.ts create mode 100755 klanker-gate/apps/gateway/routes/logs.ts create mode 100755 klanker-gate/apps/gateway/routes/logs_test.ts create mode 100755 klanker-gate/apps/gateway/routes/mcpserver.ts create mode 100755 klanker-gate/apps/gateway/routes/openrouter_ingress.ts create mode 100755 klanker-gate/apps/gateway/routes/origin-guard.ts create mode 100755 klanker-gate/apps/gateway/routes/runtime.ts create mode 100755 klanker-gate/apps/gateway/routes/settings.ts create mode 100755 klanker-gate/apps/gateway/routes/settings_store.ts create mode 100755 klanker-gate/apps/gateway/routes/telemetry.ts create mode 100755 klanker-gate/apps/gateway/routes/telemetry_test.ts create mode 100644 klanker-gate/arch/INSTALL-ARCH.md create mode 100644 klanker-gate/arch/PKGBUILD create mode 100644 klanker-gate/arch/README.md create mode 100644 klanker-gate/arch/SECURITY-UPSTREAM.md create mode 100644 klanker-gate/arch/env.example create mode 100644 klanker-gate/arch/klanker-gate.service create mode 100755 klanker-gate/arch/run.sh create mode 100644 klanker-gate/arch/sysusers.conf create mode 100644 klanker-gate/arch/tmpfiles.conf create mode 100755 klanker-gate/deno.jsonc create mode 100755 klanker-gate/deno.lock create mode 100755 klanker-gate/deploy/docker-entrypoint.sh create mode 100755 klanker-gate/deploy/observability/README.md create mode 100755 klanker-gate/deploy/observability/dashboards/frosty-gateway.json create mode 100755 klanker-gate/deploy/observability/dashboards/otel-collector.json create mode 100755 klanker-gate/deploy/observability/grafana-observer.sh create mode 100755 klanker-gate/deploy/observability/otel-collector-config.yaml create mode 100755 klanker-gate/deploy/observability/prometheus.yml create mode 100755 klanker-gate/deploy/observability/provisioning/dashboards/dashboards.yml create mode 100755 klanker-gate/deploy/observability/provisioning/datasources/datasource.yml create mode 100755 klanker-gate/deploy/observability/tempo.yaml create mode 100755 klanker-gate/deploy/vector-stores/docker-compose.yml create mode 100755 klanker-gate/docker-compose.yml create mode 100755 klanker-gate/docs/assets/diagrams/data-flow.svg create mode 100755 klanker-gate/docs/assets/diagrams/logic-flow.svg create mode 100755 klanker-gate/docs/assets/diagrams/resource-flow.svg create mode 100755 klanker-gate/docs/benchmark-report.md create mode 100755 klanker-gate/docs/concepts/architectural-overview.md create mode 100755 klanker-gate/docs/concepts/functionality-and-capabilities.md create mode 100755 klanker-gate/docs/concepts/security-model.md create mode 100755 klanker-gate/docs/contracts/fixtures/chat_completion_req.json create mode 100755 klanker-gate/docs/contracts/fixtures/chat_completion_res.json create mode 100755 klanker-gate/docs/design/DESIGN.md create mode 100755 klanker-gate/docs/design/design-system.html create mode 100755 klanker-gate/docs/design/tokens.css create mode 100755 klanker-gate/docs/design/ui-design.md create mode 100755 klanker-gate/docs/getting-started/01-installation.md create mode 100755 klanker-gate/docs/getting-started/02-configuration.md create mode 100755 klanker-gate/docs/getting-started/03-local-development.md create mode 100755 klanker-gate/docs/guides/deploying-to-production.md create mode 100755 klanker-gate/docs/guides/development-planning.md create mode 100755 klanker-gate/docs/guides/run-tests.md create mode 100755 klanker-gate/docs/guides/setting-up-monitoring.md create mode 100755 klanker-gate/docs/index.md create mode 100755 klanker-gate/docs/reference/api-endpoints.md create mode 100755 klanker-gate/docs/reference/commands-scripts.md create mode 100755 klanker-gate/docs/reference/data-model.md create mode 100755 klanker-gate/docs/reference/dependencies.md create mode 100755 klanker-gate/docs/reference/docker-reference.md create mode 100755 klanker-gate/docs/reference/environment-variables.md create mode 100755 klanker-gate/docs/reference/sbom.md create mode 100755 klanker-gate/docs/reference/sbom/sbom.cyclonedx.json create mode 100755 klanker-gate/packages/cache/.gitkeep create mode 100755 klanker-gate/packages/cache/src/invalidation.ts create mode 100755 klanker-gate/packages/cache/src/invalidation_test.ts create mode 100755 klanker-gate/packages/cache/src/pg_cache.ts create mode 100755 klanker-gate/packages/cache/src/pgvector.ts create mode 100755 klanker-gate/packages/cache/src/semantic.ts create mode 100755 klanker-gate/packages/cache/src/semantic_bench.ts create mode 100755 klanker-gate/packages/cache/src/semantic_test.ts create mode 100755 klanker-gate/packages/cache/src/store.ts create mode 100755 klanker-gate/packages/cache/src/store_bench.ts create mode 100755 klanker-gate/packages/cache/src/stores_test.ts create mode 100755 klanker-gate/packages/cache/src/two_tier_test.ts create mode 100755 klanker-gate/packages/cache/src/vector.ts create mode 100755 klanker-gate/packages/cache/src/vector_test.ts create mode 100755 klanker-gate/packages/config/.gitkeep create mode 100755 klanker-gate/packages/config/src/crypto.ts create mode 100755 klanker-gate/packages/config/src/crypto_test.ts create mode 100755 klanker-gate/packages/config/src/env.ts create mode 100755 klanker-gate/packages/config/src/env_test.ts create mode 100755 klanker-gate/packages/config/src/pg.ts create mode 100755 klanker-gate/packages/config/src/pg_test.ts create mode 100755 klanker-gate/packages/config/src/pg_types.ts create mode 100755 klanker-gate/packages/config/src/service.ts create mode 100755 klanker-gate/packages/config/src/service_crypto_test.ts create mode 100755 klanker-gate/packages/config/src/store.ts create mode 100755 klanker-gate/packages/config/src/store_bench.ts create mode 100755 klanker-gate/packages/config/src/store_contract.ts create mode 100755 klanker-gate/packages/config/src/store_memory.ts create mode 100755 klanker-gate/packages/config/src/store_memory_test.ts create mode 100755 klanker-gate/packages/config/src/store_postgres.ts create mode 100755 klanker-gate/packages/config/src/store_test.ts create mode 100755 klanker-gate/packages/contracts/.gitkeep create mode 100755 klanker-gate/packages/contracts/src/audio.test.ts create mode 100755 klanker-gate/packages/contracts/src/audio.ts create mode 100755 klanker-gate/packages/contracts/src/compat.ts create mode 100755 klanker-gate/packages/contracts/src/config.ts create mode 100755 klanker-gate/packages/contracts/src/config_test.ts create mode 100755 klanker-gate/packages/contracts/src/file_batch.test.ts create mode 100755 klanker-gate/packages/contracts/src/file_batch.ts create mode 100755 klanker-gate/packages/contracts/src/genai.ts create mode 100755 klanker-gate/packages/contracts/src/image.test.ts create mode 100755 klanker-gate/packages/contracts/src/image.ts create mode 100755 klanker-gate/packages/contracts/src/internal.ts create mode 100755 klanker-gate/packages/contracts/src/limits.test.ts create mode 100755 klanker-gate/packages/contracts/src/limits.ts create mode 100755 klanker-gate/packages/contracts/src/mcp.ts create mode 100755 klanker-gate/packages/contracts/src/mod.ts create mode 100755 klanker-gate/packages/contracts/src/pricing.test.ts create mode 100755 klanker-gate/packages/contracts/src/pricing.ts create mode 100755 klanker-gate/packages/contracts/src/provider-registry.ts create mode 100755 klanker-gate/packages/contracts/src/responses.test.ts create mode 100755 klanker-gate/packages/contracts/src/responses.ts create mode 100755 klanker-gate/packages/contracts/src/schemas.test.ts create mode 100755 klanker-gate/packages/contracts/src/schemas.ts create mode 100755 klanker-gate/packages/contracts/src/settings.ts create mode 100755 klanker-gate/packages/core/.gitkeep create mode 100755 klanker-gate/packages/core/src/accumulate.ts create mode 100755 klanker-gate/packages/core/src/accumulate_bench.ts create mode 100755 klanker-gate/packages/core/src/accumulate_test.ts create mode 100755 klanker-gate/packages/core/src/errors.ts create mode 100755 klanker-gate/packages/core/src/middleware.ts create mode 100755 klanker-gate/packages/core/src/mod.ts create mode 100755 klanker-gate/packages/core/src/orchestrator.ts create mode 100755 klanker-gate/packages/core/src/orchestrator_test.ts create mode 100755 klanker-gate/packages/core/src/responses_loop.ts create mode 100755 klanker-gate/packages/core/src/router.ts create mode 100755 klanker-gate/packages/core/src/router_test.ts create mode 100755 klanker-gate/packages/core/src/sse.ts create mode 100755 klanker-gate/packages/core/src/stream.ts create mode 100755 klanker-gate/packages/core/src/stream_test.ts create mode 100755 klanker-gate/packages/core/src/translate.ts create mode 100755 klanker-gate/packages/core/src/translate_bench.ts create mode 100755 klanker-gate/packages/core/src/translate_test.ts create mode 100755 klanker-gate/packages/governance/.gitkeep create mode 100755 klanker-gate/packages/governance/src/budget_epochs.ts create mode 100755 klanker-gate/packages/governance/src/hierarchy.ts create mode 100755 klanker-gate/packages/governance/src/pricing.ts create mode 100755 klanker-gate/packages/governance/src/pricing_sync.ts create mode 100755 klanker-gate/packages/governance/src/pricing_sync_test.ts create mode 100755 klanker-gate/packages/governance/src/pricing_test.ts create mode 100755 klanker-gate/packages/governance/src/provider_budgets.ts create mode 100755 klanker-gate/packages/governance/src/provider_budgets_test.ts create mode 100755 klanker-gate/packages/governance/src/rate_limit.ts create mode 100755 klanker-gate/packages/governance/src/rate_limit_test.ts create mode 100755 klanker-gate/packages/governance/src/shared_rate_limit.ts create mode 100755 klanker-gate/packages/governance/src/shared_rate_limit_test.ts create mode 100755 klanker-gate/packages/governance/src/virtual_keys.ts create mode 100755 klanker-gate/packages/governance/src/virtual_keys_bench.ts create mode 100755 klanker-gate/packages/governance/src/virtual_keys_test.ts create mode 100755 klanker-gate/packages/mcp/.gitkeep create mode 100755 klanker-gate/packages/mcp/src/client.ts create mode 100755 klanker-gate/packages/mcp/src/codemode/broker.ts create mode 100755 klanker-gate/packages/mcp/src/codemode/broker_test.ts create mode 100755 klanker-gate/packages/mcp/src/codemode/codegen.ts create mode 100755 klanker-gate/packages/mcp/src/codemode/executor.ts create mode 100755 klanker-gate/packages/mcp/src/codemode/executor_test.ts create mode 100755 klanker-gate/packages/mcp/src/codemode/flag.ts create mode 100755 klanker-gate/packages/mcp/src/codemode/future_test.ts create mode 100755 klanker-gate/packages/mcp/src/codemode/inference_codemode_test.ts create mode 100755 klanker-gate/packages/mcp/src/codemode/probe.ts create mode 100755 klanker-gate/packages/mcp/src/codemode/route_test.ts create mode 100755 klanker-gate/packages/mcp/src/codemode/sanitize.ts create mode 100755 klanker-gate/packages/mcp/src/codemode/sanitize_test.ts create mode 100755 klanker-gate/packages/mcp/src/codemode/vfs.ts create mode 100755 klanker-gate/packages/mcp/src/codemode/vfs_test.ts create mode 100755 klanker-gate/packages/mcp/src/codemode/worker.ts create mode 100755 klanker-gate/packages/mcp/src/monitor.ts create mode 100755 klanker-gate/packages/mcp/src/monitor_test.ts create mode 100755 klanker-gate/packages/mcp/src/registry.ts create mode 100755 klanker-gate/packages/mcp/src/registry_test.ts create mode 100755 klanker-gate/packages/plugins/.gitkeep create mode 100755 klanker-gate/packages/plugins/src/jsonparser.ts create mode 100755 klanker-gate/packages/plugins/src/jsonparser_test.ts create mode 100755 klanker-gate/packages/plugins/src/lifecycle.ts create mode 100755 klanker-gate/packages/plugins/src/lifecycle_test.ts create mode 100755 klanker-gate/packages/plugins/src/mocker.ts create mode 100755 klanker-gate/packages/plugins/src/mocker_test.ts create mode 100755 klanker-gate/packages/providers/.gitkeep create mode 100755 klanker-gate/packages/providers/src/anthropic.ts create mode 100755 klanker-gate/packages/providers/src/anthropic_advanced.ts create mode 100755 klanker-gate/packages/providers/src/anthropic_advanced_test.ts create mode 100755 klanker-gate/packages/providers/src/anthropic_test.ts create mode 100755 klanker-gate/packages/providers/src/audio.ts create mode 100755 klanker-gate/packages/providers/src/audio_test.ts create mode 100755 klanker-gate/packages/providers/src/aws_credentials.ts create mode 100755 klanker-gate/packages/providers/src/aws_credentials_test.ts create mode 100755 klanker-gate/packages/providers/src/azure.ts create mode 100755 klanker-gate/packages/providers/src/bedrock.ts create mode 100755 klanker-gate/packages/providers/src/bedrock_advanced.ts create mode 100755 klanker-gate/packages/providers/src/bedrock_advanced_test.ts create mode 100755 klanker-gate/packages/providers/src/bedrock_test.ts create mode 100755 klanker-gate/packages/providers/src/client.ts create mode 100755 klanker-gate/packages/providers/src/client_hardening_test.ts create mode 100755 klanker-gate/packages/providers/src/cohere.ts create mode 100755 klanker-gate/packages/providers/src/cohere_test.ts create mode 100755 klanker-gate/packages/providers/src/config_honoring_test.ts create mode 100755 klanker-gate/packages/providers/src/elevenlabs.ts create mode 100755 klanker-gate/packages/providers/src/elevenlabs_test.ts create mode 100755 klanker-gate/packages/providers/src/eventstream.ts create mode 100755 klanker-gate/packages/providers/src/eventstream_test.ts create mode 100755 klanker-gate/packages/providers/src/fallback.ts create mode 100755 klanker-gate/packages/providers/src/fallback_test.ts create mode 100755 klanker-gate/packages/providers/src/gcp_credentials.ts create mode 100755 klanker-gate/packages/providers/src/gemini.ts create mode 100755 klanker-gate/packages/providers/src/gemini_advanced.ts create mode 100755 klanker-gate/packages/providers/src/gemini_advanced_test.ts create mode 100755 klanker-gate/packages/providers/src/gemini_test.ts create mode 100755 klanker-gate/packages/providers/src/huggingface.ts create mode 100755 klanker-gate/packages/providers/src/huggingface_test.ts create mode 100755 klanker-gate/packages/providers/src/imagen.ts create mode 100755 klanker-gate/packages/providers/src/manager.ts create mode 100755 klanker-gate/packages/providers/src/manager_test.ts create mode 100755 klanker-gate/packages/providers/src/mistral_test.ts create mode 100755 klanker-gate/packages/providers/src/mod.ts create mode 100755 klanker-gate/packages/providers/src/openai.ts create mode 100755 klanker-gate/packages/providers/src/openai_compat.ts create mode 100755 klanker-gate/packages/providers/src/providers_test.ts create mode 100755 klanker-gate/packages/providers/src/s3.ts create mode 100755 klanker-gate/packages/providers/src/s3_test.ts create mode 100755 klanker-gate/packages/providers/src/scope.ts create mode 100755 klanker-gate/packages/providers/src/sigv4.ts create mode 100755 klanker-gate/packages/providers/src/sigv4_test.ts create mode 100755 klanker-gate/packages/providers/src/types.ts create mode 100755 klanker-gate/packages/providers/src/vertex.ts create mode 100755 klanker-gate/packages/providers/src/vertex_test.ts create mode 100755 klanker-gate/packages/providers/src/wave4_fixes_test.ts create mode 100755 klanker-gate/packages/telemetry/.gitkeep create mode 100755 klanker-gate/packages/telemetry/src/concurrency.ts create mode 100755 klanker-gate/packages/telemetry/src/concurrency_test.ts create mode 100755 klanker-gate/packages/telemetry/src/logbus.ts create mode 100755 klanker-gate/packages/telemetry/src/logbus_test.ts create mode 100755 klanker-gate/packages/telemetry/src/logenrich.ts create mode 100755 klanker-gate/packages/telemetry/src/logenrich_test.ts create mode 100755 klanker-gate/packages/telemetry/src/logstore.ts create mode 100755 klanker-gate/packages/telemetry/src/logstore_test.ts create mode 100755 klanker-gate/packages/telemetry/src/metrics.ts create mode 100755 klanker-gate/packages/telemetry/src/metrics_test.ts create mode 100755 klanker-gate/packages/telemetry/src/otel.ts create mode 100755 klanker-gate/packages/telemetry/src/otel_test.ts create mode 100755 klanker-gate/packages/telemetry/src/span_cardinality.ts create mode 100755 klanker-gate/packages/telemetry/src/span_cardinality_test.ts create mode 100755 klanker-gate/packages/telemetry/src/trace.ts create mode 100755 klanker-gate/packages/telemetry/src/trace_test.ts create mode 100755 klanker-gate/packages/telemetry/src/usage.ts create mode 100755 klanker-gate/packages/telemetry/src/usage_test.ts create mode 100755 klanker-gate/packages/telemetry/src/usagestore.ts create mode 100755 klanker-gate/packages/telemetry/src/usagestore_test.ts create mode 100755 klanker-gate/packages/testing/.gitkeep create mode 100755 klanker-gate/packages/testing/src/mock_provider.ts create mode 100755 klanker-gate/packages/testing/src/mod.ts create mode 100755 klanker-gate/permissions.md create mode 100755 klanker-gate/scripts/codemode_launch_matrix.sh create mode 100755 klanker-gate/scripts/codemode_worker_options_probe.ts create mode 100755 klanker-gate/scripts/full_suite.ts create mode 100755 klanker-gate/scripts/generate_sbom.ts create mode 100755 klanker-gate/scripts/load-bench.ts create mode 100755 klanker-gate/scripts/migrate_kv_to_pg.ts create mode 100755 klanker-gate/tests/browser/.gitignore create mode 100755 klanker-gate/tests/browser/gateway.spec.ts create mode 100755 klanker-gate/tests/browser/package-lock.json create mode 100755 klanker-gate/tests/browser/package.json create mode 100755 klanker-gate/tests/browser/playwright.config.ts create mode 100755 klanker-gate/tests/contract/advanced_apis_extended_test.ts create mode 100755 klanker-gate/tests/contract/advanced_apis_test.ts create mode 100755 klanker-gate/tests/contract/golden_chat_test.ts create mode 100755 klanker-gate/tests/contract/responses_agent_test.ts create mode 100755 klanker-gate/tests/contract/settings_contract_test.ts create mode 100755 klanker-gate/tests/contract/streaming_surfaces_test.ts create mode 100755 klanker-gate/tests/e2e/gateway_test.ts create mode 100755 klanker-gate/tests/e2e/ui_serving_test.ts create mode 100755 klanker-gate/tests/integration/admin_api_test.ts create mode 100755 klanker-gate/tests/integration/anthropic_ingress_test.ts create mode 100755 klanker-gate/tests/integration/azure_ingress_test.ts create mode 100755 klanker-gate/tests/integration/azure_longtail_test.ts create mode 100755 klanker-gate/tests/integration/budget_reset_schedules_test.ts create mode 100755 klanker-gate/tests/integration/cache_test.ts create mode 100755 klanker-gate/tests/integration/compat_prefixes_test.ts create mode 100755 klanker-gate/tests/integration/config_persistence_test.ts create mode 100755 klanker-gate/tests/integration/config_propagation_test.ts create mode 100755 klanker-gate/tests/integration/dialect_billing_test.ts create mode 100755 klanker-gate/tests/integration/durable_budgets_test.ts create mode 100755 klanker-gate/tests/integration/governance_api_test.ts create mode 100755 klanker-gate/tests/integration/log_enrichment_test.ts create mode 100755 klanker-gate/tests/integration/mcp_autoinject_test.ts create mode 100755 klanker-gate/tests/integration/mcp_hardening_test.ts create mode 100755 klanker-gate/tests/integration/mcp_plugins_test.ts create mode 100755 klanker-gate/tests/integration/mcp_transports_test.ts create mode 100755 klanker-gate/tests/integration/mcp_wave3_test.ts create mode 100755 klanker-gate/tests/integration/media_billing_test.ts create mode 100755 klanker-gate/tests/integration/observability_test.ts create mode 100755 klanker-gate/tests/integration/openrouter_ingress_test.ts create mode 100755 klanker-gate/tests/integration/origin_guard_test.ts create mode 100755 klanker-gate/tests/integration/pricing_hierarchy_test.ts create mode 100755 klanker-gate/tests/integration/provider_breadth_test.ts create mode 100755 klanker-gate/tests/integration/runtime_api_test.ts create mode 100755 klanker-gate/tests/integration/serving_headers_test.ts create mode 100755 klanker-gate/tests/integration/settings_api_test.ts create mode 100755 klanker-gate/tests/integration/shared_token_window_test.ts create mode 100755 klanker-gate/tests/integration/telemetry_test.ts create mode 100755 klanker-gate/tests/integration/tool_security_test.ts create mode 100755 klanker-gate/tests/integration/wave4_fixes_test.ts create mode 100755 klanker-gate/tests/live/postgres_state_live_test.ts create mode 100755 klanker-gate/tests/live/vector_stores_live_test.ts create mode 100644 plugins/sysdeck-klanker/index.html create mode 100644 plugins/sysdeck-klanker/klanker.js create mode 100644 plugins/sysdeck-klanker/manifest.json create mode 100644 shared/branding.css create mode 100644 shared/sysdeck-web.css create mode 100644 web/dev.log create mode 100644 web/mini-services/fester/fester.db create mode 100644 web/mini-services/fester/fester.db-shm create mode 100644 web/mini-services/fester/fester.db-wal create mode 100644 web/next-env.d.ts create mode 100644 web/scripts/manage-users.mjs create mode 100644 web/scripts/pam-auth.py create mode 100644 web/src/app/api/auth/login/route.ts create mode 100644 web/src/app/api/auth/logout/route.ts create mode 100644 web/src/app/api/auth/session/route.ts create mode 100644 web/src/components/sysdeck/login-screen.tsx create mode 100644 web/src/components/sysdeck/panels/cockpitModulesPanel.tsx create mode 100644 web/src/components/sysdeck/panels/klankerPanel.tsx create mode 100644 web/src/components/sysdeck/panels/runbookPanel.tsx create mode 100644 web/src/components/sysdeck/shell.tsx create mode 100644 web/src/lib/sysdeck/bridge/cockpitmodules.ts create mode 100644 web/src/lib/sysdeck/bridge/klanker.ts create mode 100644 web/src/lib/sysdeck/bridge/shell.ts create mode 100644 web/src/lib/sysdeck/pam.ts create mode 100644 web/src/lib/sysdeck/session.ts create mode 100644 web/src/lib/sysdeck/users.ts create mode 100644 web/tsconfig.tsbuildinfo diff --git a/BLOG.md b/BLOG.md index 9bad89f..4b3f36d 100755 --- a/BLOG.md +++ b/BLOG.md @@ -4,6 +4,159 @@ Author: **Jeremy Anderson** · · --- +## v0.3.0 — 2026-09-11 (AI Gateway Edition: klanker-gate integrated) + +*(latest release: v0.4.1 — cockpit module detection; see the last entry below)* + +v0.3.0 answers the operator's question: *"lets take a look at this project klanker-gate, i believe its mainly coded for a windows platform. how much work would it be to port it to arch linux and into the sysdeck as a module."* The answer surprised the premise, so this entry records both the verdict and the evidence. + +### The verdict: zero source changes (it was never a Windows codebase) + +klanker-gate — internally "Frosty Deno" — is a **Deno 2 + TypeScript** LLM gateway by **TykoDev** (https://github.com/TykoDev/klanker-gate, Apache-2.0; **not SysDeck code** — full credit and license notes in `klanker-gate/ATTRIBUTION.md` and `THIRD_PARTY.md`). Deno runs identically on every platform. The Windows mentions in its 448-file tree are accommodations for a second-class Windows *dev* platform, not Windows-first code: + +- `apps/gateway/cluster.ts` — `reusePortSupported()` returns true **only for `linux` and `darwin`**. Windows is locked to single-process serving, and the failure message literally directs the operator to "Use Docker/Linux for multi-process". +- `Dockerfile` — both stages are Linux images (`denoland/deno:2.9.3`, `denoland/deno:alpine-2.9.3`). The Windows comment in it is about Defender locking files on a Windows *host*. +- `deploy/docker-entrypoint.sh` — a POSIX `/bin/sh` script. +- `scripts/` — `.sh` and Deno `.ts` tasks; not a single `.bat`/`.ps1` in the tree. +- `deno.lock` win32 entries — ordinary cross-platform lockfile records that every project using npm-native dependencies carries on every OS. + +So the "port" is a **packaging exercise**: install Deno + PostgreSQL, manage the process with systemd. On top of that, moving to Arch *unlocks* a feature Windows cannot have — `FROSTY_WORKERS` multi-process serving through `SO_REUSEPORT` (Linux/darwin only). + +### What ships in sysdeck-0.3.0-master.tar.bz2 + +- `/` — the cockpit edition: **27 plugins** (new: sysdeck-klanker), 28 bridge modules. +- `/web` — the SysDeck Web Edition: **29 bridge modules** (new: klanker, the AI Gateway panel). +- `/web/mini-services/fester` — Fester, vendored + pre-integrated (independent version 0.2.1), unchanged from v0.2.0. +- `/klanker-gate` — **klanker-gate vendored + pre-integrated** (by TykoDev — https://github.com/TykoDev/klanker-gate — independent version 0.9.0, Apache-2.0, credited in its `ATTRIBUTION.md`), including the new `arch/` packaging directory. + +### Run without Cockpit — the complete runbook (0.3.0) + +v0.3.0 also answers: *"make better instructions on running sysdeck without cockpit with just the nextjs backend."* The web edition is fully standalone — no cockpit, no Python bridge, no systemd, no root — and the instructions now exist in three kept-in-sync forms: + +- **the "Run without Cockpit" panel** in the web console (system group, right under Overview): prerequisites, the one-command `make web-dev` quickstart, the granular commands, the standalone production build (bun + node-only paths), both systemd units (web + fester), the `.env` reference table, the reverse-proxy/WebSocket-gateway configs (Caddy + nginx), and a troubleshooting matrix — every command block copy-to-clipboard. +- **`web/README.md`** in the master tarball — the same runbook as plain markdown. +- **QUICKSTART §11** — the condensed version, plus §12 for the skin below. + +### The web-edition skin for Cockpit (0.3.0) + +*"…or completely theme cockpit to look like the web edition 0.3.0 as demoed"* — done, without touching a single module: + +- `shared/sysdeck-web.css` — the skin: every plugin's `index.html` links it after the base stylesheet. It ports the Next.js console's midnight/teal design (accent `#3fc9b0`, soft-tinted badges, 10px radii, tabular numerals, teal-edged scrollbars, reduced-motion support) onto the `.sysdeck-*` / `.suite-*` vocabulary. Revert: delete the file. +- `sudo make install-branding` — themes the Cockpit **shell** chrome itself (sidebar, header, login) via `/usr/share/cockpit/branding.css`, Cockpit's documented override point; covers PatternFly v4 (`pf-c-*`) and v5 (`pf-v5-*`) generations, backs up any distro `branding.css` first, `make uninstall-branding` restores it. + +### The Arch packaging (`klanker-gate/arch/`) + +- `PKGBUILD` — self-packaging (files come from the tree the file lives in): gateway tree → `/usr/share/klanker-gate`, wrapper → `/usr/bin/klanker-gate`, env → `/etc/klanker-gate/env` (pacman `backup=()`), unit + sysusers + tmpfiles in their canonical locations. `depends=('deno>=2.9')`; postgres is an `optdepends` (remote `FROSTY_PG_URL` is equally supported). +- `klanker-gate.service` — hardened systemd unit: `StateDirectory=klanker-gate`, `WorkingDirectory=/var/lib/klanker-gate` (so the permission contract's `--allow-write=data` resolves to `/var/lib/klanker-gate/data`), `ProtectSystem=full`, `PrivateTmp`, empty `CapabilityBoundingSet`. Deliberately no `MemoryDenyWriteExecute` (V8's JIT needs W^X pages). +- `run.sh` — the ExecStart wrapper: one-time `deno cache --frozen` warmup into the service user's DENO_DIR, then exec with the upstream permission flags, plus `--allow-run` scoped to the Deno binary **only** when `FROSTY_WORKERS>1` — mirroring the upstream entrypoint's own escalation policy. +- `INSTALL-ARCH.md` — the full runbook: `makepkg -si`, local postgres provisioning (role + database), env configuration, `systemctl enable --now`, healthcheck curls, the multi-worker bonus, the optional control-UI build, and the SysDeck wiring for both editions. + +### The klanker module (both editions) + +- `bridge/klanker.py` — stdlib-only REST client (urllib, 4s timeout — never outliving the panel's 5s poll), 10 subcommands: `status` (healthz + version, merged + enriched), `providers`, `models`, `vkeys`, `logs --limit`, `analytics --window`, `runtime`, `service ` (systemctl wrapper for klanker-gate.service), `journal [N]` (journalctl tail, ANSI-stripped, token-masked, 32 KB cap), `localstack` (probes ollama/llama.cpp/koboldcpp/LM Studio/SGLang/vLLM `/v1/models` on this host, 0.4s each in parallel threads, returns wiring recipes + env/admin-API examples). `KLANKER_URL` (default `http://127.0.0.1:8080`) and `KLANKER_ADMIN_TOKEN` (header-only, never echoed) drive it. Connection failures are graceful JSON with a remediation hint — same contract as fester.py. +- `plugins/sysdeck-klanker/` — full panel: gateway status card, stat grid (providers, virtual keys, requests 24h, spend 24h — upstream money is integer **micro-USD**, displayed as `$X.XXXX`), providers table with health dots, virtual keys table, recent-requests table, model catalog, **local stack wiring card** (live-probed backend matrix + copyable env/curl recipes), runtime topology card, service control (start/stop/restart with confirm) and a journal viewer. 5s auto-refresh that preserves the service/journal card state. +- Web edition — the **AI Gateway** panel under Integrations: hybrid live/demo. It probes the gateway first (`KLANKER_URL`, 1.5s timeout, Bearer `KLANKER_ADMIN_TOKEN`); unreachable → seeded demo rows, clearly badged, with a note explaining that this sandbox has no Deno/Postgres. On a host running the gateway, it flips to `source: live` untouched. +- `shared/bridge.js` — the klanker surface: 11 methods. `check-bridge-subcommands` now verifies **218 calls across 28 bridge modules** (was 216/27 — the audit pass added auth readers+certs). + +### The local stack is first-class (0.3.0 follow-up) + +A fair question after the integration: *"it seems to be mainly for SaaS +account linking, i personally only run a local stack such as ollama, +llama.cpp, koboldcpp"* — can all features be met locally? Yes: the +upstream provider registry has **five keyless self-hosted types** — +`ollama`, `lmstudio`, `sgl`, and the generic `openai-compatible` / +`anthropic-compatible` ("user supplies the base URL and an optional +key", per the registry's own comment) — and llama-server/KoboldCpp/vLLM +all speak the OpenAI wire. Governance, virtual keys, budgets, fallback ++ weighted load-balancing, model auto-discovery (`refresh-models`), the +request ring and analytics are all provider-agnostic, so they work +unchanged over a local stack — spend just reads ~$0. + +What the release adds on top: the `localstack` probe + the **Local +stack wiring** cards in both editions, env/admin-API recipes with the +8080 port-collision warning (llama-server's default = the gateway's +port), QUICKSTART §10.1, and a local-first re-seeded web demo dataset +(ollama + llama-server + koboldcpp + lmstudio + sglang + one groq +overflow row — 24h spend ≈ $0.001, all of it the cloud fallback). + +And the follow-up question — *"it should be easy to toggle this ai +gateway on or off in case i decide to use something like pi"* — is now +a first-class shell feature: **module visibility toggles** (web +edition). Every sidebar row carries a power control; a disabled module +vanishes from navigation and the command palette, lands in a +**Disabled (N)** section for one-click re-enable, and the state +persists in SQLite (`shell.disabled`, new `shell` bridge module, +audited). The `POST /api/bridge` envelope also got a correctness fix: +command-level `fail()` responses now pass through top-level instead of +being double-wrapped, so panels actually render command errors. + + +### The 0.3.0 security audit (follow-up) + +*"do a security audit on klankergate code and the full sysdeck codebase +afterwards"* — done, end to end, and the release is better for it. Four +audit passes covered the vendored gateway (Deno routes, crypto, admin +surface, compose), the cockpit bridge (28 helpers), the 27 plugin +panels, and the web edition (bridge dispatcher, 31 modules, panels, +fester service). + +**SysDeck code: fixed, not filed.** The two criticals in the cockpit +bridge were both "the guard exists 100 lines away and this command +forgot it" — `policy.py cgroup-set` wrote `/` +as root (arbitrary file overwrite → one-prompt persistent root via +`/etc/cron.d`), and `builder.py artifacts-clear` rmtree'd an +unvalidated profile argument. Both now resolve-and-bound exactly like +their siblings; the same treatment went to build-log/build-delete/ +artifacts/profile-create (traversal + config injection into +root-executed build configs). hwalert carried the tree's only `sudo sh +-c` f-string — a literal root shell injection, latent only because +the module isn't wired to a panel yet — now a direct write behind a +sysfs device-path guard. The db module's start/stop/restart accepted +arbitrary unit names (`db stop sshd`) and its `query` promised +DDL-refusal in a comment while running any statement; both are honest +now. The 8 oldest panels rendered live tool output unescaped into +`innerHTML` (package names from repos, USB descriptors, fwupd metadata +— the XSS→cockpit-session→bridge-RCE chain); all escape now, and the +27 manifests dropped `unsafe-eval`. The web edition bound every +surface to 0.0.0.0 unauthenticated — dev server, fester service, and +the port gateway's wildcard transform — so everything now pins +loopback, the bridge gained a body cap + per-IP rate limit, and +unexpected errors return generic text (details to the server log). + +**klanker-gate: audited, credited, and left unmodified.** The vendoring +contract (byte-identical upstream tree, SysDeck adds only `arch/`) +holds — so the audit's 10 upstream findings (3 critical: the admin API +runs unauthenticated when `FROSTY_ADMIN_TOKEN` is unset, the gateway +binds 0.0.0.0 by default, and `/v1/*` is open until the first virtual +key exists) are documented in `klanker-gate/arch/SECURITY-UPSTREAM.md` +as an advisory that can travel upstream, while the SysDeck packaging +layer mitigates what packaging can: the systemd unit now refuses to +start without the token (fail-closed `ExecStartPre`, opt-out via +drop-in), and `INSTALL-ARCH.md` §9 carries the firewall + create-a-vkey +-immediately runbook. The audit also recorded what upstream does +*well* — AES-256-GCM envelope key storage, SHA-256-hashed vkeys, +redacted provider views, an opt-in-only content log with deep secret +redaction, and a properly sandboxed Code Mode worker. + +**Verified:** `make check` 254/254, `check-bridge-subcommands` 218/28 (the audit pass added auth readers+certs), +python compile checks on all 8 touched bridge files, panel import +checks, `bun run lint` clean, and the tarball rebuilt with new builder +guards that fail the build if any of the fixes regress. + +### Verification + +- Cockpit tree: `make check` — **all 254 tests + guards pass** with the new module (bridge subcommands cross-check, manifest consistency, version sync across all 9 surfaces now reporting 0.3.0). +- Bridge offline smoke: `status`/`service status`/`journal` all return graceful structured JSON in the sandbox (no Deno, no systemd units here — exactly the degradation path designed). +- Web edition: `bun run lint` clean; bridge smoke — all 7 klanker commands return `ok:true` with `source:'demo'`; panel render + mobile verified with zero page/console errors. +- Master tarball rebuilt by `scripts/make-master-tarball.sh` with the vendored `klanker-gate/`; verified by extraction, file count, sha256. + +### Notes + +- klanker-gate's version (0.9.0) is intentionally independent from SysDeck's (0.3.0), same as Fester's (0.2.1) — each mirrors its own repository's version line inside the bundle. +- The gateway cannot run inside the web edition's dev sandbox (no Deno runtime, no PostgreSQL); that is what the honest DEMO badge communicates. On the operator's Arch host, `arch/INSTALL-ARCH.md` is the 5-minute path to the LIVE badge. + +--- + ## v0.2.0 — 2026-08-20 (Master Edition: one tarball, two editions, Fester pre-integrated) v0.2.0 turns SysDeck into a single distributable that ships **both** editions with **Fester vendored and wired in**. The release answers the operator's framing directly: *"fester exists as a separate repository — it deserves its own. generate a master tarball of sysdeck with fester pre-integrated."* @@ -2753,3 +2906,172 @@ check: check-metainfo-consistency The v0.0.27 release notes claimed "each subcommand now verified against the actual COMMANDS dict." That verification was done by hand at authoring time — and hand-verification rots the moment someone touches either side without re-running the verification. The new `check-bridge-subcommands` guard makes the verification automatic and continuous. Every `make check` from now on will catch any future bridge.js ↔ Python helper drift, with a message that names the exact file, line, and missing subcommand. + +--- + +## v0.3.1 — 2026-09-13 (the login gate) +### The cockpit-style login (0.3.1 follow-up) + +*"sysdeck is intended for lan side use not wan facing, so this is a +decision point for lack of crypto or auth in depth. a simple +cockpit-style login for our standalone is fine by me, lets get it +coded if it hasnt been added to the standalone nextjs side"* — the +operator made the posture call, and this entry records how it landed. + +The 0.3.0 audit ended with the web edition guarded (loopback binds, +rate limits, body caps) but still unauthenticated. That was honest +for a LAN-side console — and it left the door literally open to +anyone who could reach the port. 0.3.1 adds the one boundary that +matches the actual threat model (the roommate, the accidental +port-forward): **a shared password, exactly like the Cockpit login +the console's plugin pages already live behind.** + +Mechanically: `SYSDECK_WEB_PASSWORD` in `web/.env` (default +`sysdeck`, with an amber nag on the login screen until you change +it — the nag is intentional, so a default install advertises its own +default). The password compares in constant time; failures rate +limit per IP at 5/minute and both login and failure land in the +AuditLog with the source IP. A success mints an HttpOnly +SameSite=Lax cookie carrying an HMAC-SHA256-signed token with a 12h +expiry — and the signing key is generated per install and persisted +in the same SQLite store everything else uses, which buys a +property the project needed anyway: **the fester mini-service reads +that key straight out of the DB file and verifies the identical +token on its REST and WebSocket surface.** The browser's live DAG +event stream rides the same cookie, so the direct +`?XTransformPort=3010` path is gated, not just the proxied routes. +Until the web console has booted once, fester keeps its documented +standalone behavior (loopback, unauthenticated) — the gate arms +itself the first time the console renders. + +Everything visible is gated: the page server-renders a login screen +until the cookie verifies, every `/api/*` route answers 401 until +signed in, and a session expiring mid-flight reloads to the login +instead of plastering panels with error cards. What it is *not* is +also on the record (QUICKSTART §10.4): no user accounts, no MFA, no +online-attacker crypto — the loopback bind remains the outer +boundary, and `SYSDECK_SESSION_SECURE=1` arms the Secure cookie flag +when an operator fronts the console with TLS. + +**Verified:** `bun run lint` clean; curl smoke of the full auth +lifecycle (wrong password 401 + audited, login → cookie → bridge 200, +logout → 401 again, forged token rejected, 429 after the failure +cap, fester REST+WS gated, standalone-mode fallback); +`make check` 254/254 across the bumped release surfaces; the master +tarball rebuilt with new guards (session lib, gated routes, page +gate, fester gate, QUICKSTART §10.4). + +--- + +## v0.4.0 — 2026-09-12 (the Unix login) + +*"lets change from shared login to unix acc based login same way +cockpit does it"* — the operator's directive, and the one this whole +project was always pointed at: the console is named for the deck it +replaces, so its login should work the way that deck's login works. +You sign in with a **Unix account, and the host's PAM stack decides.** + +The mechanics are deliberately boring, because PAM already solved +this in 1997: `web/scripts/pam-auth.py` is a stdlib-only ctypes +client of `libpam` that runs the same sequence every login surface on +the box runs — `pam_start` → `pam_authenticate` → `pam_acct_mgmt` — +under the `sysdeck` service when `/etc/pam.d/sysdeck` exists, else +the stock `login` stack. The credentials arrive over stdin (argv is +world-readable in `/proc`, so it never touches argv), the +conversation callback answers only password/username prompts, and +the reply buffers come from the C allocator because Linux-PAM frees +them itself — the classic ctypes-PAM heap-corruption trap, found and +fixed the honest way (the sandbox crash first, then the malloc). + +The honest constraint surfaced early: pam_unix needs root to verify +*arbitrary* users (a non-root process only gets its own uid through +`unix_chkpwd` — a pam_unix guarantee, not ours). Cockpit answers that +by running cockpit-ws as root; SysDeck ships the same call as a +mode: `SYSDECK_AUTH_MODE=pam` (default) for the root systemd unit, +`pam+local` for unprivileged installs (PAM first, then a `SdUser` +scrypt table managed by `bun scripts/manage-users.mjs`), `local` for +console-accounts-only. A wedged PAM helper fails CLOSED — a health +incident, never a silent fallback. Failures rate limit per-IP **and** +per-username, wrong-user and wrong-password look identical, and the +AuditLog now records the unix username as the actor on both ends. + +The session token grew a spine: `v2...` — the +cookie is *bound to the account that earned it*. The shell wears the +identity cockpit-style: an account menu with avatar, `user@host`, +PAM/local provenance, the wheel "Administrative access" badge, and a +session-expiry countdown with a draining life bar; the status bar +carries `user@host` beside the vitals. The fester service verifies +the identical v2 token on REST and WebSocket. And v1 tokens still +verify — the 0.3.1 README promised restart-stable sessions, so the +0.4.0 upgrade keeps that promise: old cookies age out as legacy +"operator" sessions instead of logging anyone out. + +The login screen itself finally got the fester treatment: host +identity banner (hostname + OS pretty name — exactly what cockpit +leads its own login with), an aurora/grid backdrop that re-skins +under every console theme, caps-lock detection, a one-shot error +shake, Enter-to-advance from username to password, and the amber +default-password nag for the seeded local account until it's +rotated. Reduced-motion users get the same scene, still. + +**Verified:** PAM helper exercised against the live stack (wrong +password → PAM_AUTH_ERR, clean exits, no heap events); curl smoke of +the full lifecycle (401 pre-auth, login → v2 cookie → bridge 200, +logout → 401, forged v2 rejected by console AND fester, 429 after +the failure cap, legacy v1 acceptance); browser-driven pass over the +login scene, account menu, panel transitions; the cockpit bridge +guards re-run unchanged (218 calls / 28 modules, manifests clean) — +the plugin side is untouched by design. + +--- + +## v0.4.1 — 2026-09-12 (cockpit module detection) + +*"lets make sure any installed cockpit modules detected are also +loaded in the nextjs only side as well, such as the distro modules +like cockpit-machines and cockpit-podman for example. this will be +100% compatible at that point."* — the operator's compatibility +directive, and the honest last gap: 0.4.0 made the LOGIN cockpit-equal, +but a host with cockpit-machines or cockpit-podman installed still +showed those modules nowhere in the Next.js console. + +0.4.1 makes the console perform the same discovery the cockpit shell +does: scan `/usr/share/cockpit//manifest.json` and treat every +package with a `menu` entry as a module. That single rule carries the +whole feature — distro modules (machines, podman, networking, storage, +accounts, updates, SELinux, PCP metrics, kdump, tuned), 45Drives-style +addons, anything a packager ships with a manifest. `sysdeck-*` modules +are skipped because native panels already exist for them; `base1` and +`shell` never appear because they have no menu — the shell's own rules, +reused verbatim. Detection is pure filesystem, so it works with cockpit +stopped, absent, or merely staged (`SYSDECK_COCKPIT_SCAN` points at +extra roots, colon-separated, for DESTDIR installs). + +Every detected module **loads into the console's navigation**: a +"Cockpit" sidebar group with a LIVE/DEMO provenance badge, a ⌘K palette +group, and a per-module detail view — manifest identity, shipped files +with sizes, live backend presence probes (real `which()` checks for +`virsh`/`podman`/`nmcli`/`pkcon`/`getenforce`/`pmrep`..., on-demand +version probes), and a jump to the native panel that already covers the +domain: machines and podman → Containers & VMs, packagekit → Packages, +networkmanager → Network Security, metrics → Monitoring, storage → +Overview. A Cockpit Modules hub panel summarizes the scan: counts, +backend availability, native coverage. With no cockpit tree on the +host, the surface shows a clearly-badged typical-distro set so it stays +explorable — the same demo/live honesty the rest of the console +practices. + +The same directive retired the codenames: no more "web edition", no +edition subtitles. The console's identity is **SysDeck**, and its one +subtitle line is a single clean URL — **dcos.net** — on the login +banner, under the sidebar wordmark, and in the status bar. The page +title is just "SysDeck". + +**Verified:** live detection exercised end-to-end against a staged +cockpit tree (machines + podman detected LIVE with labels, orders, API +levels and file counts from their manifests; menu-less chrome and +sysdeck-* correctly excluded); demo fallback returns the 11-module +typical distro set badged DEMO; detail views, native-panel jumps, hub +table, and palette entries driven through a real browser; `make check` +still ALL PASS (218 calls / 28 modules, 254/254 tests, version sync at +0.4.1); lint and tsc clean on every touched file. diff --git a/Makefile b/Makefile index 3512c90..78af6a5 100755 --- a/Makefile +++ b/Makefile @@ -1,9 +1,13 @@ # SysDeck - Makefile # Author: Jeremy Anderson (https://dcos.net) # -# v0.2.0 MASTER EDITION: two distributions in one tree — -# / the cockpit edition: 26 standalone Cockpit plugins + shared bridge -# /web the SysDeck Web Edition (Next.js console, 28 bridge modules) +# v0.3.0 AI GATEWAY EDITION: two distributions in one tree — +# / the cockpit edition: 27 standalone Cockpit plugins + shared bridge +# /web the SysDeck Web Edition (Next.js console, 29 bridge modules) +# /klanker-gate — the Frosty Deno LLM gateway, vendored + pre-integrated +# (by TykoDev, https://github.com/TykoDev/klanker-gate, +# Apache-2.0 — not SysDeck code; own version 0.9.0, with +# arch/ packaging for Arch Linux) # /web/mini-services/fester — Fester, vendored + pre-integrated (own version 0.2.1) # Each plugin ships to /usr/share/cockpit/sysdeck-/ and appears as # its own sidebar entry in Cockpit. The Python bridge helpers stay at @@ -26,7 +30,7 @@ # Distro support: Arch Linux, Debian/Ubuntu, Fedora/RHEL/CentOS. PACKAGE := sysdeck -VERSION := 0.2.0 +VERSION := 0.4.1 LIB_DIR := $(DESTDIR)/usr/lib/$(PACKAGE) PYTHON_DIR := $(LIB_DIR)/bridge SHARE_DIR := $(DESTDIR)/usr/share/$(PACKAGE) @@ -55,7 +59,7 @@ SMOKE_TEST_SCRIPT := cockpit-smoke-test.sh # Generator script (regenerates plugins/ and shared/). GENERATOR := scripts/generate-plugins.py -.PHONY: install uninstall check clean dist distcheck plugins fester-start web-install web-dev master +.PHONY: install uninstall check clean dist distcheck plugins fester-start web-install web-dev master install-branding uninstall-branding # ─── plugins: regenerate from generator ────────────────────────────── plugins: @@ -86,6 +90,10 @@ install: install -m 0644 shared/manifest.json $(DESTDIR)/usr/share/cockpit/sysdeck-common/manifest.json install -m 0644 shared/bridge.js $(DESTDIR)/usr/share/cockpit/sysdeck-common/bridge.js install -m 0644 shared/sysdeck.css $(DESTDIR)/usr/share/cockpit/sysdeck-common/sysdeck.css + # v0.3.0: the web-edition skin (midnight/teal design of the Next.js + # console) — every plugin index.html links it after base sysdeck.css. + # Remove the file to revert plugin pages to the classic 0.1.x skin. + install -m 0644 shared/sysdeck-web.css $(DESTDIR)/usr/share/cockpit/sysdeck-common/sysdeck-web.css # Python bridge helpers: /usr/lib/sysdeck/bridge/ # v0.0.27: install each helper as an executable script (0755, not 0644) # so they can be invoked by absolute path: @@ -385,7 +393,7 @@ distcheck: dist @rm -rf /tmp/sysdeck-distcheck-$$ @echo ">>> Distcheck passed: tarball is self-sufficient and structurally correct." -# ─── v0.2.0 master edition: web + fester ───────────────────────────── +# ─── v0.3.0 master edition: web + fester + klanker-gate ───────────────────────────── # Run these from an extracted master tarball (where web/ sits alongside # this Makefile) or the canonical dev tree with web/ present. @@ -403,6 +411,35 @@ web-install: cd $(FESTER_DIR) && bun install web-dev: web-install + +# ─── branding: theme the Cockpit SHELL chrome to the web-edition look ──── +# /usr/share/cockpit/branding.css is Cockpit's documented override point +# for the shell (sidebar, header, login). shared/branding.css ports the +# web edition 0.3.0 midnight/teal design onto it. Any pre-existing +# branding.css (shipped by the distro) is backed up first and restored +# by `make uninstall-branding`. +install-branding: + @echo ">>> Theming the Cockpit shell to the web-edition look" + -@if test -f $(DESTDIR)/usr/share/cockpit/branding.css; then \ + cp -a $(DESTDIR)/usr/share/cockpit/branding.css $(DESTDIR)/usr/share/cockpit/branding.css.sysdeck-bak; \ + echo " existing branding.css backed up (branding.css.sysdeck-bak)"; \ + fi + install -d $(DESTDIR)/usr/share/cockpit + install -m 0644 shared/branding.css $(DESTDIR)/usr/share/cockpit/branding.css + @echo " installed /usr/share/cockpit/branding.css" + @echo " reload the Cockpit page (hard refresh) to see the shell skin" + +uninstall-branding: + @echo ">>> Restoring the Cockpit shell branding" + rm -f $(DESTDIR)/usr/share/cockpit/branding.css + -@if test -f $(DESTDIR)/usr/share/cockpit/branding.css.sysdeck-bak; then \ + mv $(DESTDIR)/usr/share/cockpit/branding.css.sysdeck-bak $(DESTDIR)/usr/share/cockpit/branding.css; \ + echo " restored original branding.css from backup"; \ + else \ + echo " no backup found — the distro package owns branding.css"; \ + echo " reinstall the cockpit-bridge package to restore defaults"; \ + fi + @echo ">>> Starting fester in the background (log: /tmp/fester.log)" cd $(FESTER_DIR) && nohup bun run dev >/tmp/fester.log 2>&1 & @echo ">>> Starting SysDeck Web Edition on :3000 (Ctrl+C stops next; fester keeps running)" diff --git a/QA.md b/QA.md index 979c5b7..57959b8 100755 --- a/QA.md +++ b/QA.md @@ -1072,3 +1072,135 @@ PASS — guard fails with a clear, actionable message naming the exact file, lin ### 11. Honest accounting The v0.0.27 release notes claimed "each subcommand now verified against the actual COMMANDS dict." That claim was overstated — the verification was hand-done at authoring time and was incomplete. The new `check-bridge-subcommands` guard makes the verification automatic, continuous, and enforced at build time. Hand-verification rots; machine verification doesn't. + +# MoE Quality Assurance Pass — v0.4.0 (Unix Login Edition) + +## v0.4.0 QA — unix-account login + web console revision + +**Scope:** the 0.4.0 revision replaces the 0.3.1 shared-password login +with Unix-account (PAM) login in the web edition and gives the web +console a visual revision. The cockpit edition is untouched; guards +were re-run to prove it. + +### 1. Authentication core + +- `web/scripts/pam-auth.py` exercised against the live host PAM stack + (wrong password → `PAM_AUTH_ERR`, code 7, clean exit 1; malformed + JSON → `bad-json`; oversized credentials rejected). The conversation + callback allocates replies from libc (strdup/malloc) — verified + heap-clean across repeated invocations (no `free(): invalid pointer` + after the fix). +- Login route policy matrix verified by curl: + - pre-auth: every `/api/*` → 401; page server-renders login screen. + - wrong username and wrong password return the identical generic + `incorrect username or password` (no account enumeration). + - failure cap: 6th bad attempt within the window → 429 with + Retry-After (per-IP AND per-username buckets). + - `pam+local` mode: PAM-definitive-failure falls through to the + SdUser scrypt store; seeded account authenticates; v2 cookie minted. + - wedged helper (timeout/protocol) → fail CLOSED (401), never a + silent fallback. pam-only with missing helper → 503 setup error. + +### 2. Session integrity + +- v2 token format `v2...`: forged + signature rejected by the console route layer AND by the fester + service (REST + WS upgrade path) — both derive the HMAC from the + shared SQLite secret. +- v1 (0.3.1) tokens still verify (legacy session, user=null) — + upgrade continuity confirmed by code inspection of both verifiers. +- Logout clears the cookie (maxAge 0) and audits with the unix + username as actor; login-failed audits never contain the attempted + secret. + +### 3. Cockpit edition compatibility (the operator's requirement) + +- `make check`: **ALL PASS** — metainfo structure + 26 launchables, + 28/28 manifests conform, Makefile recipes tab-indented, no broken + cockpit imports, no `python3 -m sysdeck.bridge` calls, **218 + bridge.js calls cross-checked against 28 Python COMMANDS dicts**, + version sync across 9 release surfaces, `py_compile` + `node --check` + clean, 254/254 unit tests (version-sync test now pins 0.4.0). +- Zero changes under `bridge/`, `plugins/`, `shared/`, `standalone-plugins/` + (except `bridge/__init__.py` version string + packaging metadata). + +### 4. Web console revision + +- `bun run lint` clean. `tsc --noEmit` clean for every new/modified + file (pre-existing strictness complaints in vendored fester and + glances remain untouched, build unaffected — `ignoreBuildErrors`). +- Visual QA (headless browser screenshots reviewed by a vision model): + login scene — "polished and premium, no visual bugs"; shell + account + menu — "release-quality, dropdown anchors perfectly"; fester and + firewall panels render with no error cards or overlaps. +- prefers-reduced-motion kills the aurora/shake/panel transitions. + +### 5. Summary + +The 0.4.0 revision does what the operator asked: log in with a Unix +account the way Cockpit does (host PAM decides), every cockpit module +keeps working (guards green, tree untouched), and the web console now +carries its identity — account menu, user@host, session countdown — +at the fester quality bar. Remaining honest limits are documented in +QUICKSTART §10.4: pam_unix needs root for arbitrary-user verification +(use the root systemd unit, or pam+local), and the local scrypt store +is an escape hatch, not the primary path. + +# MoE Quality Assurance Pass — v0.4.1 (cockpit module detection) + +## v0.4.1 QA — every installed cockpit module loads into the web console + +**Scope:** the console now performs the cockpit shell's own module +discovery (filesystem manifest scan) and loads every detected module +into its navigation. UI codenames retired; subtitle is dcos.net. + +### 1. Detection correctness + +- Live path exercised against a staged cockpit tree via + `SYSDECK_COCKPIT_SCAN`: machines + podman detected with correct + labels ("Virtual Machines", "Podman Containers"), menu orders, API + levels (`requires.cockpit`), real file counts and paths. +- Exclusion rules match the cockpit shell's own: manifest without a + `menu` block (base1, shell) never listed; `sysdeck-*` never listed + (native panels exist). Verified with deliberately staged traps for + both rules. +- Demo fallback: with no cockpit tree, the 11-module typical-distro + set returns badged DEMO with the honest note; backend `which()` + probes still run REAL binaries on that path. +- `info` returns the full manifest JSON, recursive file listing with + sizes, and an on-demand backend version probe only when the binary + is present (list stays cheap). + +### 2. Console integration + +- Sidebar "Cockpit" group renders every detected module with a + LIVE/DEMO badge and per-module icons; active state routes as + `cm:`; the ⌘K palette searches the same entries; the + Cockpit Modules hub table row-click deep-links into detail views. +- Native-panel jumps (machines/podman → Containers & VMs verified in a + real browser click path) ride the same `sysdeck:goto` event the + overview callout uses. +- Visual QA (browser screenshots + vision model): shell, machines + detail view, and hub all render clean — no overlaps, no cut-offs, no + error cards; sidebar subtitle (dcos.net) judged "clean and minimal". + +### 3. Branding sweep + +- No "web edition" string remains in any user-visible surface (login + banner, sidebar, status bar, overview badge, glances subtitle, + services/packages panel texts, page title/metadata). The subtitle is + a single URL — dcos.net — on the login banner, sidebar and status + bar. README version line dropped the edition codename. + +### 4. Cockpit edition + guards + +- `make check`: ALL PASS — 218 bridge.js calls / 28 modules, 28 + manifests, version sync at 0.4.1 across all release surfaces, + 254/254 unit tests. The cockpit tree itself is untouched. + +### 5. Summary + +The compatibility loop is closed: whatever cockpit modules the host +has, the console has — detected from disk, badged honestly, probed +live, and cross-linked to the native panels. With 0.4.0's unix login +and this, the console/host pair is 100% aligned. diff --git a/QUICKSTART.md b/QUICKSTART.md index a2e3713..9571796 100755 --- a/QUICKSTART.md +++ b/QUICKSTART.md @@ -140,9 +140,9 @@ sudo systemctl restart cockpit.socket Open the in-panel error view: every SysDeck plugin's `index.html` installs `window.addEventListener('error')` and `'unhandledrejection'` handlers that replace the "Loading…" placeholder with the actual error message on the page — no devtools required. The same page tells you whether `cockpit.js` itself loaded, whether `bridge.js` imported cleanly, and whether the panel's `mount()` threw. -## 9. The Web Edition (master tarball, v0.2.0) +## 9. The Web Edition (master tarball) -The master tarball also ships the **SysDeck Web Edition** at `web/` — a standalone browser console (no cockpit required) with 28 bridge modules, real `/proc` / `/sys` collectors, and **Fester pre-integrated** (vendored at `web/mini-services/fester`, independent version 0.2.1): +The master tarball also ships the **SysDeck Web Edition** at `web/` — a standalone browser console (no cockpit required) with 29 bridge modules, real `/proc` / `/sys` collectors, and **Fester pre-integrated** (vendored at `web/mini-services/fester`, independent version 0.2.1): ```bash make web-dev # fester service in the background (:3010) + web console (:3000) @@ -158,4 +158,270 @@ bun run dev # web console on :3000 Open `http://localhost:3000`. The master tarball can be rebuilt any time with `make master`. +## 10. The AI Gateway (master tarball, v0.3.0) + +The master tarball also vendors **klanker-gate** — the Frosty Deno LLM gateway (independent version 0.9.0, Apache-2.0, **by TykoDev: https://github.com/TykoDev/klanker-gate — not SysDeck code**, see `klanker-gate/ATTRIBUTION.md`) — at `klanker-gate/`, with the new **AI Gateway** module in both editions. On Arch Linux the whole gateway is one package away: + +```bash +cd klanker-gate/arch +pacman -S --needed deno base-devel # deno is in [extra] +makepkg -si # /usr/share/klanker-gate + systemd unit +sudoedit /etc/klanker-gate/env # FROSTY_PG_URL + one provider key (+ token) +sudo systemctl enable --now klanker-gate +curl http://localhost:8080/healthz +``` + +Then point SysDeck at it (cockpit bridge env, or `web/.env` for the web edition, then restart): + +```bash +KLANKER_URL=http://127.0.0.1:8080 +KLANKER_ADMIN_TOKEN= +``` + +Both the cockpit AI Gateway panel and the web edition's AI Gateway panel flip from their offline/demo state to live data automatically. The full runbook — postgres provisioning, multi-worker serving (`FROSTY_WORKERS`, an Arch bonus via `SO_REUSEPORT`), the optional control-UI build — is `klanker-gate/arch/INSTALL-ARCH.md`. + +### 10.1 Running an all-local stack (ollama · llama.cpp · koboldcpp) + +The gateway is **not SaaS-only** — no API key is required anywhere in this +setup. Five provider types are local-first upstream: `ollama`, `lmstudio`, +`sgl` (SGLang) natively, plus the generic `openai-compatible` type that +llama.cpp (llama-server), KoboldCpp, vLLM and TGI all speak: + +| backend | provider type | base URL | auth | +|---|---|---|---| +| Ollama | `ollama` | `http://127.0.0.1:11434/v1` | none | +| llama.cpp (llama-server) | `openai-compatible` | `http://127.0.0.1:8081/v1` | optional | +| KoboldCpp | `openai-compatible` | `http://127.0.0.1:5001/v1` | optional | +| LM Studio | `lmstudio` | `http://127.0.0.1:1234/v1` | none | +| SGLang | `sgl` | `http://127.0.0.1:30000/v1` | none | + +Env wiring (in `/etc/klanker-gate/env` or the gateway's `.env`): + +```bash +OLLAMA_BASE_URL=http://127.0.0.1:11434/v1 +OLLAMA_MODELS=qwen3:14b,llama3.1:8b,nomic-embed-text +LMSTUDIO_BASE_URL=http://127.0.0.1:1234/v1 +OPENAI_COMPAT_BASE_URL=http://127.0.0.1:8081/v1 # ONE openai-wire server +``` + +Env registers one `openai-compatible` account — to run llama.cpp **and** +koboldcpp (and vLLM) side by side, register each via the admin API, then +auto-discover its catalog: + +```bash +curl -s http://127.0.0.1:8080/api/providers -H 'Authorization: Bearer $FROSTY_ADMIN_TOKEN' \ + -H 'content-type: application/json' \ + -d '{"id":"llama-server","type":"openai-compatible","baseUrl":"http://127.0.0.1:8081/v1","enabled":true}' +curl -s -X POST http://127.0.0.1:8080/api/providers/llama-server/refresh-models \ + -H 'Authorization: Bearer $FROSTY_ADMIN_TOKEN' +``` + +**Port note:** llama-server defaults to `:8080` — the same port the gateway +listens on. Run it on another port (`--port 8081`) or move the gateway. + +Both editions ship a **Local stack wiring** card (in the AI Gateway panel) +that live-probes each backend's `/v1/models` from the host and shows these +recipes with copy buttons — `klanker localstack` at the bridge level. + +### 10.2 Turning the AI Gateway off (module toggles) + +Not using the gateway (or switched to a different assistant stack)? +Both editions let you remove it from the console without uninstalling +anything: + +- **web edition** — every sidebar module carries a power toggle (hover + a row → ⏻). Clicking it hides the module from the sidebar AND the + ⌘K palette; a **Disabled (N)** section appears at the sidebar bottom + with one-click re-enable (plus a restore-all ↻). State is persisted + in SQLite (`shell.disabled` via the `shell` bridge module) and + survives restarts; Overview is protected. If you disable the module + you are viewing, the console jumps back to Overview. +- **cockpit edition** — plugins are discovered by directory: `sudo rm + -rf /usr/share/cockpit/sysdeck-klanker` removes the sidebar entry + (bridge helper stays at `/usr/lib/sysdeck/bridge/klanker.py` for + scripts); restore with `sudo make install`. + +### 10.3 The 0.3.0 security audit (both editions + the vendored gateway) + +A full-codebase security review shipped with 0.3.0 — the cockpit bridge +helpers, the 27 plugin panels, the web edition, and the vendored +klanker-gate tree. What changed: + +- **bridge helpers fail closed now.** `cgroup-set` validates both the + cgroup path (must resolve under `/sys/fs/cgroup`) and the control-file + name (real controller knobs only); `artifacts-clear` / + `build-delete` / `build-log` / `artifacts` validate ids as single + path components before touching state/artifacts/logs dirs; + `profile-create` rejects names that aren't single components (was + directory traversal + config injection into root-executed build + configs); hwalert's `sudo sh -c` is gone (direct write, device path + validated under the scanned sysfs bases); `db start/stop/restart` + resolve engines through the registry; `db query` now actually + enforces the read-only promise (SELECT/WITH/SHOW/… only); + `themes set` rejects newlines (cockpit.conf section injection); + `packages install/remove/update` reject option-shaped names. +- **every plugin escapes its data.** The 8 oldest panels (packages, + benchmark, auth, sensors, vault, firmware, mesh, and the auth quick + actions) now escape every interpolated string — package metadata, + USB reader descriptors, fwupd device fields, sensor labels, spawn + errors — before it lands in `innerHTML`. All 27 manifests dropped + `unsafe-eval` from their CSP. Every external link carries + `rel="noopener noreferrer"`. +- **the web edition binds loopback.** `bun run dev` → `127.0.0.1:3000`, + the fester service → `127.0.0.1:3010`, the production start script + pins `HOSTNAME=127.0.0.1`; the bridge endpoint gained a body-size + cap, a per-IP rate limit and generic error responses (details go to + the server log). +- **the vendored gateway got audited, not modified.** Findings live in + `klanker-gate/arch/SECURITY-UPSTREAM.md` (10 findings, 3 critical: + no-token admin mode, 0.0.0.0 default bind, open `/v1/*` until the + first virtual key exists). Upstream source stays byte-identical per + the attribution contract; the SysDeck `arch/` packaging layer + mitigates: the systemd unit refuses to start without + `FROSTY_ADMIN_TOKEN`, `INSTALL-ARCH.md` §9 carries the firewall + + first-vkey runbook. +- **fixed along the way (functional):** the Packages panel's + firewall-backend install path (`packages.py install --` choke), the + auth panel's quick-action buttons (called a bridge.spawn that never + existed), and the mesh panel's table (read a data shape the bridge + never returned). + +### 10.4 The web edition login (Unix accounts, cockpit-style) + +SysDeck is a **LAN-side console** — loopback binding stays the outer +boundary. What 0.4.0 changes is the login itself: instead of the 0.3.1 +shared password, you now sign in with a **Unix account — the username +and password are verified by the host's PAM stack**, exactly the +mechanism Cockpit uses at its own login screen. The host decides; the +console keeps no password data of its own. + +- **PAM path:** `web/scripts/pam-auth.py` (stdlib-only ctypes client of + `libpam`) runs the `pam_start` → `pam_authenticate` → `pam_acct_mgmt` + sequence under the **`sysdeck`** service when `/etc/pam.d/sysdeck` + exists, else the stock **`login`** stack. Credentials travel over + stdin (never argv — `/proc` would leak them). Ship your own + `/etc/pam.d/sysdeck` (e.g. `auth required pam_unix.so`, plus + `pam_google_authenticator` for MFA if you want it) to tailor the + stack — `SYSDECK_PAM_SERVICE` renames it. +- **Root, or pam+local:** pam_unix needs root to read `/etc/shadow` + for *arbitrary* users (non-root processes only get the invoking uid + via `unix_chkpwd` — a pam_unix guarantee). So the modes are + `SYSDECK_AUTH_MODE=pam` (default; run the service as root, like + cockpit-ws), `pam+local` (PAM first, then the `SdUser` scrypt table + for installs that can't run privileged), or `local` (console + accounts only). Manage the local table with + `bun scripts/manage-users.mjs list|add|passwd|disable|enable|remove` + from `web/`. +- **Session:** an HttpOnly, SameSite=Lax cookie (`sd_session`) holding + an HMAC-SHA256-signed token **bound to the username** + (`v2...`), **12h** expiry. The HMAC key is random + per install and persists in the SQLite DB, so sessions survive + restarts — including the 0.3.1 → 0.4.0 upgrade (old v1 tokens still + verify as a legacy "operator" session until they age out). +- **Gate scope:** the page itself is server-rendered as the login + screen until the cookie verifies, every `/api/*` route answers 401 + until signed in, and the **fester service verifies the identical + v2 token** on its REST + WebSocket surface — no unauthenticated path + into the console's data. +- **Lockout:** wrong attempts are rate limited per-IP **and** + per-username (5 per 60s each — the same shape the sshd stack + applies). Wrong-user and wrong-password return the same generic + answer; nothing enumerates accounts. +- **Identity in the shell:** the header carries an account menu — + avatar, `user@host`, unix-account provenance (PAM vs local), the + wheel/sudo "Administrative access" badge, and a live session-expiry + countdown with a draining life bar; the status bar shows + `user@host` next to the vitals. Login/logout are audited with the + unix username as the actor. +- **TLS:** LAN deployments typically run plain http; front the console + with TLS and set `SYSDECK_SESSION_SECURE=1` to add the `Secure` + cookie flag. Sign out lives in the account menu (clears the cookie). + +The login/logout actions are audited (`module: web`, actions +`login` / `login-failed` / `logout`, actor = the unix username, with +source IP). This is deliberately *not* MFA-by-default or rate-proof +crypto — it is the host's own account system doing what it already +does at every other login surface on the box, recorded here so nobody +mistakes it for more or less than that. + +### 10.5 Cockpit module detection in the web console (v0.4.1) + +The console scans the host the same way the cockpit shell discovers +pages — every `/usr/share/cockpit//manifest.json` with a `menu` +entry is a module — and **loads each one into its own navigation**: + +- a **Cockpit** sidebar group (with a LIVE/DEMO provenance badge) lists + every detected module — distro modules (`cockpit-machines`, + `cockpit-podman`, networking, storage, accounts, updates, SELinux, + PCP metrics, kdump, tuned...) and third-party addons alike; +- each module opens a detail view with its manifest identity, shipped + files, **live backend presence probes** (`virsh`/`podman`/`nmcli`/ + `pkcon`/... — real `which()` checks), and a jump to the native + console panel covering the domain when one exists; +- `sysdeck-*` modules never duplicate (native panels already ship), and + menu-less chrome (`base1`, `shell`) is skipped — exactly the cockpit + shell's own rules; +- `SYSDECK_COCKPIT_SCAN` (colon-separated paths) adds extra scan roots + for staged trees; with no cockpit tree on the host, a clearly-badged + typical-distro set keeps the surface explorable; +- the **Cockpit Modules** hub panel (Integrations group) summarizes + detection: counts, backend availability, native coverage, and the + scan paths in play. + +This is the piece that makes the console/host pair 100% compatible: +install a cockpit module on the box, and it shows up here — no cockpit +login required to browse it. + +## 11. Run without Cockpit (the complete standalone runbook, v0.3.0) + +The web edition needs **nothing from sections 1–8** — no cockpit, no Python +bridge, no systemd, no root. One Bun runtime serves the whole console: + +```bash +tar xjf sysdeck-0.4.1-master.tar.bz2 +cd sysdeck-0.4.1-master +make web-dev # bun install + db:push + fester + next dev :3000 +``` + +Production path (standalone build, systemd on Arch, reverse proxy with the +`?XTransformPort=` websocket gateway, environment reference, troubleshooting): + +```bash +cd web +bun run build # self-contained .next/standalone/ +PORT=3000 HOSTNAME=0.0.0.0 bun run start # or: node .next/standalone/server.js +``` + +The complete runbook — with the two systemd units (web + fester), the +`.env` reference table, the Caddy/nginx websocket-gateway configs and a +troubleshooting matrix — lives in two places, kept in sync: + +- **`web/README.md`** in this tarball (plain markdown) +- the **"Run without Cockpit" panel** in the web console (system group, + right under Overview) — every command block has a copy button + +## 12. The web-edition skin for Cockpit (v0.3.0) + +Since 0.3.0 the Cockpit plugin pages wear the **web-edition skin** by +default: every plugin's `index.html` links +`../sysdeck-common/sysdeck-web.css` after the base stylesheet, porting +the Next.js console's midnight/teal design (teal accent `#3fc9b0`, +soft-tinted badges, 10px radii, tabular numerals, thin teal-edged +scrollbars) onto the classic cockpit panels. Nothing else changes — the +class vocabulary, the bridge, and every module are untouched. + +```bash +# revert the plugin pages to the classic 0.1.x skin: +sudo rm /usr/share/cockpit/sysdeck-common/sysdeck-web.css + +# also theme the Cockpit SHELL chrome (sidebar, header, login) to match: +sudo make install-branding # backs up any existing branding.css first +sudo make uninstall-branding # restore the backup +``` + +`install-branding` installs `shared/branding.css` as +`/usr/share/cockpit/branding.css` — Cockpit's documented override point +for the shell. It targets both PatternFly v5 (`pf-v5-*`, Cockpit ≥ 300) +and v4 (`pf-c-*`) selector generations, so unmatched rules simply no-op. + Author: **Jeremy Anderson** · · diff --git a/README.md b/README.md index 5a46c38..4dbcc96 100755 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ **A drop-in plugin for an existing Cockpit install — twenty-six domain modules behind one dashboard.** Author: **Jeremy Anderson** · · -Version: **0.2.0** (Master Edition) · License: **MIT** +Version: **0.4.1** · License: **MIT** --- @@ -13,6 +13,128 @@ SysDeck is a cockpit-native plugin that consolidates the day-to-day work of a Li The plugin ships as static HTML+JS+CSS plus a Python bridge helper package. It installs under `/usr/share/cockpit/sysdeck-*/` and is discovered automatically by the cockpit-bridge. No separate web server, no Node.js runtime, no database — the plugin runs inside the cockpit web service. +### v0.4.1 highlights (cockpit module detection — 100% console/host parity) + +0.4.0 brought the Unix login. 0.4.1 closes the last compatibility gap: +**every cockpit module installed on the host is now detected and loaded +into the Next.js console too** — distro modules like cockpit-machines +and cockpit-podman, addons, anything with a `menu` entry in its +`/usr/share/cockpit//manifest.json`: + +- **Detection is pure filesystem** — the same discovery the cockpit + shell performs. `sysdeck-*` modules are skipped (native panels already + ship here) and chrome without a menu (`base1`, `shell`) never shows. + Works with cockpit stopped or absent; `SYSDECK_COCKPIT_SCAN` adds + extra scan roots (colon-separated) for staged/DESTDIR trees. With no + cockpit tree the surface shows a clearly-badged typical-distro set. +- **A "Cockpit" sidebar group** appears with every detected module — + each opens a detail view: manifest identity, shipped files with + sizes, live backend presence probes (`virsh`, `podman`, `nmcli`, + `pkcon`...) plus on-demand version probes, and a jump to the native + console panel covering the domain (machines/podman → Containers & + VMs, packagekit → Packages, networkmanager → Network Security, + metrics → Monitoring...). The ⌘K palette searches them too, and the + **Cockpit Modules** hub panel lists everything with LIVE/DEMO + provenance. +- **The UI codenames are retired** — no more "web edition" or edition + subtitles anywhere in the console; the identity is simply **SysDeck** + with a single clean subtitle: **dcos.net** (login banner, sidebar, + status bar). The page title is "SysDeck". + +### v0.4.0 highlights (Unix Login Edition) + +0.3.1 gated the web console behind one shared password. 0.4.0 replaces +it with the login model the whole project is named after: **sign in +with a Unix account, verified by the host's PAM stack — the same +mechanism Cockpit uses at its own login screen.** The host decides; +the console keeps no password data of its own. + +- **PAM login** — `web/scripts/pam-auth.py`, a stdlib-only ctypes client + of `libpam`, runs `pam_start` → `pam_authenticate` → `pam_acct_mgmt` + under the `sysdeck` service when `/etc/pam.d/sysdeck` exists, else the + stock `login` stack. Credentials travel over stdin, never argv. + Ship your own `/etc/pam.d/sysdeck` to tailor the stack (MFA modules + included, if you want them). +- **User-bound sessions** — the `sd_session` cookie becomes + `v2...`; the shell shows a cockpit-style account + menu (avatar, `user@host`, PAM/local provenance, the wheel/sudo + "Administrative access" badge, a live session-expiry countdown with a + draining life bar) and the status bar carries `user@host`. 0.3.1 v1 + tokens still verify as legacy sessions — upgrades don't log anybody + out. The fester service gates its REST + WS surface on the same v2 + token. +- **Three auth modes** — `SYSDECK_AUTH_MODE=pam` (default, cockpit + faithful: run the service as root so any unix account can sign in), + `pam+local` (PAM first, `SdUser` scrypt accounts as the fallback for + unprivileged installs), `local` (console accounts only). Local + accounts are managed with `bun scripts/manage-users.mjs + list|add|passwd|disable|enable|remove`. +- **Lockout like sshd** — failures rate limited per-IP **and** + per-username (5/min each); wrong-user and wrong-password return the + same generic answer; every attempt audited with the unix username as + actor. A wedged PAM helper fails CLOSED, never silently falls back. +- **The login screen got the fester treatment** — host identity banner + (hostname + OS, exactly what cockpit leads with), aurora/grid + backdrop in the active console theme, caps-lock detection, a one-shot + error shake, and the amber default-password nag (local modes) until + the seeded account is rotated. +- **Cockpit edition untouched** — all 29 modules keep working under + cockpit exactly as before; the bridge guards (`check-bridge-subcommands` + 218 calls / 28 modules, manifest consistency) still pass. This + revision's changes live in `web/` and the docs. + +### v0.3.1 highlights (login gate for the web edition) + +The 0.3.0 audit left one honest gap: the web edition had guards but no +login. 0.3.1 closed it the LAN-side way — a **cockpit-style shared +password** (superseded by 0.4.0's Unix-account login): + +- **one shared password** — `SYSDECK_WEB_PASSWORD` in `web/.env` + (default `sysdeck`; the login screen nags in amber until you set + your own). Constant-time compare, per-IP failure rate limit + (5/min), every attempt audited with the source IP. +- **HMAC-signed session cookie** — HttpOnly, SameSite=Lax, **12h** + expiry; the signing key is random per install and persists in + SQLite, so restarts don't log you out and the **fester + mini-service verifies the identical token** straight from the same + DB — the browser's live WebSocket event stream is gated too, not + just the REST routes. +- **every surface gated** — the page server-renders the login screen + until the cookie verifies; all `/api/*` routes answer 401 until + signed in; a mid-flight expiry reloads to the login screen instead + of erroring. Logout button in the shell header. +- The posture stays LAN-side: loopback binds remain the outer + boundary, `SYSDECK_SESSION_SECURE=1` adds the `Secure` cookie flag + when the console fronts TLS. See QUICKSTART §10.4. + +### v0.3.0 highlights (AI Gateway Edition) + +v0.3.0 integrates **klanker-gate** — the Frosty Deno LLM gateway (Deno 2 + TypeScript, OpenAI-compatible API, governance, virtual keys, caching, MCP) — as the new **AI Gateway** module, vendored at `/klanker-gate` (own independent version 0.9.0, Apache-2.0). **klanker-gate is not SysDeck code** — it is by [TykoDev](https://github.com/TykoDev/klanker-gate) and is credited in `/klanker-gate/ATTRIBUTION.md` and `THIRD_PARTY.md`. + +The headline finding of this release: **"porting klanker-gate to Arch Linux" required zero upstream source changes.** The codebase is Linux-first, not Windows-first (the Windows mentions in the tree are accommodations: `reusePortSupported()` is linux/darwin-only, the Docker/entrypoint path is POSIX, `deno.lock` win32 entries are ordinary cross-platform lockfile records). The work was packaging — and it ships: + +- **`klanker-gate/arch/`** — the complete Arch packaging: `PKGBUILD` (self-packaging, `makepkg -si`), a hardened systemd unit (StateDirectory, `ProtectSystem=full`, empty `CapabilityBoundingSet`), sysusers/tmpfiles, a `/usr/bin/klanker-gate` run wrapper (module-cache warmup + `--allow-run` scoped to the Deno binary only when `FROSTY_WORKERS>1`, mirroring the upstream entrypoint's escalation policy), and `INSTALL-ARCH.md` (the full runbook: postgres provisioning, env, verification, SysDeck wiring). Bonus: moving to Arch **unlocks** `FROSTY_WORKERS` multi-process serving via `SO_REUSEPORT` — impossible on Windows. +- **cockpit side** — `bridge/klanker.py` (10 subcommands: status, providers, models, vkeys, logs, analytics, runtime, service, journal, localstack — stdlib REST client against `KLANKER_URL`, Bearer `KLANKER_ADMIN_TOKEN`, graceful offline JSON, token never echoed) and the fully-built `plugins/sysdeck-klanker/` panel (status card, spend in µUSD→USD, providers/vkeys/recent-requests tables, runtime topology, local stack wiring card, service control + journal viewer). +- **web side** — the hybrid **AI Gateway** panel (Integrations group): live REST against the gateway when it runs, clearly-badged demo data when it doesn't (this sandbox has no Deno/Postgres); flips to `source: live` automatically with `KLANKER_URL` set. +- **local stack first-class (both editions)** — the gateway is *not* SaaS-only: `ollama`/`lmstudio`/`sgl` are native keyless provider types and llama.cpp (llama-server)/KoboldCpp/vLLM plug in via the generic `openai-compatible` type. New **Local stack wiring** card live-probes each backend's `/v1/models` from the host (`klanker localstack` bridge subcommand) and shows env + admin-API wiring with copy buttons; the web demo dataset re-seeded local-first (spend/24h ≈ $0.001 — cloud overflow only). See QUICKSTART §10.1. +- **module toggles (web edition)** — every sidebar module can be turned OFF (hidden from the sidebar + ⌘K palette) and back ON from a **Disabled** section — one click, persisted in SQLite, survives restarts; disabling the active module returns to Overview. Turning the AI Gateway off when you switch stacks is now a hover + click. See QUICKSTART §10.2. +- **guards** — `check-bridge-subcommands` now verifies **218 calls across 28 bridge modules** (was 216; the audit pass added auth readers+certs); 28 plugin manifests conform. +- **0.3.0 security audit** — a full-codebase review (bridge helpers, plugin panels, web edition, vendored klanker-gate): bridge write primitives now fail closed (cgroup-set path+control validation, artifacts-clear/build-delete/build-log id validation, profile-create name validation, hwalert's `sudo sh -c` removed, db start/stop/restart registry-gated, db query read-only-guarded, themes set newline-guarded, packages argument-injection-guarded); the 8 oldest panels escape all interpolations and all 27 manifests dropped `unsafe-eval`; the web edition binds loopback (dev, fester, production start) with bridge body-cap + rate limit; the vendored gateway is audited-but-unmodified with findings in `klanker-gate/arch/SECURITY-UPSTREAM.md` and packaging-layer mitigations (systemd unit refuses to start without `FROSTY_ADMIN_TOKEN`). See QUICKSTART §10.3. + +Wire it up (either edition): + + KLANKER_URL=http://127.0.0.1:8080 + KLANKER_ADMIN_TOKEN= # see klanker-gate/arch/INSTALL-ARCH.md + +Quick start (web edition, from an extracted master tarball): + + make web-dev # fester service (background, :3010) + web console (:3000) + +Two more 0.3.0 additions close the loop between the editions: + +- **"Run without Cockpit" runbook** — the web edition is fully standalone (no cockpit, no Python bridge, no systemd, no root). The complete deployment guide — dev, standalone production build, the two systemd units, `.env` reference, reverse proxy + `?XTransformPort=` websocket gateway, troubleshooting — ships twice, kept in sync: as `web/README.md` in the tarball and as a first-class **panel** in the web console (system group, right under Overview, copy-buttons on every command block). +- **the web-edition skin for Cockpit** — since 0.3.0 every Cockpit plugin page links `shared/sysdeck-web.css` after the base stylesheet, porting the Next.js console's midnight/teal design (accent `#3fc9b0`, soft-tinted badges, 10px radii, tabular numerals) onto the classic panels; `sudo make install-branding` additionally themes the Cockpit **shell** chrome (sidebar/header/login, PatternFly v4+v5 covered, distro `branding.css` backed up first). Revert either with `make uninstall-branding` / removing the skin file. See QUICKSTART §12. + ### v0.2.0 highlights (Master Edition) v0.2.0 ships as a **master tarball — `sysdeck-0.2.0-master.tar.bz2`** — bundling the cockpit edition (this tree), the new **SysDeck Web Edition** (`web/` — a standalone Next.js console with 28 bridge modules, an Overview landing view, and the previously-orphaned Hardware Alerts panel), and **Fester pre-integrated**. diff --git a/THIRD_PARTY.md b/THIRD_PARTY.md index d0471a1..dd8014d 100755 --- a/THIRD_PARTY.md +++ b/THIRD_PARTY.md @@ -6,14 +6,41 @@ integration invokes the external tool as a **separate process** via suite (MIT) and the external tools remain independent programs. This file satisfies the attribution requirements of the licenses listed -below and documents every external integration point. +below and documents every external integration point — including the +one vendored project in the master tarball (klanker-gate, below). --- -## Bundled Dependencies (shipped with the suite) +## Vendored Project (master tarball only) + +### klanker-gate — the "Frosty Deno" LLM gateway (the AI Gateway module) + +**klanker-gate is not SysDeck's code.** All credit belongs to its +author, **TykoDev**. The master tarball vendors the upstream tree +unmodified, as a sibling of the suite, under its own Apache-2.0 +license; the suite's modules talk to it as a separate process over +REST (same no-linking rule as every other entry in this file). + +| Field | Value | +|-------|-------| +| **Project** | klanker-gate ("Frosty Deno" LLM Gateway) | +| **Author** | **TykoDev** | +| **Source** | https://github.com/TykoDev/klanker-gate | +| **License** | Apache-2.0 (full text kept at `klanker-gate/LICENSE`; notice kept at `klanker-gate/ATTRIBUTION.md`) | +| **Vendored at** | `klanker-gate/` in the master tarball, own version **0.9.0** (independent from SysDeck's version) | +| **SysDeck additions** | `klanker-gate/arch/` only (PKGBUILD, systemd unit, sysusers/tmpfiles, run wrapper, runbook) — zero upstream source changes | +| **Modules** | `sysdeck-klanker` (cockpit edition: `bridge/klanker.py` + `plugins/sysdeck-klanker/`) and the web edition `klanker` bridge + AI Gateway panel — both are thin REST *clients* containing no upstream code | +| **Integration** | REST against `KLANKER_URL` (default `http://127.0.0.1:8080`), `Authorization: Bearer ` — a separate process invoked over HTTP, never linked or embedded | +| **License compat** | MIT suite + Apache-2.0 vendored tree redistributed in source form with LICENSE and notices retained — compliant; the two programs remain independent works | + +--- + +## Bundled Dependencies (shipped with the suite itself) None. The suite is self-contained MIT-licensed code with no vendored -third-party libraries. +third-party libraries. (The master tarball separately vendors the +klanker-gate project — see the section above; it is a sibling tree, +not part of the suite.) --- @@ -247,7 +274,7 @@ For MIT/LGPL/BSD/Apache tools: fully compatible with the suite's MIT license. --- -## v0.0.46 — In-Suite 3rd-Party Module Installer +### v0.0.46 — In-Suite 3rd-Party Module Installer Prior to v0.0.46, the only way to install third-party Cockpit modules (45Drives Navigator, cockpit-pacman, cockpit-identities, etc.) was the diff --git a/bridge/__init__.py b/bridge/__init__.py index 5380505..49a9d0e 100755 --- a/bridge/__init__.py +++ b/bridge/__init__.py @@ -22,7 +22,7 @@ import os import subprocess from typing import Literal -__version__ = "0.2.0" +__version__ = "0.4.1" __author__ = "Jeremy Anderson" __url__ = "https://dcos.net" diff --git a/bridge/__pycache__/__init__.cpython-312.pyc b/bridge/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..7723a3aecac512ca090b3fd0909f8b19c4735740 GIT binary patch literal 4121 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 E0|rY&XaE2J literal 0 HcmV?d00001 diff --git a/bridge/__pycache__/auth.cpython-312.pyc b/bridge/__pycache__/auth.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..d97f382f7619f4a7dc454fea709de4789fd4f4e5 GIT binary patch literal 12552 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 literal 0 HcmV?d00001 diff --git a/bridge/__pycache__/benchmark.cpython-312.pyc b/bridge/__pycache__/benchmark.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..b6cd3b38ebfe6687ff66cf5b7b1759bcc5714483 GIT binary patch literal 8300 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

|b<9l~M;)6| z`wXhznK^J8E3Ha0$72j$w*bgo04%^fjk-+~6d}|LSplt@wqtV;noNzJm3T3HzpaA~75tS~Xsq1sBx>H+Fdo9VYx2qQ$b#FCQ|RV`ZL2M@HVPST^=N{|&4S!JRslhTY@OAUH%(2|PQ{3#5T zi_m1rmoK#3QkMJ9t?W1t(bco%u4ng(XZLSCd$FM2l|T6Nj%yu1>s)cR7ENY%-TjsW zH+{cu{G{<#_n+_jv}UEP=Wg4Hm9`VBZ6{Y-PAyoM>t9^)G-I({v(SHKU~%BRi?^M- z^U<3VxA%b$He5Nec;da2myg_U*qwjjw++pU>;kjUR;cymtna;=Z_B@P?d6Z#m(n*n zR`CV$Z-Tz=E$nADM|Z8| zvl<55EspLzw$FAm&~9*k3|yM?Z-JNfxx;XVK60IbLrdBO^y`dj2?KbPWz@2flsjWW zd`J`y(~Hpg0o-dCaI)kh?jx6;tV(_jy@Wi&hV%lAgDv;NgAPEmZaZxgt+|Q>eW$DE zu3q>*?Suv6(|7t1sh6;M0h;F(pTP}Qr%&JG$3Qi>IePWjlRKf3G)3VF!FollKd~|P zLU;rMM1-}e?0$j-J2Y8x^?Y8u`X^8B_Z)h{4EJ=-^0-&YB3}FGb!{hb?=I-h;6V?b z*=O)9B&gR>87$|6(PReEo|c$1<%}A{jJiPqm?Si@5HK<<(Wwp3p)s(ECP+M&s^5diFKsjN{+Hk%dK zCOG+oJ**YjP)%(Vg-tJ?ky3TwfeDZkrk>0)|s;AP`b+-T5TW3^vrRVIK zlL!fAl#)Vq1j&lgh@u5hJ)~*TMhge%ry4-i(^nLCG=u0hY2hbhVFU_bvHsG;dtDDg z`Z=k+GoBRFG3hX!hM5>;+4kZuI?~E57D60)52>Gs7?V z79zR!h0(>GYXp84UD(%?tIb7n4U4^{J}dOG3w^o03+ETB)(HG8*0eA+pm_7*D@6jY zd0#%V)Se%`2Ft+D;zh>BI6?P4`PzIW-|&9#N_`7{jkDvNWe0*!6d;?@5E!ZP3< literal 0 HcmV?d00001 diff --git a/bridge/__pycache__/builder.cpython-312.pyc b/bridge/__pycache__/builder.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..aca94f35077e58cccae21a7e3e0bdf9b24a526ef GIT binary patch literal 79182 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 WMR?`dvMF299S}gh&XCVIivJ4+fV!~& literal 0 HcmV?d00001 diff --git a/bridge/__pycache__/containers.cpython-312.pyc b/bridge/__pycache__/containers.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..ae51a905e7e6d16b11278a5b9284f95a486409f9 GIT binary patch literal 3526 zcma)8OKcm*8J>NTTs|bxlKgD4v1CV#Eed|Lb`-^_Oxdx5Mr}>0MF<4MYG+6-O)iPq zr6me7pcDowwFWF90mLE>K(~h~uz@;2iv~qc?Xed*a!`5W9*Pzyd?-L!ITS??{b$J) zC8cgVi+Sw)kNM}H`M-bu5DqH{%HIcW=F)zI{=$B|;%g4Jp21)RVU$9c!+f4gacKu#PUrs<%h9M0>nq849{q+W_auanu7hr2lO3O zkO}}Lh?ScN2cd@U@Tt%)&0*jP15bps;mEZ+S+PcleogIH6GhY343khxy+{q5CF&KDFA=H?m+h${9aP^U zloTrJu!(`EXsUfvwq3FY2L`ZSwBjbQV~R4I%~F!pY+|XUA}(mAs(Wfx%Z7P%+OVfo zt76%tfYq{T*zSDUQmvwDPZ9O4q*BsMl2>&Qri3k~YK$>s5UgG|$h2yhFk=~JHcwQm zq)o#jT0S3FlnG1Al0ij#rUMVtN6A-mYO##`~T-QOshn6TOBg+ z1?4Go>R60(gkiJdh#JjacVtTJGBx$4!(F$_&}^_(cj>#DId%o%yU*Vf z8-!f5-^rFlF=6)d)iU#fUMzr*&m%-D{kPl3~btXSpAQbXJkXxVh+#tJcxGUF_^ zBlVx14et)Lyc;i7Xb@;_)5$nrf@U85HQ06g!s3Muf2caXDn1zbQs6`K-{jDHnYzSF z;Voc7I%~WVhJowC-@;NWD6qqcS#a1-IqzoQKyxU^IQNu$&41cuOCBl54rWi8+m02J z%c2|H95;e)^E#hJI{)rhzQ0uJAim-$;WBJBAFpF};#2IOp%oQtO3U!%7}-hYPg zMHCQ{dFvgNMAK+Scn3{$I?uVfrco?1JABm*krpag4M2d%H>g_BY<&vidN)3pT7js2 zMg}fLb zu3fC1UGMB$?ReOEVxy~jWny`vdaibHz3b(T?)@udnN+XTCfB=TduU(lt$p|B6L%+8 z&pwF$EWOtI^#|WuJ3O=%_63xBJFL6OET|(zlK=5#5QX>uF%)|kiak0pv=)5*8RDeq zMq9^^0(AjK|9YkXueBd``Gd0;&!gWAzm_;C{_gzYM7Qw!E)H5JwC$F(fp?)W3wswr zQZU$w!hp8?9LfsY5W?Bp0P>%(cdi9C00vZ=g8*X5K8XydF>$tUw3#KgGt{SRS7 z9Z^e^WQ>_OZ3i*O*Gwwz1&s*}z+`ubGO&b+7#kmBtRmKIZP#U>Cm4}6r=eP>paFPQ z!V5FigSBt1MNX|Nr{@z-f^D}47YCR0AHOj_^0@oZ!r3Km@!Xntw8le2BOtZQS1eRq zUv{I1g?)06`P`ca2~bKy^0OLgpm9h2LwT(*iM0!K08|V@wb*NG9^Dyubm+ua zR}_Khu6P^oc*Ps~hh%B^Rb-Vl#@T@~_`T^^Fo=up)j%a(!R(m_Ad)C5Z zi?iAO+>-Mio!$iWo-R*NOD7gQjt|jC{C@}I`=XazU|p%D!)DOomK;0oJ%vYe z9IPQ@)Osvp>Aug?LnMHVr!CRsEBpFoMp=1E) zo-(gI1?R!NabyEtbS9vtBbBIO+Kw-s#zj4ycKnHNzy0>`*hrH00;?kc#=1`pLTM9K zak)^kV!Zq8a0I=8EvDH+MzH{|SNaCbuxb^cnMaR`Yx3ae) z8*S}Rl<4h~izk=t>hSXPy7JKctA49+} zO>Tnl4;6~IoF^A3dvP+aTfc$^FqGpqMTHZ$4j?Y{1?u<$DStxUby?)X3wY_wqESaM ztR}WtSC4dZGHesq&a4Wx^FJ{k9_-&jFjtR@Tu-%!@%GeuYu0L_Hgh-5WO_E+QTXKJ zwxLZ)2=Vj&dME&z?WM$GrH)`&O>VKSegKJ~h1t)9gBxOKKJmjV?_F6OU+P)uTkfl# Vs-9jtxgLJ;b5X4eNIc|j>)(!NDUSdE literal 0 HcmV?d00001 diff --git a/bridge/__pycache__/db.cpython-312.pyc b/bridge/__pycache__/db.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..51a06376af26226a39dbb8f503c60a0b754bfc01 GIT binary patch literal 20916 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 literal 0 HcmV?d00001 diff --git a/bridge/__pycache__/fester.cpython-312.pyc b/bridge/__pycache__/fester.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..056793ff50d62513b46f1f465cb5e7f9aea559a4 GIT binary patch literal 13204 zcmc&)du&_RdB2yue25f9y)4Zy%Ws3JoqD+zU zy`&YfRHGE>Cer^&GGN<8=6^Zd?tzVkiLzj!<@4$rmjpNthWaNK{;hxz0inR^u$j=RW7 z+yE!>k|oR!@a%0Fu&}pvz{=jjfQ`NF#@jJqlPzO~=5X2fSV=Z-<|L~mT*W-}jBU{0 zxv6Bk%In_)Zpl87FF6K0l2a}iwn%wz2m@Z^3z2srUnIHZVtf}53sOG5OQda*2PFmg zdF2Ahl5lVw>+6&X->?movRWETkc!ZL1N)|yRA!foQC23E$mMeJCV;?71*sIJ8>J2F zO3P5X>4DO6lvX@Yx)G(DA1K{~(k%~^R-m*}+WZD5ZNabd4Nl%9RUuc6T!kbe-7IgB zE2Wxu+yhloZ4Ok0SXCW*SIeSQe+ql7Y_o8(b!=ZQcJ(~Ut5MVSV2!j@vQ%*94w!GA zo3czKoogC-tn^L>v;3=_5?2*-&tcd%RkTfET2juXCthhSjvC*iqM?5Gi^7xe4 z5s_p?jYhcQ4G!mY2sqvv;bbLGzk;I0RL-9~pY8s0UsV9A6 zI0VonQJunTToU6E>N7bSRb?>}7?;IhG!hGhBC_PF9cHVj6{Ct+%f_e`e>?MMm_&@o zXRy3jAbbi##iE#>)uUx_6rGenEUGYAU7gY3sR{IurBEz{#YUo&qB1y5iguexUt$W&9 zT3h@sSN~{8H8GD|06>Kzz`hg?4K)$!^9BT><4p<;iAlz=kT2uz7VbQdLg0@#rw!6~0#>;~S~?CyjTieLi?iN^9D zQ^kXQM~`4cbs`#3<^J9LH)|(-8#?hvVT!Ao7$>GEpo9VCrX4KQ!td*LP~8r)u|z!-23WH;T1vE%^5P z{nPsL1Hj-o$d&;qVwYo$qWmgIIT)2>k!YbWbI35B2qGXXi^j9RyI&NW0~4WU1Gi$s z4sjx?#KdM12PA^cVfmqeDi30xeHdX}2C)XsA+jSF9>gieQDANpf1Ta@5BH%7_fA)1T}pYs(uZl;{}922=q~ z5gnoViR#1PE+PXVr>9YFLZHg33feHH$hI@fF%;0P#G3R4H8q)Y?VV^Bo0?2O_6F56 zw4k-mx0;$F(WYPk)c=Zz4~5W<1@CQXYHMdD!+}t^36xG8Yj2?al?R4+{wR0_+g%+1 zbAsRg$&Yj#4~8UkGR}j6?{%G2w%BI94H_%*L^v=d?qwtPVut`)I533sv^V@n5KN@# zKoAH<39&1%&afn&WP|E50LLjK9B|lNx;=~kWRisU9j;l&D zSW+|Og#_8wtREwT!}=$t466T%oftIgSL~F#;U>Pb8xN|AzuFXwM#DM#FUdopKm-Nm zej{t{_D(i^514-UvB4LQ9eSF?Fr+ZpJpJuv9A7-aT74~)d`w!z5zUpIBC)}oV~tqR z_~wrVw2jXpStkMG5k&@}28P127>J4A0jHQ|GJ|1*WHIB_C&vTec6#3?R&|oZHnu$? zLQF!4oH;2%P<^qiL~9i?FAJ<@2d3gmBuL9J9|F^g-@Uy^pb_&ivxXa^@^w#)+Oy}H z<*K2*O4fHQeVk87bR1*jGdW|jZ816w$pQXgN-wctJr~9@g5kI%OFn;(kJqfg$Q6rb ziJsDIjEVcKn)k&c(40CCbnH9SJ$Sfpzh)nfD&v8eLLgq@?h!=y3)hpfmOq$-ZKVi} z4&tY7Ml!>FR9t?!`F8D-%e7Db{XnYr$(7mz%kJKk(5rY+|8OL|ftrh49+!h8L%~Ny z<{7hPvYAZGZe~rijX}d)|Gdhp79N|>$JKE$X2wv9dQP$O+;MJ-pX6S(9_J=`pC$41 z^CWJf!wlObb3~B5*g#s12;{J0NgfWw!?CP3W@r>Xf9O3o$NB7<9mobZ&}CUP~MI zv%F`l|HwaM)ojo#;&RX+Hq>$);g=EZOUPXOI=caLNq>?V6F}lY(#CFroUMsAAhc;B z5F2fRu0|?C0O}OfC{-RAXSyI`dd&uF7DJNi^J^R^SWpAQvQJRRCR9q1XtvYwXiV0u z&|Q7JLME3=>k)M<+6P&+QjMaQsZK;P!~M=(bbjyL-rMfVWq0K}!Nsj_k1Z)FcjbzE zXG++~b^$vCh%`I&iP5N}2~gdpf`l3KA*y4T8bz9msU(*`)ili9X+g3`)(}5!OIlv{ z#WKCe1apw=JDEB3V;Kx)YhmshyM!^VNtaojFO%R5$2lH< z=bq5H0C+?eCuI+gIYviv-2+~7b~-PQOXmGgF_3)pdMp0e^#(1+!SSZ^zMI#_MK)kf zRVK(W(aa57Odc2!OdbdHYeqyraoI@=73XyaGE=(KKEpXw*rC;PaoT6 z_RPKki5ooHC3&-ZMC0{wCS<G1Cd! z8|f(2pK)H(D*HmyiVS(ftRmh&4yg)<08GE=FdHeT1Yo8|M*L#d(rOCB><`0aGVCoS z5Ypuco3i<6AA5g;#$#g)!boTyoQT5kom2u7@PWYCB-1Axh>>YVJ_ndpQ6_xWYXEEn zQw#93uOphhBN&t?Vu?bG1_P6sX3b<$Cv5T9aMP2Cd_Wrmw{7a5nvkIh2(U2&ngb>n zIa`z(>$&|YRE!@O0!Av@d*`UGl{@BJk80R0h;5OnSmIM!pPSO4DGAG_i*>o7yC5< zx}T)kp?E`{C=kJ%mY3OL%|@<`3C%_pyQ(=1^eg4mOrRd5!378mF98bI6w25Z`CN*K zRty~qod|^(gR+$pB4N!67-@EO0@=W%W|dzJs>F_o$n-~-Mb1HEHA*{bPvECs1TD{S zpB8fN4fC;O*XEhdwA(w=mG+jWHtk3iG|uc#JKQNp`CV_x`NUjep?<|%ecQWj*}H9V za>d&`E2L|8%{t}_mYvmiU4`fUbN<^daoHsLCqm#-gB71ys6KQr6; z2P;?5!LOECZTtCj?bg5aUGiPtaW2N7sXO~x$~kglYwFO+R6t6dlv4+Xm)k~A_J>W> zE9H6i^NggeS$5a_o^t|>Rl$~5lrG+IvEf3)o7-;}*DV*0^wpA-vxcRf+6_&qKk@D6uJM`fFr zY)iqv8oe^QG;m$HQTI>V|91P0Evaoi%ijGTI`@CRT0}E{{%IMPUwGSHz3i?g-3Uu6 z+nj26<`>o*@zl2NWpB?-XAdb$`}zB-iVb?VdTVC`_nuJOxyAb4HYeqq>w8ML_e&j= zZn1T4b-Z8WK>7O(l|6;lU)not-Nlv<+&n%$DB^qE)(?uk-SxH)YAlqm<0)TXgZ!^- z)aF-i8>JZbFqx(V9MBaT$oR6CqKhpQjywqt7<(@5=Y^Yf_XCHzv5s7uj_2=U^=1xvBRZ2 z4rtgZWF`a@I1vT83YOfzDkPB_go(ChdVng)^~XxX)uF0)Mb?4d3>x#x5Ptj zvRpNkxc>*FTN6(*uShP|1G+Pil+#A>i?=`%sHe}8!^NJ(5>X)H#n_rkh=5~-86P$# ziIi>>eIGxS7?y5#=xASj>3aPw?-MEK6JHJYoIKOWD=Envk;Y?j)zdW$LL93P^MDd z&(C^J)ntE!YhYSl*=*mXk*yG$}v>^n5r9*%y9qi_L96~f-dFkx$E}M zH@so06L!nurBT;!ClPfPE$;Dkh;m&NR^rQANwOEV3 z1Iymtl(U!6=f7OGBrLsf#qpEsl-Qao+_~azO9^ccqEEmuj0jWMLGlGo^CR3em=oLt zR&o;R|~eP%pg=>&xTI zo`8s-c0}ICI1V;WyV>Ro$7xzS&cl3|QpVKo;?Cy-3Bk}MQ6fFh4U;gnp{RtAX6S`K%q4A#4Xz%yw1Iuu@ny$X854nItFcEa zPr6Mx0 zn&lCSY09xlS&oP;EK=HLSgaq@p-gz1nsTC8_lwOSEM!TX{E>Ua#HI{X(0bcN#DYwg zooLO#CbAe>Zl&0OKtvdpB}K?#$FpFd&u7sbMi376H7kQr8i{sNn2n5H7Gu*~1XKr$ ztR-AoG2pXl)-Wt!n+m%=rdhEC)gn!6)M2|j+^ev z#hS&yrLEWMmZYn`o3(A}vWkV)h0|~CzHn-Ge>&eYUo_V<*OB%V&Odk4Q@z-+#9!*Z z)_GmH+IzEpce-}l;%UgV_@y1UYulD<+pc%sLJXp;9pQ+D7yho}N2d{znBAW$YhN?c zp_?8tUA}qtz?#yBjbB_ge|n+yLj1xG7^^xkvmL7zt7n+MTT!_Xe7j-Zv1;c^H!L`A zmDDe`B1u=(rCLs;UXfBK~k52KB}lrHMZYSZ^)_bUCSlisr>HMHqM=|nbri88Jog=3`7E_5I1CQDt7lK(u81Ogf6=6GyFBb1K&7*1C@pky$mBWe9YF@F$Z)XEeEA&FRnA?|SD?n{*nqYdk8 zNm`+DkPx>jheP2Oyur9q49F%m4;l77)dlOF3&q*4c@pn7p z@38IYq8bFK5}~SLN=7KDMWT}M)){t=v8KbIoJQS$Qlo!B0!HmE0ej9boauffQ}*3- zZ(igVJ1*I-S(m!5=G_!q)1@01iWXjYYr}=-X1g;iD(%^j@>HhFD{>g=!`N0yF57}z zu5=BbD&D;C%~a7gxI!KIDaXe2#!9fHm*+fm2C5h9SKN=KgvTDFc%lIM-J|dfVsaS^ zCaXxpe&dh8(L^>YtS4d*xfXzPZ#XbMBn8?P3Pf8mfJ&P>!(Fae>b_N@#|GCht}mQ3 z>#x9^mLmz>k~8inVd{MtS0OKB&irgvmI#>oo-uXlV&qoM?ys=i^;7Hq0!A{ba^{iq z+H#g$x;TETX4n5TmrU%$#L6V0gj`BrYiE@x zb$>KkU2E1Cy;bu#o6qOg?79<0a~PDN+4X(WZ0xPsboA-EoN1eCPLnw#-0TXAez{At z>ym_`8N|()JLJroMG;A{BSMFRYsWvQFrS1+cGW?s&r`4QTa8vPt%bg|@RU!7PL(6z2P@%{i50 ziDzAlV;Kjc2@%IxxFnBtIVCQ8?^$wWgWBf&gg>84mm;`mVrd#p*qfT@o@Ju+MdRjY z_Qu&Bk#0N;hr)8g-EjtbI^Ar7En;aDnb2Y4hr)3oy7P*w(70J)2wi#iC6`yaMYn+cQbX=HR zaaGTB-fE*@VRDu8JeSb6X(}542&!T4Dz0ma+y>Ini9=`U?m2Ho& zYqlQ@^zSv+G^ghtB6{R3YzN{rT;4+b{Gjlr97oh88xy`N~r3EmzYm!Jl^L zvoobR2L}n(!9g8C7R?GT(-B{tLQxd9Qe1&jl^@bF^YvRgeiqTxgN|mAes8D1Xkf(d zG>05H6H=lPx7%0WtsC>f%Jq>)1Uu&x4w z<~;w2;OB*VCvn*!|2JITZ#dU)xDxiiVb#eM6rLZL8=xTJ!lBgmjup?o+n(NKPw%hm zRy;>$Txd~TGE=bXve-5v9LE=~7FhU${JeCJqxY)I#+S}_-sA9EEwu1$^L4AJXj`bG z`nFG8Rs4Q_zHODG_o7vQU*C13{-@v2^S`dY$Ld!1@ix9{R$awNRr4_#uxhoC^5}%u z!Y=k&?9yw>T)cN)T4=ivTIG1b*jy*FwpK-c=6SMGTJDYJtG-0$Fxmn4;oc zi~Ur*Yqg-2-?~u0puXi><&a$x?$K*?lo$B+`Tm8v3&*K>`{FKY-o9E;&NrCF$Wk$0 zpX}jE+wPR?zEiR*U9#m)3FXrz)ptrBPnXoDODggINqK(0WyZN$<>EcFvH8w9fPw5n y9|7%It>A?G*~CrjrnHbh)A^?dUOzC`KVL_oQW(HH=Nnf%)gKBXkS|m)V*hW4sJIXS literal 0 HcmV?d00001 diff --git a/bridge/__pycache__/firewall.cpython-312.pyc b/bridge/__pycache__/firewall.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..a07fbfcc62f197d3458662dab72888c3376d55ee GIT binary patch literal 98864 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 p)UUCQO!v?Fe$qF6A^Y@v%UnzDKfHK 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 Swr#Px`4d4ZIyu3#X8sSAZS?K{ literal 0 HcmV?d00001 diff --git a/bridge/__pycache__/grafana.cpython-312.pyc b/bridge/__pycache__/grafana.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..1109fdbb0a2ab2049e8c34a709b7e4b823671646 GIT binary patch literal 22646 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 P9b5A$PXEqf9`64G$?$NW literal 0 HcmV?d00001 diff --git a/bridge/__pycache__/hwalert.cpython-312.pyc b/bridge/__pycache__/hwalert.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..ee3df05dd737acc18c490540b96ffdaed4da6209 GIT binary patch literal 27651 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 literal 0 HcmV?d00001 diff --git a/bridge/__pycache__/integrity.cpython-312.pyc b/bridge/__pycache__/integrity.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..b8da24005a4b10681ed9904bdfddda9d78120280 GIT binary patch literal 2933 zcmb7GU2Gf25#IYFkN^6!qaVkT96iOdDd(@F~ znwRWhXJ-Fqc6Yv+y+4IQegx&8Lw9m7dl32?Z@9&KBpe)qu!%5AAS_@pFC+wqiwV); zQbKaLoREP_d8ObuXwcCFpN~k$$(` zL_Ki_(u)wup*lg^k zv%y%lv167pDft$&b;JLZU(!@mu%)(eiwMYMo&eD2rEgQNDMfMx!^##_1dyoB!(?hqGP-D&1{}bnbwm;@ zC{`@nii0HD4s%XIWmzCgXkV6x2lz#x!BWDW;dzw&k!I>2}P^`wPftVY}Tn9NeTL078c#s7SvpwJ7S2cr*z(a&DF zTnl`UgJoo|>r{36!_JQ<9!7>Asl#7!+KvcZKA`|j z@qM|4BZ3FP0Y~`evBc&2d>Y*sATv7GabK{Nq)yZ9_|eqMdA)EG>#tG%MBIPO7n@nOel}y^| z_>bOtD?T+bZA)olK`4|N#GM6y2h^5x@P}Y4xKt=Ia8ML9*ix#18CR&e0aDPj2K_Gd zj6h|#fGnZiKxF+pRbk`R+UeNC_Sl{pT1~HpA4gjD{B3LJSI)0n)%eETj{nTk_--H! z!?l*adov%u^y_!-U9E*KlB8>|5*FiseFS24`hc_j wP9z7HD}R={9?QX{@n1~-d~#)Gy?1k9W1u=*9a%rW6FT!q*7hYNcROeJA1o1`dH?_b literal 0 HcmV?d00001 diff --git a/bridge/__pycache__/jellyfin.cpython-312.pyc b/bridge/__pycache__/jellyfin.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..3f5bb5375a4b69064d77196a8fdd3c09a7391987 GIT binary patch literal 14436 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

GtPY!GXa+Q!DZ^XE>56jBe4 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 literal 0 HcmV?d00001 diff --git a/bridge/__pycache__/kata.cpython-312.pyc b/bridge/__pycache__/kata.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..281f7f634fab990ec41cf012d80f001980ee013d GIT binary patch literal 27458 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 literal 0 HcmV?d00001 diff --git a/bridge/__pycache__/klanker.cpython-312.pyc b/bridge/__pycache__/klanker.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..49ca7296a41b86a3d1fe0ac85c54476e73a989e2 GIT binary patch literal 24435 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 literal 0 HcmV?d00001 diff --git a/bridge/__pycache__/mesh.cpython-312.pyc b/bridge/__pycache__/mesh.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..90b29d281a8b5612613e3adf40b3ae4ebaff235c GIT binary patch literal 3202 zcmai0Z)_CD6`%cgf7W;BzkqRKynqQlg3k%0g&IoZ*dRi|&>ShmDstAlgKv+!-RsO8 z_E~o1R!FraOSY9tJ`^fU6a`o0Ln>8jK2&O}rl0!7U{ah7ANuDLA1WPjDz#BR^v$04 zd{EQQYG>Zed-LYan>W8Ve~d;|1m*8t*U}#b5&9eN_yzi2S$iLv1!STDWD2I35e5XW z6$eDGl?EhJ!t!O&l&{GHim8}^YseA?0%i~gacDct27dUmY=(fZ;uDEqRg0ls!$2Kvcj;(d17|YS_t}fGXXu`%UuBxXk%Bc3; z{17HEfT>2ydZ~8R(zUm*0K;GzZ5Xo(=k0{{24;C;Q%$!uoXxR-(Ro|rgFz!}0D7gYSz&@yTl+m}&3fpT33jRgPNzeOG>VZ_!C2%$8LJY9w%jNXSp@;>P!`HoVS zm_XwwEk7*{I{ZMaV%=G%NgE6M!)Y&+{a(4;@*Z;~WUIDj={fS71ONqS$8q@>kvV zL_FXI*MY*70Abva4?bgJXAk_2E2jat&OhS8AD7*jq2~a2@Su}txjb`ahDVvJ7%8~h zZjf173=CKCY;Z$a%yd&{x+~KhHeA`(v)Dy$n71^i8yMyna@Y;8gVF2fTqWlaMqQaV zuF&O%a>QYd;bdIteDArqLh8W~(#R|BWwM1=+@tZRtJ3@sh#QzvS0Z`a4Nzu!_E1Kw z9N|acDj7#NDG6{opyzVfHr@IYRtEPw?1YoI&EteP#BJ#H(DGbaU3n~azP!KVqdALU z)2%&~JlhY+z%g-IAb2+E5%Hia5Lf0~Co#T|%pKy&+#iI8lPlyXhaC=;P`-{m35$gD zyozs>{s8c95|!j_O6$YO*4h5)em8P#^|`i1^W*m4ieD*GRGkt^A=DASJ$h^O_B*%U zxxe?&44WOB9xLv=*>Sz&e(mm0-k6eC>l&t%)u!f!lk+Ew7Z!*9)D)lUTW#F7aCrW3 z@zj#I()i*=|HR^JD~)@hAE}=`J$-t$`T3in>!HQ1OARZ{`&V0c-n@AI;^LK$53IC) z^MCP50kmV6Cp)}E?zbLTQ>2!LzqB4G3MC0OZ(qpI=ZoKecVf<2{NCaZZ|862mj*uB zvaFX^}u63;c-p4CH4;#j@FifXv@A*3#xA}ZdtBvTMo7T>&qID_~%y< z6y0%S>_OYnyKP6oO%Eb_?nd@}z#i;9a(C~MUtcImLga+-B@&g^Ki6$5HcU<23q4;_ zp!+r5177}d?>G7mqu(Fu>^s=-X?qOlPY)`*KHP(jACdmhEkNy^iBU;EFpa~vmz;PE zE>xu^KZPVwTY$P}idCz+ciB}P++sKiuk|Q^;t{|{VCfqFl`MUfW6T-Ma zcWvl2C0_y%Rjk6cO>!h;;B^b&A*F2SV3mxR=6B?&BBF5t)(Dtr<3VOB9|e6~zGa|O zh==+KUm`97Vx`6cot-IohK)77Xwe>P|Uw7es6w=wZB6rCBKiLKVwJyaWJ_fPMi zW5u5NkrlOVviI|v7_gV?+856+?Ywni@%80c;+M%^4g6x@v(T%2h8&)0pWQdTZ$4RU zD(c0dV)MI~mR`QEcHWm0t2GhQ1uMFt!9i}v;2_~v#AD$Kl!2_VX0a1jh z8IH%vh%??2HJ)8Tu_r^90Q_f~86<1|b@W_?S zD1Ris?{(uK1<{thm84SBEWD%N^}YAL?|a|znt$PNSUEg@UH`_=oo6`iKT#(A*tEoh z9)aVoac4P+>)|9`(v9#vJd1TbIu`4D^eh&71Qr{53@kSGm{@FPv3|rdYVEP|oFw$v zBtwr~GD;@NELq;sOIFE-AEoS)13yYRr2_mYRVWqVN2y||1V2iZN~`cgYQWUv@Hu@2 zzCvGNzpes6)6Z8Vt!_pvotMgdg;Ke%SgK%ktZE%e7cp^MzfoHAhM}iKt5vzA#^c(U zrL|~N`h;!Pq0K75u)1F-t$#!4DU&t;F3Jm%hZRm@wi&(r5fdH zRw%DQdF2Y_n^3-Xh4RfPU$;W}7L>1-YV*$m@VQXFVTJOoC>K{K--hz46~^C=@{KE$ z*P*<6h5p?ruUVnI9_5=>=zj;wH?L6MfbuOXlf0ur9j@g#d9#l58HSz%qAq*zek)3Pmc2$+Iw$GYaPRSI zNjm1a3EhOw^;|e$<~VEDgwpQw4vTf-34g#J7!VK0erdobcKAlde6qDQ92yMDP2y3X z>>HgBTLY3$R)PVsb}$qgQ=01QC2vr12Yexy)p}uvdxv}1Zt>uAr&>GP54MZlC&h;S z`}d2dPM$w_rmp+s$>U;I_w&aOy2VpISqv)(B){zQhWrt992OSHJqyJDa zI3g;ci4mVTh$*-?irs_0^iqEV;-0$%M1m6XpJRv@J;*5VlJae$C zTWmdbL>vwT`y0mr#vl0g}gzO3Vwu@tcWIF(q zeI7}1p&Kok(~Fp=o4@+oKg@LE)|$NVk|_B?9{&h>p>FFi|9<~K-GDDdz2)?x7Kqdd z){<2g6}fC*jeht=S6XZLC(#Y0Y}88xP9E!1!jr)bgc6_o|gMBQI(EWg}-BDXJsM3hGrE{@9n0XJ~wg5T>CYn3oK3!xCrTnPoovaMNac^9a#QV&>;Q@oY6WO#w>`J2gfi*kTv#dplQQngwe5(!is|E#1jHX#ps~%kzs$x zN(`1bMo`AaO(+tM2#T?-?$My+@1GE9HRvEv2K{4WV9AOwR+gu;g0}R za16P=&mXAIpvLy>yTPCmB5neN*)IA*Ubo9_wW`e5uZV|F9k1Kv-XV?vcLE9q>YHGY zsTDAfAvAP@t)s3sxk2^p@&_&ihcUPx^UQGZAXvND(cOKDu^5`76849Hl_!pLyV%*I zc3i8C>$2kD^Y{az9|zBv4?!RVq7M$po>9dub_TU$!W)!)B7qPNNFzQ8GdQbw25_tq zE{#oq>I1vfDUbO3Xc=fp>s5g3)#J`RHjzGCp0HK!AwUdlG#K#7K{rmkptz-G+%q!b z3vCfEK4GtU*z&$7>|zOLYOsDrl1QhEPwHC(6Oq*mE>Idq|PrT1;U)9;eux*?28EU#rhqQ!ZVD&KZiYK z>)?tQ@qRow9w|{v&YwIl)}9ZJ(;}&b9{JTsky_Y#=CoMbDxV&0Rg0AVi|L}SL+G>1 z)9<_Jia0#-Krpbg-a8l`9`{66wXwKvr&!w^mO~@HwxB=YirBPTYwMv`Rd#m z7CzedQ1PShLZ#_%E1NnTb6tH7-u7A|K?NlP!$YA*y#c>^jP z!%uk)!4#Jg1Vib4N6D3!r(gcqQJrv9|8!z@I93x|9k1DUYfIcwea~_9j^} zx9KTe&%&L}2O9Jr?%)w7g}$IP;iYM&pA0em_rF16`V!*d(?pPuh@?y@2t@DaCE=#w zJy?S1xupLRQqMzplln>BO=FtOK-xkMba{wrQWz>xN6QT}q`fNlt(s1krDp}wHql>t z%)F#+%0t>2Ld}jblp)#dyHJK?Lu=D-L+dlCENP4BciA|Hv`rb(Hb)MfB0-yxE)JQm zP2&#@;ZT3wzK3?uV+e9WUH8P8?_r^5Yz)E+B(h+DDFR7>hPwfDlsq9%(liL-hE|<4 zs>*@v1iYO$ie%bNnYJitI-_z5nK+$H`;#H|I9d~39k+}3i&kA*b9GI$eW9r8A$ap+PjZZRj2JK-qm3Z^o&J&V(uMzOCp=^K^66xM15b z8;aYu#f5DuoQ7T~D@uMa=@iKYGrQhcYh7gD8C3>WRcFltx;-NX`};K|8TxS`7=py? z@eX?WMtp90FFKYR5n#nz5#>(6<{g!Kwa(;r6n={;km8YD>bS6xLB9lSI?foHv|u^% zgMEpgG4N5YHGMd+K~7RmaK?yBz1rv%Q6@gPcOgvBg$l8sivi+QSb#yOWg(yVPRtVDI1 z?KN%H*38giKded2D3dmR!%i{=1vsQ`@S%rmRm{j(VU}P)9AJ)}Yja0!Hx<}}FAtyL3zw@m6T z2+2T}os05O%IW39h~#4kl6JLXFEcc|%#0x<^-zJ57GD6?S~BY=t$x_OU^Im7E2*b} z4E<2qLP=r755-PD;tRM846&1@G$ymod4G~0T+YZD!fW8k&jE(-;-~x*1VDIaS=7GZ z+%$D~(d3-^_TsvYarg1X@>f3B^M!qUisLPP{HF!0ue>(>TC{1QVDsIA?Q;ZIjp{JLuET;si}U0>+=(mlVb+I77&S`=+f>A4N!2V3V)e^m03 z@0R`E&J#CI-+Qq?J}`K1C~)t^U?LQbcb|=)JAb$Px%g|73#H#$tlIS(>f+bsmC?g* z+dsD$?8X#lFc^RLK)`E-Yz((;Ee9&hONf(RGvXXWVg=$fsKoh>?y;n4NE4MxnvCd- zlh8i$5uRjzM*y#~MD}RtOr}5BTxDj@BQFt-2{=*0*jBP@GL*)NpuTlKv}DqUL9I>t0zziF+{C7!OHaUS5WPTRFx1Icw#M?IksRGr`i@x)~VN2JV_Lsh?`F=+6%56 zo<2Ntc)`B*u6^UIZt5V=VXarrr` zWKQ$6$JDS$8P52a8dh*|B6d8FsbNc_(qqc)UsCS)l5%IdTq=0S3^Xi^H0D7=jk~Uh zh;k2Rp`xJt0+PU$v2ZuzSd5`Xtb^eJU)>mN&q~BNI9})RDM|B~>?bGLM8uBTb)Noy z_*ap$jrvA|@6UaW=R$e?V%eH&<5$O{mln$E7K=)+m0c~1LT|15yvRaq&SEe=N+AMZWfbzS zkXV7WS&6lUG>nAsDroXYMqWU3?U9x-IieNdXug`clFBNcFg;(bWs-_yzND8dkUp}| zSP2@JE%J0849)sIx3TkH^?t%PgQGd^GGaa|cMhJWPgTY$O==T`;X+amhb#>IMz)fxFH zfil~Ch{WV&&wE;4Jy9hKG3IRVlR!PmO1DuUDAH+akJRNLiR#;fCs1?rk}nmGwXB2G zowQ{9e}^8f%A}c6fHU`0Yj+1@Y7tAu-P-*bP2+};bb38wFtq!6;TIW$YeWe7E<)U5 z`kZROGKB-UqcI*x8r4%5XhN(El9vil(!d~+C-Bl`WQtKT$J+`6SU8Y$s3!QXgJ;ei zX*&olDc%oG-NuwK=ZEPn^JZd1(x1i(34;Y{T{+d6hG zA50V+iklDJHy2-VPCKKv*%v;ji;_paSLX-`7=PNhOfSmOF!^C{0$Csq<%h003GPH1 z1Y!flmvoo-tlGV#T|3$tP>+y9&~MD5gon01xe^pcy>|iv1j=XW zjgmPG5@ApnhoOt&P2oERpm&j{8bEi!p_;io1D^{nkfiUwAW6P>T&9qi^L)vD5x9Vf z8>w#KCHHj5McWLYPYXieCV4Ph#23;%y~LVzLp;^o*+f99r#>yOss|;xp2kMlg(ZRy z8WgxN9pxfqT6tnS>t`ItKJ$T#eIEGm+4ZN4!=F6#s7uCa)XBR4#aikxecyofqun{k z-Z)?v4&bS$Ietc`2eUUvc7~bT`$^`@zfNzdx%>wRAW>=i&JH1f;2UT152>9B9Mp;lwP-Wd!x~4kruOR!Et(onJ4c^%p@9B*Pl@!Od>JI8Y=eWW{ zx~YyuoAXNJbmL6pJzM2sxi}NKtiOEt`}R-Q?2mTMmR)~wuIhSke9iut837ed+h4t-j%D%u?{s9G#2UMyXUK4#ml7c3U6`rKxubI_RY93;lh*wJs1 zxCR!ZuzSqJcHq?3*ac%fdLr%j(hLN|DjA4PDF&pB!^C*ZI0sFW{M(#l{xM@*SshTa zWLo0<0_(BSZRa%W8KR9^_DD`Ymu3(ui^=-TAS`9njZ1nkE-PamxGha$6I|6q3haRs z*@0Wv@ZfwlH%!$E%x*Uh=pS2BjJNN8$ zJxYyzXvwqvniK{33IZ59P9sWM&(U2B=3r)IgfrF`((Y}j)Fd!!!r4t%_*5y5 zX|MvCX9*^sYCtq|9kqbCoN^1n6!#x(1&mUf;{{L0%}=Lvdcy(!eqqT>J5kJA_REHp zkuy6kzxw*b%)w~e509aCbnon@*|K=;)A5Ryc)>Gq^D`@v&bA#>ZQtwsPUp;?=*9(M zUDTftYIAWP9<15Yc*&+%_}00E?RZ=`{@X7~IQweyS0CUBxXQh;(0uFr<1v4{;@NmX z>s>SKEmZhWDZ(T!J6o%{+tnql`}Mc?@d)!9Od{B??-`a7N*cAkZ^#nNWkWxZ?hSJm z727j+GoVjs2+Ay=>m}o~0Yqc^agGK68>P;xV-`vf2k9G~`=BJ6FsACIKF^Ra$7K$0e5?T`e2wkRWHKSSNA=D)Yf2Lv` zkXLM0)gK&NE$UgE$*Yt)>Ir!Vac=~7NBY^(`p6Zb%)K>DIv5EChZQkAmUAhNhQ!56 z87>OeN7_7#YbY$QGLm9VdGkw@~o zB=*(Ew2pKFg@4mN$^Q|5jpw;Hiv(_h|Ct`{F`Y~nzfO4{Lb|i%-jU?Tk`{IFUcaP? z&{OIuX=xtt1#rzBxDETh)JVbUmit9<%~vLQ-ijmYOqX~y+#%FW5F?% zBN|ot;$3R-V+xqLgqWiIcM&jKDse{fS*59O zxY@l}SUPk1s%h%TeMiwkamO!l{ie9%PR)YjlVj>ziYx6OFB}=2pae3hNZ!d1EzpN+KW+oi%TQFD6 zZc3PI7uRl_ZM=RGV#L8|>&%|J=88|vwwy{In^9}itZ%OSo_S|pHd}G)(4EHHC*v>n zC2Zcf;QifiR&kq;^30n4r}hK;j7u;$V{azc=~8VwZDG!^0VY|{c`g}=!MRM4np5mr zH*Qd-zeyk|S@mX+$G7gxsWvhWr!RQ3lNA_1tt8lpX>{3z04_WEPbfo2C7fM5DAK8R z40iYtTTaW07TR9chfEszI@;E1B%nV!)<$2570nMOwjN$^bYSOJubpj=uda=)PT2Ov zg?*1&rM}q$Y&xS}O<#EhG+DKg z{0~uB{pf7{iLD(Ajw6_D*}B>8c$q7xUb%1^I?k{PbjsQ_lA@}j~FvZj0g zrzm#OE-FZ5B{Yo>k%V@Lzh7Dw6{4rFo8CHp+46;vbFKqkSKJCDww_&ZoXf%Mi95jT zQ*q(cZ@(~ULe(KvsA`(ueT)BRjq|Q}MSHy9;9c`U5~>dI42e*{j8)_ufMJ1 z5$5L!{|_Pw90No@Oa^#jERkcFIXlJW_bJ7;-k}m#dUsU1)T?yK_LLFl2UP!05iGT7 zF*EAY4*oeR?fH7!8%%6Hx8OLRCFFU_9gy=m+b)suDA77@GHmn~$BVHZ!d4@NR3R=4~rsvnYN zgsL})Oq_D4`!k{dIYa@mT9*JLC>8jmq)zrGjhYRWteZYrzF}g|D<$IQkF{T>uJ++DczzKebxPCw@$P+^1nxKXTRzeKmUOvUe@yH z7FVqK$=d5{=ZX>)TVqu}-~Rsgl}J}Uiy@bAbK>NTn=ik0HjuFac5(94sD)c^?TF+y z1i3fI%*`X7(LTx3BF9leoj))mF~!}ijGei;fw2r`#QOT<()FC>?!Y2cV`hrG=W3ombI?St8to& z*v^~l*ql|L5Wa6kY36w%CP!-V<3|n9T5F@Oi532) z8autv`{qmU)oSFXDIMQKnD2wT>^9fUi}1d!?Yaw>N2S zJ9**+{?khryOae7`-CFjMtgj_io4&qI)kg(5JF3+%k2o@G5pU|tcOC8$Brb>XZG@M zP~##NLm^H(#r?`wFta<#Uu}%9cPC2R_sxzs2i|lp78KsM7GK#uy?rJWZM{0aVBIj) z_A8qc#qq+L+3vZjchAori96jNbp5>N{hnW%pGC=xE!zH*BiE1owEyOF3!C>YtZj@} z?u$G3f299&>uu{V&BxH|uN;Llr8AzHzM1kLyaX!+nZ;q8vN$jIzv+M<(eC&{kKCKm zm7(dO=;}HCo^$Ix^R`8sosnqL+}lgD=?W&6J& zm2|4#1G}})f!TL|NxSwE>N&pkHHId}=z|mLPW=dwG<4OU&_#U+lHZFP3~>73bG=?q z02l1pJ!S=#&1TyIK7{NTllbE6>u)b_$qG`wFF)h{pu2?@*Q`4(4}dUIu29 zW~N%19S)2|Gv>+I8e=qnO)Y3IX-VjU3-CqoUG#?-3NUAo%G5}GNt&DSPbI=5z83j^ zAxos741-<3oX7LO5nR0R;2b^(w|~M}KH(gna7CYR)=#)qpKv9A$*sn&I5tgL77I5_ zIZ}4cUL4=JbG|Pjv@Y6;;#IrmFC>IRi?)(@)!tj>3E?P9?w-Gx5OC`OZ+G7^Cxj!5 zwl(pZr)~uk!ns9TdAw%Nt1;KN=A_uJd3|i(jlg^;;ck0CMV}Yj`O+xL5dkl&=ajkHcVA}Nc`u9Z)Z9_x z-RJLI{DnLILT}<|?*pp)`Fd(sqPAP?pSq9iPhc!0LZ4+7NAMZtn#ia#UU9JVqNpqvFF}* zKA>!>23_D9OZD~VX%@!0-7$TvD`vjYqIFKi((JD5ezmwgRuc2Z%5QY4#Wj`LVkGBs zijhq<7w{+e+0t3>yA?F}34SgVYnv~Lb=>$Cjedep)s*3X=tbLSORgVDag?5`nNwnI za~E&8AFzCCx1QfW(;cn83ILIu-94wD>zXsa3jmQ#+4X$Wj1=8<6#ybRo7o#GZc5oL z{AoVAdscsaKQ0L)Jy$yCjqREnys`cP%csgM{Fw{{l+J~K@~LtYzdH>9lDQBdn<}N^ z_Grmf%z%oq)^lyM7vIGMGR4{sQ*j3A)Z+3|yY5rLk*M64G9U@+F*qq>qKug{tWH!u zo3c>G${8vWm2D{-W$c`xG*Q`-a!|&}8CE4KpGg%^rkJ&9O_flll(lJ3t)k3o*5;X1 z8D+{jgFR8XH&sEIHJkwuG^Q#kvz9ZIP}l1yvtDbnfifGlHr14=;S9D!<(||g%52u4 z-h#}OIpr?o9k>S7Ha(Hzkc@Ucpa``D`{l@8eZ`_+#}$r_?{rLe&s1Nly;>V>i0+)( SzTnvKOF>NOIbjWBM*ka}?}0-A literal 0 HcmV?d00001 diff --git a/bridge/__pycache__/modules3p.cpython-312.pyc b/bridge/__pycache__/modules3p.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..7507c0c5c104ad78c607b72cdc2d66e70f3b2bfa GIT binary patch literal 23888 zcmch93vg7~dFJhx`l;?#>wzQ$T#x_>)DjxRbATC*#2W-Nf_Z2>*0lPxq!z7i&Alyz z+F~p+&JK!6kP@fVQ^94;#uaO7*aRo8@@!SM65EO6eN|crLeCxTnsuDoa@9V>fa8R0 z)$aG7`{2?cH|%c62-V+u7~J z+ZuBXxx3v%o^H=jad$Cq%NFwv`MP~W{%${h7j>5~dv}02x=WdpxtN=ISTXZ5AM>*k z=8T$Hpx?wwuen$mD`yovcNMGTxn;<$(sHZWYF5K@*RZubw;H+Yv|N#`XB&8KEvsW2 z*(SD`)n7BS1}%RJ+sYbQkTtPpwvDy0=h$}Eiu@hdoNOoC#dh<&Jv?tOd!Fa*WBdEf z9I^xK1@+P(Aw|-D-{SZ6E4kP~vJIanB|2W{I-u+ts33h^=WS#63JI!8Z zudpt5hMi@vUbC<>T8Y=#YwUHDc!PDbH_^fu*jv1XFRJ6i1%9dFWCF++*fSf>)2%$LyCy^5Z>$Yj^n)n?*!hps5y*x9h+qD;JuOk z2Ky?LP;(Qz!epduW=X&}g1__l8^zxR{Egu+b*-qoo_z@;8mAGl-((k2z9F)Oy?d@+ z5Ts3JAz~kVRU1^^3+WBWc|9*livpvTYT;v(`cr?KgfYvQrQx?mHrNHh)iow}M8fC9 zpm=5=DzV_{kdzz~+Y{k)!_lO8GQmb-5m|gmin9KQSU;rx-ag#mY8y!oB&5CKv4|8I z8WY>%EF#H?xL7}sOb*L?o10lUAveV%$p)9JJ(LW^68%9YMbAg#Vl*z}cPt`_eF;fS z@_vVx^{e&MBpwWf2gFD`DUG?JaTJY+VYMxhMZ&R=1lXcUS&T))k+>Xb6hj=UMo~_T zNa2Wh_LUPzi!=P8KJQf=NF>gQ_5B!KIF^W`4vX}Pkqb#F6izmZ04N?x0!(o@6dnr2 z8=8Qah^tRZ#FN1YTB?`FWb}P5sKF=>$42_2@di;sE>N%x$Y?wkjYq_vOngm@U?eUn zF)GRfiBYwb0X2)9kHiwg5$RFD2NFY(;Sg|-hR(U9X#YS`j00LRnGi!_OE8Q^&WT60 zvGtB5lZkkf*iNa@c)yWxw2RQ}doD9W*r9>YGS_L-9qVjMoG!|hx zR-0U|j))xXkBbst4E1P4Jio1JTT|;!kwv9QnDEuG*CmQA99za5Mb&Z2fFu^t=Y)Eb zFijjQ@q~!hG+c_|1dE^*JiQVi8;HnFVjE*LkdUb2Tih>(hKGS_HIYTb92M~XL8YM(C?<+EN!UW7+V8NG=$Ar6KrEKEsJ0Z1pHG|vC4nZI!y{6xSzY?g$&f@~ z$XI|_HQLO`BfV%N9Fb+39bh`2erW;;PXrM#6=yLFIU2`WrPar0R%bY9ycgJe9ymn{ zm|;|l4h<(HnyOGzJUxcl!T@-Zn2ZiZ2m?D*xO-K8Bt`m00EQ?>V;G>0ds(cjw!cOE$@{;p!k{Gu(Y018!c;;B#>G%dYs}{)n6$kuYS}SsBz%v?Yqe93|Vu<`G$H zjzxQ$HCEKDqN7>kNZW^-hK=R$>=nT!lOyu8*Bi!c#5jN27i9;Yy}nK@R9k#=<@CMv z><#7!@K*yUG{T}u*UxD8Y%nSAP#ntzTSFo)cQ#lQC&NaBEl8FXTVFI1V@gp-CU(?d zRxEAtF~!l2H8d)Rc0ZIDGm<@VTazluvK#w3HYGCr@qH z_iu26fsx*(aAHUYItaq2GRAKmBE|cv+EX;e5u_BU97M4UpX*l~8ceLgrI@2kvBpC~ z5yjRU8{)9B9Sh15a#iOl{9(gztR_rQUlT%(DJHrWv z;ibIBc+}tZ24+t41ne>KBse4ZlZuTY;v8p>HlB1`T0m0D>+ zRmGKS6=Y|XBEt#A%D_8Qc3B<>h8Rng=tGZ(&PV%6_(=s?cXW^>DvO;)+NRea1@>Vd zf#?DG%#ckXaB8wJSU)JoPK2|)X`ABE8%YHVz^`W-q>}61r_GMoEFMCC@)%v0q$0{9rkE-hK?3Jvv! zF!IhrT@ZvxJX=A9+nxpo%es=EwMJpYZEtEd5SQORaEGiFxJ!BRizrrksFx`YNR<#@ z`s7rpj+0cM47SagZmP^cNjC<6QcK-@3gQ_h3?~vX=pxuu0C=hY|rO1bkA6kCd5%;UIRK}v0IJ8}A?CNhekqO(W2TGu8) zb2|}@k6eJpB`4)3#W~y;iT3qHq`}nI0)sJf4QSL&Bo#Ov3ZEktNW~d3>{KCaJgxyg z5}!ULG8DyxB&WXC1?7i9Eros-8|u-LA;^WG#fkNauoMMDMVk^NLtSVM#VCIaOE)@9 z@_Fw_G?olP&ep4Q#`rwR#Nd{y_A@dpCsAt%O{1|0DO}M$aV#++lEj3*xB?qOLb6G* zK&V&Td7Y=43ZN=4-$0fIf#Nt4lGrFHOR;MrcIu1I){{PHBTGYrMXDv{K)gh;Vd&A}91rfr@CSxM(z(Bx?!N#cr0qnbpJXcGeSh@Ho>B4hF#MXu`n~YCsE8| z#H>clX2gn&nB9mujF^+hTxi8jF<70f5!-do$C`BsHqm0_d3h-xSw`{8Pro7#*fC4K zVm0h>PFOVqux+`km-Ti`IS4Z|^N_x;Xhe9uZz4&9d3PhLC z;O%@0#45#SI*5eM z2CMWE-qLPrvq*}-tN<-(bADr+8_d!%y-b5!arE?%iq_MkxO#e2qXOcdo}PE0Olmp4 zo}N5~xTi<5Q3EehKs3fzisI(_@lXVo942*8@=*jI2^=y$N@*vhQt)^9lix=0>%v`! z`@O?iSMhsCvu^Ku$Fjx#_fBLzzW0u+fw$znlWJ~2T(F9tR;`^&-l}SyIDGky1#4@z ztYYHGF;;zmAxw+Wpx^EGRN|q@0V6DkkqjpYRhVf@SG!1;lx)H&Q85teIRUvHd z$KPA41mU8PG+4$Ujd{hBo8M{kl`1Q0T@<fugAPj0UI$1V?9wOy@I+Lk*h1))dz*1k8DhO6`AhbxE70R{-${CWmDGex!O9} zI<K(3`CB$78YrK-Swv>QwFmKD`S1~;)XImMbgp{ABzsI$ zo7!9PTr?+Xf$(S0p~rIXL5K;TUgsj_t$Gv9Wy=I~#>+)W$pRAwSx!|h4>DrWzd}H8)fr<7-ck$I-le@0&o7{KJ zJ=?Y5Zpc_09z`^*caG>^LnMFk6j~}d#7>yT%t?b*1`R}_5$&P#({Z8S!mP}C-S)9I zO?=7|e!Zy-=?QJ7pMzTOpUFSJv# zC(22rVPpLQO62oYPgL&FjEInI;>fM1`J;wFBX-aGsY|J;*KgX_Ec&X`JMOFw&aV#6 zMdnwxF8Fq296NLx*+04eTJ`L~g{mzJ?ya8-CQsv{!*k`8tKE~`*IctZ7AhMSinlB{ zwk}yrw#G+k$e7x-=zK=#CstdKVSy1ttO4UOm{uXi1lEFf=(%LNQuXArc@!A{dp9OZ ztxsXDHIs1V#iUcu8RWhc`Y-hjnl*!J&HXCPYMaGH%eZCGV0^h6pxIqC9aqjhMb8(l z5Qd4qQ_sJ`1r|C!!eLb}Oe1t!`L<^LeOqilaTG^t2^{qeMP)i?9#XRU~qsg zm5dC9ld+UDD&wSw&M8y&VDO!hXe4Y zMaxRDfWs&jfFTXwC*Nwpi8WU2(O>~b9+oANB${cVO&~HM+$~=<(GKQt_2QL_mo1C# z63z)M3+|eEcijVz;IFtkJ~^H~zTm6R__k&Jr8BN+S9<%`d|7|xqQ82^JMGPu)TGbN z?YR-o`1fYl)jqIUO1zibmK=h=gx7s%!6zCOf7iFPPVlc;5(STM;@G3>k&Jkpr)T+k zEF`HCAD$o|7xTFAd&1W->(|U+BgVQMH?~l$R)f#yrCM@KAuko{25X@Je@AM8^@h{} z>k6r4sa)|OwE*)$Dp@dBJV+(}vf|N%fydRTSWz55$1UU5aWk`_y#mnx9{hfgQvg=v z*pX8JUgS8CQvhbf3&iP!GG#%~P%K?Q zzrRFBBt?>2l9dw8rk2N=iDGi+J=8ih2`PfpxE?xOMGqu3D#d!09xB2aIw!WL?OGy9 zzriM*C0ICBTX|o04I;&^(XmWxn#fzDZ>Mq8DP2O&DI)IwjtG=5PsP-Q1&=s!5c|o* z#cajuO#Snp9QkB#X49d0-{Fko@K4>9>GB2lx`~cOFLs#)@A`?uxfldoUuo7`dUarO zAnPwD($AJu%&eY<5L`L4e|mp5uxh4px>0|XRn2rxcit>}ZZ`6**oU#K`a8R(E?c=S zTUm40TQM~z; zl)5e7dj7-b=aLJxt@oVP;vM(alzEDlgfd&vqnyOp*(NwADZH!k-L8OQp$Rii@EFK~ zw5*BK^kWx5JB7+yx$sj?^rjj;h>r5OkD+Iy9qSX?aqxClGbh^N1{t*D{D) z(!9v`sc

*KMq%-}+eC0=&&2XxG?5F(B<>rL0U>2QHeI^;?d9D+-T+tr|DeNLXdR zrAuH{g|PxStG{lc(Kf7BMX1y|Rb~}~U2utrCvg1P7nO!69*HnGEN~f1ssV7RGMM~T zrzm7&?}Cp`izux{mSPg6^;E=7{vl$pPwqM)ayxITR1Cs;n;aT$-bZI52k2-i)z~XD z7>C8+g}#+}Vz6CH(tNHCq&6)ts;4*8VTfj9rdo=rnQKm|a)8GT%RwAY55s^TPs;mS zQyXbm(xV-!!0D|@TQLr)iGm=FsA47js!=<&b~#Y$*wj1%CRJ7lh*TxPup9)fJ>Nc^ z{nByvUX11dcn|Hqs(4E}oAf3Hq!GeGKpaVWivj|6nX)Hcq+|*b#Cxg{EZ6e#gxPGM z(^7<9JaQcF*3lSKsw+m~EXa`f%TD(+$(2uPVI}(nH3(9^z|p z?UKbpCN7K91zzAN<`VK7H|=ZhdaKfP>5&h%rq^UP?a6rdX1#lt%qCx8F|cN8O*%Ad zoqhE~Px{?V@Fi-e!*tiPeaT%!Z55#{Z+=@@f7y&<+L2y0yMDo6ciFbA?R^=~{;an# z53bDm9XBF3UZZ9Y-1Tf)a@wd>8(Q@UzO_(j2R|50e_^g9VH)gDw2%gVEXnW=& zkJ-9hjm>E{7tQ15CyG3V*e$Jv7-KFZnu9f&3n{xG(jo{^_G`pIOmYb`J*K{c+=Ho< zQ+To{p0hMSeRk{21-1-xA%TN=ID7IW$==PMoXB7j;^D{CD%NVz`R}8(jy$H8SF1$} z{n1*%$JFv^wP0{7P%8jY)L*z}o_M+@R~OFHRk~3D4%m;IuPU@zz&L?am>X6;NZqnB zjTETC#z#|G^)-8!P%9+K8qA-;YCV>F*9p@3il{05xtFIwKE&5OTB zP03AqS&V$o%8?gp>6eeEP%XCVnx#J-lJxKr{nFT>Oyyh1*Uq^QCvQftZZOf}q6 zwW@g@fvVr5;gc#3jmSyO>yRtQdJWCv_zC`pC+4IW1 zPeUOkY=u`#!z!**s7eIcsMJEQBc?cpLg%1-NV2*;ld`Dza=HgM3F34_>#&FXlKjRm zNJBl40P<4siyU9MVL=IylVF6w`Hnnl50VxJy@q=p_GktOGCN3Ds8bp^><$tPDT3lW zc!37hN?WlZSmOkxH_X6j_n%Y(g#TX9g-)0%;kxv&}HnU zQwML^tFsl=vm3uv|6zS*=h5uiT6G7`G#tpRe&L?QH2K>)+MXCHgaBxw_kqW>~MU?w#D5uDt19zvx+= zK6u-+AzK_Eds}+P&0=xU1C8poXPut5=Vo!;qNgT(_O_?CP}<$R+cm ziDkANpD#U;@tkwO`r5;TG}!dFFmlB zoE@g+SPA0Xl52Stt7ZnK2X0oh&aNl)B62-^WBtc{_ehlbIf+vL^uQ~4%0Cy(gf+Fh z>++43kI&z7?1c6?yW?BCKivJD@F(m4q;KB0|F&cQKP}l%_M!Y7Ci{D(FI9Hz5dLCE z>EQ#GzjU;1J+#O2!|f*g_~9N8!oRXL9o}R4E1vRKdu&K4Y^44o+IqZ^nsla%rku5? zpr{0!5^Vp_<@#-`i2hx-e@x315ALf74USG#Pa)9_*8Z-C?mT5C<qdLVu{-MxK!#?2Z?hEAi^;;p#m$?DJUyQBh0&yj3qdF(pcK+F zb0VGOOVUXB1MM1GB(r?o*qndOlrzZ}6c#Znm=9Q}VCgMl4*GZ9xomD+om@wJ=%E9s zDBYgHHuBJC?3aAUkoi`D$sva;vcYowQZcohk*)!>28TLHTni$utJ?RA)Vhl2l@YkD z4n;VF%$a0$Er|pqt{N`alBOw{f;%*yVMOw@q;%iBdn2qcZcq)3uZY>5?~h&@egEA{ z?@qB>_SIyS`NCY?T*cg%!N?Z8q&OL~@13%zt7hBh%I1<8N9&X4pKJhp{;Lr_ZvOKr z7p%XgDHp^^tcQ&Plgwkq;V_k zN4EbwL%NdfkvUEyaC=$DVi8w)+v1hb1KauI3rCWOxbaNfcq+2$?o*f#tl#0 z?)1{aAKYKm3a-s} zoSWvIn;y!f27Irj!+ywQ`Ju^n$Yoz9{ZKCF>T6+I`v1z+>+-l$fs>|XJ3m3mA2KpM zf&UjcmjXpE=W>R*sL+YjGw^?dpXjhX!v77k&tPS)j2?R`{{Q3_aN`~vxfmmaRex}` z-g53ucky)|OD3g%CTA;AIa{ij%m}zeCW1L6$21=2A|40wh9|Z32LM6(76r8w%uzt% zk@T-9ARJ5A5u{2B@Ti(mBq{5g=5M(^bj`Ki61U|9u8Wz~`xm?iGL8d_)+aEfK-tXZ z>CL1mrRCWZ3xSr)c7vDXGo@7-&z^iL2UE)D1-oWD=IXLOn88U|nmc^s<%QZ^nGL%$ z#e2xg{QO;wpV_TW*Ds1!Fo^FLwY7FwEPr9Kb$FID2t(|@3zXzLOXZ1oPkpd1`}twH z6^LE&ka&I#Ry8O24;QdrGCQ}W(qR-qwBk{>d>q@m^^8IS5B!&5iB?@bxQmuMCu-qV zzTx|mceroKS(gj3i=1^3HbaA0A%fsD73u(VUdRo6Yo)zVMFvUw;>kO)#989D&JJaO;E(Qxkd|R z_7&;(=ois>%5Pj+)l6X0Z&I=+mkisM9hbWk=W%6OJqXV;S`ju>+BdXa?i?kBkpb`D zA>2fXNHbLGcPZF~ApZnhRkBGMQN1=vLCDD)iqil?`UE*;*!krDhRD+$e6LE|Zn@VQ z#-baEdEcRo^&<yXY3I&0^uV9){mtIm!$8s^>mGS+?n z@W8#oa?<>r(i<=TNyRPb^G~vzkh1y5Z8a}#6~4dK*&eifzqP5o-tvPDCd7YGZ$lV{ zTwpvj(_Bti$p#9YYLn-K`V?B2g*>*B22tw^oSCj0?xrBFClj_MFTa2cKsydS`d)}r z=K_8zNDZjv@y>AnZjN)}et~$E}03 zh*vzx^?JhK20fO0=U5VRq>!xR6Nf2JufsSywOu{1@r~fdq2NY#X5*2)8&B@t*wx+O zRLn_S6jnXz3t6GzbOs-_L|iFi5nNJ^DPHwXL=QfvAXB<2^w2hv>YzKUZ@o@~e!@`83IB;~{$74nPL-fw`m!ZkcUG-GF zFTHZK1!Rj^1`Q-)PA? zoLB9W_ODsFf8EXn|AyH!^ZtfgjxE2i2(E2+oXzvj=7;=J#rL<@x7S&IPzTyxzQvP5 z#%cRI>K2cm>Qjie3*8pTd6?*{O?EL;F0~&P8`|8-OEh-`oXaPxY0h67DJy` z#g+Up4cgb?%6%1y^5KnH!NmqcOURYWZR!On-mo9)aKm48pA|v~L5|!6 z+?4gNA{W$a$#0}SNX^wPtZTmQ-*(R}lvi-FS*m~o+oHcJySZ`hoqsttwJUuvy)(Tl zQ(Ak^Zl!|~tE~u!9lH+Xe*VJ)yHHknr*!jt>1G^c6gS;fyO_1jCes)H#vjCqMRC)^ zU-&?k86wGT7}NL6RqbBO_iE~1+F<>@U`G7=8#cAO?LTl({0A-*#oZQ)du{Ej>|xX~ z9#UjE*~U28g1GW7Xz5!X$mpB=9*i)EvMZm$k|RwK9HX4Gx6bl{IKM>XkL^cGppKgqt?zp3tNB zx*Ypr_`CxP9gsdlsS(0p9U>FLpZad?KDw~_*!=FJH(JtdcWN5vYZ`CYG-Y-ly}A0> zKjM$GvEN*^dHH2Ew4e@;RzacRb!otvB$_ig^ecGDplU(0kP;vqn)3t=5$A+(ZRfS! zk9x0Kk;hR<@_l|^*tPf-V11{Z|Nq>e{!EQ%U?!E`P=yuQS%d_u% zc;?y*k9Ka%<8vrlsB?0?KTp0rR&1%bDs^7S9YBDtY$`x0|H{uMr2;Ec_O}4+G`hj- z1H7akBPa;_1wvUZasm5hh)b8(Pg=8;TjoNU%I2G^PGxo+{n5*r9i6cAt}fsW{0#V% z{<8|sCr<=t9N_-T2 zR!n=TV!=nu9*?PugsDoD1Ew~9^zt_?vu(4X4?BL}d8>N&jdlbaDp_S@VsuY0RUV-uAtlMdnF*7hl zR)qC|^KM!n4QAMG22w@A;6NlcoGLr3-LAkjCC!UzuSoa0={)FXRDt^&gLf9uNBQDO z`i2$%P5~Ds&*0v*Z5ZFqL3&RQON4Qy`oB`87ZBjH2xBt$0pdcU^dBjMY&ZA-5`4+v zd4{iw;KN7s^)ZWL4N3jyal3v9pCrQvGVvvZWyj)*mA(up{T$UV(YpO@L?(n!-99)p zn5K7S);7(THZ3}euk>H>;yV4JD{!@OvT-VzZkrxmaIKqY|J3b8cE(?aGnDlozBYR_ z<8As**LSudw#-1w8&lkt>kwRP=Q7>+(SFVrg<&o;v@PG=f`kHUHi9!`!m7$nNa+hQ1Y4J`b_YB zCb)krY?~Lh{a6r}T!OE5!kzUrO*mntobWEy3%>PP|AuTyTefsfHc*=l9L|>2L5Ip# zt<8wfWh%F4%hpcqyI1apI#ezA)@A)-wgjK1PzxW-mf-@QT6kNgvL#zqGqu+!thQ_v zM$7O}%Wrwj)bmE++N{4WTk@jb@=^2)cecA_r_jYco27~z`^(p&!fG!-sS|t~c!fRL z(wb}l|J$-<8?*kZC96=@ZqAmi<8|(p7NhW5!H2BP*^=#g;f`G4qbR(==wNmFSf+Hd z+J`9kfQe1nlD&Gh_G}rz1W=hxuFQN3E0A*Uan96ZVXA&3)lDv&p=4!77;Dzc8;g)hr1J?pyG4iGJRH3F~V2 zeTUWLzh7f9ZMp9$GOf8Eu$mh0drM8e`!AZ-n2HcqnSu|FnD!uk!DK_|cOa;!Ho5Na z^&{A_%CsL%c}-g%Y$!#}wi1)?UXuV%%1`~ZpO$R7XSS3S-E#HyXYG~G^Xl7Cqv36?Zn(TyRA7$?*6>rT}twcMaeWO%mX1uk_ZcSA?crs2> zQ@h`PZr|=JAuM}pF6#6A&-QfB9o!a;^7Qh=rn0EE0>w60uY)6U)U4u~MuOt8cpdE5%)6jZ`IhX(-}4 zsZ?}J-a(sK`?|HiTC9`2EN&6&U$^wvhyshRle`lBN(W6;D(hOr2Efz;e)W8lxE}d+ z;)YynZiEQdrPaXdvWOc2Rlg=wBcOz3P==ZYE#fBRH;9{Af7cuOY7v`|vte1u)q3Fv z)W&5{%j@5Q{6=vrtADlf+mOErbMa`NNo+>m=Evl{%I!E0c3kl(Zic@a zyTn~KpW-g@{@vp(HjZ=kPAHvH@VwA0^hi-f3JM40kT@g>hozA*Np`i5N6$s%y}}Vm zmPRLp_OK|)N+c{aor^}tl)bI3Vlbk#gr%t8<+`}NWqZpLJB1TnCr@86gmihAv713IZ$~#z;buup%_kBud+akw`EwvJDe68i`8&Wvz~j#tP7CcE+i1 zY)!%}pBfPa3H6=DQk=tz8QZxOik=gWcXzU>kWg$iFgAwS(w6(Xnw_vk~g5 z%$CovTFa*~dMSjtYibj)S0iE36~O#Q{X!rtVx>WP~uu)X3&|)g%2+I@NaN!1qhGc07ecdZWBhkQ!kXu+~ zTQ>sMKe!xsJExrxO{W4snB+aHX%*4QIu9xu5A?-lw>&~BSb4_XpL2wUwPUx z!r(~ck|OBqp|69`G!i;*JSg4Ah0#bj6phGKXMRjtFPHaGl%fHg1d*Lc?5W`R==ccE zCKgfOl2nPlPnGOQ^XthrHk}_y-22G()~i4b+ELfb3+&8GVOnbJ>#c^ZDKwpz#-iG} z&Egb>G|u95HPfO8fTx!4R_c^-_daq`zCewx7nQ(}M7so|odCiN?-W|c6}fdJbhcHQ zP(&cuR&BErI>3f&8Jo~Iz?Up#SmrNTGQXH#vS4ljT@Q#R_4s+!ay$?{2jAQtp75Jg zPjAPG7Y`ih>Ky3mc}evRC;@Dt(4;gF8IR&Xg`2Q^-wX~F~d?MFRI3!MMW zeKyT4l%HDcd-;P4i4))5nY{34yFRRKo$G)z-*(sW?lZF&-cwQ)M*#6Vh1Tb_rY$^r zWTrK=3`as?ze#o@T_&j_+e5j662OsY&Qp{FA%vUeJ}N9pmy|8J3*yl?o|?II&s~=; zsYo~y6*K!6Htt$1*%j|h7ZzQ+bmh__>s6}!RI2dQg5%Uf6z3*`uQWY-{AnD!khuSJ z?^ge5Wh*5x0%%MCmA`ml|7kG~A%(?d32aV`MaCpaX7S1Y{>o`(3soB;(oSj5`uAp& zs(ZzEnO98Uye;}E?&UUcQ6@tZU{sfxxoEE3a6vAZ3k;gU!JB#R z6wY;yJGV|oPK<{I8Y<&$G|UG|D`Lzo!U={56=Cyc0nGRy$ndx)LC~?^pA1RSOu0bIUM1?Gd)qk_ar{X>3=S zu#YGo-;==lu~mtTPF#W}(L(?7`U=`fZ6IZru)j`$en8r@XmT5NSZ=RIvpW@gEw?)j zyUCi{O}5F#6Od?xfG~*lAby|dC^1mWH_)VYJUZCCN43k+*a#3AkhVh#2O}gksW#vY zaGO2a@>^9)WQ_1RiApkIYPImVB#(v^1ri2ZVfksK0@Z(oGEIZ$jF?_^4N$=W>W|zF zfCzrd0XSG!iv{cIEt=VwteCI*xp&`P83f?^?%CaMKJ^b~uHfnW-hKDH`~L7T7V_&q zurIjxtXRSgsOGhTUEGXsi#h4s#r<3BPW!(z@8bRfzrQSP_nP1L@NiW-n5n1~jxJkS zFAB4jT@Q?LoeR+*y4NgW)**F&%@R56_JvDWbx7S`vkjXLsrze|uBjx-YtJI%0gxLi_saA1vU{zz(`6zk8fSm=)qaxb2AWRmKkN6~3zT062O% zioA_j$f>C;*6zy+tU29;nAav4B-gBB0*a7OR1t`SlLI3zLi;)PUQ>`L(2*#@#gKGK zL+`4U^+k2)T~S^6uHe9HECCA;BcM8Tc0zUKnFvr_9WKi-_#N`|m<_pyoMYr1C+7un zPLR_}P9Gdl7nd?N5{iz5!jhu8JG)*QINIIQHE^O!wT=d&!E>s0NREt;sb&ntZ0i=NtqnDT6h^B)!Z(w@?^ujsmc)}FAZd_vs%ab-=S z{nnuyhmuE9>snKl+n1_q6ML->esuOLucHh{Yd@5DboT_MfG~u72 zj!%l5wXV2p=E-}Gs*IZ}YRuGep7I4tnOuVb2Jy>%G(8NBk&gSiff0&QD*N)NM&IDr z&ZfzYj8s6oB?C3C*wsonFs7V~L|Z1kc`+U{4J=P{%`o{Lj`c<(+=UvN2gm?Ze8{f? z?A!P$#c-y%kKJ`QC+AvI^?Ot9eG8U-tf*=o0OO-9L(3%qXX6n-{*2?%(J&EN#WckA za`_Ps19s(?gEtxAa)dMwou3`vFnhVuW<%-EYsC_?#7vW53a6|wtEdTixGI^l4slbq zWhls{KSEu0Fcd_nR(vsgh|`&fDF=wgS}@L-6QpQs%()t*XckR+ zZ867M5NwRD$JAt5vnB%stI7SCnlOiJ^vM9hYVtg$Cfk}d86a3q-k5Ee*uxcH%pNV$ zlVYxzbrLJ7ub3gn{n#AOV{^RAa~?Ai<2tRrl7`uROdW<w%k0zAN!$qW?iwxUOoOH^5p zW{IyBUCBYLq4FlaflYy~fX*tsiIfg$+c@*AOuVc{*%>KkArOQ9f( zJC~f^Yjs!ZQqHRB14|aiH;;VdNL+dS*pjcFff=*Y1-_XbSB9n!rR@bX4X?%0#bt5( zuQxPKA6zP`NH}j5+$cy@Hr+4s$E|5^`L#1w&Lo~pu1|Rz>e#;d&-I)@ucuQtG=P90k zUSsl%k|+@)#uU{d_4An`up0)32kgTtGU}u3#+g^aY?vYQn8|`*3e1eiQwYj)5IL|k z7Lny-<2LBYR*`9AX1$z&;j!lR9mus(t?+GG-=6hZ?S@jeWu+WhC}-Apkq@Ha*2ypQ zOhghUCo~Mbf*gF2TdXXGKf3I$Y_lTd%I2h$)rBvHNkbgx(`Smpc>M``Ry7WvugY}iANfe;uZN{ zQsiI3QD_yj*hWJ&1%zsH!&KyN0pNcSW7sCGV+-po*A3TO zo?jF-0$F&9W*V;?jq|_u70qm!^(Jc;ea%Q-9?PB8bY)dy%MI^RSvAm0!YBvS z8?EVz%0$&{O!vMB&?gn13fJFr9-JsQSG*~+@i8YVCpP^#*2s9G^5Os!G{8`a<`Cq6 z=!bAWv)UDsewwDtqJ>UQGRZ}AZV-w$4>5Qb>;f(0Nece zZLM90(=dBEO^zWgr>i-?8*^zK>3_lsxuU>U+84`}hTaZ$PNy;VQ|>7b?h10kQp_21 z)9mV41Wdh$>3phyOI$Hi%sx!w=!#DtFF|6a?b=MQx$gYce}vpv##a*De;kkjruCY6;MD0Z_><}zyUU$kMdXhRA()|Pe`%nUBN*Cow!yB3AEw8uMBbmd^Yow%^Fn(L=$PbW8|$~Gl0 zESCA>u1AvYm$fdIwWiA2K0lA{V<+Eu_GU1-<*mp)ck{Al$}8jUrOJlnmS0phUpX9a zkB?_eX7A3W#-_RI`Sv@tsm9$i?nKu;-};P|t7^%(xJ_+wTYU0BiQ1{`MKFIfa_dNcuff zGyT`yMV-4$KiO5$<+J{@kcX=}&PkUC0-`7{Q!?f54?o1hkuiY`ekaBOck&aa)o%gv zbb(Z<%rM} z8iVl>WF&u!{KuFdTn_P?p!k6B)r0>BN99`{&?G*nBmlEK1CO0t`Cn6lO-{m7ZK1IN zG^aYKHWso_h^3<`)f5_2NF%2mGVOGEpwOj$vqh7?k6ai%ataAN)7)RXOJR66rg;>4TvI4-{O zMCq(I{`Ev4Sw7cxyJp_9C_J%H|K$7&cWv*j|B>ed%i`{%3%j2G;Dv?bFD>++UOaw! z;Zez$5O!!Ev&{UIGk+z%nhr7M6zwr5w!Y&# z`6J;HotJqc%c2YQFk%ctnC*IawXun=Wj*jt72qJ|v~@AB=*b;Kw;{d2knRB^w+dkw zy`0JF@4(XHx%5hfPbEc@wAkkpRrkN!YnU!D)t0SRMcrcPv zt&uryp5GvRwbc+OTh*MMZWD}jfx|?JR5!T2p73ioV5{io7`5KUWH#Lxc7U)Pv-BAt zoJ4o!WE)G#F2YCIsFnxe>%zD+PB-e=kgkk-L{x-frLJ(x8dz$*GG3%$Sn?kM8}vMY zP{1(;=>cJeNym#l&-ENT+0&%*eoZw;2>1aZdil@bsAd#}MTV6PhoQPl6#7&XSDxzYE5>`lD1&#*}|VSxz=Iq8akP zr^FwV^B*Y3LW==&1ajTzqh6&#J5!@1c~_$;g~YKL_M!Y!fNmtRG6x6ug~i;)P16TI zE?$=yNEJ6vKbQ8^-1lu-^leJ{n&yh;ic`LAaZB1)dfheadealP5>qt(#@>YKThD^q zTB_eV*EQd8=Wwe2$wkMyxMOC^QbY5c1PdJ^W_4gtgQY8lboIt`)%tYJCJI*yxLhO= z*$EN3fh%hMbQf1r`z6QCe{262A9r5LU)h@~$o+=ic+s1TkY)Lv;Y#a>+bjFXS4u2i zna1M%m(7)3HKw1|IJ-7mf4Yf>yE={iF&5S+)(_=`JWyRaYwR*TK4 zg1GGrn$DD=2N3Wz)fs4bCwkySoT<>VasFy)%r}pOo;_zVKo;si@uxAUi6q}y}rcxizW}X>xTCN zZuu`oz`VnN>}oCY|{HWY0YVig_| zqf3&4wgKQXc8L}hI!AMlp|$~}`FZ(I;3?RJTvlv!JUtQ^Ju3$GPu3DT%Dyu&Sm0)} z$5DoHlha%>Fz32GIv=>}dUy2RrVgeT?jr+R++2|D)@A8k0K;slg@lt0X7MlrK?1$CfytMb!gPr;a48UqYFP{|D{55~msEz@&rayq z6RI;O8bb^q?IFXqOib2D1fw3Lvdn5I<$i=!x1n1yiDmLVa_GgNHkFTPD%qt-b{geh z0PZZ!+}|SrbAzJ#q$yRjVfx5YaRV+aiW{e&Tk_Q=&axYjUpFA*m)^=apLToVC$H>H zl%-&8+}QfPS8l&D-#$O|?veNSRO5m7URZ2A7(X&|AyF|qdCx;aY(W)_4Qqv@>#bu; zRqO6lr)pohQ=M#|zi_wu-KqC3+}nNlgZ2-G|J(Bmr_ZF0p7}R{x$4E*S5j32ODI#5 zs%=i!G~U{OWB*+9-J(>@zI3^8JvN4Ck@he)l)EuP7v6yx&;XQR#Yrp9QXJ(+ohD z?Pt=D(Vl*I8k0r)`SUH_uJKvq13m+l6=rH70T2w2uU1wMdDtxk12ZKkp>CkS`^DS7X!VVpW>LV;-v= z(tia9MpZAuQ(e8iy6KgCk}_W=hgdX-?Dr{5Y?yo+&T=zchh}4?DDqz;?`w3fNX-RK zshlgTxnJ72Slaku>E?$eZjbGgVy>(y-nmp%bHAuzv8W-rF;%pA!LvEu4kuH@l~-Ip zIeRj3GF8@?E-TN4m&z&=MYk$$R3s}>(3X{Lc_hIIoh~iAerWbk;^3Rd9(wIgSH_1% zKcVI`oE!W9H&7kfm@eVX10@IimLPhY`$)yW_IwKhMy4Ro2*5tvG?d$U`tBgiKKaa1 zh7Gf?keHG7vX@9BcpVm;*oHTT^dbqWSmPt47F-7_lQ!*P;~f*5PG;UL6O~{HU1L%0 z*|$&f|3vy9(}Y(bKz36l-|kFoOgc!VSh^+U-nwAf%F2D&Ar66b{b`QJFbvT&xvvXo zU4Pd1$it}5AE4hN|1EktX=#pz$FyDPHV_o~V;bl3!OH)O(#Uy}21?{LJJ5So&!$Sh zl5)2%SlYEg0wDJh_F?NtM8UX@x<P6j&n|>^Z3-|6kORpyo*g zo933>#cw>dU@844wJJYA9cY)0>;E8ZY?TQg#u@oL0NgaTT>i#03zkaOvfr-Svm^~R znA%H4`R6FWh@?grX@{_;mLYcHBiZE8QwXNgwCl_YdtBtpKat3zaeWuS?A-7)Vl`x| z*QJ+Wmt&{%b&6&k;w|nwro5T@Fir1@@0}d~XtcN!t)gu;y-{9=lQ)Rhd!hSZuFVO1 zzTbJft>NMo-9*#`(_f6sA#(YL*`oa&r>SyLy<&4$DeY^EsUP=5u&jJz4r< z_?uHOA<}E$B_qlEq%XA( z42ZCL9Z($|$DV(_y{A(Xx>YmY9vlmo}*eV&Z`@*ZMm{#wl`6l2qexXD&IUa*Y=^S`9n(! zSTuGtRL8&o^>tt%J3k>@v(u^jD|FevUH%lF{23h86j4-@EXjK)<+tQSDA`VrJMdCd zregwQr3reVEL$l5D=Z}xRwQVr&A6GO#%O^s*EY~Xtb*zo#Oswyfsqlz^A~sbC@F~8 z-IR85g{KCiWAxVH*HrIIwWd$HvB zlhL`3`I5QAx4-s~qM0MShp$YqAyp=yoGY0N&Q;!imWEcDsoR61PbAGZp2~0t zVl3kP&b#LMJ$J&3{%0OiS|-Mq^ESPw2%@JO=9Ian+po|tZJ9k@WMg{ZCHj&ZlgeC2 z^78GL#oD%ql$H@p{I;3W88NXlX-@2!4KEfo(hO|NxJ>*3ex{Gcm+VMfzR`kvMantA zXIwmArZ)41$29H8l)HGZ zzU&Cn;=;>Ra~4m0@}9XWZShQZeDm-(4qxe;*>JsSwkgq;*fFy$<*om@MaY;rOZ8`& zLmtji@*B>M9hzRZ@4eoIzLSe3FHax+#ALPh^9d!xkw1s|A^&d2Z<(L5QUOf^^?tSE Skd^zHwfs=M`Db-J-2V??u~Oau literal 0 HcmV?d00001 diff --git a/bridge/__pycache__/packages.cpython-312.pyc b/bridge/__pycache__/packages.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..c1261bfec4dc0b1c7bd6b0bec545fcec05b9e5d1 GIT binary patch literal 25739 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 NNOIXONFAJY{|_>YzuW)- literal 0 HcmV?d00001 diff --git a/bridge/__pycache__/policy.cpython-312.pyc b/bridge/__pycache__/policy.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..edd7f00f09ce5450a703c1c1c1a773d6213b600a GIT binary patch literal 64794 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 literal 0 HcmV?d00001 diff --git a/bridge/__pycache__/prometheus.cpython-312.pyc b/bridge/__pycache__/prometheus.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..ec1e451e010b6093cfd4b756ca4b10acf5179a20 GIT binary patch literal 25919 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 literal 0 HcmV?d00001 diff --git a/bridge/__pycache__/remotefs.cpython-312.pyc b/bridge/__pycache__/remotefs.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..45f0ab7f4f1fd9ec1ba31caddc5c8a075d4e1f8a GIT binary patch literal 17445 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 literal 0 HcmV?d00001 diff --git a/bridge/__pycache__/themes.cpython-312.pyc b/bridge/__pycache__/themes.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..86a4de65448bfa7bff21a7f379caf7940228ad37 GIT binary patch literal 24146 zcmdUXdsG}(dS_L?X&RcB1PDD!0ts3c4G8qY5;o!?$$A5NOU6xC0WF{#RdplL1`nQ0 zVwh24lyM?-EX6qEOi$}BP%qY9( z?C-nPRoyLt#-8~rCH1Xab>H9p-rp_$b54$(!v?DHkimjp8P~R;5@ax>zq5 z2Q@c~FNj;jYQ5Yo)`+!f<#pm#v0iM@%eRT!#T{w0SS#)ncVQMg#YW5#^X#*n&6S)| zo|G@ydkv*nKK-Nu@x|S^i@U`=Vw3oi_%c@2jC{Vhm*rcKZx!2Ez8(2B;y#w&kNg4g z6_!7U{2}o$%O64hsCbO!k0ak9cCvgI@+ZWTEPqNoEuIn2viv#mym&!;wT~0K|Bx47 z!(2Tm?Ge3H+UGc1xa6EgnlpCJc(DqZr!G;_cXP$Eb$azWE5$x3U+ia)A?W0*&`G8y z(f2je*<$hH(&cEE-)j;FP^X0D4dNj3rD~s|0cAsYUM2dmj(mc6a-`%Qpge%`GI4m> z@=GXR{hV?cz~vAWt4AtPWvk;FMm$^QIuCa zr#y`EjnC=-b(C)s$G*mi$gL@ z!4&PCqe`pf9T2L8u6}7q659NIKEEXFm3_b}VZSsuEXnrfP_REBHwv#vvNSX*H2XzK zRsw#Zsy`SURvK$+L~lTG`=y}EZXc<2*ShN)gtpVinvb-$wF+HFg}Pn4b_vIho@(o; z?mBw(kkHw6=1`kkI3~$LNI@d{WXT)!jYy654MCcQB6$3wFy!<5{Cxu6j0%295`|zu z=#d2J$}mdt*c*_AAHJ>J03 z5E>|rc0rIOk62B^`1*vj>%UIC!-|9&Dd??9PDi_|)AA@nP`VOCAD3lcP|`ZtO)s0M zE9!$4(F)Y%Wv&Yqg(@Y4EvId9p{G79X}3a~6Yw_)=LV$F3tCT96acCuO9gBOJ)(-x zD+h)$K!%Fqtk!iA^2h>cDq{=uH7j;|i^`0J%bw9jHjf@vTA_ahc-jMe0}oe{Vr6hto&GDG zf{H`K9z_9*e;rkmoUT!*49cUG!ni6@LP1ZE_Z z$TdAaKOqqM@CFCBq~7%hlpvuBdkcBsc0rCLp#oebT%-9XrJ3MEcgsl)R6n;@hVZ$t z#eS}fko3Y9;oMP@_>}1c9Krf2dnnM?4z?!A^sry@3C~;FMUu$f!=vd^{7beZKEO6aXbB8Lrh*}U$sUq|MimM}KGE;%>nE+?%gkSg z22Cf$(tG-Z%JMo-ooB13(*6_DIFGpagz1&ML84^4wZ2^Jy1_kdZHs0g)fR|VP+a(!bF|A3<)!Xdcw?zAZ#tK?XB&t^B^PD(!a2&ymq@6 zkLvPWrYDYcxTHVz_C3i^d?ZCH;YhNZ zZqfr2IS>`y+J-0zi)U~cDtf}w%D{Th` z1f3=KgRw#4o05OTCkOmP*bzlwtgHVpdbwm_j?qd;!aqV5VlTHFcN{hwig+1 zuT<+Lpx1kMu{mu8)+NlIp&r_~X1ay8Vg9E)@{o!atVo{+@q|I&0JyGd2()x`K|7Yg zVmyOx)*<${7Mb4Op4wz)r;_fQQ+V@Nn0l82$gYb&Oa{1PCK z{|rBsQGc-ivS&m}jss%BI8v=x+uMr;?ef;+k(N{M3Fq_!`lP9|EeYrB$0G}d5FvT)h6PD;q-m5!l30T~8e z!g{Qut+TDGlT}Zcy~KXdU7bY#!p>tP!$`~RW8Bkak_*sJ!ZhIXiwSE|)0GQp02(S> za7^|MdE`-MlmP3fH)o0nhC)F}ME5({6)rkV&owY60)9YIZN;V=Uf;|JTkV1^nKTNe zhCs)VR|%J-`c=uTI)zg8^8&>)HQ?)_u>s81$~t&2vx&ZT;VhZ1i`2PSrQfn zc90h+?hnXAoPf3E7KW4oC`eD2lr z7cRWuI)CB3vbiE$`6_NyMkeIBSNEJ(s7%?640{a?y~(}VRgo}};hV5PD}iz4P5NLl zMUJ!JXTGTq}2Bj^oBnW2TGL{;5l=C9gxd z`KdxYYULNL&z6aX50m4{{MGuPO{;Mc-r=XOF_Ttz+&pHM%f^hddCV}zj~U5RIcB~{ z^#0T(8ud0tTI*FQNL0$MUowVe%%bpD$H$CFC&nyG_O8$9d2qoTmu{_l$Z7sp|DB0@ zYDIh}=dy%9Ip&c`$IEcVkjuj7hmrGo(ht!nybi=@Btva{9O@hCJuUFBFhNuW&H;r$ z33_0T0?(OXDks;)i!K4SCrTiLNjYa$9O;F_G^nbAp)lkHGD)gIK}M}7h_SVb4hT}HTd!b|Dwu2jhJY{h$*{l3A-{3CeG$a+6ktTunekq zJDamY6HeGW+giE~96i$A(Uvd|d4k^lgqfJ~aKe1>Bwo6#vKxR**peVoK*g&03e7^z zbTg&HL_;O`p@d;zL?NUkVBva__q0Nr$nIub?shN^#opYk!132xEBy-+=|KfYF& z*cUHcF>xrKS9INZ&H4Sj^^>N!v+(WkRQT%HOz^$eZ@m8Q_=9yjV(WI?wJ)q|i93oO zIaWS!td2QW&veZ@HcavlvmFneWpP*Sv~6Z}EN@f1Z1sCPZtR%d{_abYhvF+sCihXJ zoPxK{O`V%5n$KA~X-H(}{O*=`rR$HcynkiR{H?D%sN5Z^+&y2}G`Vkj@AduH_RpO7 z=Fv|b6^o@@{)W$2akirP`l?CW^y-*xZ9Kbh;^z>x5T>{{sJqCXq2MH$ zcB+0xkR#L)vRauX1A|Qzs*W+!OQ?76ctPz9#jKtry4=DkkVVq9L^d8Xw6`!<TYp@*XxazDx>LZYZ0||aaAu+`0O^{ouTW2@SQx$qDops`E3_oQG38S;p z!+f;(@I+g@M0hXvM(+1Zs-_&1*6G5yE$3~=lw-PM!B(Q>FU=IsWXpO+1CH{{{YyH)-9iTw`HE0kDfoH? zPme-&;S+Nx*jv5xHlMIbes6&E9JZS99xtO({6QD0b(cCyTEjsZs9N_)57iTo-S^iTLH4M?zyCZoCFk+bZ9 zb7Rc8F>*QT+&J&t5w-33`6I`gn_v01HJZKiryOrCc<9VuG~)h5Aw%t}D@wSTq8j6$ z@ioQ_HbuZ=z$c(zPX|5N(9_paB#eMHZAh{Cb89+R#xTO@laXv`p z>`EcG#wf@5E4*T$g91Svz9}{u%+gw>-id~rCa_myxL(C<##>3dc!rY-FC-NfjSDL7 z5Uspis3K==k`|a-F4g@%aa=AxsssV+4uLIs2ycIipHhzmyOO=)8@nf3f1FqN z&Y}6d^-v!^&D$R>+5f0$-K=T8Xyc>8;u+I78$Zu7J1mQw*=$j_#`Mh2=#1!Z(C>9_ zj8oWA4IdOp!dnW7kMm$p`i`YwjQub@Z`YREE75$@$QYQet_KlQRj*zH*j6cw3QMZj zF`ey+R?&9T=rx?-#H?|{8-~v0{W7n!ID7anj>1>Ta5;0Mnbu0;wGRPC__>JCR7F?y zRW23(bCVcTwNXgUpl1LLiC^MnuTMru3a}hu3Mx!OQkDf4&C#9aV4))Xuc${peYmds zfK8V|0T}JnG(=6{g(h*9FeA7LV?T!`2F3VN2yr!0CWPoBi;`9pl>D*}FA^p)P}`rh z!gtT~W(_NK-@M@M_xLF`Ta|RQFY91;xs389^eGRsj-e0)k3b3&2apFSVXVYBh@sEX z<9Q@#;-@^*myOaDuR?^2MW~3IM~JI(lO*O|M)Ci{PoZWL+~*F?kr&S^o>>=lx}r80 zMCCL;wPT`n(O@>e#y_$ZPPZ=DO5Perpg z{**JIa=c8qQy)=2+!-%<^>ZVi_Zt6yI6mt&{$Cc2RQyDNipTx-rUvfY&gRv|kBqs^ z#m0~FdCC`?k!J8Hof6!@^-M}gVSfrUHPck%QKaZ5>BT^%!^SYA`W|DvLCht3b~(d$_QCD_MT6U!w6{dWT-8t200a!Z zEuaZTp-IXlj_1* zTG_d9_65NoplB5o8t`jIDg~Yqk8hBCI#58F?yUTTs1@Z$af6Fb8dZKmJo5=Z4I z4;GS98wuy+18OVQYi_DnY7a`I&SY}hCd_R5kR@&OiU}0hq7W&s({l(3BtIj}CVHVa zR7`jTbtcKvO5_QE%WvX!E+(juww$8MXlZTKSr@g{J#wzP+4%16d1n>O)ZBvUx~V>x zs@B};{+M-bysTpO)a-%CxoGLGh2tlpCtrPV(i=PJoj)nfAMcGh`=YkKM^^h}!*7gE z*L`(7UbZ2!f9_Ol%bu9CDQatq+jA!`O;t{`#I5$}s+k?Jg7TQP927rmoC!rMUc9^S z?yh^y(bD!u8#c{enX|?!cRr}x6RX@aU-{DfhL?%b_wtLN^u7F#ir3C6bEoh2#kRHI zQ)1f=#ugiEHGWYJ7O!-f&{Ih&qSl2zkEN{v{V@5HDbSxMnd0Oot zxi{IBl;svNH@HH3H>Mxs6rEyTQkLhBTi&o>h3uXt%L_aQ0fx>bQz06HvX!p9W%3Yq z79dHAI3qtLkm#Ie8*sVJQ#Dl~73{=(0f4mF|b+$pICE{74HSGrK6 zN2}G7SuWCUST}xBMCj;&)>>7PnLbm+SRvW_1W+7ZbJr5ILQqmh1y2Z`yQErkM4hg( zML2Te5YiqQ&Up#ZEec8}IUw;~=tnsFRk;VYr=OTLp5a*Z2hGQMW;#7g`tXQMwoLC#0sRjDJIj1*kEHWe->}-Af*$CPcnr+qv`ddH?y^ z3t`K-^XK4P4-d#vZ-}%yI>LmWmcp1J5D0aaZF=9q3As~MRYiO4hq!22h*u%xs>rY6FB$|R32bl7+)Q%D{`n{;ofswy&BMDv_N zaXHDNUm!EV{R|GrY+ZKb6-P_=L{+)5YVFLW89U!=i1EfyM2fAgU$8d(C&S-n{Z-aI*ZorqJ5NC6gveNRj08vCaV9>F^FM-+ z^1c~HS+uk@>THYJ+8(Kv73AMc!@N^~^xIN5v0pWTXRNaY^QMZ~&X~!itE=xEo!viQ zRJ~wx$BWA%*1N`gQp|ZMYCH6_vRb_Q10_=btt-nZt820PkF!_p-Nb#o$=Q-?{CHb# zOP29FMjrX^WSNm>=&QO-z4KR=Xqj?MmnG@is_sxl2!|gtjqr*UydQazgwQV?q6M;! zoUf2vNV9I4MLW|KlXb=ndpNDWVx-(2k~O*xyp-JfFe$kqYqCk!h>oPysN0G`a`3Q= zNt%*zGpxoXq!!JE*^D{k<~PicGwhxwXL7^qI@BYPOnrRzAf}r!0F`m9PBM-q&sU~c zM!P(YsfF{I&X>mPG;J?oW*x9TVc*H$KIBM^+hgKd3ZE zI6}&wrv7D-`lk+l6-6yXbpL=1913ab8d1ZJsB>r3wlfYt0)K7COzUj^jeU&No*|E< zPB>d0IX%}Gt8R*IXo{DvjWo;|BcW(R+r7SfXYPBVu46Ih@u=;1+?F?89J7_qlt)=C zVS3-E*{cztm_8MCtXVXe%=NGU(@3gxO{DT}xvDp&5fpE(#~^nKBb^_X#GG3{vu%aV zhnG(jqN0yVN}IQGA8mE+Ej513=j<&qew@owzQ~L;gNk&$FoJep&JoM}QPiGYDF?2G zy`qum0QwQ_f?7xSdL*$HAHJm9V|q(-5^F8v{2QeIv3nZUTElf6?C^AkY=-sP!?Xb8(bqQ5n1tx?Zl;!du;P;JK?ODZ~{d z4+^_dem|AJ1Fv-+d~u~#D|HW+XnBU~MhZtO#G)L`Yo=Q0W|eT*@OE`ttaBbw>*QjHpAf%J_XDd% z-OSwoBb4_536%ec4A^#V!8<#?F*LF7$N3v(&&2ZG6R*Ur`46lmF>A?!b=9N%;u-sU z&Ku5%ZN8-L`}tcZn->k%oYF_7Yu;ORW7TZ)A0K@G;N9ZcgYy;5^QC*IEyVA)Uu&A} zowLL?ZI4%NnQoui6@x#y>V-)2hnD11yr}H@k!we0w=WcJ{`q1KS5UuLz?HyZ7diFe z>R9&nsA>Dpe`e-Zls+h`iWODesf-qGMSv!!^kH5J*mO?m6NPZ|BWrX1-YUb#Ri!N^ zGwC@da|ZpAjzC)bV+x7Am6hp})4Xk8&q{gcCbN3MQNLzO(RZtUgajWICaE5tI({Nc zv`l3|U$NdY(uah^sZ*R-*MtlyJtgIdW?^bQ=79MnFVU`1r5dg9aZ7Opt#9yxO=$sW z<5Gu2#!M+I{i3ejXybuib?Y4TT4f@=O6MC(QNK1n7Diuc1vy#`Z3W|2^hqq2T^HAA zxzt_E6?5-6HP}KX;seZ!>r&-fPAvEuH)ef4L^RlFvmnz;ETnYY78JCeE|zP#)P2k* z7TsBqHjCV;tl&ni9NccH)jqplm0F9`{pmTS^=iHN1?r2%NiRu})@M-XmPswZB4!6k zon}{>m&B3O0?bJt3A?AX0C3>t`dl>*&kO!7-S5hqCZ-cF)32)}v=>upW}nUIpJ`F3 zpZWFiu}%PoV>2`lvfoM5ptI+xlgX;mT%fWq!6#<=STjhS$h;oE#|=n+k3Y!q%mse| zNKfP{vaeyM4D?+e&Q!qx_51vF9!Q^W;zs#DH6p%kU`+prd;uLGW@2#H${!$`$Vzfg zpO_3(OsLjp*6uzDUXEe8DO^rkak3ga1Mc2E81Q-ql_s}dX_R;_q5seS2lw*{E?UrZ zqi-fZk{@ZfT|9SS-nHkQx*L7JcuWf3Z{(El*Q_P{k+3bf^vZB{B{K$?=OnxVeGg`Q z-9+Ez>CRt3N`a20Tj(6qgq%G~-7{3!SZ^JRZ^WeFcP z+Q;oFaSzY;pV34JPU_ij9S0exwDNyLZ%N=*hN@jsvZ2o!#vR4zSK(E*;tBzeX*R zp$f5G;PyXdJDEeII_h*s;nm11nm&HbI&t7pPX2sB)2%)v_e}FSZ4>QrYtFW)}$NA$$`Snl~r<0ISJ2R2vC=8BZxwIW8jV0#(E6qUpa${x9@BP0Jdd^>#CJzsO+ z)0(6AJ07@C#M~$5-KXYVrys4~INSfnL+=lLD=@!)dn)|?^PjEYa#klpm%MqK9=VJ; z8?ZQLyl(rKpXH@j>Op*S-CWUq^TO$K(evH&r(cUXJ)hY;ta1;cn|rWv9~Tz2t>Zr4 z)4ZxZi~D4qv)y9+#GTX58$WrGNB(=fnbIt0`&Q%kN}TQ0#_v_~l&>};&EOPd|1fQf zdsn&jtx;3!bD}?2(CRJoGMc10 zO_MZ#DNPbb1k*K12rN4Z^l<~UCxR=xma-^lw(mgB~hMR}1(_o*wbRJXiRRi<=Z z5Oc}7h4_r74bkCicEEZG4N}b2MqkR})O4S5!xTF%t*#2IbJFS%!%^cudekSSkEGdr zYjvA1KitTUpk;oW38%gGsi?-bld+E~NX4W>!{t_$iIo~6ED^NLz~*0Dp@0OYb*b|1 zwo#RLByq9hqp1u@09vQiP=5ED&b<;cfHxB z=@Wo+ufB*60$xX5W};=$U@_-Ba&CO!tcf{m?iNR#kSooTrbmw4w+~DmxVhmy*A3Tf>$^44(#?^Y z=*q^st@Dm%b__dSPB=P%uFjUGSs z_lIKHXQHMvWFzOSy?HsBT}j6ma~|3pHka?M1d%Qq9v!e_6kIx4=#Tw|u5}7u+&|Rnt!58aMhY#lkTo9D1wZ z1k{Z}wUko&%SLIfS0LPC)>`{*;>xtL;#+1wBL!vMm6z%bL41N=(lG+#-3~_2izUf$ z%WAD=ki6>bN}8-Du{5||dzW$>imQ?)t99J;284si?kSViRTf@*iXD8-lmWWYm>zA> z!n7I_s79<%P`_`|{tiYe=8{1h1Wn!O`Bkh*`D66JOiB4W$o*1okC`pO?eBtF$={`( z7(2>9+A*B@mzDfOxln^IRSmEZR!FBb#mi!>>fu9ccmD9)cI1>_R^xkVXj-kEckKTxd(0DSetjDOJcX z=h?2gwwSXqYHQ30rqso?;B(Ab`Zn+8oAD zEIjg`ILt^h2+NH6s?b5^Us1GdeakQT6B+I<`FF4+c^XN({JTgg2*#A0Z_@h#N>WJw z4jvWanwp=!Aj3#eV_=`6{2vIV$=26nV6ZGV#%vqsilVlS3%2bn2Db1*^L*vrn6o8n zYsuKRWrCdY@1w_zjiF6p8&iTzx-QVChqyYq+dTgE06R%8{|&088(Fs9gZLU;^fbwz zq2&8CYx0;*a38!hw{@X>`?Dt1vLmLnk4{crKqnZJ&H%%-BNZ@hR5lh%9?*a!sV2CE z4O{0L7B=ksHAj3CoyiZD8!wLWn6C~C+!1N`V9&2P+SGGLdx+8Ghg78<$rszVrJ#8A zxuYZqhNKl@O}ippAH4J>MoY&MZO?v>MtcfRQakp8w9yVkdOkS(YmW9u&mHX%jg~~n zqjP%~Dt3H{;g$mBUq2U=Kg4j4ljvApKi9fYzUxbjw-hkRW3fCi{|hZIIbOxKxt@iJ zFEid!>myrfdG!BBj3@siRr&Q0l7#m1qx|Ss7-cEg5g%P1cK?(PyX|xGLPaB6mCF&% zrZ38pf7C7PV8M&zll&Ld=WkN-4N9&d31_QbQD#Vo9mxk44+!VzIw%EU!w&rsW`5ce z6q#_YUdE(AfjUq$E zFwuEe`G2GAzZ1$6%1&?(@%2mtozRM|b;ky83oq3P!1#fSe+Amo>ftmc%y40f*?M}MK!QI=gpZ|}j)pISyYcZ;fc_p?z^`BmieICKirOHg`GFiw(6cZebn==;6I3QzC|BPk{Blezc5y5~w zZ@>T(zS0b`Jyi`#V!3K4#Fd{fAu154n|}Te!TUXc2rKbnj5x(A`{&u&X3OF#&RHDK zD~T5$kCzIwJ0km{8ycb|+qBxQWomcR00*KQw%@C~zdhRA5i9A`nmjweN`#l<#YfZu z+M^rlqa_Vm?d1m8zhv!a`(7K*TNf`rxa{QPg6s2a3$50H)xHiRY`tN^I$1O+PS?*E zr*}^IV_9n+bFc8cVNo=2xvS@__s&GShhwHo@tiXCL5Z1y@tn1h%l8gMUk%1gp?J`ggtI6m^8d?pRz{__srXxCd`YL6255KyU5`-BhJ=GOp(qx zoaelq8!g`+Tf5^i6)bMg;a4wJy2zp6Zp&l3EedA7U>bSc7W0gJ1NDmAjQE&tiv>2` z0^{9yW7i^wY@{gSovV-Z-(LHe9v4^U@~`n)IpxzU(DUMk0{$>RT`{d(b1ia|pEb>P zM#^VTzn}Y}`B>|E!Z?Kb+S=S6z~{*byh0_D^Dpy$QSMSPuB0on9&JTAI1P1>m&KDGcHl`ow0 z&XwFg!WQs(3Ab{`!xcLpuB>JMw?151_YiMZ?n?XL_<3oL176-m!Ojz2wM>mJa>&kf zKBgP^gvl`({?u3+H#sI+-rE1>{;96%^6ORCs%GkDwoY%E&sq1GNkGunw2BFZ{|^OU B=41c> literal 0 HcmV?d00001 diff --git a/bridge/__pycache__/vault.cpython-312.pyc b/bridge/__pycache__/vault.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..e8cb8d5f15b968736e051d2d35b157fd6b697b35 GIT binary patch literal 3077 zcmai0T})iZ6`uRIKbPen8^;!AVpErOVaX3pQWqfu#~VD zF0fl1Q3=&xQ5n=mY*eb)c`=p!;7XO6hdxw!?Tc+lgKzxQKKY^2x};K?r=GdXWihhb zS?$c3Gjq;p4HTeNpP-6w_G)+w4Zv0~{uiJQ#rAdp@60DH3 zDZyj93CanbOed&?C(|1KP`Ac(_}=*sBlt44^UScYW?-7l*#)eF4s|L;4Q!aMDm-cG z`7DTPjN%-l7By^aGF(xTws79iCbhJ#jp-EsefBrlPEnjSNnWSw`4~=V2GK#-h;w zeaej+b$radN}^)wO7DX~g8xqlXbgNTX{)65WtygiP)8l6*$wB)YqO z{?q7#286DO^U^#=wK5mDA&Y21o)JB50$Oa6(Sj61N0D9UX|p{7Wk7y!vjZL{)8tXr z(QfhRak&FAMLPaEM)2?s@Njw@U}w-OzO*rpw@6ra z6c=zsvD+--=)i^Ht~0TbtCxnmE?m449l3PzTy&%>c5ZaIi!))@`H`^X)FreW;G4$H zyq(M2j%?Fu+ffoJK$H`5BV#)-5<8%PYjL+V!++}-a6cATxbt_fZ{M5>jBWL3jrNJa$|Rv`k%ExVATPK}#-%s5X(j%;XI>L5q9(nad1Su=0gIWui=XTavL z%s9ANJ?}UOStE2#AdyPzgi#|bI#QC_j!(Kf>kOVA1*|bl=XR&F0pXcb4BNon3GK-dfwE z=C?Lm4y{C&qno(nPT+Q6ZU6nIb=5(lZ6_YJ{pj;1=$3{bw;U+#`}o~w5<1+s1t&q_8b`JAV>xC* z+O`~tPfOk4|vMlnhFv z8!gr<5dWD)WqF^{^dz+ZW^^IygoZZ{wcjH5JHAn*T6JD12T*7D&h+i+J0IQtXyf~D zFWNVY3&ql_pLTxIxlw=g^UL${W>d?&-*ZEBZSZf+J@Z5V7K63;SqrLdTx$N+xpFIN zd98c^HMGvxefuQTwp#qEz3*Xr-=E1>{g)s1U)~t`>4Tb|{R;_7(`J3+jbK><>N|_4 zvAKc%K?!{!^$ebnzUUR8FS>VeYsqt2fG1bCc<_q9va4)(2|k9xLNqM61_%p|64zML zvUsAv6(a@ubS*nZw0`yjU`F6?@rpEyRtu{?X9t0FWRRJB?z24kxSvI!iKiaspMjd> zzDmSN2$E|3vUloNH5?0qy;G-NRGmCsp2Rg_MzD@lbrQ+j@museaaRp^K?S?Flfd9b zqf~X+JM}8FQ)}Q>+f76>0(`?qQ1xX*lzVbZ1%qasyge1&t7Ty>qF}Oe;2mC+f0#CUcfa6QUgUPVN0Y}L(o^|~3I589PxDyz< zc;Ui8^mMEeIublI+yJADv33x46q3*8tgy(s_)JPrQVXT{Pu&xk%!HZCxP0<1NkL*OFZtSe{x}+h>O!*VMuCgNBY< zBWthT{>iO#59%U+jQwf!52IfOPH_o2xY%*?_`>n!SgEC?mBva3e*MAP>lo5*xJ zZM*mVJ#zJXIIg_s`q==8bIV#5bP!d7@Jv<(dFucYLSLiCuaR2zwF*s3y;}%|@>@u0 zc-VNnEQuj;Hc(cBLhZc0G_+7CBOpt$Ej~aT%c1$=Bk7e*IW#+T{p>H!E{rT4UFlrz XEWKWOW2tMsw*5;Pmn9@0bPx4kG@-{F literal 0 HcmV?d00001 diff --git a/bridge/auth.py b/bridge/auth.py index b798e46..84f8a22 100755 --- a/bridge/auth.py +++ b/bridge/auth.py @@ -31,6 +31,7 @@ Usage: import json import os import re +import shutil import subprocess import sys from typing import Any @@ -84,6 +85,25 @@ def readers() -> list[dict[str, str]]: ] +def certs() -> dict[str, Any]: + """PKCS#11 objects of type cert via pkcs11-tool. + + v0.1.4: the auth panel's "List Certificates" button used to call a + bridge.spawn() that bridge.js never exported — the button has + always thrown. The listing now lives here (fixed argv list, no + shell), matching every other spawn in this suite. + """ + if not shutil.which("pkcs11-tool"): + return {"available": False, + "reason": "pkcs11-tool not installed (opensc)", + "count": 0, "output": ""} + raw = run(["pkcs11-tool", "--list-objects", "--type", "cert"]) + lines = [line for line in raw.splitlines() if line.strip()] + return {"available": True, + "count": sum(1 for line in lines if "Certificate" in line), + "output": "\n".join(lines) or "(no certificates on any slot)"} + + def pcscd_state() -> str: """pcscd.service state via systemctl.""" raw = run(["systemctl", "is-active", "pcscd"]).strip() @@ -238,6 +258,7 @@ COMMANDS = { "summary": lambda _args: summary(), "slots": lambda _args: slots(), "readers": lambda _args: readers(), + "certs": lambda _args: certs(), "identities": lambda _args: identities(), "ssh-keys": lambda _args: ssh_keys(), "kerberos": lambda _args: kerberos(), diff --git a/bridge/benchmark.py b/bridge/benchmark.py index e329ff7..85991d3 100755 --- a/bridge/benchmark.py +++ b/bridge/benchmark.py @@ -21,6 +21,7 @@ Usage: """ import json +import re import subprocess import sys from typing import Any @@ -96,6 +97,13 @@ def run_test(args: list[str]) -> dict[str, Any]: "error": "no test name provided", } test_name = args[0] + # v0.1.4 SECURITY: the test name is passed to `sysbench run` + # as one argv element — a leading dash makes it an OPTION (e.g. + # --config=…), so validate it as a plain identifier (the sysbench + # builtin test vocabulary is cpu/memory/threads/mutex/fileio/oltp_*). + if not re.fullmatch(r"[A-Za-z0-9_.-]{1,64}", test_name) or test_name.startswith("-"): + return {"raw": "", "events_per_sec": None, "latency_ms": None, + "error": f"invalid sysbench test name: {test_name!r}"} # Some sysbench tests (fileio) require a prepare step before run. # We deliberately keep this simple — for arbitrary test names, just # invoke ``sysbench run``. If the user wants fileio with diff --git a/bridge/builder.py b/bridge/builder.py index 2a1539b..fafdf59 100755 --- a/bridge/builder.py +++ b/bridge/builder.py @@ -485,6 +485,34 @@ BUILDER_LOGS_DIR = Path("/var/lib/sysdeck/builder/logs") BUILDER_ARTIFACTS_DIR = Path("/var/lib/sysdeck/builder/artifacts") +# v0.1.4 SECURITY: build-ids and profile names are used to build paths +# under the three dirs above (state/.json, logs/.log, +# artifacts//). They arrive as raw argv from the bridge caller, +# so they must be validated as a single safe path component before any +# filesystem use — otherwise `build-log ../../etc/foo` reads arbitrary +# *.log files, `build-delete ` unlinks arbitrary *.json/*.log, +# and `artifacts-clear /etc` would rmtree an arbitrary directory as root +# (found by the 0.3.0 security audit; every one of these now fails closed). +_SAFE_ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$") + + +def _valid_id(token: str) -> bool: + """True if token is a safe single path component (no separators, + no traversal, no leading dash, bounded length).""" + if not isinstance(token, str) or not token: + return False + return bool(_SAFE_ID_RE.match(token)) and ".." not in token + + +def _under_dir(path: Path, root: Path) -> bool: + """True if (resolved) path stays inside root (defends symlinks + traversal).""" + try: + path.resolve().relative_to(root.resolve()) + return True + except (ValueError, RuntimeError, OSError): + return False + + def _ensure_state_dirs() -> None: """Create the state/logs/artifacts dirs. Best-effort; the cockpit superuser channel handles root perms when needed.""" @@ -1092,6 +1120,15 @@ def profile_create(args: list[str]) -> dict[str, Any]: backend_id = positional[1] base = positional[2] if len(positional) > 2 else None + # v0.1.4 SECURITY: the name becomes /etc/mkosi/profiles// (or + # /etc/vmdb2/.yaml) and is .format()-ed into the scaffold's + # config templates — a name containing '/', '..' or newlines is + # directory traversal plus arbitrary config-line injection into + # files that mkosi/vmdb2 later execute as root during builds. + if not _valid_id(name): + return {"error": "profile name must be a single path component " + "(letters, digits, '.', '_', '-'; no slashes, no '..', no newlines)"} + # Validate backend. valid_backends = ("mkosi", "vmdb2") if backend_id not in valid_backends: @@ -1870,6 +1907,10 @@ def build_log(args: list[str]) -> dict[str, Any]: if not args: return {"error": "build-id required"} build_id = args[0] + # v0.1.4 SECURITY: build-id is used to build the log path under + # BUILDER_LOGS_DIR — a traversal id would read arbitrary *.log files. + if not _valid_id(build_id): + return {"error": "invalid build-id (must be a single path component)"} log_path = _build_log_path(build_id) if not log_path.is_file(): return {"error": f"no log file for build {build_id}", "build_id": build_id} @@ -1893,6 +1934,11 @@ def artifacts(args: list[str]) -> dict[str, Any]: if not BUILDER_ARTIFACTS_DIR.is_dir(): return {"artifacts": [], "by_profile": {}} profile_filter = args[0] if args else None + # v0.1.4 SECURITY: a traversal profile filter would list an + # arbitrary directory's contents (names/sizes/mtimes) to the browser. + if profile_filter and (not _valid_id(profile_filter) or + not _under_dir(BUILDER_ARTIFACTS_DIR / profile_filter, BUILDER_ARTIFACTS_DIR)): + return {"error": "invalid profile filter (must be a single path component)"} by_profile: dict[str, list[dict[str, Any]]] = {} if profile_filter: profiles_to_scan = [BUILDER_ARTIFACTS_DIR / profile_filter] @@ -1954,6 +2000,13 @@ def artifacts_clear(args: list[str]) -> dict[str, Any]: if not args: return {"error": "usage: artifacts-clear "} profile = args[0] + # v0.1.4 SECURITY: profile is used to rmtree a directory as root — + # an absolute path ('/etc') or traversal ('../..') escapes the + # artifacts root. Validate as a single component AND resolve the + # target under BUILDER_ARTIFACTS_DIR (same guard artifact-delete + # has had since v0.0.31; artifacts-clear missed it). + if not _valid_id(profile) or not _under_dir(BUILDER_ARTIFACTS_DIR / profile, BUILDER_ARTIFACTS_DIR): + return {"error": f"refusing to clear: '{profile}' is not a profile directory under {BUILDER_ARTIFACTS_DIR}"} prof_dir = BUILDER_ARTIFACTS_DIR / profile if not prof_dir.is_dir(): return {"error": f"no artifacts directory for profile '{profile}'"} @@ -1989,6 +2042,10 @@ def build_delete(args: list[str]) -> dict[str, Any]: if not args: return {"error": "build-id required"} build_id = args[0] + # v0.1.4 SECURITY: build-id builds state/log paths that get unlinked + # as root — a traversal id would delete arbitrary *.json/*.log files. + if not _valid_id(build_id): + return {"error": "invalid build-id (must be a single path component)"} delete_artifacts = "--artifacts" in args[1:] deleted = [] errors = [] @@ -2018,13 +2075,18 @@ def build_delete(args: list[str]) -> dict[str, Any]: errors.append(f"log: {exc}") # Optionally delete artifacts. if delete_artifacts and profile_name: - prof_dir = BUILDER_ARTIFACTS_DIR / profile_name - if prof_dir.is_dir(): - try: - shutil.rmtree(prof_dir) - deleted.append(str(prof_dir) + "/ (artifacts dir)") - except (PermissionError, OSError) as exc: - errors.append(f"artifacts: {exc}") + # v0.1.4 SECURITY: profile_name comes from the (deleted) state + # file's JSON — treat it as untrusted before rmtree'ing with it. + if not _valid_id(profile_name) or not _under_dir(BUILDER_ARTIFACTS_DIR / profile_name, BUILDER_ARTIFACTS_DIR): + errors.append(f"artifacts: refusing to clear untrusted profile path {profile_name!r}") + else: + prof_dir = BUILDER_ARTIFACTS_DIR / profile_name + if prof_dir.is_dir(): + try: + shutil.rmtree(prof_dir) + deleted.append(str(prof_dir) + "/ (artifacts dir)") + except (PermissionError, OSError) as exc: + errors.append(f"artifacts: {exc}") if not deleted and not errors: return {"error": f"no build found with id '{build_id}'"} return {"deleted": True, "build_id": build_id, "profile": profile_name, diff --git a/bridge/db.py b/bridge/db.py index 520e808..d638938 100755 --- a/bridge/db.py +++ b/bridge/db.py @@ -288,6 +288,16 @@ def cmd_status(engine_id): return {"error": f"Unknown engine: {engine_id}"} +def _engine_registered(engine_id): + """v0.1.4 SECURITY: True if engine_id is in ENGINE_REGISTRY. The + status/connections/query subcommands already resolved engines + through the registry, but start/stop/restart passed the raw id into + `systemctl {engine_id}.service` — letting any cockpit + session stop/start ARBITRARY system units as root (db stop sshd). + All unit-control subcommands now require a registered engine.""" + return any(e[0] == engine_id for e in ENGINE_REGISTRY) + + def cmd_start(engine_id): # v0.0.32: was `sudo systemctl start` — but sudo shell-out from # the bridge fails when the cockpit user has no passwordless sudo @@ -297,6 +307,9 @@ def cmd_start(engine_id): # action. The bridge runs systemctl directly as root (the cockpit # superuser channel escalates privileges via polkit when the # operator authenticates). + # v0.1.4 SECURITY: registry check added (see _engine_registered). + if not _engine_registered(engine_id): + return {"error": f"Unknown engine: {engine_id}"} rc, out, err = run_rc(["systemctl", "start", f"{engine_id}.service"], timeout=30) return {"action": "start", "engine": engine_id, "rc": rc, "output": out or err or "started", "success": rc == 0, @@ -304,6 +317,10 @@ def cmd_start(engine_id): def cmd_stop(engine_id): + # v0.1.4 SECURITY: registry check added (see _engine_registered) — + # without it, `db stop sshd` stopped arbitrary root units. + if not _engine_registered(engine_id): + return {"error": f"Unknown engine: {engine_id}"} rc, out, err = run_rc(["systemctl", "stop", f"{engine_id}.service"], timeout=30) return {"action": "stop", "engine": engine_id, "rc": rc, "output": out or err or "stopped", "success": rc == 0, @@ -311,6 +328,9 @@ def cmd_stop(engine_id): def cmd_restart(engine_id): + # v0.1.4 SECURITY: registry check added (see _engine_registered). + if not _engine_registered(engine_id): + return {"error": f"Unknown engine: {engine_id}"} rc, out, err = run_rc(["systemctl", "restart", f"{engine_id}.service"], timeout=30) return {"action": "restart", "engine": engine_id, "rc": rc, "output": out or err or "restarted", "success": rc == 0, @@ -331,13 +351,30 @@ def cmd_connections(engine_id): def cmd_query(engine_id, sql): - """Execute a SQL query against an engine (SQL family only).""" - # Safety: refuse DDL/DML for certain contexts + """Execute a read-only SQL query against an engine (SQL family only). + + v0.1.4 SECURITY: the old comment said "refuse DDL/DML" but no check + existed — any statement (DROP DATABASE, COPY ... TO PROGRAM) ran as + root through psql/mysql/sqlite. The guard is now real: only + SELECT/WITH/SHOW/EXPLAIN/DESCRIBE/PRAGMA-first-token statements + pass. Mutations belong in the engine's own tooling, not in a + dashboard query box. + """ for e in ENGINE_REGISTRY: if e[0] == engine_id: family, cli = e[2], e[5] if family != "sql" and engine_id not in ("clickhouse", "timescaledb", "duckdb"): return {"error": "Query only supported for SQL-family engines"} + # v0.1.4 SECURITY: read-only statement guard. + tokens = (sql or "").lstrip("(\t\r\n ").split(None, 1) + first_token = tokens[0].upper() if tokens else "" + read_only = first_token in ( + "SELECT", "WITH", "SHOW", "EXPLAIN", "DESCRIBE", "DESC", + "PRAGMA", "TABLE", "ANALYZE", + ) + if not read_only: + return {"error": "read-only queries only — DDL/DML is rejected " + "(first token was not a read statement)"} if cli == "psql": out = run(["psql", "-tAc", sql], timeout=30) elif cli in ("mysql", "mariadb"): diff --git a/bridge/firewall.py b/bridge/firewall.py index 44cf174..f702228 100755 --- a/bridge/firewall.py +++ b/bridge/firewall.py @@ -1537,7 +1537,11 @@ def cmd_install_backend(args: list[str]) -> dict[str, Any]: for p in pkgs: if not _validate_filename(p): return {"error": f"invalid package name: {p!r}"} - cmd = [python3, packages_helper, "install", "--", *pkgs] + # v0.1.4 FIX: the trailing '--' separator made packages.py's + # install() see '--' as args[0] and fail with "no targets" — the + # backend-install path had never worked. packages.py now skips + # leading '--' argv elements anyway, so both sides are fixed. + cmd = [python3, packages_helper, "install", *pkgs] try: r = subprocess.run( cmd, capture_output=True, text=True, check=False, timeout=300, diff --git a/bridge/hwalert.py b/bridge/hwalert.py index 63e5771..13ea385 100755 --- a/bridge/hwalert.py +++ b/bridge/hwalert.py @@ -507,28 +507,61 @@ def cmd_dismiss(alert_id): return {"action": "dismiss", "alertId": alert_id, "status": "dismissed"} +def _device_path_ok(device_id): + """v0.1.4 SECURITY: device ids from the scanners are absolute sysfs + paths (/sys/bus/usb/devices/..., /sys/bus/thunderbolt/devices/..., + /sys/bus/pci/devices/...). block/unblock write to /authorized as + root, so the id must resolve inside one of those scanned bases — + otherwise cmd_block was an arbitrary file-overwrite ('0') and + cmd_unblock was a root shell injection via `sudo sh -c` with the + f-string path (found by the 0.3.0 security audit; both sudo + fallbacks are also gone: the cockpit superuser channel already + escalates this helper via polkit, so shelling out through sudo + only ever added the injection primitive).""" + if not isinstance(device_id, str) or not device_id.startswith("/"): + return False + bases = ( + "/sys/bus/usb/devices", + "/sys/bus/thunderbolt/devices", + "/sys/bus/pci/devices", + ) + p = os.path.realpath(device_id) + return any(p == b or p.startswith(b + "/") for b in bases) + + def cmd_block(device_id): """Block a device — for USB, writes '0' to authorized sysfs.""" - # Try USB authorization + if not _device_path_ok(device_id): + return {"action": "block", "deviceId": device_id, "result": "invalid-device-path"} auth_path = os.path.join(device_id, "authorized") if os.path.exists(auth_path): try: with open(auth_path, 'w') as f: f.write('0') return {"action": "block", "deviceId": device_id, "result": "blocked", "method": "usb-authorize"} - except PermissionError: - # Need sudo - run(["sudo", "tee", auth_path], timeout=5) - return {"action": "block", "deviceId": device_id, "result": "blocked", "method": "usb-authorize-sudo"} + except (PermissionError, OSError) as exc: + return {"action": "block", "deviceId": device_id, "result": "error", + "error": str(exc)} return {"action": "block", "deviceId": device_id, "result": "no-method-available"} def cmd_unblock(device_id): """Unblock a device.""" + if not _device_path_ok(device_id): + return {"action": "unblock", "deviceId": device_id, "result": "invalid-device-path"} auth_path = os.path.join(device_id, "authorized") if os.path.exists(auth_path): - run(["sudo", "sh", "-c", f"echo 1 > {auth_path}"], timeout=5) - return {"action": "unblock", "deviceId": device_id, "result": "unblocked"} + # v0.1.4 SECURITY: was `sudo sh -c f"echo 1 > {auth_path}"` — a + # device_id containing shell metacharacters was literal root RCE. + # Direct write (this helper already runs privileged through the + # cockpit superuser channel when the operator approves polkit). + try: + with open(auth_path, 'w') as f: + f.write('1') + return {"action": "unblock", "deviceId": device_id, "result": "unblocked"} + except (PermissionError, OSError) as exc: + return {"action": "unblock", "deviceId": device_id, "result": "error", + "error": str(exc)} return {"action": "unblock", "deviceId": device_id, "result": "no-method-available"} diff --git a/bridge/klanker.py b/bridge/klanker.py new file mode 100644 index 0000000..aed10b3 --- /dev/null +++ b/bridge/klanker.py @@ -0,0 +1,571 @@ +#!/usr/bin/env python3 +""" +SysDeck - Klanker Bridge Helper (AI Gateway) +Author: Jeremy Anderson (https://dcos.net) + +UPSTREAM ATTRIBUTION: this helper is a REST *client* of klanker-gate — +the "Frosty Deno" LLM gateway by TykoDev +(https://github.com/TykoDev/klanker-gate, Apache-2.0), which the master +tarball vendors unmodified at /klanker-gate. klanker-gate is NOT +SysDeck code and no upstream code is contained here — see +klanker-gate/ATTRIBUTION.md and THIRD_PARTY.md. + +v0.3.0 NEW MODULE. Client of the vendored klanker-gate service — the +Frosty Deno LLM gateway (Deno 2 + TypeScript, REST on 127.0.0.1:8080) — +so sysdeck ships an operator view of the local inference gateway: +providers, virtual keys, request logs, spend/cost rollups, cache and +runtime topology, plus systemd service control. + +This helper is a thin stdlib-only REST client (urllib.request + json, +4s timeout — no requests library, no curl dependency), the same +contract as bridge/fester.py. Every read subcommand prints the +service's JSON response; `status` merges the public /healthz and +/api/version probes and enriches them with the local connection +facts. HTTP error bodies (401 auth errors, 404s) are JSON on this +service and are surfaced verbatim. Connection failures are graceful: +{"ok": false, "error": ...} with a remediation hint, exit code 0 — +never a traceback. + +Authentication: operator routes under /api/* take +`Authorization: Bearer ` when the gateway has one +configured. The token is read from KLANKER_ADMIN_TOKEN here and sent +as a header ONLY — it is never echoed in any output, never placed in +a URL, and journal output is scrubbed of its value defensively. + +Subcommands: + status GET /healthz + /api/version, merged + enriched + providers GET /api/providers (browser-safe list) + models GET /v1/models (aggregated catalog) + vkeys GET /api/virtual-keys + logs [--limit N] GET /api/logs?limit=N (recent request ring, + default 25) + analytics GET /api/analytics (rollups: requests, spend, + cache, latency; optional --window 1h|24h|7d) + runtime GET /api/runtime (workers/cache/postgres) + service systemctl start|stop|restart|status|enable|disable + klanker-gate.service + journal [N] journalctl -u klanker-gate -n N --no-pager + (default 40, sanitized) + localstack probe local AI backends (ollama, llama.cpp, + koboldcpp, lmstudio, sglang, vllm) + wiring recipes + +Usage: + python3 /usr/lib/sysdeck/bridge/klanker.py status + python3 /usr/lib/sysdeck/bridge/klanker.py providers + python3 /usr/lib/sysdeck/bridge/klanker.py logs --limit 50 + python3 /usr/lib/sysdeck/bridge/klanker.py service restart + KLANKER_URL=http://10.0.0.5:8080 KLANKER_ADMIN_TOKEN=... \\ + python3 /usr/lib/sysdeck/bridge/klanker.py analytics +""" + +import json +import os +import re +import shutil +import subprocess +import sys +import threading +import time +import urllib.error +import urllib.parse +import urllib.request + +# The vendored klanker-gate service binds REST here by default +# (apps/gateway/main.ts: PORT env, default 8080). Override with +# KLANKER_URL when it lives elsewhere. +KLANKER_URL = os.environ.get("KLANKER_URL", "http://127.0.0.1:8080").rstrip("/") + +# Optional bearer token for the gateway's admin surface (/api/* takes +# Authorization: Bearer when the operator set one). +# Header-only usage — NEVER printed, NEVER in a URL. +KLANKER_ADMIN_TOKEN = os.environ.get("KLANKER_ADMIN_TOKEN") + +# The systemd unit the service subcommand wraps. The gateway itself +# ships no unit (docker-compose / `deno task gateway` are its native +# runners); operators who deploy it natively use this name, matching +# the fester-service convention. +KLANKER_SERVICE = "klanker-gate.service" + +# Strict 4s timeout — the panel polls every 5s, so a hung request must +# never outlive one refresh cycle. +KLANKER_TIMEOUT = 4 # seconds + +# Connection-level failure messages (callers print this and exit 0 — +# graceful, same contract as fester.py and the other bridge helpers). +UNREACHABLE_MSG = ( + "klanker-gate service unreachable at {url} — start it with " + "`systemctl start klanker-gate` (arch/ packaging) or `deno task dev` " + "in the vendored klanker-gate tree, or set KLANKER_URL" +) + + +def _unreachable() -> dict: + """Return the graceful offline response (remediation hint included).""" + return {"ok": False, "error": UNREACHABLE_MSG.format(url=KLANKER_URL)} + + +def _base_port() -> int: + """Port of the base URL (8080 for the default vendored service).""" + try: + return urllib.parse.urlparse(KLANKER_URL).port or 8080 + except ValueError: + return 8080 + + +def _request(path: str) -> dict: + """One HTTP GET against the gateway. Returns parsed JSON. + + HTTPError bodies are JSON on this service — surface them verbatim + (a 401 "Missing or invalid admin token." is a fact the operator + needs to see). Connection-level failures raise URLError/OSError; + the _get wrapper translates those into the graceful offline + response. + """ + url = KLANKER_URL + path + headers = {"Accept": "application/json"} + if KLANKER_ADMIN_TOKEN: + # Sent as a header only — the token value never appears in + # `url`, in any error string, or in any printed JSON. + headers["Authorization"] = f"Bearer {KLANKER_ADMIN_TOKEN}" + req = urllib.request.Request(url, headers=headers, method="GET") + try: + with urllib.request.urlopen(req, timeout=KLANKER_TIMEOUT) as resp: + raw = resp.read().decode("utf-8", errors="replace") + except urllib.error.HTTPError as exc: + try: + raw = exc.read().decode("utf-8", errors="replace") + if raw.strip(): + return json.loads(raw) + except (OSError, ValueError): + pass + return {"ok": False, "error": f"HTTP {exc.code}: {exc.reason}"} + try: + return json.loads(raw) if raw.strip() else {"ok": False, "error": f"empty response from {url}"} + except json.JSONDecodeError: + return {"ok": False, "error": f"non-JSON response from {url}"} + + +def _get(path: str) -> dict: + """GET with graceful offline handling.""" + try: + return _request(path) + except (urllib.error.URLError, OSError, ValueError): + return _unreachable() + + +# ── subcommands ────────────────────────────────────────────────────── + + +def cmd_status(_args: list[str]) -> dict: + """GET /healthz + /api/version, merged + enriched with local facts. + + Both probes are public (no admin token required). The merge keeps + the gateway's own fields (status, version, timestamp) and layers: + port — port of the base URL (8080 default) + base_url — the URL this helper is talking to + deno — Deno runtime version from /api/version + transport — "rest" + auth — whether an admin token is configured HERE (boolean; + the token value itself is never reported) + """ + try: + data = _request("/healthz") + except (urllib.error.URLError, OSError, ValueError): + return _unreachable() + if not isinstance(data, dict) or data.get("status") != "ok": + # Non-healthy gateway (or an error body) — surface it verbatim. + if isinstance(data, dict) and data.get("ok") is not False: + data = dict(data) + data.setdefault("status", "error") + return data if isinstance(data, dict) else {"ok": False, "error": "non-object healthz response"} + + out = dict(data) + # /api/version is best-effort — a healthy gateway always serves it, + # but a failure here must not sink the status probe. + version = _get("/api/version") + if isinstance(version, dict) and version.get("ok") is not False: + out["deno"] = version.get("deno") + if version.get("version"): + out["version"] = version["version"] + out["ok"] = True + out["port"] = _base_port() + out["base_url"] = KLANKER_URL + out["transport"] = "rest" + out["auth"] = bool(KLANKER_ADMIN_TOKEN) + return out + + +def cmd_logs(args: list[str]) -> dict: + """GET /api/logs — the recent request ring, optionally limited. + + Usage: logs [--limit N] (default 25, range 1-500) + """ + limit = 25 + i = 0 + while i < len(args): + arg = args[i] + if arg == "--limit" and i + 1 < len(args): + raw = args[i + 1] + i += 2 + try: + limit = int(raw) + except ValueError: + return {"ok": False, "error": f"limit must be an integer between 1 and 500: {raw!r}"} + if not 1 <= limit <= 500: + return {"ok": False, "error": f"limit must be an integer between 1 and 500: {limit}"} + else: + return {"ok": False, "error": f"unknown argument: {arg}"} + return _get(f"/api/logs?limit={limit}") + + +def cmd_analytics(args: list[str]) -> dict: + """GET /api/analytics — rollups (requests, spend, cache, latency). + + Usage: analytics [--window 1h|24h|7d] (default 24h) + """ + window = "24h" + i = 0 + while i < len(args): + arg = args[i] + if arg == "--window" and i + 1 < len(args): + window = args[i + 1] + i += 2 + if window not in ("1h", "24h", "7d"): + return {"ok": False, "error": f"window must be one of 1h, 24h, 7d: {window!r}"} + else: + return {"ok": False, "error": f"unknown argument: {arg}"} + return _get(f"/api/analytics?window={window}") + + +# ── systemd service control ───────────────────────────────────────── +# +# Same pattern as bridge/jellyfin.py / bridge/mining.py: plain +# subprocess.run(["systemctl", ...]) with capture_output, check=False, +# a timeout, and a structured result. No `sudo` shell-out — the JS +# panel's bridgeCmd already passes superuser:'try' so cockpit prompts +# via polkit. + +SERVICE_ACTIONS = ("start", "stop", "restart", "status", "enable", "disable") + + +def _have(binary: str) -> bool: + """True if binary is on PATH.""" + return shutil.which(binary) is not None + + +def _service_status() -> dict: + """Read-only unit state: active/sub + enabled-at-boot.""" + if not _have("systemctl"): + return {"ok": False, "error": "systemctl not on PATH"} + try: + r = subprocess.run( + ["systemctl", "show", KLANKER_SERVICE, + "--property=ActiveState,SubState,UnitFileState,ActiveEnterTimestamp"], + capture_output=True, text=True, timeout=5, + ) + props = dict( + line.split("=", 1) + for line in r.stdout.strip().splitlines() + if "=" in line + ) + active = props.get("ActiveState", "unknown") + sub = props.get("SubState", "unknown") + enabled = props.get("UnitFileState", "unknown") + return { + "ok": True, + "service": KLANKER_SERVICE, + "active": active, + "sub": sub, + "enabled": enabled, + "running": active == "active", + } + except (subprocess.TimeoutExpired, FileNotFoundError, OSError) as exc: + return {"ok": False, "error": str(exc)} + + +def cmd_service(args: list[str]) -> dict: + """systemctl wrapper for klanker-gate.service. + + Usage: service + """ + if not args: + return {"ok": False, "error": "action required: service "} + action = args[0] + if action not in SERVICE_ACTIONS: + return {"ok": False, "error": f"unknown action: {action!r} (expected one of {', '.join(SERVICE_ACTIONS)})"} + if len(args) > 1: + return {"ok": False, "error": f"unknown argument: {args[1]}"} + + if action == "status": + return _service_status() + + if not _have("systemctl"): + return {"ok": False, "error": "systemctl not on PATH"} + timeout = 30 if action == "restart" else 15 + try: + r = subprocess.run( + ["systemctl", action, KLANKER_SERVICE], + capture_output=True, text=True, timeout=timeout, + ) + return { + "ok": r.returncode == 0, + "action": action, + "service": KLANKER_SERVICE, + "rc": r.returncode, + "output": (r.stdout or "").strip(), + "stderr": (r.stderr or "").strip(), + } + except (subprocess.TimeoutExpired, FileNotFoundError, OSError) as exc: + return {"ok": False, "action": action, "service": KLANKER_SERVICE, + "rc": 1, "stderr": str(exc)} + + +# ── journal ───────────────────────────────────────────────────────── +# +# journalctl tail, sanitized like the suite's other raw-output +# helpers (netsec/grafana _sanitize_output pattern): ANSI escapes and +# non-printable control chars are stripped, the output is capped, and +# — defensively — the admin token value is masked if it somehow ends +# up in a log line. + +_ANSI_RE = re.compile(r"\x1b\[[0-9;?]*[a-zA-Z]") +_JOURNAL_MAX_CHARS = 32_768 # 32 KB cap — the panel renders it monospace + + +def _sanitize_journal(text: str) -> str: + """Strip ANSI/control chars, cap length, mask the admin token.""" + if not text: + return "" + text = _ANSI_RE.sub("", text) + text = "".join(c if (32 <= ord(c) < 127 or c in "\t\n\r") else " " for c in text) + if KLANKER_ADMIN_TOKEN: + # NEVER echo the token, even if the service logged it. + text = text.replace(KLANKER_ADMIN_TOKEN, "***") + if len(text) > _JOURNAL_MAX_CHARS: + text = text[:_JOURNAL_MAX_CHARS] + " ... (truncated)" + return text + + +def cmd_journal(args: list[str]) -> dict: + """journalctl -u klanker-gate -n N --no-pager (default 40 lines). + + Usage: journal [N] (range 1-1000) + """ + lines = 40 + if args: + try: + lines = int(args[0]) + except ValueError: + return {"ok": False, "error": f"line count must be an integer between 1 and 1000: {args[0]!r}"} + if not 1 <= lines <= 1000: + return {"ok": False, "error": f"line count must be an integer between 1 and 1000: {lines}"} + if len(args) > 1: + return {"ok": False, "error": f"unknown argument: {args[1]}"} + + if not _have("journalctl"): + return {"ok": False, "error": "journalctl not on PATH"} + try: + r = subprocess.run( + ["journalctl", "-u", "klanker-gate", + "-n", str(lines), "--no-pager"], + capture_output=True, text=True, timeout=10, + ) + log = _sanitize_journal(r.stdout or "") + return { + "ok": r.returncode == 0, + "service": "klanker-gate", + "lines": lines, + "count": log.count("\n") + 1 if log else 0, + "log": log, + "stderr": (r.stderr or "").strip(), + } + except (subprocess.TimeoutExpired, FileNotFoundError, OSError) as exc: + return {"ok": False, "error": str(exc)} + + +# ── local stack wiring (probed) ────────────────────────────────────── + +# The gateway is NOT SaaS-only: ollama / lmstudio / sglang are native +# keyless provider types upstream (packages/contracts/src/provider- +# registry.ts), and llama.cpp (llama-server) / koboldcpp / vLLM / TGI / +# any OpenAI-wire server plug in through the generic "openai-compatible" +# type. This catalog mirrors the web edition's LOCAL_BACKENDS and the +# upstream defaults (packages/providers/src/openai_compat.ts). +LOCAL_BACKENDS = [ + { + "id": "ollama", + "name": "Ollama", + "provider_type": "ollama", + "base_url": "http://127.0.0.1:11434/v1", + "auth": "none", + "caps": "streaming, tools, embeddings", + "env_wiring": "OLLAMA_BASE_URL=http://127.0.0.1:11434/v1\n" + "OLLAMA_MODELS=qwen3:14b,llama3.1:8b,nomic-embed-text", + "note": "native provider type — keyless local daemon", + }, + { + "id": "llamacpp", + "name": "llama.cpp (llama-server)", + "provider_type": "openai-compatible", + "base_url": "http://127.0.0.1:8081/v1", + "auth": "key optional", + "caps": "streaming, tools", + "env_wiring": "OPENAI_COMPAT_BASE_URL=http://127.0.0.1:8081/v1\n" + "OPENAI_COMPAT_DEFAULT_MODEL=qwen2.5-coder-7b", + "note": "llama-server DEFAULTS TO :8080 — the gateway's own port. " + "Run it elsewhere (8081 here) or move the gateway", + }, + { + "id": "koboldcpp", + "name": "KoboldCpp", + "provider_type": "openai-compatible", + "base_url": "http://127.0.0.1:5001/v1", + "auth": "key optional", + "caps": "streaming, tools", + "env_wiring": "OPENAI_COMPAT_BASE_URL=http://127.0.0.1:5001/v1", + "note": "koboldcpp serves the OpenAI wire on its main port", + }, + { + "id": "lmstudio", + "name": "LM Studio", + "provider_type": "lmstudio", + "base_url": "http://127.0.0.1:1234/v1", + "auth": "none", + "caps": "streaming, tools, embeddings", + "env_wiring": "LMSTUDIO_BASE_URL=http://127.0.0.1:1234/v1", + "note": "native provider type", + }, + { + "id": "sglang", + "name": "SGLang", + "provider_type": "sgl", + "base_url": "http://127.0.0.1:30000/v1", + "auth": "none", + "caps": "streaming, tools, embeddings", + "env_wiring": None, + "note": "native provider type — self-hosted serving framework", + }, + { + "id": "vllm", + "name": "vLLM", + "provider_type": "openai-compatible", + "base_url": "http://127.0.0.1:8000/v1", + "auth": "key optional", + "caps": "streaming, tools", + "env_wiring": "OPENAI_COMPAT_BASE_URL=http://127.0.0.1:8000/v1", + "note": "via the generic openai-compatible account", + }, +] + +# Probes run in parallel threads (0.4s timeout each) so the whole +# subcommand answers in well under the 4s bridge budget. +LOCAL_PROBE_TIMEOUT = 0.4 + + +def _probe_one(backend: dict) -> dict: + """GET /models with a 0.4s timeout; offline = reachable:False.""" + url = backend["base_url"].rstrip("/") + "/models" + req = urllib.request.Request(url, headers={"Accept": "application/json"}, method="GET") + started = time.monotonic() + try: + with urllib.request.urlopen(req, timeout=LOCAL_PROBE_TIMEOUT) as resp: + ok = 200 <= resp.status < 300 + except (urllib.error.URLError, OSError, ValueError): + ok = False + out = dict(backend) + out["reachable"] = ok + out["latency_ms"] = round((time.monotonic() - started) * 1000) if ok else None + return out + + +def cmd_localstack(_args: list[str]) -> dict: + """Probe the local AI-stack backends from this host and return the + wiring recipes (provider type, base URL, env / admin-API examples). + + Always LIVE — the probes do not involve the gateway at all: green + means that local daemon answered /v1/models on this machine. This + is the wiring aid for an all-local inference stack (ollama, + llama.cpp, koboldcpp, LM Studio, SGLang, vLLM). + """ + workers = [] + threads = [] + for backend in LOCAL_BACKENDS: + worker = {"backend": backend, "result": None} + workers.append(worker) + + def run(b=backend, w=worker): + w["result"] = _probe_one(b) + + thread = threading.Thread(target=run) + thread.start() + threads.append(thread) + for thread in threads: + thread.join(timeout=LOCAL_PROBE_TIMEOUT + 0.2) + + backends = [w["result"] or {**w["backend"], "reachable": False, "latency_ms": None} for w in workers] + reachable = sum(1 for b in backends if b.get("reachable")) + token_note = "$KLANKER_ADMIN_TOKEN" + example_lines = [ + f"curl -s {KLANKER_URL}/api/providers -H 'Authorization: Bearer {token_note}' " + "-H 'content-type: application/json' " + "-d '{\"id\":\"llama-server\",\"type\":\"openai-compatible\"," + "\"baseUrl\":\"http://127.0.0.1:8081/v1\",\"enabled\":true," + "\"models\":[\"qwen2.5-coder-7b\"]}'", + f"curl -s {KLANKER_URL}/api/providers -H 'Authorization: Bearer {token_note}' " + "-H 'content-type: application/json' " + "-d '{\"id\":\"koboldcpp\",\"type\":\"openai-compatible\"," + "\"baseUrl\":\"http://127.0.0.1:5001/v1\",\"enabled\":true," + "\"models\":[\"mistral-nemo-12b\"]}'", + "# auto-discover the model catalog after registering:", + f"curl -s -X POST {KLANKER_URL}/api/providers/llama-server/refresh-models " + f"-H 'Authorization: Bearer {token_note}'", + ] + return { + "ok": True, + "source": "live", + "backends": backends, + "count": len(backends), + "reachable": reachable, + "base_url": KLANKER_URL, + "admin_register_example": "\n".join(example_lines), + "note": ( + f"probed from this host (0.4s timeout each): {reachable}/{len(backends)} " + "local backends answered /v1/models. ollama + lmstudio register via env; " + "llama.cpp + koboldcpp + vllm register as openai-compatible accounts " + "(env registers ONE such account — use POST /api/providers for several). " + "Port note: llama-server defaults to :8080, the gateway's own port" + ), + } + + +# ── dispatch table ─────────────────────────────────────────────────── + +COMMANDS = { + "status": lambda _args: cmd_status(_args), + "providers": lambda _args: _get("/api/providers"), + "models": lambda _args: _get("/v1/models"), + "vkeys": lambda _args: _get("/api/virtual-keys"), + "logs": cmd_logs, + "analytics": cmd_analytics, + "runtime": lambda _args: _get("/api/runtime"), + "service": cmd_service, + "journal": cmd_journal, + "localstack": cmd_localstack, +} + + +def main(argv: list[str]) -> int: + if not argv or argv[0] in ("-h", "--help"): + print(__doc__) + return 0 + cmd = COMMANDS.get(argv[0]) + if not cmd: + print(f"Unknown subcommand: {argv[0]}", file=sys.stderr) + print(f"Available: {', '.join(sorted(COMMANDS))}", file=sys.stderr) + return 2 + print(json.dumps(cmd(argv[1:]), indent=2)) + return 0 + + +if __name__ == "__main__": + sys.exit(main(sys.argv[1:])) diff --git a/bridge/modules/__pycache__/__init__.cpython-312.pyc b/bridge/modules/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..ca6260ffe99a6d5f906d8a4fd00a0f5df703e2c2 GIT binary patch literal 372 zcmXv~yG{c!5IlznB1`!Jsx3*uT^|rqfQE=i5h@z%@GW;HKHIYQ1V_z3@C|$me~^}j z27yRNg-;iz*`3wQXfqqw{zMRJq0Y%+NI;EOh#qFlm0 z9D7xi2p6_!GzLCvsIqMX6zyQ+9rBjp!mp>S|v!2&B5ZGwXyRZO-@+e8S2S61Dgt~tW z@KHr!bUX7ow30v(5QCKO&YqIX@_}%T_$~M87 j5XvYLB6aO*bl6{y{C1Rf^@Xn8?=8mI&tQiQ{V>u$UG{Wj literal 0 HcmV?d00001 diff --git a/bridge/packages.py b/bridge/packages.py index 2d96af1..cd3c428 100755 --- a/bridge/packages.py +++ b/bridge/packages.py @@ -295,6 +295,34 @@ def info(args: list[str]) -> dict[str, Any]: return fn(args) if fn else {} +def _pkg_name_ok(pkg: str) -> bool: + """v0.1.4 SECURITY: package names are passed to the system package + manager as one argv element. A leading dash turns them into manager + OPTIONS (pacman --config=…, dnf --setopt=…) and a URL makes dnf + fetch a remote RPM — argument injection, not shell injection. One + safe component: no leading dash, no whitespace/control chars, no + URL scheme, bounded length.""" + return ( + isinstance(pkg, str) + and 0 < len(pkg) <= 256 + and not pkg.startswith("-") + and "://" not in pkg + and not re.search(r"[\s\x00\x1b]", pkg) + ) + + +def _first_pkg_arg(args: list[str]) -> str | None: + """v0.1.4 FIX: firewall.py's install-backend used to call this + helper as `packages.py install -- ` (a `--` argv separator, + shell convention) — install() read args[0] == '--' and the + backend-install path has been broken since it shipped. Skip any + leading '--' separators instead of choking on them.""" + for a in args: + if a != "--": + return a + return None + + def install(args: list[str]) -> dict[str, str]: """Install a package — actually runs the package manager via subprocess. @@ -315,7 +343,14 @@ def install(args: list[str]) -> dict[str, str]: """ if not args: return {"error": "No package name provided"} - pkg = args[0] + pkg = _first_pkg_arg(args) + if not pkg: + return {"error": "No package name provided"} + if not _pkg_name_ok(pkg): + return {"error": f"invalid package name: {pkg!r}"} + # _pkg_name_ok already rejects leading-dash/URL names (argument + # injection), so no '--' end-of-options separator is needed here — + # pacman in particular does not accept one. cmd_map = {"pacman": ["pacman", "-S", "--noconfirm", pkg], "dnf": ["dnf", "install", "-y", pkg], "apt": ["apt", "install", "-y", pkg]} @@ -336,7 +371,11 @@ def remove(args: list[str]) -> dict[str, str]: """Remove a package — actually runs the package manager. See install().""" if not args: return {"error": "No package name provided"} - pkg = args[0] + pkg = _first_pkg_arg(args) + if not pkg: + return {"error": "No package name provided"} + if not _pkg_name_ok(pkg): + return {"error": f"invalid package name: {pkg!r}"} cmd_map = {"pacman": ["pacman", "-R", "--noconfirm", pkg], "dnf": ["dnf", "remove", "-y", pkg], "apt": ["apt", "remove", "-y", pkg]} @@ -354,7 +393,11 @@ def update(args: list[str]) -> dict[str, str]: """Update a package — actually runs the package manager. See install().""" if not args: return {"error": "No package name provided"} - pkg = args[0] + pkg = _first_pkg_arg(args) + if not pkg: + return {"error": "No package name provided"} + if not _pkg_name_ok(pkg): + return {"error": f"invalid package name: {pkg!r}"} cmd_map = {"pacman": ["pacman", "-S", "--noconfirm", pkg], "dnf": ["dnf", "upgrade", "-y", pkg], "apt": ["apt", "upgrade", "-y", pkg]} diff --git a/bridge/policy.py b/bridge/policy.py index 3778eb1..abc9e2b 100755 --- a/bridge/policy.py +++ b/bridge/policy.py @@ -245,6 +245,28 @@ def cmd_acl_default(args: list[str]) -> dict[str, Any]: CGROUP_ROOT = Path("/sys/fs/cgroup") +def _cgroup_path_ok(path: Path) -> bool: + """v0.1.4 SECURITY: True if (resolved) path stays inside the unified + cgroup hierarchy. cmd_cgroup_create has always enforced a prefix + check, but the cgroup-* siblings didn't — cgroup-set wrote to + `/` as root (arbitrary file overwrite: + `cgroup-set /etc/cron.d x '* * * * * root curl ...'` was a one-prompt + persistent-root primitive; found by the 0.3.0 security audit). All + cgroup subcommands now resolve + bound-check the same way.""" + try: + path.resolve().relative_to(CGROUP_ROOT.resolve()) + return True + except (ValueError, RuntimeError, OSError): + return False + + +# v0.1.4 SECURITY: cgroup control files are a closed vocabulary (cgroup.* +# + controller knobs). Restricting the filename to this shape blocks +# using cgroup-set as an arbitrary-named file writer. +_CGROUP_CTRL_RE = re.compile(r"^(cgroup\.(procs|controllers|subtree_control|type|freeze|kill)|" + r"(memory|cpu|io|pids|rdma|misc|hugetlb)\.[A-Za-z0-9_.-]{1,32})$") + + def _cgroup_v2_available() -> bool: """True if /sys/fs/cgroup/ is a cgroups v2 unified hierarchy.""" return (CGROUP_ROOT / "cgroup.controllers").is_file() @@ -315,6 +337,11 @@ def cmd_cgroup_show(args: list[str]) -> dict[str, Any]: if not args: return {"error": "cgroup path required"} path = Path(args[0]) + # v0.1.4 SECURITY: cgroup paths are client-supplied; every cgroup + # subcommand must keep them inside the unified hierarchy — see the + # _cgroup_path_ok guard comment below cmd_cgroup_create. + if not _cgroup_path_ok(path): + return {"error": f"cgroup path must be under {CGROUP_ROOT}"} if not path.is_dir(): return {"error": f"{path} is not a directory"} info: dict[str, Any] = {"path": str(path), "name": path.name} @@ -356,6 +383,8 @@ def cmd_cgroup_procs(args: list[str]) -> dict[str, Any]: """List PIDs in a cgroup (just the PIDs, no metadata).""" if not args: return {"error": "cgroup path required"} + if not _cgroup_path_ok(Path(args[0])): + return {"error": f"cgroup path must be under {CGROUP_ROOT}"} procs_file = Path(args[0]) / "cgroup.procs" if not procs_file.is_file(): return {"error": f"{procs_file} not found"} @@ -373,7 +402,9 @@ def cmd_cgroup_create(args: list[str]) -> dict[str, Any]: path = Path(args[0]) if not _cgroup_v2_available(): return {"available": False, "reason": "cgroups v2 not mounted"} - if not str(path).startswith(str(CGROUP_ROOT)): + # v0.1.4 SECURITY: upgraded from a str().startswith() prefix check + # (which "/sys/fs/cgroup-evil" would satisfy) to resolve + bound. + if not _cgroup_path_ok(path): return {"error": f"cgroup path must be under {CGROUP_ROOT}"} try: path.mkdir(parents=True, exist_ok=False) @@ -390,6 +421,8 @@ def cmd_cgroup_move(args: list[str]) -> dict[str, Any]: if len(args) < 2: return {"error": "usage: cgroup-move "} pid, cgrp = args[0], args[1] + if not _cgroup_path_ok(Path(cgrp)): + return {"error": f"cgroup path must be under {CGROUP_ROOT}"} procs_file = Path(cgrp) / "cgroup.procs" if not procs_file.is_file(): return {"error": f"{procs_file} not found"} @@ -407,6 +440,16 @@ def cmd_cgroup_set(args: list[str]) -> dict[str, Any]: if len(args) < 3: return {"error": "usage: cgroup-set "} path, control, value = args[0], args[1], args[2] + # v0.1.4 SECURITY: this command writes as root. Both halves of the + # target are client-supplied, so BOTH are validated: the cgroup path + # must resolve under /sys/fs/cgroup (was missing — arbitrary root + # file overwrite, see _cgroup_path_ok) and the control file must be + # a real cgroup controller knob name, not a traversal/probe. + if not _cgroup_path_ok(Path(path)): + return {"error": f"cgroup path must be under {CGROUP_ROOT}"} + if not _CGROUP_CTRL_RE.match(control): + return {"error": f"'{control}' is not a cgroup control file name " + "(expected e.g. memory.max, cpu.weight, cgroup.procs)"} # control is a filename like 'memory.max' or 'cpu.weight' target = Path(path) / control if not target.parent.is_dir(): diff --git a/bridge/themes.py b/bridge/themes.py index 7261436..11e7d66 100755 --- a/bridge/themes.py +++ b/bridge/themes.py @@ -292,10 +292,23 @@ def cmd_set(args: list[str]) -> dict[str, Any]: """Set one key in cockpit.conf. Usage: set

. Creates the section if absent. + + v0.1.4 SECURITY: section/key/value arrive as raw argv and are + serialized into /etc/cockpit/cockpit.conf with naive `f"{k} = {v}" + lines. A value containing a newline could inject whole new + sections/keys into cockpit.conf ([WebService]/[Session] knobs) the + next time cockpit parses it (found by the 0.3.0 security audit). + Newlines, NULs, brackets in section names and '=' in keys are now + rejected; write-config remains the operator's explicit raw editor. """ if len(args) < 3: return {"error": "usage: set
"} section, key, value = args[0], args[1], args[2] + if re.search(r"[\r\n\0]", section + key + value) or re.search(r"[\[\]]", section): + return {"error": "refusing to set: section/key/value must be single-line " + "(no newlines, no NULs; no brackets in section names)"} + if "=" in key: + return {"error": "refusing to set: key must not contain '='"} text = _read_text() sections = _parse_conf(text) sections.setdefault(section, {})[key] = value diff --git a/compat/compat-manifest.json b/compat/compat-manifest.json index fd8a180..36b034c 100755 --- a/compat/compat-manifest.json +++ b/compat/compat-manifest.json @@ -1,6 +1,6 @@ { "_comment": "Compatibility Manifest — sysdeck v0.1.3", - "version": "0.2.0", + "version": "0.4.1", "suite_requires": { "cockpit": ">=239", "python": ">=3.9" }, "modules": { "containers": { diff --git a/klanker-gate/.dockerignore b/klanker-gate/.dockerignore new file mode 100755 index 0000000..6299a5d --- /dev/null +++ b/klanker-gate/.dockerignore @@ -0,0 +1,11 @@ +# Docker build context excludes (context root = frosty-deno). +# The Control UI is built inside the image (multi-stage Dockerfile), so host +# node_modules (Windows/Linux platform mismatch) and any prebuilt / host-locked +# dist must never enter the build context. Keeping these out also fixes the +# Windows "EPERM rm" failure that blocks a clean host `deno task build-ui`. +**/node_modules +apps/control-ui/dist +apps/control-ui/dist_new +data +*.log +.env diff --git a/klanker-gate/.editorconfig b/klanker-gate/.editorconfig new file mode 100755 index 0000000..7223b34 --- /dev/null +++ b/klanker-gate/.editorconfig @@ -0,0 +1,9 @@ +root = true + +[*] +insert_final_newline = false +end_of_line = lf +charset = utf-8 + +[*.{js,jsx,ts,tsx,mjs,json,md,css,scss,html}] +insert_final_newline = false diff --git a/klanker-gate/.env.example b/klanker-gate/.env.example new file mode 100755 index 0000000..9325a64 --- /dev/null +++ b/klanker-gate/.env.example @@ -0,0 +1,328 @@ +# ============================================================================= +# Frosty Deno - full configuration surface +# ============================================================================= +# Every operator knob the gateway reads, grouped the same way as +# docs/reference/environment-variables.md so the two can be diffed. For the +# smallest config that boots, use `.env.example.dev` instead. +# +# cp .env.example .env +# docker compose up -d postgres # REQUIRED (section 4) +# deno task setup && deno task dev +# +# Conventions in this file: +# * A blank value means "leave the feature off" - it is never a placeholder to +# be filled in blindly. Secrets ship blank on purpose. +# * A value that IS filled in is either a real default or an inert format +# example (a URL shape, a model list), safe to copy as-is. +# * Anything absent from this file has a safe default. Adding a new knob means +# a bounded parse, a row in the reference doc above, and a line here. +# +# Sections +# 1 Provider credentials and provider catalogs +# 2 Azure OpenAI, Bedrock, and Vertex AI +# 3 Generic compatible endpoints +# 4 Core gateway and PostgreSQL <- required +# 5 Worker topology and shared governance +# 6 Admin protection and origin control +# 7 Cache and vector store +# 8 MCP and Code Mode +# 9 Logging, analytics display, and observability +# 10 Pricing sync, encryption, plugins, HTTP client +# 11 Not operator knobs (harness, migration, supervisor-set) + +# ============================================================================= +# 1. Provider credentials and provider catalogs +# ============================================================================= +# Any subset. A provider registers itself at boot when its credential is set, +# and stays absent otherwise, so a blank key is a supported state rather than a +# broken one. Persisted config from /api/providers overlays these and WINS on an +# id collision. + +# OpenAI-wire vendors. +OPENAI_API_KEY= +ANTHROPIC_API_KEY= +GEMINI_API_KEY= +OPENROUTER_API_KEY= +GROQ_API_KEY= +MISTRAL_API_KEY= +XAI_API_KEY= +PERPLEXITY_API_KEY= +CEREBRAS_API_KEY= +NEBIUS_API_KEY= +PARASAIL_API_KEY= + +# Native adapters (their own wire formats, translated to canonical internally). +HF_TOKEN= +COHERE_API_KEY= +# Audio only: /v1/audio/speech and /v1/audio/transcriptions. +ELEVENLABS_API_KEY= + +# Ollama needs no key; it registers on BASE_URL alone. MODELS is a +# comma-separated catalog, because Ollama's tag list is host-specific. +OLLAMA_BASE_URL= +OLLAMA_MODELS= + +# ============================================================================= +# 2. Azure OpenAI, Bedrock, and Vertex AI +# ============================================================================= +# These three do not take a bare API key alone: each needs its own coordinates +# before a model name can be resolved to an endpoint. + +# Azure routes per DEPLOYMENT, not per model, so the deployment list is what +# makes models addressable. Comma-separated. +AZURE_OPENAI_API_KEY= +AZURE_OPENAI_ENDPOINT=https://my-resource.openai.azure.com +AZURE_OPENAI_API_VERSION=2024-02-15-preview +AZURE_OPENAI_DEPLOYMENTS=gpt-4o-deployment,gpt-35-deployment + +# AWS Bedrock (SigV4). SESSION_TOKEN only for temporary credentials. +AWS_REGION=us-east-1 +AWS_ACCESS_KEY_ID= +AWS_SECRET_ACCESS_KEY= +AWS_SESSION_TOKEN= +BEDROCK_MODELS= + +# Vertex AI. The service-account JSON goes in as ONE line, quotes intact. +VERTEX_PROJECT_ID= +VERTEX_LOCATION=us-central1 +VERTEX_SERVICE_ACCOUNT_JSON= +VERTEX_MODELS=gemini-2.5-pro + +# ============================================================================= +# 3. Generic compatible endpoints +# ============================================================================= +# Point Frosty at any OpenAI-wire or Anthropic-wire server. Each stays off until +# its BASE_URL is set; the API key is optional because many local servers take +# none. + +# Any OpenAI-wire server (vLLM, llama.cpp, TGI, ...). Id: `openai-compatible`. +OPENAI_COMPAT_BASE_URL= +OPENAI_COMPAT_API_KEY= +OPENAI_COMPAT_DEFAULT_MODEL= + +# Any Anthropic Messages-wire server. Id: `anthropic-compatible`. +ANTHROPIC_COMPAT_BASE_URL= +ANTHROPIC_COMPAT_API_KEY= +ANTHROPIC_COMPAT_DEFAULT_MODEL= + +# LM Studio (local OpenAI-compatible server; its default base URL shown). +LMSTUDIO_BASE_URL=http://localhost:1234/v1 +LMSTUDIO_API_KEY= +LMSTUDIO_DEFAULT_MODEL= + +# ============================================================================= +# 4. Core gateway and PostgreSQL +# ============================================================================= +PORT=8080 + +# Provider account used for model names without a `provider/` prefix. Must match +# a registered id from section 1-3. +FROSTY_DEFAULT_PROVIDER=openai + +# --- PostgreSQL: the gateway's ONE stateful dependency, and it is REQUIRED ---- +# Holds the control-plane config, governance counters, the request-log trail, +# the L2 response cache, and the pgvector embedding index. There is no fallback: +# an unreachable or unset URL ABORTS BOOT rather than serving with empty +# governance state (decision-log 61). Deno KV, which used to hold this, is +# retired - migrate an existing data/frosty.kv with +# `deno task migrate:kv-pg -- --commit`. +# +# docker compose up -d postgres +# +# Credentials are required; the Compose service sets user/password/db to +# `frosty`. Use `localhost` from the host, `postgres` from inside Compose. +FROSTY_PG_URL=postgres://frosty:frosty@localhost:5432/frosty + +# Session-stable connection used ONLY for LISTEN (cross-process cache +# invalidation). Defaults to FROSTY_PG_URL, which is correct until a pooler sits +# in between: LISTEN through PgBouncer's transaction mode stops delivering +# SILENTLY. With `--profile pgbouncer` up, point FROSTY_PG_URL at :6432 and +# leave this one on :5432. +FROSTY_PG_DIRECT_URL= + +# Connections held PER PROCESS (default 8, max 100). The number PostgreSQL sees +# is this times FROSTY_WORKERS, plus one LISTEN connection per process. +FROSTY_PG_POOL_SIZE= + +# Table holding the semantic cache's embedding vectors. +FROSTY_PG_TABLE=frosty_vectors + +# ============================================================================= +# 5. Worker topology and shared governance +# ============================================================================= +# Worker processes sharing one port through SO_REUSEPORT. Unset or 1 = single +# process. LINUX/macOS ONLY - Windows has no SO_REUSEPORT and the second bind +# fails with os error 10048, so the gateway logs why and serves single-process +# instead. See docs/guides/multi-process.md. +FROSTY_WORKERS= + +# Fleet-wide rate limiting. Fixed rate/token windows live in an in-process Map +# by default, which is exact for ONE process and admits N times the limit across +# N. `auto` (default) moves them to a shared PostgreSQL authority only when +# FROSTY_WORKERS>1, because that reservation costs ~1.8 ms per governed request +# versus ~1 us in memory. Set `on` when running separate REPLICAS (auto cannot +# see those); `off` accepts N-times-the-limit. auto|on|off +FROSTY_SHARED_RATE_LIMIT= + +# How often each process re-reads durable config, in ms (0-3600000, 0 disables). +# Config changes normally arrive over LISTEN/NOTIFY within milliseconds; this +# poll is the backstop that bounds staleness when a notification is lost, so a +# revoked virtual key stops working even then. Default 30000. +FROSTY_CONFIG_RECONCILE_MS= + +# ============================================================================= +# 6. Admin protection and origin control +# ============================================================================= +# Unset = explicit local admin mode (no auth on /api/*). Set to require +# "Authorization: Bearer " on all /api/* config routes. +FROSTY_ADMIN_TOKEN= + +# Admin origin guard allow-list (DNS-rebind defense). localhost, 127.0.0.1, and +# ::1 are always allowed; add your public host(s) here (comma-separated) when +# the gateway is reachable beyond localhost. Applies to every /api/* request. +FROSTY_ALLOWED_HOSTS= + +# ============================================================================= +# 7. Cache and vector store +# ============================================================================= +# Unset = off, "exact" = exact-match, "semantic" adds embedding similarity via +# the embed model below (the provider must support embeddings). +FROSTY_CACHE= +FROSTY_CACHE_TTL_MS= + +# Sent VERBATIM and case-sensitive, so it must match an id the provider serves +# (check GET /v1/models). Lookups are fail-open, so a wrong id reduces the cache +# to exact-match only; every failed lookup warns `semantic cache lookup degraded +# to miss`, and frosty_cache_events_total stays at 100% result="miss". +FROSTY_CACHE_EMBED_MODEL=text-embedding-3-small + +# Similarity vectors live in-process unless FROSTY_VECTOR_STORE=pgvector puts +# them in the same PostgreSQL as everything else, which is also what makes them +# survive a restart. The Redis (RediSearch) option was removed: Compose +# provisioned it and no shipped configuration ever selected it, so it was a +# dependency that served zero requests (decision-log 60). +FROSTY_VECTOR_STORE= + +# ============================================================================= +# 8. MCP and Code Mode +# ============================================================================= +# MCP client transports are configured per server via POST /api/mcp/clients +# ("transport": "http-sse" (default) | "streamable-http" | "auto" | "stdio"). +# Servers that only speak streamable-http need an explicit transport (or "auto" +# for spec-order negotiation). +# +# stdio (subprocess) MCP servers are DISABLED by default. Enabling them requires +# BOTH this flag ("1" or "true") AND running with --allow-run, which is outside +# the standard permission set on purpose (see permissions.md). +FROSTY_MCP_ALLOW_STDIO= + +# Background MCP health sweeps (0/unset = on-demand via /api/mcp/health only). +FROSTY_MCP_HEALTH_INTERVAL_MS= + +# Code Mode is default-off behind TWO independent gates. The VFS metadata +# surface is live and inert; the sandboxed executor is HARD-OFF and +# experimental. off|on (default off). The executor additionally requires a +# per-request `x-frosty-code-mode: run` header and still refuses (501) because +# the run primitive is intentionally stubbed. +FROSTY_CODE_MODE=off +FROSTY_CODE_MODE_VFS=on + +# ============================================================================= +# 9. Logging, analytics display, and observability +# ============================================================================= +# Durable request-log store, in the same PostgreSQL as the rest of the state. ON +# by default. Disable with FROSTY_LOG_STORE=off. The legacy value `kv` is still +# accepted and means "on" - it named the retired Deno KV backend, and rejecting +# it would break existing .env files over a backend that is gone. +FROSTY_LOG_STORE=pg +FROSTY_LOG_STORE_MAX=5000 + +# Paths kept OUT of the Logs dashboard trail (live stream + durable store). The +# container healthcheck and the Prometheus scrape hit the gateway on a fixed +# interval, so without this they accumulate until they are ~99% of the capped +# trail and real requests get pruned away. Console access logging is NOT +# affected: `docker logs` still shows every request. Blank uses the default +# below; set to `off` to log everything; `/prefix/*` matches a subtree. +FROSTY_LOG_EXCLUDE_PATHS=/healthz,/metrics,/favicon.ico + +# Request/response CONTENT capture in the durable log store (OPT-IN; default OFF +# for privacy; secrets are never captured). on to enable. +FROSTY_LOG_CONTENT= + +# Display currency: cost is accounted in USD internally; the Control UI presents +# euros by multiplying by this EUR-per-USD rate (default 0.92). Operator-only. +FROSTY_EUR_RATE=0.92 + +# OpenTelemetry OTLP/HTTP trace export. Unset = off. For Docker Compose use +# http://otel-collector:4318 and start `--profile observability`; spans go to +# Tempo for drill-down and to Prometheus as RED metrics. +OTEL_EXPORTER_OTLP_ENDPOINT= +OTEL_FLUSH_INTERVAL_MS=5000 + +# Distinct models admitted as the `frosty.metrics.model` span-metric label +# before the rest fold to "other". Bounds Prometheus series growth; traces keep +# the real model either way. Non-positive/unparseable falls back to the default. +FROSTY_OTEL_MODEL_CARDINALITY_CAP=11 + +# ============================================================================= +# 10. Pricing sync, encryption, plugins, HTTP client +# ============================================================================= +# LiteLLM pricing sync. Opt-in and DEFAULT OFF (offline/no-outbound default): +# set FROSTY_PRICING_SYNC=on to fetch model prices + metadata at boot and +# refresh on an interval. Operator /api/pricing overrides always win over synced +# prices. POST /api/pricing/force-sync triggers a sync on demand even when this +# is off. +FROSTY_PRICING_SYNC= +# Refresh cadence (ms); default 24h, floored at 60s. +FROSTY_PRICING_SYNC_INTERVAL_MS=86400000 +# Source URL for the price list (server-side only; never taken from a request). +FROSTY_PRICING_URL=https://raw.githubusercontent.com/BerriAI/litellm/main/model_prices_and_context_window.json + +# --- Config secret encryption-at-rest (OPT-IN; default OFF = plaintext) ------- +# Set a base64-encoded 32-byte key (preferred) OR a strong passphrase (PBKDF2). +# When set, provider API keys / AWS secret+session / Vertex SA-JSON / proxy +# password / CA cert / virtual-key tokens / MCP header values are AES-256-GCM +# encrypted in PostgreSQL. Fail-closed: once a store has encrypted data, an +# unset or wrong key REFUSES boot. The key is effectively set-once (rotate via +# _OLD). +# Generate: `deno eval "console.log(btoa(String.fromCharCode(...crypto.getRandomValues(new Uint8Array(32)))))"` +FROSTY_ENCRYPTION_KEY= +# Rotation only: set to the previous key alongside a new FROSTY_ENCRYPTION_KEY +# to rewrap the data-encryption key offline (data is not re-encrypted). +FROSTY_ENCRYPTION_KEY_OLD= + +# JSON-repair plugin (OPT-IN; default OFF). Repairs invalid-JSON model output +# post-response and, for streams, post-completion via the reconstructed message +# (the live client stream is never mutated). on|1|true|yes to enable. +FROSTY_JSON_REPAIR= + +# Request mocker: short-circuits upstream calls with synthetic responses, for +# offline dev/demo/load-testing. OPT-IN and OFF unless set to on|1|true|yes - +# leave it empty for a realistic deployment. Rules come from +# FROSTY_MOCKER_CONFIG (inline JSON starting with `{`, or a file path). +FROSTY_MOCKER= +FROSTY_MOCKER_CONFIG= + +# Provider HTTP client: default per-request timeout in ms (default 120000; 0 +# disables). Never total-caps an in-progress SSE/eventstream. FROSTY_NO_PROXY +# takes comma-separated bypass patterns (*, .example.com, *.example.com, or an +# exact host). +FROSTY_HTTP_TIMEOUT_MS=120000 +FROSTY_NO_PROXY= + +# ============================================================================= +# 11. Not operator knobs +# ============================================================================= +# Listed for parity with the code, so a reader who greps for one of these finds +# out why it is not above. Do NOT set these in a deployment .env. +# +# FROSTY_BASE_URL target for scripts/full_suite.ts and the browser +# harness; defaults to http://localhost:8080 +# FROSTY_BENCH_TARGET scripts/load-bench.ts only +# FROSTY_BENCH_KEY scripts/load-bench.ts only +# FROSTY_BENCH_MODEL scripts/load-bench.ts only +# FROSTY_KV_PATH read ONLY by scripts/migrate_kv_to_pg.ts, to find an +# existing data/frosty.kv to migrate. It configures +# nothing at runtime; Deno KV is retired. +# FROSTY_WORKER_ROLE set BY the supervisor on each child it spawns +# FROSTY_WORKER_INDEX set BY the supervisor on each child it spawns diff --git a/klanker-gate/.env.example.dev b/klanker-gate/.env.example.dev new file mode 100755 index 0000000..b56a765 --- /dev/null +++ b/klanker-gate/.env.example.dev @@ -0,0 +1,71 @@ +# ============================================================================= +# Frosty Deno - minimal local development configuration +# ============================================================================= +# cp .env.example.dev .env +# docker compose up -d postgres # REQUIRED, see below +# deno task setup # one-time +# deno task dev # gateway on http://localhost:8080 +# +# Everything omitted here has a safe default, so this is the smallest config +# that boots a useful gateway. For the full surface copy `.env.example` and read +# docs/reference/environment-variables.md. + +# --- REQUIRED: PostgreSQL ----------------------------------------------------- +# The gateway keeps ALL durable state here (control-plane config, governance +# counters, the request-log trail, the L2 response cache, the pgvector index) +# and refuses to boot without it - `FROSTY_PG_URL is required` (decision-log +# 61). There is no embedded fallback; Deno KV used to hold this and is retired. +# +# docker compose up -d postgres +# +# The Compose service sets user/password/db to `frosty`. Use `localhost` when +# the gateway runs on the host (`deno task dev`); inside Compose the gateway +# service already defaults itself to the `postgres` hostname. +FROSTY_PG_URL=postgres://frosty:frosty@localhost:5432/frosty + +# --- REQUIRED: one provider + the default route ------------------------------- +# The gateway boots with no key, but has nothing to route to. Set ONE of the +# blocks below and point FROSTY_DEFAULT_PROVIDER at its id. + +# a) A hosted vendor. +OPENAI_API_KEY= +# ANTHROPIC_API_KEY= + +# b) Any OpenAI-compatible server (vLLM, llama.cpp, LM Studio, TGI, ...). +# Registers as provider id `openai-compatible`. +# OPENAI_COMPAT_BASE_URL=http://localhost:8000/v1 +# OPENAI_COMPAT_API_KEY= +# OPENAI_COMPAT_DEFAULT_MODEL= + +# Provider used for model names without a `provider/` prefix. Must match an id +# above (`openai`, `anthropic`, `openai-compatible`, ...). +FROSTY_DEFAULT_PROVIDER=openai + +# --- Gateway basics (optional; defaults shown) -------------------------------- +PORT=8080 +# Unset = admin API open on localhost, which is what local dev wants. Set it to +# require `Authorization: Bearer ` on every /api/* route. +FROSTY_ADMIN_TOKEN= + +# --- OPTIONAL: response cache ------------------------------------------------- +# Unset = off. "exact" needs nothing beyond the Postgres above. "semantic" also +# needs an embedding model, and FROSTY_VECTOR_STORE=pgvector to keep the vectors +# in that same database rather than in-process (so they survive a restart). +# +# FROSTY_CACHE=semantic +# FROSTY_VECTOR_STORE=pgvector +# +# One thing that fails SILENTLY here, because cache lookups are fail-open: an +# embed model id the provider does not serve 404s on every lookup. The id below +# is sent verbatim and is case-sensitive - check GET /v1/models. Boot logs +# `semantic cache lookup degraded to miss: ...` when it is wrong. +# FROSTY_CACHE_EMBED_MODEL=text-embedding-3-small + +# --- OPTIONAL: metrics and traces --------------------------------------------- +# `GET /metrics` is always on and needs nothing. Traces need a collector: +# +# docker compose --profile observability up -d +# +# then set the endpoint below (it is deliberately not defaulted, because without +# that profile the host does not resolve). Grafana lands on http://localhost:3000. +# OTEL_EXPORTER_OTLP_ENDPOINT=http://otel-collector:4318 diff --git a/klanker-gate/.gitattributes b/klanker-gate/.gitattributes new file mode 100755 index 0000000..7310309 --- /dev/null +++ b/klanker-gate/.gitattributes @@ -0,0 +1,21 @@ +# Force LF line endings in the repository AND on checkout across every platform. +# `eol=lf` (not just `text=auto`) is what keeps Windows working trees from +# drifting to CRLF, which `deno fmt` rejects. This is the authoritative codebase +# line-ending rule; `.editorconfig` mirrors it for editors. +* text=auto eol=lf + +# Shell scripts must always be LF (kept explicit for clarity). +*.sh text eol=lf + +# Binary assets: never normalize EOL, never diff as text. +*.kv binary +*.kv-shm binary +*.kv-wal binary +*.png binary +*.jpg binary +*.jpeg binary +*.gif binary +*.ico binary +*.webp binary +*.woff binary +*.woff2 binary diff --git a/klanker-gate/.github/CODEOWNERS b/klanker-gate/.github/CODEOWNERS new file mode 100755 index 0000000..1236e14 --- /dev/null +++ b/klanker-gate/.github/CODEOWNERS @@ -0,0 +1,75 @@ +# CODEOWNERS - review ownership for Frosty Deno (klanker-gate) +# +# Each line maps a path pattern to one or more owners. The LAST matching pattern +# for a changed file wins. Owners are auto-requested for review on pull requests +# that touch their paths (requires the repository to be on GitHub and the owners +# to have write access). +# +# NOTE: the handles below are PLACEHOLDERS. Replace @frosty-maintainers and the +# team handles with the real GitHub users or teams for this repository before +# relying on auto-review. Do not leave placeholder handles in a live repo - an +# unresolvable owner silently disables review requests for that path. + +# --------------------------------------------------------------------------- +# Default owner for everything not matched more specifically below. +# --------------------------------------------------------------------------- +* @frosty-maintainers + +# --------------------------------------------------------------------------- +# Gateway composition root and HTTP surface (middleware onion, routing, context). +# --------------------------------------------------------------------------- +/apps/gateway/ @frosty-maintainers +/apps/gateway/main.ts @frosty-maintainers +/apps/gateway/context.ts @frosty-maintainers + +# --------------------------------------------------------------------------- +# Control-plane SPA (design system ds-r2, same-origin, no external origins). +# --------------------------------------------------------------------------- +/apps/control-ui/ @frosty-maintainers +/apps/control-ui/src/styles/ @frosty-maintainers +/apps/control-ui/CONVENTIONS.md @frosty-maintainers + +# --------------------------------------------------------------------------- +# Core pipeline: canonical translation, streaming, router, orchestrator. +# --------------------------------------------------------------------------- +/packages/core/ @frosty-maintainers + +# Provider adapters (one family per vendor; wire translation). +/packages/providers/ @frosty-maintainers + +# Governance: virtual keys, hierarchy, budgets, pricing, rate limiting. +/packages/governance/ @frosty-maintainers + +# Cache, MCP + Code Mode, telemetry, config/secrets, plugins, contracts. +/packages/cache/ @frosty-maintainers +/packages/mcp/ @frosty-maintainers +/packages/telemetry/ @frosty-maintainers +/packages/config/ @frosty-maintainers +/packages/plugins/ @frosty-maintainers +/packages/contracts/ @frosty-maintainers + +# --------------------------------------------------------------------------- +# Security-sensitive surfaces: crypto-at-rest, origin guard, admin auth, +# permission contract. Changes here warrant extra scrutiny. +# --------------------------------------------------------------------------- +/packages/config/src/crypto.ts @frosty-maintainers +/apps/gateway/routes/origin-guard.ts @frosty-maintainers +/apps/gateway/routes/admin.ts @frosty-maintainers +/permissions.md @frosty-maintainers +/SECURITY.md @frosty-maintainers + +# --------------------------------------------------------------------------- +# Infrastructure, deployment and observability. +# --------------------------------------------------------------------------- +/Dockerfile @frosty-maintainers +/docker-compose.yml @frosty-maintainers +/deploy/ @frosty-maintainers +/.github/ @frosty-maintainers + +# --------------------------------------------------------------------------- +# Documentation and decision records. +# --------------------------------------------------------------------------- +/docs/ @frosty-maintainers +/docs/contracts/decision-log.md @frosty-maintainers +/CLAUDE.md @frosty-maintainers +/AGENTS.md @frosty-maintainers diff --git a/klanker-gate/.github/ISSUE_TEMPLATE/bug_report.yml b/klanker-gate/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100755 index 0000000..08a9e3a --- /dev/null +++ b/klanker-gate/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,129 @@ +name: Bug report +description: Report a problem or regression in Bifrost +title: "[Bug]: " +labels: [bug] +assignees: [] +body: + - type: markdown + attributes: + value: | + Thanks for taking the time to fill out a bug report! Please provide as much detail as possible. + + - type: checkboxes + id: prerequisites + attributes: + label: Prerequisites + options: + - label: I have searched existing issues and discussions to avoid duplicates + required: true + - label: I am using the latest version (or have tested against main/nightly) + required: false + + - type: textarea + id: description + attributes: + label: Description + description: What happened? Include screenshots if helpful. + placeholder: Clear and concise description of the bug + validations: + required: true + + - type: textarea + id: reproduction + attributes: + label: Steps to reproduce + description: Provide a minimal, reproducible example. Link to a repo, gist, or include exact steps. + placeholder: | + 1. Go to '...' + 2. Run '...' + 3. Observe '...' + validations: + required: true + + - type: input + id: expected + attributes: + label: Expected behavior + placeholder: What did you expect to happen? + validations: + required: true + + - type: input + id: actual + attributes: + label: Actual behavior + placeholder: What actually happened? + validations: + required: true + + - type: dropdown + id: area + attributes: + label: Affected area(s) + multiple: true + options: + - Core (Go) + - Framework + - Transports (HTTP) + - Plugins + - UI (Next.js) + - Docs + validations: + required: true + + - type: input + id: version + attributes: + label: Version + description: Affected version(s). + placeholder: e.g., v1.0.3 + validations: + required: true + + - type: textarea + id: env + attributes: + label: Environment + description: Include as many as apply. + placeholder: | + - OS: macOS 14.5, Linux x.y, Windows 11 + - Go: 1.22.x + - Node: 20.x, npm/pnpm/yarn version + - Browser (if UI): Chrome/Firefox/Safari versions + - Bifrost components and versions (core, transports, ui) + - Any relevant environment flags/config + render: text + validations: + required: false + + - type: textarea + id: logs + attributes: + label: Relevant logs/output + description: Paste error logs, stack traces, or console output. + render: shell + placeholder: | + + validations: + required: false + + - type: input + id: regression + attributes: + label: Regression? + description: If this worked in a previous version, which version? + placeholder: e.g., Worked in v0.8.0, broke in v0.9.0 + validations: + required: false + + - type: dropdown + id: severity + attributes: + label: Severity + options: + - Low (minor issue or cosmetic) + - Medium (some functionality impaired) + - High (major functionality broken) + - Critical (blocks releases or production) + validations: + required: true diff --git a/klanker-gate/.github/ISSUE_TEMPLATE/config.yml b/klanker-gate/.github/ISSUE_TEMPLATE/config.yml new file mode 100755 index 0000000..3ba13e0 --- /dev/null +++ b/klanker-gate/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1 @@ +blank_issues_enabled: false diff --git a/klanker-gate/.github/ISSUE_TEMPLATE/docs_issue.yml b/klanker-gate/.github/ISSUE_TEMPLATE/docs_issue.yml new file mode 100755 index 0000000..f874724 --- /dev/null +++ b/klanker-gate/.github/ISSUE_TEMPLATE/docs_issue.yml @@ -0,0 +1,43 @@ +name: Documentation issue +description: Report missing, unclear, or incorrect documentation +title: "[Docs]: " +labels: [documentation] +assignees: [] +body: + - type: markdown + attributes: + value: | + Help us improve the docs! Please provide links and suggestions. + + - type: checkboxes + id: prerequisites + attributes: + label: Prerequisites + options: + - label: I have searched existing issues and docs to avoid duplicates + required: true + + - type: input + id: page + attributes: + label: Affected page(s) + description: Provide the path or URL to the affected doc(s) + placeholder: docs/usage/providers.md or https://... + validations: + required: true + + - type: textarea + id: issue + attributes: + label: What’s wrong or missing? + description: Be as specific as possible. + validations: + required: true + + - type: textarea + id: suggestion + attributes: + label: Suggested change + description: Propose wording or structure improvements. + validations: + required: false diff --git a/klanker-gate/.github/ISSUE_TEMPLATE/feature_request.yml b/klanker-gate/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100755 index 0000000..2ff1857 --- /dev/null +++ b/klanker-gate/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,67 @@ +name: Feature request +description: Suggest an idea or enhancement for Bifrost +title: "[Feature]: " +labels: [enhancement] +assignees: [] +body: + - type: markdown + attributes: + value: | + Thanks for proposing a feature! Please fill out the details below. + + - type: checkboxes + id: prerequisites + attributes: + label: Prerequisites + options: + - label: I have searched existing issues and discussions to avoid duplicates + required: true + + - type: textarea + id: problem + attributes: + label: Problem to solve + description: What problem does this feature solve? Who benefits? + placeholder: Describe the problem clearly. + validations: + required: true + + - type: textarea + id: proposal + attributes: + label: Proposed solution + description: Describe your proposed API/UX/CLI. Include examples if helpful. + placeholder: Provide details about how this should work. + validations: + required: true + + - type: textarea + id: alternatives + attributes: + label: Alternatives considered + description: What other solutions or workarounds did you consider? + validations: + required: false + + - type: dropdown + id: area + attributes: + label: Area(s) + multiple: true + options: + - Core (Go) + - Framework + - Transports (HTTP) + - Plugins + - UI (Next.js) + - Docs + validations: + required: true + + - type: textarea + id: additional + attributes: + label: Additional context + description: Add any other context, sketches, or references here. + validations: + required: false diff --git a/klanker-gate/.github/pull_request_template.md b/klanker-gate/.github/pull_request_template.md new file mode 100755 index 0000000..7237e68 --- /dev/null +++ b/klanker-gate/.github/pull_request_template.md @@ -0,0 +1,73 @@ +## Summary + +Briefly explain the purpose of this PR and the problem it solves. + +## Changes + +- What was changed and why +- Any notable design decisions or trade-offs + +## Type of change + +- [ ] Bug fix +- [ ] Feature +- [ ] Refactor +- [ ] Documentation +- [ ] Chore/CI + +## Affected areas + +- [ ] Gateway / core +- [ ] Providers/Integrations +- [ ] Config / persistence +- [ ] Plugins +- [ ] Control UI +- [ ] Docs / CI + +## How to test + +Describe the steps to validate this change. Include commands and expected +outcomes. + +```sh +# Gateway (Deno) — run from repository root +deno fmt --check +deno lint +deno task check +deno task test + +# Control UI (driven through Deno; no npm) +deno task setup # once: installs the whole workspace +deno task test-ui +deno task build-ui +``` + +If adding new configs or environment variables, document them here. + +## Screenshots/Recordings + +If UI changes, add before/after screenshots or short clips. + +## Breaking changes + +- [ ] Yes +- [ ] No + +If yes, describe impact and migration instructions. + +## Related issues + +Link related issues and discussions. Example: Closes #123 + +## Security considerations + +Note any security implications (auth, secrets, PII, sandboxing, etc.). + +## Checklist + +- [ ] I read `README.md` and followed the guidelines +- [ ] I added/updated tests where appropriate +- [ ] I updated documentation where needed +- [ ] I verified builds succeed (Deno gateway and control UI) +- [ ] I ran the full gate locally and pasted the commands and results below + (nothing runs it automatically - there is no CI in this repository) diff --git a/klanker-gate/.gitignore b/klanker-gate/.gitignore new file mode 100755 index 0000000..5fc221a --- /dev/null +++ b/klanker-gate/.gitignore @@ -0,0 +1,49 @@ +.env +.vscode +.DS_Store +*_creds* +**/venv/ +**/__pycache__/** +private.* +.venv +test-coverage-local.sh +.harness-state/ +skillset-saves/ +.playwright-mcp/ +DENO_KB + +# Temporary directories +**/temp/ +node_modules +/dist +apps/control-ui/dist/ +**/tmp/ +temp*/ +tmp/ +tmp-* +private + +# Sqlite DBs +*.db +*.db-shm +*.db-wal + +# Test reports +test-reports + +# Diagram render checks: throwaway PNG rasterizations used to eyeball an SVG in +# docs/assets/diagrams/ before committing it. Root-anchored so it cannot swallow +# a real asset under docs/. +/*-check.png + +# Editor / assistant +.claude +.cursor/ + +# Build outputs +build/ +data/ +target/ + +# Coverage output (deno test --coverage) +cov_profile/ diff --git a/klanker-gate/AGENTS.md b/klanker-gate/AGENTS.md new file mode 100755 index 0000000..fd60d49 --- /dev/null +++ b/klanker-gate/AGENTS.md @@ -0,0 +1,183 @@ +# AGENTS.md - Technical Documentation and Agent Guidelines + +This file consolidates technical documentation and development guidelines into a +single agent-readable brief. It is the primary reference for AI agents, +copilots, and developers working on Frosty Deno ("klanker-gate"). It is +intentionally consistent with [CLAUDE.md](CLAUDE.md); where deeper detail is +needed, it links into [docs/](docs/). + +Frosty Deno is a clean-room Deno 2 + TypeScript rebuild of an LLM gateway +(reference: the retired Go implementation, Bifrost). One `Deno.serve` process +fronts 20+ model providers behind an OpenAI-compatible API, adds governance, +caching, MCP tooling and telemetry, and serves the React control-plane SPA +same-origin from the same port. + +## Section 1: Persona and Role + +- **Persona and Role:** Senior development architect. A proactive expert focused + on robust, secure, scalable Deno TypeScript. +- **Primary Goal:** Translate user requests into high-quality, production-ready + code that fits the existing structure. +- **Core Traits:** Analytical, systematic, supportive, solutions-oriented, a + clear communicator. +- **Core Expertise:** Full-stack implementation, architectural design, code + quality, and complex problem deconstruction. + +## Section 2: Default Workflow + +- **Step 1 - Build:** Default to building the complete, working solution, in one + cohesive, fully-commented change that matches surrounding code. +- **Step 2 - Fallback:** Only if a request is too large or ambiguous, propose a + concise Solution Design (stack, components, data flow), offer a step-by-step + plan, and stop for explicit approval. +- **Step 3 - Plan:** After approval, write the full plan as a single Markdown + document. + +## Section 3: Guiding Principles (non-negotiable) + +- **Security by Design:** Fail closed. Unknown hierarchy references deny; a + broken durable budget authority denies; a failed crypto boot refuses to start; + the Code Mode capability probe defaults to `false` on any error. Secrets never + reach the browser. +- **Architectural Integrity:** OpenAI's `chat.completion` / + `chat.completion.chunk` SSE is the single canonical wire format. Every + non-OpenAI surface is produced by translating that one canonical stream in + [packages/core/src/translate.ts](packages/core/src/translate.ts), never by a + parallel per-vendor pipeline. +- **Code Quality:** Strict TypeScript (`"strict": true`). Clean, idiomatic, DRY. + The control UI is Tailwind v4 with the `ds-r2` design system. +- **Clarity:** Comment the "why," not the "what." Money is integer micro-USD + everywhere in accounting - never floating point. + +## Section 4: Project-Specific Code Patterns + +- **Errors** always go through `GatewayError` / `errorResponse` and the + canonical `{error:{message,type,param,code}}` envelope. No ad-hoc JSON error + bodies. +- **Request schemas are `.passthrough()`** (`ChatCompletionRequest`, + `CompletionRequest`, `EmbeddingRequest`, `AnthropicMessagesRequest`) so + unknown vendor fields survive to provider egress. Response schemas stay + strict. +- **Streaming is Web Streams end to end.** A passive tee + (`withStreamCompletion` + `StreamAccumulator`) reconstructs the assistant + message for plugins, cost and logging while byte-identical bytes reach the + client. Never buffer a client stream to inspect it. +- **Providers** implement `IProviderAdapter` + ([packages/providers/src/types.ts](packages/providers/src/types.ts)); only + `chatCompletions` is required. `dispatchWithFallback` reroutes only on + 429/5xx/network `TypeError`, never on a client abort or a 4xx. + +## Section 5: Quality Assurance (pre-response check) + +Before providing code, verify: Goal Alignment, Code Integrity (compiles, fits +the seam), Clarity, Assumption Handling (state assumptions), and a Security +Review (fail-closed, secret handling, permission surface). + +## Section 6: Documentation Overview + +- [docs/index.md](docs/index.md) - the documentation landing page. +- [docs/getting-started/](docs/getting-started/) - install, configure, local + dev. +- [docs/guides/](docs/guides/) - deploying-to-production, setting-up-monitoring, + run-tests, and development-planning. +- [docs/concepts/](docs/concepts/) - architectural-overview (embeds the three + SVG diagrams), security-model, and + [functionality-and-capabilities.md](docs/concepts/functionality-and-capabilities.md) + (the authoritative capability inventory). +- [docs/design/ui-design.md](docs/design/ui-design.md) - the UI design system, + components, screens and flows. +- [docs/reference/](docs/reference/) - api-endpoints, data-model, + environment-variables, commands-scripts, dependencies, docker-reference, and + [sbom.md](docs/reference/sbom.md) plus the machine-readable + [sbom.cyclonedx.json](docs/reference/sbom/sbom.cyclonedx.json). +- [docs/assets/diagrams/](docs/assets/diagrams/) - the canonical + [logic-flow.svg](docs/assets/diagrams/logic-flow.svg), + [data-flow.svg](docs/assets/diagrams/data-flow.svg) and + [resource-flow.svg](docs/assets/diagrams/resource-flow.svg). +- [TODO.md](TODO.md) - the register of known follow-ups and accepted risks, and + the replacement for the numbered decision log retired on 2026-07-30. Check it + before changing behavior. Beware provenance numbers cited in code: several + early deferrals (Bedrock streaming, GenAI/Cohere compat streaming, stdio MCP, + Code Mode) have since shipped, so an early item is not proof a feature is + still missing. + +## Section 7: Rules and Guidelines + +- **Branching:** `feature/...` and `bugfix/...`. **Commits:** Conventional + Commits (e.g. `fix(gateway): ...`). **PRs:** fill the template and link + issues. +- **The gate:** `deno fmt --check`, `deno lint`, `deno task check`, + `deno task test`, plus `deno task check-ui` / `test-ui` / `build-ui` for the + UI. No automation runs it - there is no CI workflow in this repository, so + every step is the author's responsibility. +- **`deno task test` ignores `apps/control-ui`, `tests/browser` and + `tests/live`.** The live vector-store suite drives `docker compose` itself and + is reached only through `deno task test:live`, which needs a running Docker + daemon. +- **Definition of done:** tests in the matching suite prove the behavior (a + fixed bug gets a regression test that fails on the old code), the full gate is + green, the exact commands you ran are recorded as evidence, and docs moved + with the code. Missing evidence is treated as incomplete, not implied success. +- Performance work is measure-first: a win inside measurement noise is rejected. + +## Section 8: File and Folder Structure + +- `apps/gateway/` - the composition root ([context.ts](apps/gateway/context.ts)) + and the HTTP surface. The middleware onion in [main.ts](apps/gateway/main.ts) + is load-bearing and ordered on purpose. +- `apps/control-ui/` - the React + Vite + TypeScript control plane (built to + `dist/` and served same-origin). Binding contract: + [apps/control-ui/CONVENTIONS.md](apps/control-ui/CONVENTIONS.md). +- `packages/*` - plain directories imported by relative path (no manifests). + Flow: + `contracts -> core/providers/governance/cache/mcp/config -> apps/gateway`. + `packages/auth/` is reserved and currently empty. +- `deploy/`, `Dockerfile`, `docker-compose.yml` - infrastructure. There is no + Kubernetes/Helm packaging (decision-log item 55). +- `tests/` - contract, e2e and integration run in `deno task test`; `live/` + (Docker-backed vector stores) and the separate `browser/` Playwright harness + are both outside it and have their own tasks. + +## Section 9: SDKs and Dependencies + +- **Runtime:** Deno 2.9.x. JSR: `@std/assert`, `@std/http`, `@std/path`. npm via + Deno specifiers: `zod@4` (validation), `postgres@3` (pgvector cache), No npm + CLI - the UI builds through Deno `npm:` specifiers. +- **Control UI:** React 19 + React DOM 19, Vite 8, TypeScript 7, Vitest 4, + Tailwind CSS 4, `lucide-react`, `clsx`, `tailwind-merge`. +- See [docs/reference/sbom.md](docs/reference/sbom.md) for the complete + component inventory and + [docs/reference/dependencies.md](docs/reference/dependencies.md) for + rationale. + +## Section 10: Configuration + +- Config is env-first, then overlaid by persisted PostgreSQL config; **persisted + wins on id collision**. `dev` / `start` load `.env` via `--env-file`; the + container does not (Compose supplies the process env). +- Every subsystem (cache, OTel, log store, pricing sync, Code Mode, encryption) + is an env-gated field on `AppContext`. [.env.example](.env.example) documents + the 76 checked-in gateway knobs; the full list and exact parse behavior live + in + [docs/reference/environment-variables.md](docs/reference/environment-variables.md). +- The Deno permission flag set is part of the contract: + `--unstable-net --unstable-worker-options --allow-net --allow-env --allow-read --allow-write=data`. + See [permissions.md](permissions.md). + +## Section 11: Core Components and Logic + +The request lifecycle is: client -> alias rewrite -> `errorHandler` -> plugin +transport hooks -> request logger -> metrics -> admin origin guard -> admin +token auth -> governance admission -> innermost telemetry -> router -> route +handler -> zod validation -> semantic cache lookup -> provider resolve -> +dispatch with narrow fallback -> streaming tee -> edge translation -> response, +then an unwind that bills usage (except on cache hits) and emits metrics and +spans. Provider credentials and config live in PostgreSQL, optionally +AES-256-GCM encrypted at rest. Background work (MCP health sweep, pricing sync, +OTel flush, durable counter sinks) runs strictly off the request path. + +The canonical visual representations are +[logic-flow.svg](docs/assets/diagrams/logic-flow.svg), +[data-flow.svg](docs/assets/diagrams/data-flow.svg), and +[resource-flow.svg](docs/assets/diagrams/resource-flow.svg), explained in +[docs/concepts/architectural-overview.md](docs/concepts/architectural-overview.md). diff --git a/klanker-gate/ATTRIBUTION.md b/klanker-gate/ATTRIBUTION.md new file mode 100644 index 0000000..64865c2 --- /dev/null +++ b/klanker-gate/ATTRIBUTION.md @@ -0,0 +1,41 @@ +# Attribution + +## Upstream project + +This tree is a **vendored, unmodified copy** of **klanker-gate** — the +"Frosty Deno" LLM gateway — distributed inside the SysDeck master +tarball. + +| Field | Value | +|---------------|------------------------------------------------------| +| **Project** | klanker-gate (Frosty Deno LLM Gateway) | +| **Author** | **TykoDev** | +| **Source** | https://github.com/TykoDev/klanker-gate | +| **License** | Apache-2.0 (full text: [`LICENSE`](LICENSE)) | +| **Version** | 0.9.0 (independent from SysDeck's version) | + +**klanker-gate is NOT SysDeck code.** All credit for the gateway — +the Deno 2 + TypeScript OpenAI-compatible API surface, provider +management, virtual keys, governance, caching, MCP integration, and +the same-origin React control plane — belongs to TykoDev. + +## What SysDeck added + +SysDeck's integration work is **additive only** — the upstream source +required zero changes (the "port" to Linux was packaging, not code): + +- `arch/` — Arch Linux packaging (PKGBUILD, hardened systemd unit, + sysusers/tmpfiles, run wrapper, `INSTALL-ARCH.md` runbook), written + by the SysDeck project for the SysDeck master tarball. +- Outside this tree, SysDeck ships two *clients* of the gateway + (they contain no upstream code): `sysdeck-klanker` — a Cockpit + panel + Python bridge helper — and the Web Edition "AI Gateway" + panel, which talk to the gateway over its REST API. + +Everything else in this tree is upstream klanker-gate code by +TykoDev, redistributed under the Apache-2.0 license, which permits +redistribution in source form provided the license and copyright +notices are retained (they are — see `LICENSE`). + +Upstream releases, issues, and development happen at +https://github.com/TykoDev/klanker-gate. diff --git a/klanker-gate/CHANGELOG.md b/klanker-gate/CHANGELOG.md new file mode 100755 index 0000000..25c54a6 --- /dev/null +++ b/klanker-gate/CHANGELOG.md @@ -0,0 +1,34 @@ +# Changelog + +All notable changes to this project are documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). + +## [Unreleased] + +### Added + +- A complete Diataxis-oriented documentation set under [docs/](docs/), including + tutorials, guides, concepts, design reference, technical reference, and + standalone SVG architecture diagrams. +- A human-readable and machine-readable SBOM generated from the checked-in + manifests, lockfile, and Docker assets. +- A repository-local SBOM generation script at `scripts/generate_sbom.ts`. + +### Changed + +- Root documentation was aligned with the current PostgreSQL-backed + architecture, same-origin control-plane flow, and shipped observability stack. +- Contributor-facing documentation now points at the new documentation index and + the checked-in validation and SBOM workflows. + +### Security + +- Security documentation now points directly at the current security model, + SBOM, and private-reporting workflow. + +## Historical note + +The repository does not currently expose a tag-based release history. Earlier +release entries are therefore not reconstructed here from commit names alone, +because that would require guessing at version boundaries and release dates. diff --git a/klanker-gate/CLAUDE.md b/klanker-gate/CLAUDE.md new file mode 100755 index 0000000..24305c0 --- /dev/null +++ b/klanker-gate/CLAUDE.md @@ -0,0 +1,251 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with +code in this repository. + +## What this is + +Frosty Deno ("klanker-gate") is a clean-room Deno 2 + TypeScript rebuild of an +LLM gateway (reference: the retired Go implementation, Bifrost). One +`Deno.serve` process fronts 20+ model providers behind an OpenAI-compatible API, +adds governance / caching / MCP tooling / telemetry, and serves the React +control-plane SPA **same-origin** from the same port. + +## Commands + +Run everything from the repo root. There is no `npm`/`node` toolchain — the +control UI is driven through Deno (`deno run -A npm:vite`, `npm:vitest`, +`npm:typescript`). Do not reintroduce an npm CLI step. + +```bash +deno task setup # one-time bootstrap (deno install --allow-scripts=npm:esbuild) +deno task dev # gateway on :8080 with --watch, loads .env +deno task start # same without --watch + +# The full gate - run it yourself; nothing runs it for you: +deno task test:all # every stage below, in order, one verdict + # (+ live and browser stages; skips are reported, never silent) + +# Or the same checks one at a time: +deno fmt --check +deno lint +deno task check # backend typecheck (deno check) +deno task test # unit + contract + integration + e2e (ignores control-ui, tests/browser, tests/live) +deno task check-ui # control-ui tsc --noEmit +deno task test-ui # control-ui vitest (jsdom) +deno task build-ui # control-ui production build -> apps/control-ui/dist +``` + +Single test / filter — pass the same unstable flags the task does: + +```bash +deno test --unstable-net --unstable-worker-options -A tests/integration/cache_test.ts +deno test --unstable-net -A --filter "fallback" packages/providers/ +``` + +Other suites: `deno task test:e2e`, `deno task test:live` (drives +`docker compose up -d --wait postgres` itself, so it needs a running Docker +daemon), `deno task test:load` (`scripts/load-bench.ts`), `deno task bench` (23 +micro-benchmarks in six `*_bench.ts` files colocated next to the source they +measure). The two performance harnesses answer different questions: `test:load` +measures end-to-end request cost, `bench` isolates a single pure hot-path +function, which is what decision-log 45's measure-first rule needs. Neither is +part of `test:all`; both are recorded in +[docs/benchmark-report.md](docs/benchmark-report.md) (decision-log 78). +`tests/browser/` is a Node/Playwright harness with its own `package.json`, +outside `deno task test` — it is the `browser` stage of `deno task test:all` and +needs a running gateway. + +The `test` task carries `--ignore=apps/control-ui,tests/browser,tests/live`. +`--ignore` _replaces_ the `test.exclude` list in `deno.jsonc` rather than adding +to it, which is why all three are repeated in the task string; and a +config-level exclude would also filter the explicit path `test:live` passes, so +`tests/live` can only be dropped from the gate at the task level. + +The gateway runs API-only until `deno task build-ui` has produced +`apps/control-ui/dist`; boot logs say which mode you are in. + +## Architecture + +Read +[docs/concepts/architectural-overview.md](docs/concepts/architectural-overview.md) +for the full picture with diagrams. The parts that matter before you edit: + +**OpenAI's wire format is the lingua franca.** Internally every response is a +canonical `chat.completion` / `chat.completion.chunk` SSE stream ending in +`data: [DONE]`. Provider adapters translate _inbound_ to canonical; every +non-OpenAI surface the gateway exposes (Anthropic Messages, OpenAI Responses, +Google GenAI, Cohere v2, legacy completions) is produced by translating the +canonical stream in `packages/core/src/translate.ts` — never by a second +parallel pipeline per vendor. This is what keeps provider count × surface count +from multiplying. + +**The middleware onion order in [apps/gateway/main.ts](apps/gateway/main.ts) is +load-bearing.** The order is recorded here, not in code comments - long +rationale lives in the register and code carries JSDoc plus short notes only +(the rule retired decision-log item 68 stated). Outermost `errorHandler` (so +even plugin-hook failures return the canonical envelope) → plugin transport +hooks → compat prefix rewrite → request logger → metrics → admin origin guard → +admin token auth → governance → telemetry (innermost, so usage is captured even +with zero virtual keys) → router → SPA fallback. Alias prefixes (`/openai`, +`/anthropic`, `/litellm`, `/langchain`, `/pydanticai`) are pure URL rewrites +applied _before_ auth/governance so aliased paths are admitted identically to +`/v1/*`. + +**`AppContext` ([apps/gateway/context.ts](apps/gateway/context.ts)) is the +composition root.** `createContext()` is env-only and is what unit tests use; +`createDefaultContext()` is the production path — it opens **PostgreSQL and +refuses to start without it**, attaches optional encryption-at-rest, seeds +providers from env then overlays persisted config (**persisted wins on id +collision**), and wires durable counter sinks. Deno KV was retired for it +(decision-log 57): everything durable now lives in one Postgres, reached through +the `StateStore` seam in `packages/config/src/store.ts` — `PostgresStateStore` +in production, `MemoryStateStore` in tests, with one shared contract +(`store_contract.ts`) run against both so they cannot drift. Nearly every +optional subsystem (cache, OTel, log store, pricing sync, Code Mode) is an +env-gated field on this object. + +**Package layout.** `packages/*` are plain directories with **no manifests** — +they are imported by relative path (`../../contracts/src/mod.ts`), not by a +workspace alias. Only `apps/control-ui` is a Deno workspace member. The ten +directories are `cache`, `config`, `contracts`, `core`, `governance`, `mcp`, +`plugins`, `providers`, `telemetry`, `testing`. Only `contracts`, `core`, +`providers`, and `testing` have `src/mod.ts` barrels; the rest are imported +file-by-file. Dependency flow is +`contracts → core/providers/governance/cache/mcp/config → apps/gateway`. A +change that wants to cross a package boundary usually means the seam is wrong — +`mcp` must not import from `core`'s dependents, and `providers` deliberately has +no `governance` import (the budget guard is a structural interface satisfied by +`ProviderBudgetTracker`, wired in `context.ts`). + +**Providers.** One adapter per vendor family implementing `IProviderAdapter` +([packages/providers/src/types.ts](packages/providers/src/types.ts)) — only +`chatCompletions` is required; everything else (`completions`, `embeddings`, +`listModels`, `generateImage`, `rawProxy`, `countTokens`) is optional and its +absence has a defined fallback. `ProviderManager.resolve()` maps +`provider/model` or a bare name to an account; `resolveChain()` adds +request-level `fallbacks` plus the pool; `dispatchWithFallback` reroutes only on +429 / 5xx / network `TypeError` — never on a client abort or a 4xx. + +**Streaming** is Web Streams end to end. A passive tee (`withStreamCompletion` + +`StreamAccumulator`) reconstructs the assistant message for plugins, cost, and +logging while byte-identical bytes still reach the client. Never buffer a client +stream to inspect it. The plugin stream-complete tap runs on the **canonical** +stream, before edge translation. + +## Conventions that will bite you + +- **Errors** always go through `GatewayError` / `errorResponse` and the + canonical `{error:{message,type,param,code}}` envelope. No ad-hoc JSON error + bodies. +- **Request schemas are `.passthrough()`** (`ChatCompletionRequest`, + `CompletionRequest`, `EmbeddingRequest`, `AnthropicMessagesRequest`) so + unknown vendor fields survive to provider egress. Do not "tidy" this into + `.strict()`. Everything else uses bare `z.object()`, which _strips_ unknown + keys rather than rejecting them. The two `.strict()` schemas in the contracts + package are `GatewayConfigSchema` (`config.ts`) and `ModelPriceSchema` + (`pricing.ts`), for the same reason (decision-log 46): both are whole-object + replaces of persisted operator data, where silent key-stripping wipes fields + the operator never meant to clear. The pricing one has a deliberate non-strict + twin, `PersistedModelPriceSchema`, so a catalog written by a newer gateway + still loads on an older one. Two response families are deliberately + `.passthrough()` because their vendor payloads vary: `TranscriptionResponse` + (`audio.ts`) and the file/batch family (`file_batch.ts`). +- **Money is integer micro-USD** everywhere in accounting. No floating-point + accumulation. +- **Fail closed.** Unknown hierarchy references deny; a broken durable budget + authority denies; a failed crypto boot refuses to start; the Code Mode + capability probe defaults to `false` on any error. +- **The Deno permission flag set is part of the contract**: + `--unstable-net --unstable-worker-options --allow-net --allow-env --allow-read --allow-write=data`. + Needing more is a design escalation — see [permissions.md](permissions.md). + `--unstable-worker-options` _narrows_ (it lets the Code Mode worker spawn with + everything denied); it grants the process nothing. `--allow-run` is opt-in for + stdio MCP only and is kept solely in the `test` task for a fixture. +- **Secrets never reach the browser.** The admin API returns redacted views with + `hasX` presence markers. Gateway `PUT` is a shallow top-level merge, so a + nested group you send _replaces_ the stored group — diff and send only changed + groups. +- **Code Mode is default-off** (`FROSTY_CODE_MODE`) behind two independent + gates. The VFS metadata surface is live and inert; the executor requires both + the app gate and a passing boot probe. +- New config knobs need an env var with a bounded parse, a row in + [docs/reference/environment-variables.md](docs/reference/environment-variables.md), + and `.env.example` coverage. +- **Comments are JSDoc plus short notes.** Public API gets JSDoc (editors + surface it); a non-obvious constraint gets a note under four lines at the + point of use. Long rationale goes in [TODO.md](TODO.md), which is the register + now that the numbered decision log is retired - a duplicated explanation in + code drifts and then misleads. Cite it by **stable key** + (`TODO.md D-REBUILD-HEADERS`), never by item number: the numbered items are + reading order and renumber as items close, which is exactly how the previous + scheme became uncitable. Add a keyed entry only when the constraint genuinely + will not fit in a note at the point of use. Test files are exempt: a comment + explaining why an assertion exists has no other home. +- Gateway-specific request/response headers are `x-frosty-*` + (`confirm-side-effects`, `mcp-tools`, `cache`, `cache-type`, `code-mode`, + `virtual-key`, `responses-passthrough`). + +## Before you change behavior + +The numbered decision log that recorded every deliberate divergence from the Go +original was **retired on 2026-07-30**; [TODO.md](TODO.md) is the register in +its place, and item 1 there carries the consequences. Item numbers still cited +in code and below are provenance only and resolve to nothing on disk. Still-live +"missing on purpose" items: **serving** a cache hit as a stream (cache _reads_ +are non-streaming; completed streams _are_ stored via the passive tee), +Bedrock-native ingress under the aggregator prefixes (explicit 501 stubs), and +Kubernetes/Helm packaging (item 55). Do **not** assume the older deferrals still +hold — Bedrock streaming, GenAI/Cohere compat streaming, stdio MCP, Code Mode, +and **multi-replica deployment** all started as deferrals and have since +shipped, each with a follow-up entry. Multi-process serving in particular is +live (`FROSTY_WORKERS`, decision-log 62/70/71): budgets and rate-limit windows +are fleet-wide through PostgreSQL, and the residual per-process gap is named in +item 73. Reopening a decision is fine, but do it explicitly, and record any new +divergence as a [TODO.md](TODO.md) item with its mechanism, evidence, "done +means" and reopen trigger. + +Definition of done per +[docs/guides/development-planning.md](docs/guides/development-planning.md): +tests in the matching suite prove the behavior (a fixed bug gets a regression +test that fails on the old code), the full gate is green, **the exact commands +you ran are recorded as evidence** (missing evidence is treated as incomplete, +not implied success), and docs moved with the code. Nothing enforces the gate +automatically — there is no CI workflow in this repository, so running it and +reporting the result honestly is entirely on the author. + +Performance work is measure-first: a win inside measurement noise is rejected +and reverted (see decision-log item 45 and +[docs/benchmark-report.md](docs/benchmark-report.md)). + +## Control UI + +[apps/control-ui/CONVENTIONS.md](apps/control-ui/CONVENTIONS.md) is the binding +contract for the SPA — design system `ds-r2`, tokens in `src/styles/tokens.css`, +`PageHeader` on every view, `DataTable` for every resource list, hash router +keyed off the first segment, and all transport through `src/api.ts` (no `fetch` +in views). Hard taste rules there include **zero em/en dashes anywhere** (plain +hyphen only), one cool-blue accent, lucide-react icons only, and same-origin +only — no external CDN/font/script origins. + +## Related + +`AGENTS.md` holds the shorter agent-facing brief. `docs/` is the deep reference: +[getting-started/](docs/getting-started/), [guides/](docs/guides/), +[reference/](docs/reference/), [concepts/](docs/concepts/), +[design/](docs/design/). + +Four pages carry more ground truth than the rest and are worth reading before a +non-trivial change: + +- [docs/concepts/functionality-and-capabilities.md](docs/concepts/functionality-and-capabilities.md) + — the authoritative capability inventory, including a "gaps and partial + implementations" table and a list of stale claims found in older docs. +- [TODO.md](TODO.md) - every accepted risk and known follow-up with the + constraint holding it and the trigger that reopens it, since the separate + open-risks register was retired. +- [docs/reference/sbom.md](docs/reference/sbom.md) plus + [sbom.cyclonedx.json](docs/reference/sbom/sbom.cyclonedx.json) — the + component-level bill of materials. +- [docs/assets/diagrams/](docs/assets/diagrams/) — the canonical + `logic-flow.svg`, `data-flow.svg`, `resource-flow.svg`. diff --git a/klanker-gate/CODE_OF_CONDUCT.md b/klanker-gate/CODE_OF_CONDUCT.md new file mode 100755 index 0000000..c714daf --- /dev/null +++ b/klanker-gate/CODE_OF_CONDUCT.md @@ -0,0 +1,129 @@ +# Contributor Covenant Code of Conduct + +## Our Pledge + +We as members, contributors, and leaders pledge to make participation in our +community a harassment-free experience for everyone, regardless of age, body +size, visible or invisible disability, ethnicity, sex characteristics, gender +identity and expression, level of experience, education, socio-economic status, +nationality, personal appearance, race, religion, or sexual identity and +orientation. + +We pledge to act and interact in ways that contribute to an open, welcoming, +diverse, inclusive, and healthy community. + +## Our Standards + +Examples of behavior that contributes to a positive environment for our +community include: + +- Demonstrating empathy and kindness toward other people +- Being respectful of differing opinions, viewpoints, and experiences +- Giving and gracefully accepting constructive feedback +- Accepting responsibility and apologizing to those affected by our mistakes, + and learning from the experience +- Focusing on what is best not just for us as individuals, but for the overall + community + +Examples of unacceptable behavior include: + +- The use of sexualized language or imagery, and sexual attention or advances of + any kind +- Trolling, insulting or derogatory comments, and personal or political attacks +- Public or private harassment +- Publishing others' private information, such as a physical or email address, + without their explicit permission +- Other conduct which could reasonably be considered inappropriate in a + professional setting + +## Enforcement Responsibilities + +Community leaders are responsible for clarifying and enforcing our standards of +acceptable behavior and will take appropriate and fair corrective action in +response to any behavior that they deem inappropriate, threatening, offensive, +or harmful. + +Community leaders have the right and responsibility to remove, edit, or reject +comments, commits, code, wiki edits, issues, and other contributions that are +not aligned to this Code of Conduct, and will communicate reasons for moderation +decisions when appropriate. + +## Scope + +This Code of Conduct applies within all community spaces, and also applies when +an individual is officially representing the community in public spaces. +Examples of representing our community include using an official e-mail address, +posting via an official social media account, or acting as an appointed +representative at an online or offline event. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be +reported to the community leaders responsible for enforcement at +akshay@getmaxim.ai. All complaints will be reviewed and investigated promptly +and fairly. + +All community leaders are obligated to respect the privacy and security of the +reporter of any incident. + +## Enforcement Guidelines + +Community leaders will follow these Community Impact Guidelines in determining +the consequences for any action they deem in violation of this Code of Conduct: + +### 1. Correction + +**Community Impact**: Use of inappropriate language or other behavior deemed +unprofessional or unwelcome in the community. + +**Consequence**: A private, written warning from community leaders, providing +clarity around the nature of the violation and an explanation of why the +behavior was inappropriate. A public apology may be requested. + +### 2. Warning + +**Community Impact**: A violation through a single incident or series of +actions. + +**Consequence**: A warning with consequences for continued behavior. No +interaction with the people involved, including unsolicited interaction with +those enforcing the Code of Conduct, for a specified period of time. This +includes avoiding interactions in community spaces as well as external channels +like social media. Violating these terms may lead to a temporary or permanent +ban. + +### 3. Temporary Ban + +**Community Impact**: A serious violation of community standards, including +sustained inappropriate behavior. + +**Consequence**: A temporary ban from any sort of interaction or public +communication with the community for a specified period of time. No public or +private interaction with the people involved, including unsolicited interaction +with those enforcing the Code of Conduct, is allowed during this period. +Violating these terms may lead to a permanent ban. + +### 4. Permanent Ban + +**Community Impact**: Demonstrating a pattern of violation of community +standards, including sustained inappropriate behavior, harassment of an +individual, or aggression toward or disparagement of classes of individuals. + +**Consequence**: A permanent ban from any sort of public interaction within the +community. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], +version 2.0, available at +https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. + +Community Impact Guidelines were inspired by +[Mozilla's code of conduct +enforcement ladder](https://github.com/mozilla/diversity). + +[homepage]: https://www.contributor-covenant.org + +For answers to common questions about this code of conduct, see the FAQ at +https://www.contributor-covenant.org/faq. Translations are available at +https://www.contributor-covenant.org/translations. diff --git a/klanker-gate/CONDUCT.md b/klanker-gate/CONDUCT.md new file mode 100755 index 0000000..130cb6e --- /dev/null +++ b/klanker-gate/CONDUCT.md @@ -0,0 +1,71 @@ +# Conduct and Contribution Quickstart + +This file collects the practical contribution and collaboration rules for Frosty +Deno. Community behavior expectations still apply through the separate +[CODE_OF_CONDUCT.md](CODE_OF_CONDUCT.md). + +## How you can contribute + +- Report bugs through the forms in + [.github/ISSUE_TEMPLATE/](.github/ISSUE_TEMPLATE/). +- Suggest improvements or new capabilities through issues before implementing + large behavioral changes. +- Improve the documentation in [docs/](docs/), especially when code and docs + drift. +- Contribute code for provider adapters, governance, caching, observability, + MCP, control-plane UI work, tests, or deployment hardening. + +## Development setup + +Detailed tutorials live under [docs/getting-started/](docs/getting-started/). +The shortest verified setup is: + +```bash +git clone klanker-gate +cd klanker-gate +deno task setup +cp .env.example .env +docker compose up -d postgres +deno task dev +``` + +Important repo-specific notes: + +- Deno 2.9.x is the primary toolchain. +- The gateway and control UI are driven through Deno, not the npm CLI. +- PostgreSQL is a hard dependency on the production bootstrap path. +- `tests/browser` is a separate Node-based Playwright harness and is the only + area that expects `npx`. + +## Submission guidelines + +- Branches should follow `feature/` or `bugfix/`. +- Commits should follow + [Conventional Commits](https://www.conventionalcommits.org/). +- Pull requests should link their issue when applicable and use the checked-in + [pull request template](.github/pull_request_template.md). +- Run the validation gate locally before opening the PR: + +```bash +deno fmt --check +deno lint +deno task check +deno task test +deno task check-ui +deno task test-ui +deno task build-ui +``` + +- If the change affects dependencies, regenerate the SBOM with + `deno run -A scripts/generate_sbom.ts`. +- If the change affects public or operator-facing behavior, update the relevant + documentation under [docs/](docs/). + +## Coding standards + +- Preserve the canonical error envelope and shared request/response contracts. +- Preserve the load-bearing middleware order unless the change explicitly + requires a routing or security move. +- Keep same-origin control-plane assumptions intact. +- Follow [apps/control-ui/CONVENTIONS.md](apps/control-ui/CONVENTIONS.md) for UI + work. diff --git a/klanker-gate/CONTRIBUTING.md b/klanker-gate/CONTRIBUTING.md new file mode 100755 index 0000000..189792c --- /dev/null +++ b/klanker-gate/CONTRIBUTING.md @@ -0,0 +1,82 @@ +# Contributing to Frosty Deno + +Thank you for considering a contribution. Frosty Deno is a clean-room Deno 2 + +TypeScript LLM gateway, and it improves fastest when fixes, provider additions, +UI refinements and documentation all come from people who use it. Whether you +are fixing a typo, adding a provider adapter, or hardening the governance layer, +your work is welcome and valued. + +## Ways to contribute + +- **Report bugs** and **request features** through GitHub Issues using the issue + forms in [.github/ISSUE_TEMPLATE/](.github/ISSUE_TEMPLATE/). +- **Improve the docs** in [docs/](docs/) - they move with the code, so a + docs-only PR that corrects a stale claim is a real contribution. +- **Write code**: pick up an issue labelled `good first issue`, or open an issue + first for anything that changes behavior so the approach can be agreed. + +## Development setup + +Full instructions live in [docs/getting-started/](docs/getting-started/) and +[docs/index.md](docs/index.md). The short version: + +```bash +git clone klanker-gate +cd klanker-gate +deno task setup # one-time bootstrap (installs esbuild for the UI build) +cp .env.example .env # then set at least one provider key +deno task dev # gateway on http://localhost:8080 (loads .env) +``` + +There is no `npm` / `node` toolchain. The control UI is driven entirely through +Deno (`deno task dev-ui`, `deno task build-ui`, `deno task test-ui`). Do not +reintroduce an npm CLI step. + +## Before you open a pull request + +Run the full gate yourself - there is no CI in this repository, so nothing runs +it for you and nothing blocks a pull request that skips it: + +```bash +deno fmt --check +deno lint +deno task check # backend typecheck +deno task test # unit + contract + integration + e2e +deno task check-ui # control-UI typecheck +deno task test-ui # control-UI tests +deno task build-ui # control-UI production build +``` + +- **Tests prove the behavior.** A fixed bug must come with a regression test + that fails on the old code. New behavior needs tests in the matching suite. +- **Record your evidence.** Paste the exact commands you ran and their result in + the PR. Missing evidence is treated as incomplete, not implied success. +- **Docs move with the code.** If you change a knob, endpoint or behavior, + update the relevant file under [docs/reference/](docs/reference/) (and + [.env.example](.env.example) for a new env var). If the change affects setup + or operations, update the matching tutorial or guide under [docs/](docs/). +- **Check the register.** Deliberate divergences and accepted risks are recorded + in [TODO.md](TODO.md), which replaced the numbered decision log retired on + 2026-07-30. If your change reopens one, say so, and record a new divergence + there with its mechanism, evidence, "done means" and reopen trigger. + +## Submission guidelines + +- **Branches:** `feature/` or `bugfix/`. +- **Commits:** [Conventional Commits](https://www.conventionalcommits.org/), for + example `fix(gateway): reject empty Azure api-version` or + `feat(providers): add adapter`. +- **Pull requests:** fill out the + [pull request template](.github/pull_request_template.md), link the issue it + closes, and keep the change focused. +- **Coding standards:** `deno fmt` and `deno lint` are the source of truth for + style. The control UI additionally follows + [apps/control-ui/CONVENTIONS.md](apps/control-ui/CONVENTIONS.md) (design + system `ds-r2`, `lucide-react` icons only, same-origin only, plain hyphens - + no em or en dashes). + +## Code of conduct + +By participating you agree to uphold the standards in [CONDUCT.md](CONDUCT.md). + +See also [AGENTS.md](AGENTS.md) for the deeper technical and agent-facing brief. diff --git a/klanker-gate/Dockerfile b/klanker-gate/Dockerfile new file mode 100755 index 0000000..f85f53d --- /dev/null +++ b/klanker-gate/Dockerfile @@ -0,0 +1,57 @@ +# syntax=docker/dockerfile:1 + +# --- Stage 1: build the Control UI bundle (Vite/React/TS) INSIDE the image --- +# The runtime no longer depends on a prebuilt apps/control-ui/dist on the host +# (which, on Windows, can be locked by Defender / Docker file-sharing and block +# `deno task build-ui`). The builder is the glibc (Debian) Deno image, thrown away +# after emitting dist/ - glibc dodges the musl/rollup/oxide native-binding edge +# cases, and the emitted bundle is static, so the runtime still runs deno:alpine. +# There is no npm in the repo: the UI builds THROUGH Deno via npm: specifiers. +FROM denoland/deno:2.9.3 AS ui-builder +WORKDIR /app +# Dep-install layer: cache on the workspace root config + lockfile + the member +# manifest only. `deno task setup` == `deno install --allow-scripts=npm:esbuild`; +# esbuild's postinstall is required or the Vite build cannot start. +COPY deno.jsonc deno.lock ./ +COPY apps/control-ui/package.json ./apps/control-ui/ +RUN deno task setup +# The UI imports shared types via ../../../packages, so mirror the repo layout +# (packages as a sibling of apps) before building. +COPY packages ./packages +COPY apps/control-ui ./apps/control-ui +RUN deno task build-ui + +# --- Stage 2: Deno runtime --- +# Deno 2 base image (decision D3). The previous 1.40.4 pin predated `jsr:` +# specifier support and could not `deno cache` this workspace. +FROM denoland/deno:alpine-2.9.3 + +WORKDIR /app + +COPY deno.jsonc deno.lock ./ +# deno.jsonc declares apps/control-ui as a workspace member, so its manifest must be +# present for config resolution. The gateway itself stays on Deno's global module +# cache via --node-modules-dir=none, so NO node_modules is baked into the runtime +# image (identical to pre-migration behavior; only the build stage uses one). +COPY apps/control-ui/package.json ./apps/control-ui/ +COPY packages ./packages +COPY apps/gateway ./apps/gateway +# The Control UI bundle comes from the builder stage above, so the image always +# serves the UI same-origin without any prebuilt host dist. +COPY --from=ui-builder /app/apps/control-ui/dist ./apps/control-ui/dist + +COPY deploy/docker-entrypoint.sh /usr/local/bin/frosty-entrypoint + +RUN deno cache --node-modules-dir=none apps/gateway/main.ts \ + && mkdir -p /app/data \ + && chmod +x /usr/local/bin/frosty-entrypoint \ + && chown -R deno:deno /app + +EXPOSE 8080 +USER deno + +# The entrypoint mirrors the `start` task in deno.jsonc and permissions.md, and +# adds a Deno-scoped --allow-run only when FROSTY_WORKERS>1. --unstable-net is +# REQUIRED for multi-process serving: Deno.serve({reusePort:true}) throws +# "Unstable API 'Deno.listen({ reusePort: true })'" without it. +ENTRYPOINT ["/usr/local/bin/frosty-entrypoint"] diff --git a/klanker-gate/LICENSE b/klanker-gate/LICENSE new file mode 100755 index 0000000..dc8841f --- /dev/null +++ b/klanker-gate/LICENSE @@ -0,0 +1,201 @@ +Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2025 H3 Labs Inc. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. \ No newline at end of file diff --git a/klanker-gate/README.md b/klanker-gate/README.md new file mode 100755 index 0000000..b3099a9 --- /dev/null +++ b/klanker-gate/README.md @@ -0,0 +1,84 @@ +# Frosty Deno LLM Gateway + +[![Deno](https://img.shields.io/badge/Deno-2.9-white?logo=deno&logoColor=black)](https://deno.com) +[![Version](https://img.shields.io/badge/version-0.9.0-blue.svg)](CHANGELOG.md) +[![License](https://img.shields.io/badge/License-Apache_2.0-blue.svg)](LICENSE) +[![API](https://img.shields.io/badge/API-OpenAI--compatible-green.svg)](docs/reference/api-endpoints.md) + +Frosty Deno is a clean-room Deno 2 + TypeScript LLM gateway with a same-origin +React control plane. One gateway surface fronts 20+ provider types behind +OpenAI-compatible APIs, adds governance and pricing controls, optional exact or +semantic caching, MCP integration, and telemetry, and stores durable state in +PostgreSQL. + +## Overview + +This repository is for teams that want one operational surface for many model +providers instead of many separate SDKs, credential stores, budget systems, and +observability paths. Frosty Deno gives you one API boundary, one operator +control plane, one governance layer, and one place to wire caching, logging, +metrics, tracing, and MCP tooling. + +## Key features + +- One gateway surface for chat, completions, embeddings, images, audio, files, + batches, and provider-specific compatibility families. +- Built-in governance with virtual keys, rate limits, request and cost budgets, + team and customer rollups, and pricing-aware metering. +- Same-origin control plane for providers, settings, logs, runtime diagnostics, + pricing, cache, and MCP management. +- Optional exact and semantic cache backed by PostgreSQL and pgvector. +- Optional observability profile with Prometheus, Grafana, OTEL Collector, + MinIO, and Tempo. + +## Getting started + +Prerequisites: + +- Deno 2.9.x +- Docker and Docker Compose v2.20+ if you want the shipped PostgreSQL service + +Fastest verified local path: + +```bash +git clone klanker-gate +cd klanker-gate +deno task setup +cp .env.example .env +docker compose up -d postgres +deno task dev +``` + +At minimum, set one provider credential and `FROSTY_PG_URL` in `.env`. + +Useful checks: + +```bash +curl http://localhost:8080/healthz +curl http://localhost:8080/v1/models +``` + +Build the control UI when you want the same-origin operator interface: + +```bash +deno task build-ui +``` + +## Documentation + +Start with [docs/index.md](docs/index.md). + +- Tutorials: [docs/getting-started/](docs/getting-started/) +- Guides: [docs/guides/](docs/guides/) +- Concepts: [docs/concepts/](docs/concepts/) +- Design: [docs/design/ui-design.md](docs/design/ui-design.md) +- Reference: [docs/reference/](docs/reference/) + +## Contributing + +Contribution workflow, setup, and validation expectations are documented in +[CONTRIBUTING.md](CONTRIBUTING.md) and [CONDUCT.md](CONDUCT.md). + +## License + +Licensed under the [Apache License 2.0](LICENSE). diff --git a/klanker-gate/SECURITY.md b/klanker-gate/SECURITY.md new file mode 100755 index 0000000..cf80428 --- /dev/null +++ b/klanker-gate/SECURITY.md @@ -0,0 +1,69 @@ +# Security Policy + +## Overview + +Security is a first-class implementation concern in Frosty Deno. The gateway +fails closed around durable state, governance, and config encryption; redacts +secrets before they reach the browser; and keeps its runtime permissions +intentionally narrow. + +The detailed implementation model is documented in +[docs/concepts/security-model.md](docs/concepts/security-model.md). The full +dependency inventory and SBOM are documented in +[docs/reference/sbom.md](docs/reference/sbom.md). + +## Supported versions + +The current checked-in gateway version is `0.9.0`. The repository does not +publish a richer tagged release matrix, so the support statement is +intentionally conservative. + +| Version line | Supported | +| ------------------------------------- | --------------------------------------- | +| `0.9.x` | Yes | +| Earlier or untagged historical states | No support commitment published in-repo | + +## Reporting a vulnerability + +Do not open a public GitHub issue for a security vulnerability. + +Use a private channel instead: + +1. If the repository is hosted on GitHub with security advisories enabled, use + the repository's **Security** tab and choose **Report a vulnerability**. +2. If that private advisory flow is unavailable in the hosting environment, + contact the maintainers through a private maintainer channel rather than a + public issue. + +No dedicated security email address is defined in the checked-in repository +files, so this document intentionally does not invent one. + +Include the following in your report: + +- a clear description of the issue and why it matters +- affected routes, components, or integrations +- reproduction steps, including required configuration +- the version or commit you tested +- any logs, payloads, or proof-of-concept details that help reproduce the issue + safely + +## Disclosure process + +The intended process is: + +1. Acknowledge the report privately. +2. Reproduce the issue and assess scope and severity. +3. Prepare a fix and matching regression coverage. +4. Release or publish the remediation. +5. Coordinate public disclosure after a fix exists. + +## Dependency security + +The checked-in SBOM and the repository-local SBOM generator are the source of +truth for dependency inventory: + +- [docs/reference/sbom.md](docs/reference/sbom.md) +- [docs/reference/sbom/sbom.cyclonedx.json](docs/reference/sbom/sbom.cyclonedx.json) +- `scripts/generate_sbom.ts` + +Recommended follow-up scans are documented in the SBOM itself. diff --git a/klanker-gate/TODO.md b/klanker-gate/TODO.md new file mode 100755 index 0000000..14a1883 --- /dev/null +++ b/klanker-gate/TODO.md @@ -0,0 +1,313 @@ +# TODO + +Deliberate, known follow-ups. Each item names what is missing, why it was left, +and what has to move for it to be done. + +This file used to be a **pointer** to two owning records - a numbered decision +log and an open-risks register. Both were retired on 2026-07-30 (item 1), and +this file is now **the register itself**: it is where a new deliberate +divergence gets recorded, and where a code comment points when its rationale is +too long to sit at the point of use. That raises the bar on what goes in it: an +entry needs the mechanism, the evidence in code, what "done" means, and the +trigger that reopens it, because there is no second document to carry the +rationale. + +Two kinds of entry live here, and they are kept apart on purpose: + +- **Open follow-ups** (the numbered items) - work that is not done. Their + numbers are reading order and will change as items are added and closed, so + **never cite an open item by number from code**. +- **[Decisions the code cites](#decisions-the-code-cites)** - closed decisions + whose rationale a source comment depends on. Each carries a **stable key** + such as `D-REBUILD-HEADERS`. Code cites the key, never a position, which is + the one property the retired numbered scheme had and the reason it was citable + at all. + +Bare numbers such as "decision-log 57" that survive in comments or in +`CLAUDE.md` are **provenance only**: they record that a decision was taken and +where it was once written down. They resolve to nothing on disk, and four of +them resolve to nothing anywhere (item 1). + +--- + +## 1. The decision log and open-risks register are retired, and 38 files still cite them + +**Status: decided 2026-07-30 (owner: retirement accepted). Links and code +citations closed; four items' rationale is unrecoverable.** +`docs/contracts/decision-log.md` (50 448 B at HEAD) and +`docs/guides/open-risks.md` (23 279 B) were deleted from the working tree, along +with nine other `docs/contracts/*` files, `docs/guides/admin-api-cookbook.md`, +both `docs/runbooks/*` files and `docs/assets/architecture-diagram.md`. The +owner accepted the retirement rather than restoring from HEAD. + +**Owner:** unassigned · **Severity:** Major · **Records:** this item + +**What makes it work rather than a clean deletion.** The numbered scheme was +load-bearing. 38 files in the tree cite it, and not only docs: + +| Citer | Was | Now | +| ------------------------------------------------------------------ | --- | ------------------------------------------ | +| `CLAUDE.md` | 10 | 0 links, inline numbers kept as provenance | +| [apps/gateway/context.ts](apps/gateway/context.ts) | 4 | 0 | +| [packages/core/src/translate.ts](packages/core/src/translate.ts) | 3 | 0 | +| [packages/core/src/middleware.ts](packages/core/src/middleware.ts) | 2 | 0 | +| `AGENTS.md`, `permissions.md`, tests, other docs | 19 | prose provenance only | + +15 of those citations were **markdown links**, and were dead links, in +`CLAUDE.md`, `AGENTS.md`, `CONTRIBUTING.md`, `docs/benchmark-report.md` and this +file; all 15 are gone. The nine in the three source files above were prose +references of the form "(decision-log 71)" and are gone too - see below for what +that cost. + +**Nine source citations, and only two carried content the comment did not.** +Reviewed one by one on 2026-07-30. Eight of the nine comments already stated the +constraint they cited, so the number was decoration and dropping it lost +nothing. The exception was `middleware.ts`, which said _"see decision-log 87 for +the three rebuild sites, the 15 affected routes and the accepted `serveDir` +consequence"_ - a forward reference to information held nowhere else. That +content is now [D-REBUILD-HEADERS](#d-rebuild-headers), re-verified against the +code rather than copied from memory, and the shared-rate-limit rationale is +[D-SHARED-RATE-LIMIT](#d-shared-rate-limit). + +**Four items are gone for good: 14, 15, 69 and 79.** They were cited by +`translate.ts` (14, 15, 79) and `context.ts` (69). Each cited comment states its +own constraint, so no behavior is undocumented: + +| Lost item | The constraint that survives, in the comment | +| --------- | ---------------------------------------------------------------------------------- | +| 14 | a zero token count means "the provider never told us", never "known and discarded" | +| 15 | edge translators run AFTER the plugin stream tap, so hooks see the canonical shape | +| 69 | config reload applies REMOVALS, because an upsert-only reload cannot revoke | +| 79 | one canonical execution path is what lets every ingress dialect share it | + +What is lost is the reasoning behind each, and any alternative that was +rejected. Reconstructing them would mean inventing rationale, so they are +recorded as lost rather than guessed at. If one of these four decisions is ever +reopened, treat it as undecided and re-derive it. + +The retired revision is also not recoverable. HEAD holds an _older_ lineage: its +log is the original Wave-1..Wave-5 prose with no numbered entries at all (items +57, 68, 81 and 87 return zero hits) and its risk register stops at `R19`. The +numbered 1..87 log and `R20`..`R25` existed only in the uncommitted tree. There +is no stash, no `checkout`/`reset` in the reflog, and no editor local-history +copy. + +**Consequence absorbed.** This file is the new home, and +[Decisions the code cites](#decisions-the-code-cites) is where a divergence +whose rationale a comment depends on gets recorded, keyed rather than numbered. +Work that planned to append entries 88-91 or risks R26-R27 records them there +instead, with a fresh key each, at the point the work lands rather than in +advance. + +**Still open:** + +- `docs/contracts/fixtures/` holds the two golden fixtures that + [tests/contract/golden_chat_test.ts:28](tests/contract/golden_chat_test.ts#L28) + reads at module load, and is now the only inhabitant of a retired directory. + Moving it under `tests/contract/` would finish the retirement; it was left + alone because the deletion of those two files is what made the suite red in + the first place and re-touching them was not worth bundling into that repair. +- The bare numbers left inline in `CLAUDE.md` and in `permissions.md`, tests and + other docs. They are provenance, not links, and are labelled as such in the + preamble here. + +**Reopen trigger:** a reader following a citation that resolves to nothing, or a +new divergence recorded as a bare number instead of a key. + +## 2. Telemetry does not cost the media surfaces + +**Status: done for the chat surfaces (2026-07-29); the media surfaces are +mid-build.** `INFERENCE_PATHS` is now an `isInferencePath()` predicate covering +the four canonical paths plus `/v1/messages`, `/cohere/v2/chat`, GenAI generate +actions, the Azure deployment-scoped ops and OpenRouter chat and embeddings, +with metrics, usage, spans and log enrichment widened together as required +below. + +**Owner:** unassigned · **Severity:** Minor · **Records:** this item; provenance +decision-log 56 (the gap) and 81 (the chat-surface closure), open-risks R19 + +What is left is `/v1/images/generations`, `/v1/audio/speech` and +`/v1/audio/transcriptions`. They still need `normalizeUsage` and the pricing +catalog to model per-image, per-character and per-second billing before they can +be observed without producing counted-but-uncosted records. + +| Route | Billing unit | +| -------------------------- | ------------------- | +| `/v1/images/generations` | per image | +| `/v1/audio/speech` | per character | +| `/v1/audio/transcriptions` | per second of audio | + +`/v1/batches`, `/v1/files`, `/v1/count_tokens` and `/v1/models` are correctly +outside the set: none is a per-request billable completion. + +**Why it was left.** The log trail was wired to the telemetry layer that already +existed; that did not change what the layer observes. Widening the predicate +moves billing-adjacent accounting - usage records feed governance budgets and +the analytics rollups - which is a materially larger blast radius than a log +column. + +**Done means:** metrics, usage records, spans and log enrichment all widen +**together**, not logs alone. + +**Reopen trigger:** any request to observe a non-`/v1`-canonical inference +surface, or a report that spend on one of the routes above is missing from +`/api/analytics` or the Grafana dashboards. + +## 3. `LOG_LEVEL` is documented and plumbed but read by nothing + +**Status: OPEN.** `LOG_LEVEL` has a row in the quick-reference table of +[docs/reference/environment-variables.md](docs/reference/environment-variables.md) +and a mention under "Core gateway and PostgreSQL", and +[docker-compose.yml](docker-compose.yml) forwards it into the container. No code +reads it, on any file type. An operator setting `LOG_LEVEL=debug` gets silence. + +**Owner:** unassigned · **Severity:** Minor · **Records:** this item + +It was removed from `.env.example` in the 2026-07-30 env cleanup, because an +example file that lists a dead knob is the defect. The docs row and the Compose +passthrough still promise it. + +**Done means:** either implement a bounded log-level parse and restore the +`.env.example` line, or drop the docs row and the Compose passthrough too. + +**Reopen trigger:** a report that log verbosity cannot be changed. + +## 4. `totalTokens` is an unclamped vendor sum + +**Status: OPEN, narrowed.** In +[apps/gateway/routes/telemetry.ts:135](apps/gateway/routes/telemetry.ts#L135) +`totalTokens` is `prompt + completion + (cacheCreation ?? 0)` with no ceiling. +It reaches the usage record (`:159`, `:190`) and the `gen_ai.usage.total_tokens` +span attribute (`:216`). It never passes through `costMicroUsd`, so the clamp +that protects the cost counter does not cover it. + +**Owner:** unassigned · **Severity:** Minor · **Records:** this item + +A provider returning three fields at `MAX_SAFE_INTEGER` yields a usage row and a +span attribute of `3 x MAX_SAFE_INTEGER`. Money is unaffected. + +**Done means:** the same bounded parse the cost path uses, applied **after** the +vendor sum rather than per field - clamping each field independently leaves +`2 x MAX_SAFE_INTEGER`, which is the measured failure of the per-field approach. + +**Reopen trigger:** an implausible token total in `/api/analytics` or a Tempo +span. + +## 5. Deliberate gaps that are still live + +Each is a decision, not an oversight; the row is here so it stays visible. The +evidence column is the code that implements the refusal. + +| Gap | Shape | Evidence | +| -------------------------------------------------- | -------------------------------------- | -------------------------------------------------------------------------------------- | +| OpenRouter native `GET /generation` and `GET /key` | Explicit 501 | [openrouter_ingress.ts:171](apps/gateway/routes/openrouter_ingress.ts#L171) | +| Aggregator-path Bedrock native ingress | Explicit 501 | [compat_families.ts:795](apps/gateway/routes/compat_families.ts#L795) | +| Serving a cache hit as a stream | Not implemented by design | cache reads are non-streaming; completed streams are stored via the passive tee | +| Code Mode executor | Two gates, and the run primitive stubs | [packages/mcp/src/codemode/](packages/mcp/src/codemode/), `FROSTY_CODE_MODE` | +| Kubernetes and Helm packaging | Not present | deployment assets are Docker and Compose only | +| Some provider-panel config fields | Persisted and surfaced, not enforced | field comments in [packages/contracts/src/config.ts](packages/contracts/src/config.ts) | + +The authoritative version of this table is the "Gaps and partial +implementations" section of +[docs/concepts/functionality-and-capabilities.md](docs/concepts/functionality-and-capabilities.md). +Do not let the two drift; that file wins. + +## 6. Multi-process serving has a residual per-process gap + +**Status: shipped with a named residual.** `FROSTY_WORKERS` is live, and budgets +and rate-limit windows are fleet-wide through PostgreSQL. The residual gap is +per-process state that no shared authority covers. + +**Owner:** unassigned · **Severity:** Info · **Records:** this item; provenance +decision-log 62, 70, 71 (the shipped behavior) and 73 (the residual) + +**Reopen trigger:** an operator report of limit overshoot that shared-authority +rate limiting does not explain. + +--- + +# Decisions the code cites + +Closed decisions whose rationale a source comment depends on. Each has a +**stable key**; a comment cites the key and nothing else, so entries can be +added, split or reordered without invalidating a citation. A key is never reused +or renamed. + +Only decisions a comment genuinely cannot carry inline belong here. If the +constraint fits in a note under four lines at the point of use, that is where it +goes and no entry is needed - which is why this section is short and is expected +to stay short. + +## D-REBUILD-HEADERS + +**Rebuilding a `Response` around a new body invalidates the headers that +describe the old one, so the serving boundary drops all three.** + +`REBUILT_BODY_HEADERS` in +[packages/core/src/middleware.ts](packages/core/src/middleware.ts) is +`content-encoding`, `content-length`, `transfer-encoding`, matched lowercased. + +Why each one: + +- `Content-Encoding` - Deno's `fetch` decompresses transparently but **keeps the + header**. A rebuild loses the internal already-decoded flag, so the header + stops describing the bytes and becomes an instruction the client acts on and + fails. This is the one that corrupted responses rather than merely + mis-describing them. +- `Content-Length` - a provider's value describes the **encoded** bytes. +- `Transfer-Encoding` - hop-by-hop; the serving runtime owns framing. + +**Three sites rebuild a `Response` around a replacement body. Two strip, one +deliberately does not:** + +| Site | Behavior | +| --------------------------------------------------------------------------------- | -------------------------------------------------------------- | +| [middleware.ts:90](packages/core/src/middleware.ts#L90) (`makeRequestLogger`) | strips - this is the serving boundary | +| [routes/helpers.ts:147](apps/gateway/routes/helpers.ts#L147) (`rebuild`) | strips | +| [providers/src/client.ts:171](packages/providers/src/client.ts#L171) (`withBody`) | does **not** strip, and is safe because of the placement below | + +**The fix is at the serving boundary because that placement is terminal in both +directions.** `withBody` is INWARD of the request logger, so a stale header it +attaches is cleaned on the way out. `reshapeError` +([compat_families.ts:188](apps/gateway/routes/compat_families.ts#L188)) rebuilds +around a different body too, but lives inside `compatPrefixMiddleware`, which +`main.ts` composes OUTWARD of the logger - it is safe for the opposite reason, +because it copies headers the logger has already cleaned. A third header-copy at +[governance.ts:584](apps/gateway/routes/governance.ts#L584) reuses the _same_ +body, so its framing headers are still true. + +**A denylist, not an allowlist.** An allowlist would silently drop +`openai-organization`, `x-request-id` and the `x-ratelimit-*` family, which +reach the client today and must continue to. The trade is that a future provider +header this rebuild also invalidates is inherited rather than caught. + +**Accepted consequence.** The rule applies to every response passing the logger, +including static assets from `serveDir` +([main.ts:110](apps/gateway/main.ts#L110)). Those lose a `Content-Length` the +runtime then re-derives, so the cost is a recomputation, not a behavior change. + +**Provenance:** decided 2026-07-29 as decision-log 87 and measured then as +repairing 15 live-broken routes. That route count is recorded as it was measured +on that date and has not been re-derived since; the mechanism and the three +sites above were re-verified 2026-07-30. + +## D-SHARED-RATE-LIMIT + +**Fixed-window rate limiting moves to a shared PostgreSQL authority only when +more than one process shares the port, because a single process already is the +whole fleet.** + +Measured: a shared reservation costs **~1.8 ms** at 50 concurrent against local +PostgreSQL, versus **~1 us** for the in-process `Map`. In single-process mode +the Map is already fleet-accurate, so paying that buys nothing. + +`FROSTY_SHARED_RATE_LIMIT` is `auto|on|off`, resolved by +[`sharedRateLimitEnabled`](apps/gateway/context.ts) - `auto` keys off +`FROSTY_WORKERS`, `on` forces it for operators running separate replicas that +`auto` cannot detect, `off` accepts N-times-the-limit across N processes. When a +shared limiter is present, `VirtualKeyManager` stands its own in-process windows +down so exactly one authority counts. + +**Provenance:** decided as decision-log 71; the numbers live in +[docs/benchmark-report.md](docs/benchmark-report.md), which is their maintained +home. diff --git a/klanker-gate/apps/control-ui/CONVENTIONS.md b/klanker-gate/apps/control-ui/CONVENTIONS.md new file mode 100755 index 0000000..3227ec0 --- /dev/null +++ b/klanker-gate/apps/control-ui/CONVENTIONS.md @@ -0,0 +1,210 @@ +# Control UI conventions (flagship contract) + +Shared build patterns every control-ui view follows. Set by the Providers +flagship pass; later views (Model Catalog, Settings, Logs, MCP, Governance, +Dashboard) must match. When in doubt, read `ProvidersView.tsx` and its +`components/providers/*` for a worked example. + +Design system is **ds-r2** (dense neutral-monochrome dark console). Tokens live +in `src/styles/tokens.css` - do not hand-edit component CSS to diverge; consume +the Tailwind token classes. + +## Taste hard-rules (non-negotiable, from `taste.md`) + +- **Zero em/en dashes** anywhere. Plain hyphen only. (deno lint + review check.) +- **One cool-blue accent** (`ring`, links, active-nav indicator) - never a fill. + Primary is a monochrome emphasis surface, not a chromatic color. +- **Monochrome-first.** Status/semantic color (success/warning/destructive/info) + is a functional vocabulary only; never decorative. +- **Dual-theme AA** in both light and dark. Every text/surface pair is + contrast-checked. +- **6px radius family**, one system. `rounded-sm|md|lg|xl` map to 4/6/8/12px. +- **44px min hit area** (`hit-target` class), **2px focus ring @ 2px offset** + (global `:focus-visible`), **full keyboard path** on every control. +- **lucide-react icons only**; no hand-rolled decorative SVG, no glow, no + glassmorphism. Elevation via borders + surface steps. +- **Motion is feedback-only** (`--motion-fast|default|slow`); reduced-motion + collapses to 0. No decorative animation. +- **Same-origin only.** No external CDN/script/font/style/image origins. +- **Frosty identity.** Own brand, `sk-`/`vk-` prefixes; never the reference + product's name, logos, or hues. + +## Page structure + +- Every view opens with ``. The title is an + `h2` and the focus target on nav change (do not add a second `h2`). +- Primary actions go in `PageHeader actions` (right cluster). Destructive or + bulk actions live inside the relevant card/panel, not the header. +- **Keep a destructive operation away from a Save button.** When a panel mixes + configuration edits with immediate-effect operations, the operations go below + the panel's save footer behind a divider, in their own labelled block - see + `CacheOpsPanel` mounted under `CachingPanel`'s `PanelFooter` + (`CachingPanel.tsx:322-340`), so a destructive purge is never adjacent to the + Save button that applies configuration edits. +- Content max width is `--container-max` (110rem / 1760px; the app shell applies + it). Page gutters use `--gutter`, which tightens from 24px to 16px at <= 48rem + so a tablet does not spend a quarter of its width on padding. Tables and + dashboards may use the full width; anything text-heavy takes `.measure` + instead. +- Errors: `` quoting the gateway's `error.message` verbatim + (`ApiError.message`). Info/warn use `tone="info"|"warn"`. +- Loading: `TableSkeleton` / `TileSkeleton` / `Skeleton`, never a bare spinner + page. + +## Tabs: SubTabs vs UnderlineTabs vs Tabs + +- **`SubTabs`** (pill row) - dashboard-style section switcher across a wide + surface (e.g. Overview / Provider Usage / Model Rankings). +- **`UnderlineTabs`** - configuration panels and settings sub-navigation + (Providers config Network/Proxy/...; Settings Security/Compatibility/...). + This is the "sub-page within a view" bar. +- **`Tabs`** (segmented, on a muted track) - available for a small + binary/ternary local switch inside a card, but currently unused: no view + renders ``. Only `tabPanelProps(value)` and the `TabItem` type are + consumed today. Logs Live/Stored is a `Button` with `aria-pressed`, not a Tabs + instance. Reach for `Tabs` only when a real segmented switch appears; + otherwise prefer `SubTabs` / `UnderlineTabs`. +- Always pass a unique `label`; spread `tabPanelProps(value)` on the matching + panel container for the `role="tabpanel"` wiring. + +## Tables: `DataTable` + +Use `DataTable` for every resource list. Never hand-roll `` sorting. + +- `columns`: `{ key, header, cell, sortValue?, align?, width? }`. Provide + `sortValue` to make a column sortable; `headerLabel` when `header` is not + plain text. +- `rowMenu`: return a `` for per-row actions (Edit / Make default + / Delete). Keep row action clusters out of cells; the kebab is the pattern. +- `pageSize`: set to enable the "Showing X-Y of Z" footer + prev/next. +- `caption` is required (labels the scroll region + screen readers). +- `empty`: pass a node; default copy is "No results." Prefer the shared empty + string **"No data available"** for analytics-style empties (see EmptyState). + +## Forms + +- Field grid: **`.field-grid`** (index.css). It is + `repeat(auto-fit, minmax(min(100%, 16rem), 22rem))` - as many columns as fit, + each capped at 22rem. Do NOT go back to `sm:grid-cols-2`: that sized every + field to half the container, so one "ID" input was 350px in a wide pane and + 560px at the current container width. The 22rem cap is the point - a field + stops growing at a width appropriate to its content and leftover space stays + empty. Use **`.field-wide`** (a direct child of `.field-grid`) for fields that + genuinely need the row: JSON blobs, PEM, long descriptions. +- Wrap prose and single-column form panels in **`.measure`** (`--measure-max`, + 60rem). Raising `--container-max` to 110rem means an uncapped label-control + pair can span 1760px, which puts the label a screen away from its input. +- **`Field`** (`label` + control + hint/error) for text inputs (`Input`, + `Textarea`, `NativeSelect`). `NativeSelect` is the only select for plain + option lists (G9). +- **`NumberField`** for numeric config (unit suffix + help). Emits a raw string + so empty stays representable; parse with a `numOrUndef` helper on save. +- **`KeyValueRows`** for repeatable Name/Value editors (extra headers). +- **`PemTextarea`** for PEM/cert blobs (non-blocking validity hint). +- **`SegmentedSelect`** for inline 2-3 option choices (beta-header override + default/enabled/disabled). +- **`Combobox`** for searchable single-select (reset periods, model/customer + filters). +- **`ToggleGridItem`** for a labelled switch tile in a responsive grid. +- **`Switch`** for a lone boolean; wrap with a label + description row. + +## Secrets (never render values) + +The server returns **redacted** views: secrets become `hasX` presence markers +(`hasApiKey`, `hasCloudCredentials`, `hasProxy`, `hasProxyPassword`, +`hasCaCert`). The raw value never reaches the browser. + +- Show a **marker** ("Configured" + `••••••••`) plus a **Replace** affordance + that reveals an input to submit a _new_ secret. Use + `components/providers/SecretReenter` for single-line secrets; `PemTextarea` + (with a "Configured" badge) for `caCertPem`. +- Blank input = keep the current server value where possible. +- `MaskedSecretCell` (reveal + copy) is for values the browser legitimately + holds once - e.g. a freshly minted `vk-` token in a create response - never + for a redacted-at-rest secret you cannot actually reveal. +- **Gateway PUT is a shallow top-level merge** (`{...existing, ...patch}`). A + nested group you send _replaces_ the stored group, dropping any redacted + secret it contains. So: diff each config group against its loaded state and + send only changed groups (see `ProviderConfigPanel`), and warn when a save + would clear a stored secret the operator did not re-enter. + +## Status pills (`Badge`) + +- `tone="muted"` for neutral labels (CUSTOM, default, counts). Prefer muted to + stay monochrome. +- `tone="ok|warn|err|info"` only for genuine status semantics (enabled, missing + key, error, read-only). Soft variant by default; `solid` sparingly. +- Key presence in lists: plain `"set"` / `"missing"` micro-text (muted / + warning), not a loud pill. + +## Empty states + +- ``; use `tone="info"` for "feature off / coming + later" notices. Standard analytics empty copy is **"No data available"** to + match the reference. + +## Fixed-width rails must clip + +Any fixed-width flex rail (`TwoPane`'s `aside`) needs `overflow-hidden`, and any +badge/marker cluster inside a flex row needs `shrink-0`. Without both, a row +whose intrinsic content exceeds the rail paints its trailing badges OUTSIDE the +rail and on top of the neighbouring pane - the Providers overlap bug. The +combination makes a crowded row a truncation problem instead of an overlap one. +Locked by `two-pane.overflow.test.tsx`. + +## Density & spacing + +- Body 13.5px (`text-base`), secondary `text-sm`, micro labels `text-2xs` + uppercase tracking-wide muted. Mono (`font-mono`) for all telemetry: ids, + keys, latency, cost, tokens, versions. +- Control heights: `--control-h` (34px) default, `--control-h-sm` (30px) dense. +- Card padding `px-5 py-4`; section gaps `gap-4`/`gap-5`; page section spacing + `mb-5`/`mb-6`. + +## Navigation & routing + +- The IA is grouped: **Overview** (Dashboard, Logs, Status) / **Gateway** + (Providers, Model Catalog, Extensions) / **Governance** (Virtual keys, Teams, + Customers, Pricing) / **System** (Settings). +- **Status is an Overview leaf**, not System: it answers "is the gateway healthy + right now", which sits with Dashboard and Logs rather than with configuration. +- **Cache and Config are Settings tabs**, not views. `#/cache` and `#/config` + are kept alive by `REDIRECTS` in `App.tsx`, which `history.replaceState`s them + onto `#/settings/caching` and `#/settings/config`. When you fold a view into a + tab, add the redirect - a bookmark that lands on the fallback view reads as a + broken link, not as a reorganization. +- Hash router in `App.tsx` keys off the **first** hash segment (`baseSegment`), + so a view owning sub-pages uses `#//` and manages its own sub-nav + + `history.replaceState` (see `SettingsView`). Add a view by extending `NAV` + + `renderView`; the sidebar, search, and Cmd/Ctrl-K palette pick it up + automatically. +- **Pinned browser contract:** keep nav leaves matchable by accessible name + `Providers`, `Status`, `Logs`, `Extensions` (unique). Do not introduce sibling + elements whose accessible name _contains_ a pinned token (avoid a button named + "Refresh providers" - collides with "Providers"; keep aria labels distinctive, + e.g. "Reload configuration"). + +## api.ts client (Phase 3b endpoints, already wired) + +`src/api.ts` owns the transport, types, and endpoint clients. Consume these; do +not add `fetch` calls in views. + +- `getCatalog(): CatalogView` - `GET /api/catalog` (Model Catalog). +- `getSettings(): SettingsView` / `putSettings(update): SettingsView` - + `/api/settings`. Each group has `{ values, sources }`; `sources[field]` is + `default|env|override` (drive a provenance pill) and `enforcement["g.field"]` + is whether the gateway enforces it. +- `getCodeModeVfs(binding): CodeModeVfsView` - `GET /api/mcp/codemode/vfs`. +- `getRuntime(): RuntimeView` - `GET /api/runtime` (Status > Runtime). Process + topology, saturation, and limit state. Two of its numbers are **per-process** + (`concurrency.*` and per-window `rateLimit`), and the UI must label them as + such on the tile itself, not only in a footnote: under `FROSTY_WORKERS=N` an + unqualified "12 in flight" reads as fleet-wide and under-reports load by a + factor of N. Budgets are unaffected - those run on shared atomic counters. + Render `workers.reason` verbatim; it is the gateway's own explanation of why + fan-out did or did not happen. +- Providers: `getConfig`, `createProvider`, `updateProvider`, `deleteProvider`, + `refreshModels`, `setDefaultProvider`. + +All clients normalize partial/malformed bodies and (catalog) treat a 404 as +"feature off", so consumers stay total. diff --git a/klanker-gate/apps/control-ui/deno.jsonc b/klanker-gate/apps/control-ui/deno.jsonc new file mode 100755 index 0000000..89c8954 --- /dev/null +++ b/klanker-gate/apps/control-ui/deno.jsonc @@ -0,0 +1,15 @@ +{ + "tasks": { + "dev": "deno run -A npm:vite", + "build": "deno run -A npm:typescript@7.0.2/tsc && deno run -A npm:vite build", + "preview": "deno run -A npm:vite preview", + "check": "deno run -A npm:typescript@7.0.2/tsc", + "test": "deno run -A npm:vitest run" + }, + "fmt": { + "exclude": ["node_modules", "dist"] + }, + "lint": { + "exclude": ["node_modules", "dist"] + } +} diff --git a/klanker-gate/apps/control-ui/index.html b/klanker-gate/apps/control-ui/index.html new file mode 100755 index 0000000..af62be8 --- /dev/null +++ b/klanker-gate/apps/control-ui/index.html @@ -0,0 +1,28 @@ + + + + + + Klanker Gateway Manager + + + +
+ + + diff --git a/klanker-gate/apps/control-ui/package.json b/klanker-gate/apps/control-ui/package.json new file mode 100755 index 0000000..9cec716 --- /dev/null +++ b/klanker-gate/apps/control-ui/package.json @@ -0,0 +1,28 @@ +{ + "name": "control-ui", + "private": true, + "version": "0.7.0", + "type": "module", + "dependencies": { + "clsx": "^2.1.1", + "lucide-react": "^1.25.0", + "react": "^19.2.8", + "react-dom": "^19.2.8", + "tailwind-merge": "^3.6.0" + }, + "devDependencies": { + "@tailwindcss/vite": "^4.3.3", + "@testing-library/jest-dom": "^7.0.1", + "@testing-library/react": "^16.3.3", + "@testing-library/user-event": "^14.6.6", + "@types/react": "^19.2.18", + "@types/react-dom": "^19.2.5", + "@vitejs/plugin-react": "^6.1.1", + "jsdom": "^30.0.1", + "tailwindcss": "^4.3.3", + "typescript": "^7.0.2", + "vite": "^8.2.2", + "vitest": "^4.1.11", + "zod": "^4.4.3" + } +} diff --git a/klanker-gate/apps/control-ui/src/App.nav.test.tsx b/klanker-gate/apps/control-ui/src/App.nav.test.tsx new file mode 100755 index 0000000..252255b --- /dev/null +++ b/klanker-gate/apps/control-ui/src/App.nav.test.tsx @@ -0,0 +1,49 @@ +// Navigation restructure: Status moved to Overview, Cache and Config folded +// into Settings tabs, and the legacy hashes kept working. +// +// The redirect cases are the ones worth locking. Removing a nav leaf is +// visible immediately; a bookmark that silently lands on the wrong view is not, +// and reads as a broken link rather than as a reorganization. + +import { describe, expect, it } from "vitest"; +import { redirectFor } from "./App"; + +describe("legacy hash redirects", () => { + it("sends the old Cache page to the Settings caching tab", () => { + expect(redirectFor("#/cache")).toBe("settings/caching"); + }); + + it("sends the old Config page to the Settings config tab", () => { + expect(redirectFor("#/config")).toBe("settings/config"); + }); + + it("tolerates the hash with and without a leading slash", () => { + expect(redirectFor("#cache")).toBe("settings/caching"); + expect(redirectFor("#/cache")).toBe("settings/caching"); + }); + + it("leaves current routes alone", () => { + for (const hash of ["#/status", "#/settings", "#/providers", "#/logs"]) { + expect(redirectFor(hash)).toBeNull(); + } + }); + + it("leaves an already-migrated settings sub-route alone", () => { + // Redirecting this would loop: the destination contains the source token. + expect(redirectFor("#/settings/caching")).toBeNull(); + expect(redirectFor("#/settings/config")).toBeNull(); + }); + + it("does not invent a destination for a deep legacy path", () => { + // "#/cache/anything" was never a route this app minted. Rewriting it would + // guess at an intent that was never expressed. + expect(redirectFor("#/cache/entry/123")).toBeNull(); + expect(redirectFor("#/config/export")).toBeNull(); + }); + + it("ignores an empty or unknown hash", () => { + expect(redirectFor("")).toBeNull(); + expect(redirectFor("#/")).toBeNull(); + expect(redirectFor("#/nonsense")).toBeNull(); + }); +}); diff --git a/klanker-gate/apps/control-ui/src/App.rebuild.test.tsx b/klanker-gate/apps/control-ui/src/App.rebuild.test.tsx new file mode 100755 index 0000000..0edec52 --- /dev/null +++ b/klanker-gate/apps/control-ui/src/App.rebuild.test.tsx @@ -0,0 +1,98 @@ +import { render, screen, waitFor } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import { LogsView } from "./views/LogsView"; +import { apiFetch, clearAdminToken, saveAdminToken } from "./api"; + +function jsonResponse(body: unknown): Response { + return new Response(JSON.stringify(body), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); +} + +function sseResponse(frames: string[]): Response { + const stream = new ReadableStream({ + start(controller) { + const encoder = new TextEncoder(); + for (const frame of frames) { + controller.enqueue(encoder.encode(frame)); + } + controller.close(); + }, + }); + return new Response(stream, { + status: 200, + headers: { "Content-Type": "text/event-stream" }, + }); +} + +describe("LogsView fetch-SSE (security #9: no EventSource)", () => { + it("streams the log via fetch and renders replayed frames", async () => { + const fetchSpy = vi.spyOn(globalThis, "fetch").mockImplementation( + (input) => { + const url = String(input); + if (url.includes("/api/logs/stream")) { + const entry = { + ts: "12:00:00", + level: "info", + message: "GET /v1/models", + status: 200, + }; + return Promise.resolve( + sseResponse([`data: ${JSON.stringify(entry)}\n\n`]), + ); + } + if (url.includes("/api/logs/stored")) { + return Promise.resolve(jsonResponse({ entries: [], total: 0 })); + } + return Promise.resolve(jsonResponse({})); + }, + ); + + render(); + + // The Logs view renders immediately (Live is the default source). + expect(screen.getByText(/Live request stream and stored history/)) + .toBeInTheDocument(); + + // The replayed SSE frame is decoded and rendered. + await waitFor(() => + expect(screen.getByText(/GET \/v1\/models/)).toBeInTheDocument() + ); + + // The stream was consumed via fetch, and no EventSource was constructed. + expect( + fetchSpy.mock.calls.some((call) => + String(call[0]).includes("/api/logs/stream") + ), + ).toBe(true); + const fake = (globalThis as Record).__FakeEventSource as { + instances: unknown[]; + }; + expect(fake.instances.length).toBe(0); + }); +}); + +describe("apiFetch auth scope (security #2)", () => { + it("attaches Bearer only to /api/* and not to /healthz or /v1/*", async () => { + saveAdminToken("secret-token-value"); + const fetchSpy = vi + .spyOn(globalThis, "fetch") + .mockImplementation(() => Promise.resolve(jsonResponse({}))); + + await apiFetch("/api/config"); + await apiFetch("/healthz"); + await apiFetch("/v1/models"); + + const authFor = (path: string): string | null => { + const call = fetchSpy.mock.calls.find((c) => String(c[0]) === path); + return new Headers(call?.[1]?.headers).get("Authorization"); + }; + + expect(authFor("/api/config")).toBe("Bearer secret-token-value"); + expect(authFor("/healthz")).toBeNull(); + expect(authFor("/v1/models")).toBeNull(); + + clearAdminToken(); + }); +}); diff --git a/klanker-gate/apps/control-ui/src/App.test.tsx b/klanker-gate/apps/control-ui/src/App.test.tsx new file mode 100755 index 0000000..9c314dc --- /dev/null +++ b/klanker-gate/apps/control-ui/src/App.test.tsx @@ -0,0 +1,164 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it, vi } from "vitest"; +import App from "./App"; +import { ProvidersView } from "./views/ProvidersView"; +import { StatusView } from "./views/StatusView"; + +function jsonOk(body: unknown): Response { + return new Response(JSON.stringify(body), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); +} + +function mockGateway() { + return vi.spyOn(globalThis, "fetch").mockImplementation((input) => { + const url = String(input); + if (url.includes("/api/config")) { + return Promise.resolve(jsonOk({ + defaultProvider: "openai", + providers: [ + { + id: "openai", + type: "openai", + enabled: true, + models: ["gpt-4o", "gpt-4o-mini"], + priority: 0, + hasApiKey: true, + }, + { + id: "anthropic", + type: "anthropic", + enabled: false, + models: [], + priority: 0, + hasApiKey: false, + }, + ], + })); + } + if (url.includes("/healthz")) { + return Promise.resolve(jsonOk({ + status: "ok", + version: "0.7.0", + timestamp: "2026-07-13T00:00:00Z", + })); + } + if (url.includes("/api/version")) { + return Promise.resolve(jsonOk({ version: "0.7.0", deno: "2.9.2" })); + } + if (url.includes("/v1/models")) { + return Promise.resolve(jsonOk({ + object: "list", + data: [{ id: "openai/gpt-4o", object: "model", owned_by: "openai" }], + })); + } + if (url.includes("/api/mcp/clients")) { + return Promise.resolve(jsonOk({ + clients: [{ + id: "weather", + url: "https://mcp.example.com/rpc", + enabled: true, + transport: "http-sse", + toolCount: 2, + lastSyncAt: "2026-07-13T00:00:00Z", + }], + })); + } + if (url.includes("/api/mcp/tools")) { + return Promise.resolve(jsonOk({ + tools: [{ + name: "get_weather", + clientId: "weather", + annotations: { readOnlyHint: true }, + }, { + name: "delete_notes", + clientId: "weather", + }], + })); + } + if (url.includes("/api/plugins")) { + return Promise.resolve(jsonOk({ plugins: ["tagger"] })); + } + return Promise.resolve(jsonOk({})); + }); +} + +describe("ProvidersView", () => { + it("renders the configured providers list from the gateway config", async () => { + mockGateway(); + render(); + + expect((await screen.findAllByText("openai")).length).toBeGreaterThan(0); + expect(screen.getAllByText("anthropic").length).toBeGreaterThan(0); + expect(screen.getByText("default")).toBeInTheDocument(); + // Traffic-light status badge: green "online" (enabled + key) vs red "disabled". + expect(screen.getByText("online")).toBeInTheDocument(); + expect(screen.getByText("disabled")).toBeInTheDocument(); + }); + + it("shows an add-provider form", async () => { + mockGateway(); + render(); + expect( + await screen.findByRole("button", { name: "Add provider" }), + ).toBeInTheDocument(); + expect(screen.getByPlaceholderText("openai")).toBeInTheDocument(); + }); +}); + +describe("StatusView", () => { + it("shows health, runtime version, and the model catalog", async () => { + mockGateway(); + render(); + + expect(await screen.findByText("ok")).toBeInTheDocument(); + expect(screen.getByText(/gateway v0\.7\.0/)).toBeInTheDocument(); + expect(screen.getByText(/Deno 2\.9\.2/)).toBeInTheDocument(); + expect(await screen.findByText("openai/gpt-4o")).toBeInTheDocument(); + }); +}); + +describe("ExtensionsView", () => { + it("shows MCP servers, synced tools with safety badges, and plugins", async () => { + mockGateway(); + const user = userEvent.setup(); + const { ExtensionsView } = await import("./views/ExtensionsView"); + render(); + + // Default tab renders MCP servers: the client row plus the transport + // column and the add-form selector (default http-sse, decision D11). + expect((await screen.findAllByText("weather")).length).toBeGreaterThan(0); + expect(screen.getAllByText("http-sse").length).toBeGreaterThan(1); + + // Synced tools tab: tool names and per-call safety badges. + await user.click(screen.getByRole("tab", { name: "Synced tools" })); + expect(await screen.findByText("get_weather")).toBeInTheDocument(); + expect(screen.getByText("read-only")).toBeInTheDocument(); + expect(screen.getByText("needs confirmation")).toBeInTheDocument(); + + // Plugins tab: built-in plugin names from GET /api/plugins. + await user.click(screen.getByRole("tab", { name: "Plugins" })); + expect(await screen.findByText("tagger")).toBeInTheDocument(); + }); +}); + +describe("App", () => { + it("switches between tabs", async () => { + mockGateway(); + const user = userEvent.setup(); + render(); + + expect( + await screen.findByText("Configured Providers"), + ).toBeInTheDocument(); + + await user.click(screen.getByRole("button", { name: "Logs" })); + expect(screen.getByText(/Live request stream and stored history/)) + .toBeInTheDocument(); + + await user.click(screen.getByRole("button", { name: "Status" })); + expect(await screen.findByText("Gateway health")).toBeInTheDocument(); + }); +}); diff --git a/klanker-gate/apps/control-ui/src/App.tsx b/klanker-gate/apps/control-ui/src/App.tsx new file mode 100755 index 0000000..33366c3 --- /dev/null +++ b/klanker-gate/apps/control-ui/src/App.tsx @@ -0,0 +1,337 @@ +import { useEffect, useRef, useState } from "react"; +import { + Activity, + Boxes, + Building2, + CircleDollarSign, + KeyRound, + LayoutDashboard, + Menu, + Plug, + Puzzle, + ScrollText, + SlidersHorizontal, + Users, +} from "lucide-react"; +import { ProvidersView } from "./views/ProvidersView"; +import { StatusView } from "./views/StatusView"; +import { LogsView } from "./views/LogsView"; +import { ExtensionsView } from "./views/ExtensionsView"; +import { ModelCatalogView } from "./views/ModelCatalogView"; +import { SettingsView } from "./views/SettingsView"; +import { VirtualKeysView } from "./views/VirtualKeysView"; +import { TeamsView } from "./views/TeamsView"; +import { CustomersView } from "./views/CustomersView"; +import { PricingView } from "./views/PricingView"; +import { DashboardView } from "./views/DashboardView"; +import { type NavItem, Sidebar } from "./components/shell/Sidebar"; +import { CommandPalette } from "./components/shell/CommandPalette"; +import { AdminTokenDialog } from "./components/shell/AdminTokenDialog"; +import { ToastProvider } from "./components/ui/toast"; +import { Banner } from "./components/ui/banner"; +import { Button } from "./components/ui/button"; +import { type AuthState, hasAdminToken, subscribeAuth } from "./api"; + +const NAV: NavItem[] = [ + { + id: "dashboard", + label: "Dashboard", + group: "Overview", + icon: LayoutDashboard, + }, + { id: "logs", label: "Logs", group: "Overview", icon: ScrollText }, + { id: "status", label: "Status", group: "Overview", icon: Activity }, + { id: "providers", label: "Providers", group: "Gateway", icon: Plug }, + { + id: "model-catalog", + label: "Model Catalog", + group: "Gateway", + icon: Boxes, + }, + { id: "extensions", label: "Extensions", group: "Gateway", icon: Puzzle }, + { + id: "virtual-keys", + label: "Virtual keys", + group: "Governance", + icon: KeyRound, + }, + { id: "teams", label: "Teams", group: "Governance", icon: Users }, + { id: "customers", label: "Customers", group: "Governance", icon: Building2 }, + { + id: "pricing", + label: "Pricing", + group: "Governance", + icon: CircleDollarSign, + }, + { + id: "settings", + label: "Settings", + group: "System", + icon: SlidersHorizontal, + }, +]; + +const IDS = NAV.map((item) => item.id); + +/** + * Hashes that pointed at views which are now Settings tabs. Without this a + * bookmarked #/cache would fall through to the default view, which looks like a + * broken link rather than a reorganization. + */ +const REDIRECTS: Record = { + cache: "settings/caching", + config: "settings/config", +}; + +/** Resolve a legacy hash to its replacement route, or null when current. */ +export function redirectFor(hash: string): string | null { + const raw = hash.replace(/^#\/?/, ""); + const base = raw.split("/")[0]; + const target = REDIRECTS[base]; + // Only redirect a BARE legacy hash. "#/cache/anything" is not a route this + // app ever minted, so rewriting it would invent a destination. + return target && raw === base ? target : null; +} + +/** First hash segment -> view id, tolerating sub-routes like "settings/mcp". */ +function baseSegment(hash: string): string { + return hash.replace(/^#\/?/, "").split("/")[0]; +} + +function hashToView(hash: string): string { + const base = baseSegment(hash); + return IDS.includes(base) ? base : "providers"; +} + +/** + * Rewrites a legacy hash in place before routing. Uses replaceState, not a + * push, so the browser Back button does not bounce between the old hash and + * its replacement. + */ +function applyRedirect(hash: string): boolean { + const target = redirectFor(hash); + if (!target) { + return false; + } + try { + history.replaceState(null, "", `#/${target}`); + } catch { + // hash write unavailable: fall through and route by state alone + } + return true; +} + +function renderView(id: string) { + switch (id) { + case "dashboard": + return ; + case "model-catalog": + return ; + case "settings": + return ; + case "status": + return ; + case "logs": + return ; + case "extensions": + return ; + case "virtual-keys": + return ; + case "teams": + return ; + case "customers": + return ; + case "pricing": + return ; + default: + return ; + } +} + +function App() { + const [view, setView] = useState(() => { + applyRedirect(location.hash); + return hashToView(location.hash); + }); + const [authState, setAuthState] = useState("unknown"); + const [authNonce, setAuthNonce] = useState(0); + const [tokenOpen, setTokenOpen] = useState(false); + const [paletteOpen, setPaletteOpen] = useState(false); + const [mobileNavOpen, setMobileNavOpen] = useState(false); + const [collapsed, setCollapsed] = useState(() => { + try { + return localStorage.getItem("frosty.sidebar") === "rail"; + } catch { + return false; + } + }); + const [theme, setTheme] = useState<"dark" | "light">(() => + document.documentElement.classList.contains("dark") ? "dark" : "light" + ); + + useEffect(() => subscribeAuth(setAuthState), []); + + // Global command palette shortcut (Cmd/Ctrl-K); cleaned up on unmount. + useEffect(() => { + function onKey(event: KeyboardEvent) { + if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "k") { + event.preventDefault(); + setPaletteOpen((open) => !open); + } + } + globalThis.addEventListener("keydown", onKey); + return () => globalThis.removeEventListener("keydown", onKey); + }, []); + + const firstRender = useRef(true); + useEffect(() => { + // Focus the active view heading on nav change so screen readers announce + // the new context (spec section 4). Skip the initial mount. + if (firstRender.current) { + firstRender.current = false; + return; + } + document.querySelector("#main h2")?.focus(); + }, [view]); + + useEffect(() => { + function onHash() { + // React only to known view hashes; in-page anchors (e.g. Extensions' + // "#tools") must not hijack the router. Sub-routes ("settings/mcp") map + // to their base view, which owns the sub-navigation. + applyRedirect(location.hash); + const base = baseSegment(location.hash); + if (IDS.includes(base)) { + setView(base); + } + } + globalThis.addEventListener("hashchange", onHash); + return () => globalThis.removeEventListener("hashchange", onHash); + }, []); + + function navigate(id: string) { + setView(id); + setMobileNavOpen(false); + try { + history.replaceState(null, "", `#/${id}`); + } catch { + // hash write unavailable: state is still authoritative + } + } + + function toggleCollapse() { + setCollapsed((current) => { + const next = !current; + try { + localStorage.setItem("frosty.sidebar", next ? "rail" : "expanded"); + } catch { + // preference is best-effort + } + return next; + }); + } + + function toggleTheme() { + setTheme((current) => { + const next = current === "dark" ? "light" : "dark"; + const root = document.documentElement; + root.classList.toggle("dark", next === "dark"); + root.dataset.theme = next; + try { + localStorage.setItem("frosty.theme", next); + } catch { + // preference is best-effort + } + return next; + }); + } + + const tokenStatus = authState === "denied" + ? "denied" + : hasAdminToken() + ? "ok" + : "none"; + + return ( + +
+ Skip to content + {mobileNavOpen && ( + + setTokenOpen(false)} + onTokenChange={() => setAuthNonce((n) => n + 1)} + /> + setPaletteOpen(false)} + items={NAV} + onSelect={navigate} + /> + + ); +} + +export default App; diff --git a/klanker-gate/apps/control-ui/src/api.ts b/klanker-gate/apps/control-ui/src/api.ts new file mode 100755 index 0000000..8fd1af8 --- /dev/null +++ b/klanker-gate/apps/control-ui/src/api.ts @@ -0,0 +1,1102 @@ +import type { + ConfigExport, + ProviderAccountConfig, + ProviderAccountPublic, +} from "../../../packages/contracts/src/config.ts"; +import type { LogEntry } from "../../../packages/telemetry/src/logbus.ts"; + +export type { + ConfigExport, + LogEntry, + ProviderAccountConfig, + ProviderAccountPublic, +}; + +/* ----------------------------- auth state ------------------------------ */ + +const TOKEN_KEY = "frosty.admin-token"; + +export type AuthState = "unknown" | "ok" | "denied"; + +let authState: AuthState = "unknown"; +const authListeners = new Set<(state: AuthState) => void>(); + +function setAuthState(next: AuthState): void { + if (next === authState) { + return; + } + authState = next; + for (const listener of authListeners) { + listener(next); + } +} + +export function getAuthState(): AuthState { + return authState; +} + +export function subscribeAuth( + listener: (state: AuthState) => void, +): () => void { + authListeners.add(listener); + return () => { + authListeners.delete(listener); + }; +} + +export function getAdminToken(): string | null { + try { + return sessionStorage.getItem(TOKEN_KEY); + } catch { + return null; + } +} + +export function hasAdminToken(): boolean { + return Boolean(getAdminToken()); +} + +export function saveAdminToken(token: string): void { + try { + sessionStorage.setItem(TOKEN_KEY, token); + } catch { + // storage unavailable: the token lives for this page only + } + setAuthState("unknown"); +} + +export function clearAdminToken(): void { + try { + sessionStorage.removeItem(TOKEN_KEY); + } catch { + // ignore + } + setAuthState("unknown"); +} + +/* ------------------------------ transport ------------------------------ */ + +export class ApiError extends Error { + constructor(public status: number, message: string) { + super(message); + this.name = "ApiError"; + } +} + +function isAdminSurface(path: string): boolean { + return path.startsWith("/api/") || path === "/metrics"; +} + +async function extractError(res: Response): Promise { + try { + const body = await res.json() as { error?: unknown }; + const err = body?.error; + if ( + typeof err === "object" && err !== null && + typeof (err as { message?: unknown }).message === "string" + ) { + return (err as { message: string }).message; + } + if (typeof err === "string") { + // Defensive fallback. The canonical envelope above is the contract; the + // stored-logs 404 no longer uses a flat {error: string} body, and only + // the pricing force-sync divergences (api-endpoints.md E2/E3) still do. + return err; + } + } catch { + // non-JSON body: fall through to the status line + } + return res.statusText || `HTTP ${res.status}`; +} + +export async function apiFetch( + path: string, + init?: RequestInit, +): Promise { + const headers = new Headers(init?.headers); + if (init?.body !== undefined && !headers.has("Content-Type")) { + headers.set("Content-Type", "application/json"); + } + const admin = isAdminSurface(path); + if (admin) { + const token = getAdminToken(); + if (token) { + headers.set("Authorization", `Bearer ${token}`); + } + } + // fetch must receive a plain string URL (test mocks key on String(input)). + const res = await fetch(path, { ...init, headers }); + if (res.status === 401) { + if (admin) { + setAuthState("denied"); + } + throw new ApiError(401, await extractError(res)); + } + if (!res.ok) { + throw new ApiError(res.status, await extractError(res)); + } + if (admin) { + setAuthState("ok"); + } + if (res.status === 204) { + return undefined as T; + } + return await res.json() as T; +} + +/* ------------------------------- shapes -------------------------------- */ + +export interface HealthInfo { + status: string; + version: string; + timestamp: string; +} + +export interface VersionInfo { + version: string; + deno: string; +} + +export interface ModelInfo { + id: string; + object: string; + owned_by: string; +} + +export interface GatewayConfigView { + defaultProvider?: string; + providers: ProviderAccountPublic[]; + /** Operator EUR-per-USD display rate (FROSTY_EUR_RATE); micro-USD stays canonical. */ + eurRate?: number; +} + +export interface MCPClientView { + id: string; + url?: string; + enabled: boolean; + /** Header names only. Stored values never leave the gateway. */ + headerNames: string[]; + transport?: "auto" | "streamable-http" | "http-sse" | "stdio"; + requestTimeoutMs?: number; + /** True when a server-side stdio command is configured. */ + hasCommand: boolean; + /** True when stored URL user-info was removed from `url`. */ + hasUrlCredentials: boolean; + toolCount: number; + lastSyncAt?: string; +} + +export interface MCPClientInput { + id: string; + url?: string; + enabled: boolean; + headers?: Record; + transport?: string; + requestTimeoutMs?: number; + command?: string[]; +} + +export interface MCPToolView { + name: string; + description?: string; + clientId: string; + annotations?: { readOnlyHint?: boolean; [key: string]: unknown }; +} + +export interface MCPHealthView { + clientId: string; + status: "healthy" | "unhealthy" | "disabled"; + toolCount: number; + consecutiveFailures: number; + lastError?: string; + lastCheckedAt: string; +} + +export interface LimitWindow { + maxRequests?: number; + maxTokens?: number; + windowMs: number; +} + +export interface Budget { + maxRequests?: number; + maxCostUsd?: number; +} + +export interface VirtualKeyPublic { + id: string; + name: string; + enabled: boolean; + rateLimit?: { maxRequests: number; windowMs: number }; + tokenLimit?: { maxTokens: number; windowMs: number }; + budget?: Budget; + teamId?: string; + /** Admission scope; absent = unrestricted. */ + allowedProviders?: string[]; + allowedModels?: string[]; + usedRequests: number; + usedCostMicroUsd: number; + tokenHint: string; + usedCostUsd: number; +} + +export interface VirtualKeyInput { + name: string; + enabled?: boolean; + rateLimit?: { maxRequests: number; windowMs: number }; + tokenLimit?: { maxTokens: number; windowMs: number }; + budget?: Budget; + teamId?: string; + /** + * Admission scope. Create: omit for unrestricted (empty array is rejected). + * Update: an array sets scope, `null` explicitly clears it, omit leaves it. + */ + allowedProviders?: string[] | null; + allowedModels?: string[] | null; +} + +export interface Team { + id: string; + name: string; + enabled: boolean; + customerId?: string; + budget?: Budget; + usedRequests: number; + usedCostMicroUsd: number; +} + +export interface Customer { + id: string; + name: string; + enabled: boolean; + budget?: Budget; + usedRequests: number; + usedCostMicroUsd: number; +} + +export interface ModelPrice { + inputPerMTokUsd: number; + outputPerMTokUsd: number; +} + +export interface StoredLogsResult { + entries: LogEntry[]; + total: number; +} + +/* ------------------------------ analytics ------------------------------ */ + +/** Server rollup window; the dashboard only surfaces 1h/24h today. */ +export type AnalyticsWindow = "1h" | "24h" | "7d"; + +export interface AnalyticsTotals { + requests: number; + promptTokens: number; + completionTokens: number; + totalTokens: number; + costMicroUsd: number; + costUsd: number; + errorRatePct: number; + cacheHits: number; + cacheMisses: number; +} + +export interface AnalyticsBucket { + /** 1-based bucket ordinal ("1".."12"), aligned with buildSeries labels. */ + label: string; + requests: number; + promptTokens: number; + completionTokens: number; + totalTokens: number; + costMicroUsd: number; + errors: number; +} + +export interface AnalyticsModelRow { + model: string; + provider: string; + requests: number; + promptTokens: number; + completionTokens: number; + totalTokens: number; + costMicroUsd: number; +} + +export interface AnalyticsProviderRow { + provider: string; + requests: number; + totalTokens: number; + costMicroUsd: number; +} + +export interface AnalyticsRollup { + /** False when the gateway does not track token/cost analytics. */ + tracked: boolean; + window: AnalyticsWindow; + generatedAt: string; + totals: AnalyticsTotals; + series: AnalyticsBucket[]; + byModel: AnalyticsModelRow[]; + byProvider: AnalyticsProviderRow[]; +} + +/* ---------------------------- status surface --------------------------- */ + +/** Process topology, saturation, and limit state. GET /api/runtime. */ +export interface RuntimeView { + workers: { + configured: number; + /** Processes actually serving; 1 wherever reusePort is unsupported. */ + effective: number; + index: number | null; + reusePortSupported: boolean; + platform: string; + /** Human-readable explanation, rendered verbatim. */ + reason: string; + }; + concurrency: { + /** Connections open right now, counted for their full lifetime. */ + active: number; + peak: number; + total: number; + completed: number; + /** Mean lifetime over the last 1000 completed connections, ms. */ + avgLifetimeMs: number; + maxLifetimeMs: number; + /** Age of the oldest connection still open, ms. */ + longestOpenMs: number; + /** Handlers executing right now; excludes time spent streaming a body. */ + dispatching: number; + peakDispatching: number; + since: string; + /** Always "per-process" - never render this number as fleet-wide. */ + scope: string; + }; + rateLimit: { + enforced: boolean; + keysWithLimits: number; + totalKeys: number; + /** "fleet" when one shared counter governs every worker. */ + scope: string; + windows: Array<{ keyId: string; maxRequests?: number; windowMs: number }>; + }; + postgres: { + poolSize: number; + estimatedFleetConnections: number; + /** host:port/database - the gateway strips credentials before sending. */ + target: string; + listenerActive: boolean; + }; + cache: { mode: string; sharedTier: boolean; localEntries: number }; + process: { uptimeSeconds: number; denoVersion: string; v8Version: string }; +} + +/** + * Runtime view. Normalized like every other client so a partial body from an + * older gateway renders as zeros instead of throwing mid-page. + */ +export async function getRuntime(): Promise { + const raw = await apiFetch>("/api/runtime"); + return { + workers: { + configured: raw.workers?.configured ?? 1, + effective: raw.workers?.effective ?? 1, + index: raw.workers?.index ?? null, + reusePortSupported: raw.workers?.reusePortSupported ?? false, + platform: raw.workers?.platform ?? "unknown", + reason: raw.workers?.reason ?? "", + }, + concurrency: { + active: raw.concurrency?.active ?? 0, + peak: raw.concurrency?.peak ?? 0, + total: raw.concurrency?.total ?? 0, + completed: raw.concurrency?.completed ?? 0, + avgLifetimeMs: raw.concurrency?.avgLifetimeMs ?? 0, + maxLifetimeMs: raw.concurrency?.maxLifetimeMs ?? 0, + longestOpenMs: raw.concurrency?.longestOpenMs ?? 0, + dispatching: raw.concurrency?.dispatching ?? 0, + peakDispatching: raw.concurrency?.peakDispatching ?? 0, + since: raw.concurrency?.since ?? "", + scope: raw.concurrency?.scope ?? "per-process", + }, + rateLimit: { + enforced: raw.rateLimit?.enforced ?? false, + keysWithLimits: raw.rateLimit?.keysWithLimits ?? 0, + totalKeys: raw.rateLimit?.totalKeys ?? 0, + scope: raw.rateLimit?.scope ?? "per-process", + windows: raw.rateLimit?.windows ?? [], + }, + postgres: { + poolSize: raw.postgres?.poolSize ?? 0, + estimatedFleetConnections: raw.postgres?.estimatedFleetConnections ?? 0, + target: raw.postgres?.target ?? "unknown", + listenerActive: raw.postgres?.listenerActive ?? false, + }, + cache: { + mode: raw.cache?.mode ?? "off", + sharedTier: raw.cache?.sharedTier ?? false, + localEntries: raw.cache?.localEntries ?? 0, + }, + process: { + uptimeSeconds: raw.process?.uptimeSeconds ?? 0, + denoVersion: raw.process?.denoVersion ?? "", + v8Version: raw.process?.v8Version ?? "", + }, + }; +} + +export function getHealth(): Promise { + return apiFetch("/healthz"); +} + +export function getVersion(): Promise { + return apiFetch("/api/version"); +} + +export async function getModels(): Promise { + const body = await apiFetch<{ data?: ModelInfo[] }>("/v1/models"); + return Array.isArray(body?.data) ? body.data : []; +} + +/* -------------------------- providers / config ------------------------- */ + +export async function getConfig(): Promise { + const body = await apiFetch("/api/config"); + return { + defaultProvider: body?.defaultProvider, + providers: Array.isArray(body?.providers) ? body.providers : [], + eurRate: typeof body?.eurRate === "number" ? body.eurRate : undefined, + }; +} + +export function createProvider( + input: ProviderAccountConfig, +): Promise { + return apiFetch("/api/providers", { + method: "POST", + body: JSON.stringify(input), + }); +} + +export function updateProvider( + id: string, + patch: Partial, +): Promise { + return apiFetch( + `/api/providers/${encodeURIComponent(id)}`, + { method: "PUT", body: JSON.stringify(patch) }, + ); +} + +export function deleteProvider(id: string): Promise { + return apiFetch(`/api/providers/${encodeURIComponent(id)}`, { + method: "DELETE", + }); +} + +export function refreshModels( + id: string, +): Promise<{ id: string; models: string[] }> { + return apiFetch<{ id: string; models: string[] }>( + `/api/providers/${encodeURIComponent(id)}/refresh-models`, + { method: "POST" }, + ); +} + +/** Read-only: the provider's full live model list, without changing which + * models are enabled (the account's `models`). Powers the catalog toggle grid. */ +export function getProviderAvailableModels( + id: string, +): Promise<{ id: string; models: string[] }> { + return apiFetch<{ id: string; models: string[] }>( + `/api/providers/${encodeURIComponent(id)}/available-models`, + ); +} + +/** Live provider reachability for the status badge. */ +export interface ProviderHealthView { + id: string; + type: string; + status: "ok" | "error" | "unknown" | "disabled"; + lastError?: string; + checkedAt: string; +} + +export async function getProviderHealth(): Promise { + const body = await apiFetch<{ health?: ProviderHealthView[] }>( + "/api/providers/health", + ); + return Array.isArray(body?.health) ? body.health : []; +} + +export function setDefaultProvider( + id: string | undefined, +): Promise<{ defaultProvider?: string }> { + return apiFetch<{ defaultProvider?: string }>("/api/config", { + method: "PUT", + body: JSON.stringify({ defaultProvider: id }), + }); +} + +export function exportConfig(includeSecrets: boolean): Promise { + return apiFetch( + includeSecrets + ? "/api/config/export?include_secrets=true" + : "/api/config/export", + ); +} + +export function importConfig( + payload: unknown, +): Promise<{ imported: boolean; providers: number }> { + return apiFetch<{ imported: boolean; providers: number }>( + "/api/config/import", + { method: "POST", body: JSON.stringify(payload) }, + ); +} + +export function reloadConfig(): Promise< + { reloaded: boolean; providers: number; defaultProvider?: string } +> { + return apiFetch< + { reloaded: boolean; providers: number; defaultProvider?: string } + >("/api/config/reload", { method: "POST" }); +} + +/* --------------------------------- logs -------------------------------- */ + +export async function getLogs(limit: number): Promise { + const body = await apiFetch<{ logs?: LogEntry[] }>( + `/api/logs?limit=${limit}`, + ); + return Array.isArray(body?.logs) ? body.logs : []; +} + +export async function getStoredLogs(params: { + q?: string; + status?: number; + limit?: number; + offset?: number; +}): Promise { + const search = new URLSearchParams(); + if (params.q) { + search.set("q", params.q); + } + if (typeof params.status === "number" && !Number.isNaN(params.status)) { + search.set("status", String(params.status)); + } + if (typeof params.limit === "number") { + search.set("limit", String(params.limit)); + } + if (typeof params.offset === "number") { + search.set("offset", String(params.offset)); + } + const body = await apiFetch>( + `/api/logs/stored?${search.toString()}`, + ); + return { + entries: Array.isArray(body?.entries) ? body.entries : [], + total: typeof body?.total === "number" ? body.total : 0, + }; +} + +export function clearStoredLogs(): Promise<{ deleted: number }> { + return apiFetch<{ deleted: number }>("/api/logs/stored", { + method: "DELETE", + }); +} + +/** + * Fetch-based SSE reader for /api/logs/stream (EventSource is forbidden: + * it cannot carry the admin bearer header). Resolves when the stream ends; + * the caller owns reconnection. Abort via the provided signal. + */ +export async function readLogStream( + onEntry: (entry: LogEntry) => void, + signal: AbortSignal, + onOpen?: () => void, +): Promise { + const headers = new Headers({ Accept: "text/event-stream" }); + const token = getAdminToken(); + if (token) { + headers.set("Authorization", `Bearer ${token}`); + } + const res = await fetch("/api/logs/stream", { headers, signal }); + if (res.status === 401) { + setAuthState("denied"); + throw new ApiError(401, "Missing or invalid admin token."); + } + if (!res.ok || !res.body) { + throw new ApiError(res.status, res.statusText || `HTTP ${res.status}`); + } + setAuthState("ok"); + onOpen?.(); + const reader = res.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ""; + try { + while (true) { + const { done, value } = await reader.read(); + if (done) { + break; + } + buffer += decoder.decode(value, { stream: true }); + let sep = buffer.indexOf("\n\n"); + while (sep >= 0) { + const frame = buffer.slice(0, sep); + buffer = buffer.slice(sep + 2); + for (const line of frame.split("\n")) { + if (!line.startsWith("data:")) { + continue; + } + try { + onEntry(JSON.parse(line.slice(5).trim()) as LogEntry); + } catch { + // malformed frame: drop silently (security seed #11) + } + } + sep = buffer.indexOf("\n\n"); + } + } + } finally { + try { + reader.releaseLock(); + } catch { + // already released + } + } +} + +/* ------------------------------ analytics ------------------------------ */ + +const EMPTY_ANALYTICS_TOTALS: AnalyticsTotals = { + requests: 0, + promptTokens: 0, + completionTokens: 0, + totalTokens: 0, + costMicroUsd: 0, + costUsd: 0, + errorRatePct: 0, + cacheHits: 0, + cacheMisses: 0, +}; + +/** A well-formed rollup that reads as "not tracked" for the empty state. */ +function emptyAnalyticsRollup(window: AnalyticsWindow): AnalyticsRollup { + return { + tracked: false, + window, + generatedAt: new Date().toISOString(), + totals: { ...EMPTY_ANALYTICS_TOTALS }, + series: [], + byModel: [], + byProvider: [], + }; +} + +/** + * Token/cost/model rollup for the dashboard. A 404 (older gateway) means the + * feature is off, so we resolve to a well-formed untracked rollup instead of + * throwing (mirrors the stored-logs 404 = "feature off" pattern). Partial or + * malformed bodies are normalized so downstream chart helpers stay total. + */ +export async function getAnalytics( + window: AnalyticsWindow, +): Promise { + try { + const body = await apiFetch>( + `/api/analytics?window=${window}`, + ); + return { + tracked: body?.tracked === true, + window: body?.window ?? window, + generatedAt: typeof body?.generatedAt === "string" + ? body.generatedAt + : new Date().toISOString(), + totals: { ...EMPTY_ANALYTICS_TOTALS, ...(body?.totals ?? {}) }, + series: Array.isArray(body?.series) ? body.series : [], + byModel: Array.isArray(body?.byModel) ? body.byModel : [], + byProvider: Array.isArray(body?.byProvider) ? body.byProvider : [], + }; + } catch (err) { + if (err instanceof ApiError && err.status === 404) { + return emptyAnalyticsRollup(window); + } + throw err; + } +} + +/* ----------------------------- MCP / plugins --------------------------- */ + +export async function getMCPClients(): Promise { + const body = await apiFetch<{ clients?: MCPClientView[] }>( + "/api/mcp/clients", + ); + return Array.isArray(body?.clients) ? body.clients : []; +} + +export function createMCPClient(input: MCPClientInput): Promise { + return apiFetch("/api/mcp/clients", { + method: "POST", + body: JSON.stringify(input), + }); +} + +export function updateMCPClient( + id: string, + patch: Partial, +): Promise { + return apiFetch( + `/api/mcp/clients/${encodeURIComponent(id)}`, + { method: "PUT", body: JSON.stringify(patch) }, + ); +} + +export function deleteMCPClient(id: string): Promise { + return apiFetch(`/api/mcp/clients/${encodeURIComponent(id)}`, { + method: "DELETE", + }); +} + +export function syncMCPClient( + id: string, +): Promise<{ id: string; tools: number }> { + return apiFetch<{ id: string; tools: number }>( + `/api/mcp/clients/${encodeURIComponent(id)}/sync`, + { method: "POST" }, + ); +} + +export function syncAllMCP(): Promise<{ synced: number }> { + return apiFetch<{ synced: number }>("/api/mcp/sync", { method: "POST" }); +} + +export async function getMCPTools(): Promise { + const body = await apiFetch<{ tools?: MCPToolView[] }>("/api/mcp/tools"); + return Array.isArray(body?.tools) ? body.tools : []; +} + +export async function getMCPHealth(): Promise { + const body = await apiFetch<{ health?: MCPHealthView[] }>("/api/mcp/health"); + return Array.isArray(body?.health) ? body.health : []; +} + +export async function getPlugins(): Promise { + const body = await apiFetch<{ plugins?: string[] }>("/api/plugins"); + return Array.isArray(body?.plugins) ? body.plugins : []; +} + +/* -------------------------------- cache -------------------------------- */ + +export function clearCache(): Promise<{ cleared: number }> { + return apiFetch<{ cleared: number }>("/api/cache", { method: "DELETE" }); +} + +export function deleteCacheEntry( + requestBody: unknown, +): Promise<{ deleted: boolean }> { + return apiFetch<{ deleted: boolean }>("/api/cache/by-key", { + method: "DELETE", + body: JSON.stringify(requestBody), + }); +} + +/* ------------------------------ governance ----------------------------- */ + +export async function getVirtualKeys(): Promise { + const body = await apiFetch<{ virtualKeys?: VirtualKeyPublic[] }>( + "/api/virtual-keys", + ); + return Array.isArray(body?.virtualKeys) ? body.virtualKeys : []; +} + +/** The 201 body carries the full token exactly once; never store it. */ +export function createVirtualKey( + input: VirtualKeyInput, +): Promise { + return apiFetch("/api/virtual-keys", { + method: "POST", + body: JSON.stringify(input), + }); +} + +export function updateVirtualKey( + id: string, + patch: Partial, +): Promise { + return apiFetch( + `/api/virtual-keys/${encodeURIComponent(id)}`, + { method: "PUT", body: JSON.stringify(patch) }, + ); +} + +export function deleteVirtualKey(id: string): Promise { + return apiFetch(`/api/virtual-keys/${encodeURIComponent(id)}`, { + method: "DELETE", + }); +} + +export async function getTeams(): Promise { + const body = await apiFetch<{ teams?: Team[] }>("/api/teams"); + return Array.isArray(body?.teams) ? body.teams : []; +} + +export function createTeam( + input: { + name: string; + enabled?: boolean; + customerId?: string; + budget?: Budget; + }, +): Promise { + return apiFetch("/api/teams", { + method: "POST", + body: JSON.stringify(input), + }); +} + +export function updateTeam( + id: string, + patch: Partial< + { name: string; enabled: boolean; customerId: string; budget: Budget } + >, +): Promise { + return apiFetch(`/api/teams/${encodeURIComponent(id)}`, { + method: "PUT", + body: JSON.stringify(patch), + }); +} + +export function deleteTeam(id: string): Promise { + return apiFetch(`/api/teams/${encodeURIComponent(id)}`, { + method: "DELETE", + }); +} + +export async function getCustomers(): Promise { + const body = await apiFetch<{ customers?: Customer[] }>("/api/customers"); + return Array.isArray(body?.customers) ? body.customers : []; +} + +export function createCustomer( + input: { name: string; enabled?: boolean; budget?: Budget }, +): Promise { + return apiFetch("/api/customers", { + method: "POST", + body: JSON.stringify(input), + }); +} + +export function updateCustomer( + id: string, + patch: Partial<{ name: string; enabled: boolean; budget: Budget }>, +): Promise { + return apiFetch(`/api/customers/${encodeURIComponent(id)}`, { + method: "PUT", + body: JSON.stringify(patch), + }); +} + +export function deleteCustomer(id: string): Promise { + return apiFetch(`/api/customers/${encodeURIComponent(id)}`, { + method: "DELETE", + }); +} + +export async function getPricing(): Promise> { + const body = await apiFetch<{ prices?: Record }>( + "/api/pricing", + ); + return body?.prices && typeof body.prices === "object" ? body.prices : {}; +} + +export function putPricing( + prices: Record, +): Promise<{ prices: Record }> { + return apiFetch<{ prices: Record }>("/api/pricing", { + method: "PUT", + body: JSON.stringify(prices), + }); +} + +/* ------------------------------ model catalog -------------------------- */ + +/** + * One row of the Model Catalog surface: a provider with its advertised models + * and 24h traffic/cost rollup. `custom` marks bring-your-own providers + * (openai-compatible / anthropic-compatible / lmstudio). + */ +export interface CatalogProviderRow { + id: string; + type: string; + custom: boolean; + models: string[]; + traffic24h: number; + cost24h: number; +} + +export interface CatalogTotals { + providers: number; + models: number; + requests24h: number; + cost24h: number; +} + +export interface CatalogView { + providers: CatalogProviderRow[]; + totals: CatalogTotals; +} + +const EMPTY_CATALOG_TOTALS: CatalogTotals = { + providers: 0, + models: 0, + requests24h: 0, + cost24h: 0, +}; + +/** + * Model + provider catalog for the Model Catalog view (Phase 3b). Partial or + * malformed bodies are normalized so consumers stay total; a 404 (older + * gateway) resolves to an empty catalog rather than throwing (mirrors the + * analytics "feature off" pattern). + */ +export async function getCatalog(): Promise { + try { + const body = await apiFetch>("/api/catalog"); + const rows = Array.isArray(body?.providers) ? body.providers : []; + return { + providers: rows.map((row) => ({ + id: String(row?.id ?? ""), + type: String(row?.type ?? ""), + custom: row?.custom === true, + models: Array.isArray(row?.models) ? row.models : [], + traffic24h: typeof row?.traffic24h === "number" ? row.traffic24h : 0, + cost24h: typeof row?.cost24h === "number" ? row.cost24h : 0, + })), + totals: { ...EMPTY_CATALOG_TOTALS, ...(body?.totals ?? {}) }, + }; + } catch (err) { + if (err instanceof ApiError && err.status === 404) { + return { providers: [], totals: { ...EMPTY_CATALOG_TOTALS } }; + } + throw err; + } +} + +/* -------------------------------- settings ----------------------------- */ + +/** Gateway settings groups surfaced by the Settings view (Phase 3b). */ +export type SettingsGroup = + | "security" + | "compatibility" + | "performance" + | "caching" + | "mcp"; + +/** Where a settings value came from: a built-in default, an env var, or an + * operator override written through this UI. */ +export type SettingSource = "default" | "env" | "override"; + +export interface SettingsSection { + /** Field -> current value (shape is per-group; typed loosely on purpose). */ + values: Record; + /** Field -> provenance, drives the "default / env / override" pill. */ + sources: Record; +} + +export type SettingsMap = Partial>; + +export interface SettingsView { + settings: SettingsMap; + /** "group.field" -> whether the gateway currently enforces the value. */ + enforcement: Record; +} + +/** + * Partial write payload accepted by PUT /api/settings. The gateway schema reads + * groups FLAT off the root (e.g. `{ caching: {...} }`), not wrapped in + * `settings`/`values` (a wrapped body is silently dropped by zod). + */ +export type SettingsUpdate = Partial< + Record> +>; + +function normalizeSettings( + body: Partial | undefined, +): SettingsView { + const settings = (body?.settings && typeof body.settings === "object") + ? body.settings as SettingsMap + : {}; + const enforcement = + (body?.enforcement && typeof body.enforcement === "object") + ? body.enforcement as Record + : {}; + return { settings, enforcement }; +} + +/** Read the full settings tree. */ +export async function getSettings(): Promise { + const body = await apiFetch>("/api/settings"); + return normalizeSettings(body); +} + +/** Write a partial settings update; returns the full re-read tree. */ +export async function putSettings( + update: SettingsUpdate, +): Promise { + const body = await apiFetch>("/api/settings", { + method: "PUT", + body: JSON.stringify(update), + }); + return normalizeSettings(body); +} + +/* ---------------------------- MCP code mode VFS ------------------------ */ + +/** Binding granularity for the generated Code Mode virtual file system. */ +export type CodeModeBinding = "server" | "tool"; + +export interface CodeModeVfsFile { + path: string; + server: string; + tools: string[]; + sizeBytes: number; + sha256: string; + source: string; +} + +export interface CodeModeVfsView { + bindingLevel: string; + files: CodeModeVfsFile[]; + generatedAt: string; +} + +/** + * Generated Code Mode VFS listing for the MCP tooling surface (Phase 3b). The + * binding query selects server- vs tool-level bundling. Bodies are normalized + * so downstream tree/preview components stay total. + */ +export async function getCodeModeVfs( + binding: CodeModeBinding, +): Promise { + const body = await apiFetch>( + `/api/mcp/codemode/vfs?binding=${binding}`, + ); + const files = Array.isArray(body?.files) ? body.files : []; + return { + bindingLevel: typeof body?.bindingLevel === "string" + ? body.bindingLevel + : binding, + files: files.map((file) => ({ + path: String(file?.path ?? ""), + server: String(file?.server ?? ""), + tools: Array.isArray(file?.tools) ? file.tools : [], + sizeBytes: typeof file?.sizeBytes === "number" ? file.sizeBytes : 0, + sha256: String(file?.sha256 ?? ""), + source: String(file?.source ?? ""), + })), + generatedAt: typeof body?.generatedAt === "string" + ? body.generatedAt + : new Date().toISOString(), + }; +} diff --git a/klanker-gate/apps/control-ui/src/components/catalog/ProviderModelsDialog.tsx b/klanker-gate/apps/control-ui/src/components/catalog/ProviderModelsDialog.tsx new file mode 100755 index 0000000..d75584f --- /dev/null +++ b/klanker-gate/apps/control-ui/src/components/catalog/ProviderModelsDialog.tsx @@ -0,0 +1,232 @@ +import { useEffect, useMemo, useState } from "react"; +import { Search } from "lucide-react"; +import { + type CatalogProviderRow, + getProviderAvailableModels, + updateProvider, +} from "../../api"; +import { Dialog } from "../ui/dialog"; +import { Button } from "../ui/button"; +import { Input } from "../ui/input"; +import { Banner } from "../ui/banner"; +import { ToggleGridItem } from "../ui/toggle-grid-item"; +import { ProviderIcon } from "../ui/provider-icon"; +import { useToast } from "../ui/toast"; + +export interface ProviderModelsDialogProps { + /** The clicked catalog row; null closes the dialog. */ + provider: CatalogProviderRow | null; + onClose: () => void; + /** Fired after a successful save so the catalog can reload. */ + onSaved: () => void; +} + +/** + * Per-provider model enablement grid. Opens from a Model Catalog row, fetches + * the provider's full live model list, and shows every model (the live list + * unioned with the currently-enabled ones) as an on/off tile. Saving writes the + * enabled subset back to the account's `models` - the set the gateway routes + * on. Providers without live listing fall back to their stored models. + */ +export function ProviderModelsDialog( + { provider, onClose, onSaved }: ProviderModelsDialogProps, +) { + const toast = useToast(); + const [available, setAvailable] = useState([]); + const [enabled, setEnabled] = useState>(new Set()); + const [query, setQuery] = useState(""); + const [loading, setLoading] = useState(false); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + const [noLiveListing, setNoLiveListing] = useState(false); + + const open = provider !== null; + const providerId = provider?.id ?? null; + + useEffect(() => { + if (!provider) { + return; + } + let alive = true; + setQuery(""); + setError(null); + setNoLiveListing(false); + setEnabled(new Set(provider.models)); + setAvailable(provider.models); + setLoading(true); + getProviderAvailableModels(provider.id) + .then((res) => { + if (alive) setAvailable(res.models); + }) + .catch((err) => { + if (!alive) return; + // A 400 means the provider type cannot list models live; the stored + // enabled set is still editable, so degrade instead of failing. + setNoLiveListing(true); + setError(err instanceof Error ? err.message : String(err)); + }) + .finally(() => { + if (alive) setLoading(false); + }); + return () => { + alive = false; + }; + }, [providerId]); + + // Union of the live list and the enabled set, so a model that is enabled but + // no longer advertised still shows (and can be turned off). + const allModels = useMemo(() => { + const set = new Set(available); + for (const m of enabled) set.add(m); + return [...set].sort((a, b) => a.localeCompare(b)); + }, [available, enabled]); + + const filtered = useMemo(() => { + const q = query.trim().toLowerCase(); + return q ? allModels.filter((m) => m.toLowerCase().includes(q)) : allModels; + }, [allModels, query]); + + function toggle(model: string, on: boolean) { + setEnabled((prev) => { + const next = new Set(prev); + if (on) next.add(model); + else next.delete(model); + return next; + }); + } + + function setAll(on: boolean) { + setEnabled((prev) => { + const next = new Set(prev); + for (const m of filtered) { + if (on) next.add(m); + else next.delete(m); + } + return next; + }); + } + + async function save() { + if (!provider) return; + setSaving(true); + try { + const models = [...enabled].sort((a, b) => a.localeCompare(b)); + await updateProvider(provider.id, { models }); + toast.success(`Models updated for "${provider.id}"`); + onSaved(); + onClose(); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + setError(message); + toast.error(message); + } finally { + setSaving(false); + } + } + + return ( + + + + + } + > +
+
+ {provider && ( + + )} + + {enabled.size} of {allModels.length} enabled + +
+ + +
+
+ +
+
+ + {noLiveListing && ( + + This provider type does not support live model listing. Editing the + models it already advertises. + + )} + {error && !noLiveListing && {error}} + +
+ {loading + ? ( +

+ Loading models... +

+ ) + : filtered.length === 0 + ? ( +

+ {allModels.length === 0 + ? "No models available." + : `No models match "${query}".`} +

+ ) + : ( +
+ {filtered.map((model) => ( + toggle(model, on)} + /> + ))} +
+ )} +
+
+
+ ); +} diff --git a/klanker-gate/apps/control-ui/src/components/dashboard/ChartCard.tsx b/klanker-gate/apps/control-ui/src/components/dashboard/ChartCard.tsx new file mode 100755 index 0000000..53625f9 --- /dev/null +++ b/klanker-gate/apps/control-ui/src/components/dashboard/ChartCard.tsx @@ -0,0 +1,166 @@ +import { type ReactNode, useState } from "react"; +import { BarChart3, LineChart as LineChartIcon } from "lucide-react"; +import { Card, CardContent, CardHeader, CardTitle } from "../ui/card"; +import { + Chart, + ChartLegend, + type ChartSeries, + type LegendItem, +} from "../ui/chart"; +import { cn } from "../../lib/utils"; + +export interface ChartCardProps { + title: string; + /** Accessible name for the SVG chart. */ + ariaLabel: string; + series?: ChartSeries[]; + legend?: LegendItem[]; + defaultType?: "line" | "bar"; + /** Right-aligned header controls (e.g. a model / provider Combobox). */ + filter?: ReactNode; + /** Evenly spaced x-axis tick labels rendered under a time-series chart. */ + xTicks?: string[]; + /** Micro unit hint (e.g. "USD", "tokens", "ms"). */ + unit?: string; + loading?: boolean; + /** Force the empty state (feature off / dimension not recorded / filtered). */ + empty?: boolean; + /** One-line muted note under the empty message explaining the gap. */ + emptyNote?: string; + /** Hide the bar/line toggle (untracked cards have nothing to toggle). */ + hideToggle?: boolean; + className?: string; +} + +/** + * Dashboard analytics card: title, optional filter + bar/line toggle, a legend + * row, the dependency-free SVG Chart, and a graceful "No data available" state. + * A card with no positive value collapses to the empty state automatically, so + * an all-zero window never renders a misleading flat line. + */ +export function ChartCard( + { + title, + ariaLabel, + series = [], + legend = [], + defaultType = "line", + filter, + xTicks, + unit, + loading, + empty, + emptyNote, + hideToggle, + className, + }: ChartCardProps, +) { + const [type, setType] = useState<"line" | "bar">(defaultType); + + const hasData = series.some((s) => s.values.some((v) => v > 0)); + const showEmpty = Boolean(empty) || (!loading && !hasData); + const showToggle = !hideToggle && !showEmpty; + + return ( + + + {title} +
+ {filter} + {showToggle && ( +
+ setType("bar")} + > + + + setType("line")} + > + + +
+ )} +
+
+ + {showEmpty + ? ( +
+

+ No data available +

+ {emptyNote && ( +

+ {emptyNote} +

+ )} +
+ ) + : loading + ?
+ : ( +
+ {(legend.length > 0 || unit) && ( +
+ + {unit && ( + + {unit} + + )} +
+ )} + + {xTicks && xTicks.length > 0 && ( +
+ {xTicks.map((tick, i) => ( + + {tick} + + ))} +
+ )} +
+ )} + + + ); +} + +function ToggleButton( + { label, active, onClick, children }: { + label: string; + active: boolean; + onClick: () => void; + children: ReactNode; + }, +) { + return ( + + ); +} diff --git a/klanker-gate/apps/control-ui/src/components/dashboard/adapters.ts b/klanker-gate/apps/control-ui/src/components/dashboard/adapters.ts new file mode 100755 index 0000000..3a0b933 --- /dev/null +++ b/klanker-gate/apps/control-ui/src/components/dashboard/adapters.ts @@ -0,0 +1,260 @@ +import type { + AnalyticsBucket, + AnalyticsModelRow, + AnalyticsProviderRow, + AnalyticsRollup, + LogEntry, +} from "../../api"; +import type { ChartSeries, LegendItem } from "../ui/chart"; +import type { ComboboxOption } from "../ui/combobox"; +import type { CsvColumn } from "../../lib/csv"; +import { percentile } from "../../lib/analytics"; +import { getEurRate } from "../../lib/currency"; + +const MICRO = 1_000_000; + +/* ----------------------------- overview trends ------------------------- */ + +/** Request Volume: success (requests - errors) vs error count per bucket. */ +export function requestVolumeSeries(rollup: AnalyticsRollup): ChartSeries[] { + return [ + { + name: "Success", + color: "2", + values: rollup.series.map((b) => Math.max(0, b.requests - b.errors)), + }, + { name: "Error", color: "4", values: rollup.series.map((b) => b.errors) }, + ]; +} + +/** + * Token Usage: input (prompt) vs output (completion) tokens per bucket. The + * gateway does not record cached-token counts per bucket, so the Cached measure + * stays a flat zero series: present for legend parity, never fabricated. + */ +export function tokenUsageSeries(rollup: AnalyticsRollup): ChartSeries[] { + return [ + { + name: "Input", + color: "1", + values: rollup.series.map((b) => b.promptTokens), + }, + { + name: "Output", + color: "2", + values: rollup.series.map((b) => b.completionTokens), + }, + { name: "Cached", color: "5", values: rollup.series.map(() => 0) }, + ]; +} + +/** Cost trend: micro-USD per bucket converted to euros. */ +export function costTrendSeries(rollup: AnalyticsRollup): ChartSeries[] { + return [ + { + name: "Cost", + color: "3", + values: rollup.series.map((b) => (b.costMicroUsd / MICRO) * getEurRate()), + }, + ]; +} + +/** Model Usage trend: total tokens per bucket across every model. */ +export function tokenTrendSeries(rollup: AnalyticsRollup): ChartSeries[] { + return [ + { + name: "Total tokens", + color: "1", + values: rollup.series.map((b) => b.totalTokens), + }, + ]; +} + +/* ------------------------------- latency ------------------------------- */ + +/** Split entries into ordered time buckets (span when every ts parses). */ +function bucketByTime(entries: LogEntry[], bucketCount: number): LogEntry[][] { + const buckets: LogEntry[][] = Array.from({ length: bucketCount }, () => []); + if (entries.length === 0 || bucketCount <= 0) { + return buckets; + } + const parsed = entries.map((e) => Date.parse(e.ts)); + const valid = parsed.filter((t) => !Number.isNaN(t)); + const min = valid.length > 0 ? Math.min(...valid) : 0; + const max = valid.length > 0 ? Math.max(...valid) : 0; + const useTime = valid.length === entries.length && max > min; + for (let i = 0; i < entries.length; i++) { + const index = useTime + ? Math.min( + bucketCount - 1, + Math.floor(((parsed[i] - min) / (max - min)) * bucketCount), + ) + : Math.min( + bucketCount - 1, + Math.floor((i / entries.length) * bucketCount), + ); + buckets[index].push(entries[i]); + } + return buckets; +} + +/** + * Latency trend: avg / p90 / p95 / p99 of durationMs per time bucket. Derived + * from stored request logs (the rollup carries no latency), reusing the shared + * nearest-rank percentile helper. + */ +export function latencyTrendSeries( + entries: LogEntry[], + bucketCount = 12, +): ChartSeries[] { + const groups = bucketByTime(entries, bucketCount); + const avg: number[] = []; + const p90: number[] = []; + const p95: number[] = []; + const p99: number[] = []; + for (const group of groups) { + const durations = group + .map((e) => e.durationMs) + .filter((d): d is number => typeof d === "number" && d >= 0) + .sort((a, b) => a - b); + const mean = durations.length === 0 + ? 0 + : durations.reduce((sum, v) => sum + v, 0) / durations.length; + avg.push(mean); + p90.push(percentile(durations, 90)); + p95.push(percentile(durations, 95)); + p99.push(percentile(durations, 99)); + } + return [ + { name: "Avg", color: "1", values: avg }, + { name: "P90", color: "2", values: p90 }, + { name: "P95", color: "3", values: p95 }, + { name: "P99", color: "4", values: p99 }, + ]; +} + +/* --------------------------- provider breakdown ------------------------ */ + +/** Providers sorted by total tokens (descending), optionally to one row. */ +function filterProviders( + rows: AnalyticsProviderRow[], + filter: string, +): AnalyticsProviderRow[] { + const sorted = [...rows].sort((a, b) => b.totalTokens - a.totalTokens); + return filter === "all" + ? sorted + : sorted.filter((row) => row.provider === filter); +} + +/** Provider Cost: one bar per provider (euros), chart-3. */ +export function providerCostSeries( + rollup: AnalyticsRollup, + filter: string, +): ChartSeries[] { + return [ + { + name: "Cost", + color: "3", + values: filterProviders(rollup.byProvider, filter).map((r) => + (r.costMicroUsd / MICRO) * getEurRate() + ), + }, + ]; +} + +/** Provider Token Usage: one bar per provider (total tokens), chart-1. */ +export function providerTokenSeries( + rollup: AnalyticsRollup, + filter: string, +): ChartSeries[] { + return [ + { + name: "Total tokens", + color: "1", + values: filterProviders(rollup.byProvider, filter).map((r) => + r.totalTokens + ), + }, + ]; +} + +/** Provider names behind the single provider bar series, in bar order. */ +export function providerLegend( + rollup: AnalyticsRollup, + filter: string, + color: LegendItem["color"], +): LegendItem[] { + return filterProviders(rollup.byProvider, filter).map((row) => ({ + name: row.provider, + color, + })); +} + +/* ----------------------------- filter options -------------------------- */ + +/** Distinct model options for the per-card model filter ("All Models" first). */ +export function modelOptions(rollup: AnalyticsRollup): ComboboxOption[] { + const seen = new Set(); + const options: ComboboxOption[] = [{ value: "all", label: "All Models" }]; + for (const row of rollup.byModel) { + if (!seen.has(row.model)) { + seen.add(row.model); + options.push({ value: row.model, label: row.model }); + } + } + return options; +} + +/** Distinct provider options ("All Providers" first). */ +export function providerOptions(rollup: AnalyticsRollup): ComboboxOption[] { + const seen = new Set(); + const options: ComboboxOption[] = [{ value: "all", label: "All Providers" }]; + for (const row of rollup.byProvider) { + if (!seen.has(row.provider)) { + seen.add(row.provider); + options.push({ value: row.provider, label: row.provider }); + } + } + return options; +} + +/* -------------------------------- csv ---------------------------------- */ + +/** Overview export: the analytics time-series buckets. */ +export const overviewCsvColumns: CsvColumn[] = [ + { header: "bucket", value: (b) => b.label }, + { header: "requests", value: (b) => b.requests }, + { header: "errors", value: (b) => b.errors }, + { header: "prompt_tokens", value: (b) => b.promptTokens }, + { header: "completion_tokens", value: (b) => b.completionTokens }, + { header: "total_tokens", value: (b) => b.totalTokens }, + { + header: "cost_eur", + value: (b) => ((b.costMicroUsd / MICRO) * getEurRate()).toFixed(6), + }, +]; + +/** Provider Usage export: the by-provider rollup rows. */ +export const providerCsvColumns: CsvColumn[] = [ + { header: "provider", value: (r) => r.provider }, + { header: "requests", value: (r) => r.requests }, + { header: "total_tokens", value: (r) => r.totalTokens }, + { + header: "cost_eur", + value: (r) => ((r.costMicroUsd / MICRO) * getEurRate()).toFixed(6), + }, +]; + +/** Model Rankings export: the by-model rollup rows. */ +export const modelCsvColumns: CsvColumn[] = [ + { header: "model", value: (r) => r.model }, + { header: "provider", value: (r) => r.provider }, + { header: "requests", value: (r) => r.requests }, + { header: "prompt_tokens", value: (r) => r.promptTokens }, + { header: "completion_tokens", value: (r) => r.completionTokens }, + { header: "total_tokens", value: (r) => r.totalTokens }, + { + header: "cost_eur", + value: (r) => ((r.costMicroUsd / MICRO) * getEurRate()).toFixed(6), + }, +]; diff --git a/klanker-gate/apps/control-ui/src/components/logs/ColumnPicker.tsx b/klanker-gate/apps/control-ui/src/components/logs/ColumnPicker.tsx new file mode 100755 index 0000000..bd58a85 --- /dev/null +++ b/klanker-gate/apps/control-ui/src/components/logs/ColumnPicker.tsx @@ -0,0 +1,99 @@ +import { useEffect, useId, useRef, useState } from "react"; +import { Columns3 } from "lucide-react"; +import { Button } from "../ui/button"; +import { Checkbox } from "../ui/checkbox"; +import type { ColumnMeta } from "./logs-model"; + +export interface ColumnPickerProps { + columns: ColumnMeta[]; + visible: Set; + onToggle: (key: string, checked: boolean) => void; +} + +/** + * Show/hide column control (spec: Logs top bar). A disclosure button opens a + * checkbox panel; Escape and click-outside close it. The last visible column + * cannot be hidden so the table never collapses to nothing. + */ +export function ColumnPicker( + { columns, visible, onToggle }: ColumnPickerProps, +) { + const panelId = useId(); + const [open, setOpen] = useState(false); + const rootRef = useRef(null); + + useEffect(() => { + if (!open) { + return; + } + function onPointerDown(event: MouseEvent) { + if (!rootRef.current?.contains(event.target as Node)) { + setOpen(false); + } + } + function onKeyDown(event: KeyboardEvent) { + if (event.key === "Escape") { + setOpen(false); + } + } + document.addEventListener("mousedown", onPointerDown, true); + document.addEventListener("keydown", onKeyDown, true); + return () => { + document.removeEventListener("mousedown", onPointerDown, true); + document.removeEventListener("keydown", onKeyDown, true); + }; + }, [open]); + + const shownCount = columns.reduce( + (n, column) => (visible.has(column.key) ? n + 1 : n), + 0, + ); + + return ( +
+ + {open && ( +
+

+ Columns +

+
    + {columns.map((column) => { + const checked = visible.has(column.key); + const lockLast = checked && shownCount === 1; + return ( +
  • + +
  • + ); + })} +
+
+ )} +
+ ); +} diff --git a/klanker-gate/apps/control-ui/src/components/logs/LogsAnalytics.tsx b/klanker-gate/apps/control-ui/src/components/logs/LogsAnalytics.tsx new file mode 100755 index 0000000..8b2b742 --- /dev/null +++ b/klanker-gate/apps/control-ui/src/components/logs/LogsAnalytics.tsx @@ -0,0 +1,164 @@ +import { useState } from "react"; +import { ChevronDown } from "lucide-react"; +import type { LogEntry } from "../../api"; +import { Card, CardContent, CardHeader, CardTitle } from "../ui/card"; +import { StatTile } from "../ui/stat-tile"; +import { Chart, ChartLegend, type ChartSeries } from "../ui/chart"; +import { buildSeries } from "../../lib/analytics"; +import { classifyOutcome, entryTokens, formatCostUsd } from "./logs-model"; +import { cn } from "../../lib/utils"; + +/** + * KPI row over the currently visible logs. Every tile derives from recorded + * fields: Total Requests / Success Rate / Avg Latency from the base request + * fields, Total Tokens / Total Cost from the telemetry enrichment carried on + * inference entries. A window with no inference traffic still shows an honest + * "N/A" for the last two rather than a fabricated zero, because "no request + * recorded usage" and "usage was zero" are different facts. + */ +export function LogsKpiRow( + { entries, loading }: { entries: LogEntry[]; loading: boolean }, +) { + let success = 0; + let error = 0; + let cancelled = 0; + let latencyCount = 0; + let latencySum = 0; + let tokenTotal = 0; + let tokenEntries = 0; + let costMicroUsd = 0; + let costEntries = 0; + for (const entry of entries) { + const outcome = classifyOutcome(entry); + if (outcome === "success") { + success += 1; + } else if (outcome === "error") { + error += 1; + } else if (outcome === "cancelled") { + cancelled += 1; + } + if (typeof entry.durationMs === "number") { + latencyCount += 1; + latencySum += entry.durationMs; + } + const tokens = entryTokens(entry); + if (tokens !== null) { + tokenTotal += tokens; + tokenEntries += 1; + } + if (typeof entry.costMicroUsd === "number") { + costMicroUsd += entry.costMicroUsd; + costEntries += 1; + } + } + + const terminal = success + error + cancelled; + const successRate = terminal > 0 ? (success / terminal) * 100 : null; + const avgLatency = latencyCount > 0 ? latencySum / latencyCount : null; + + return ( +
+ + + + + +
+ ); +} + +/** + * Collapsible Request Volume card: success vs error counts bucketed over the + * visible logs' time range. Both series are derived from recorded status/level; + * an empty window shows the standard "No data available" state. + */ +export function RequestVolumeCard({ entries }: { entries: LogEntry[] }) { + const [open, setOpen] = useState(true); + + const series = buildSeries(entries, 12); + const volume: ChartSeries[] = [ + { name: "Success", color: "2", values: series.map((b) => b.success) }, + { name: "Error", color: "4", values: series.map((b) => b.errors) }, + ]; + const hasData = volume.some((s) => s.values.some((v) => v > 0)); + + return ( + + + + + + {open && ( + + {hasData + ? ( + + ) + : ( +
+

+ No data available +

+
+ )} +
+ )} +
+ ); +} diff --git a/klanker-gate/apps/control-ui/src/components/logs/LogsFacetRail.tsx b/klanker-gate/apps/control-ui/src/components/logs/LogsFacetRail.tsx new file mode 100755 index 0000000..edaecd1 --- /dev/null +++ b/klanker-gate/apps/control-ui/src/components/logs/LogsFacetRail.tsx @@ -0,0 +1,221 @@ +import { useState } from "react"; +import { PanelLeftClose, Search } from "lucide-react"; +import { type FacetGroup, FacetRail } from "../ui/facet-rail"; +import { Collapsible } from "../ui/collapsible"; +import { Checkbox } from "../ui/checkbox"; +import type { LogEntry } from "../../api"; +import { + type FacetSelection, + facetValues, + HONEST_FACETS, + OUTCOME_LABEL, + OUTCOME_ORDER, + type OutcomeCounts, + VALUE_FACETS, + type ValueFacet, +} from "./logs-model"; + +export interface LogsFacetRailProps { + /** Selected outcome classes (facet-rail controlled model). */ + outcome: string[]; + onOutcomeChange: (values: string[]) => void; + /** Per-class counts over the currently loaded (time+search filtered) logs. */ + counts: OutcomeCounts; + /** Entries the live value facets enumerate their options from. */ + entries: LogEntry[]; + /** Selected values per live facet (model / provider / type). */ + selection: FacetSelection; + onSelectionChange: (id: ValueFacet["id"], values: string[]) => void; + onHide: () => void; +} + +/** + * Left filter rail for the Logs view. Outcome, Models, Provider, and Type are + * live facets backed by recorded fields (Type is projected from the recorded + * path). The groups below them are the faithful professional shell shown + * honest-empty, because the gateway records none of those dimensions on a log + * entry - except Cost, which is recorded per entry but has no range filter. + */ +export function LogsFacetRail( + { + outcome, + onOutcomeChange, + counts, + entries, + selection, + onSelectionChange, + onHide, + }: LogsFacetRailProps, +) { + const outcomeGroup: FacetGroup = { + id: "outcome", + label: "Outcome", + defaultOpen: true, + options: OUTCOME_ORDER.map((value) => ({ + value, + label: OUTCOME_LABEL[value], + count: counts[value], + })), + }; + + return ( +
+
+

+ Filters +

+ +
+ + { + /* Wrapped so the group keeps a bottom divider: FacetRail strips the + border on its last group, which is the only group we pass it. */ + } +
+ onOutcomeChange(values)} + /> +
+ + {VALUE_FACETS.map((facet) => ( + onSelectionChange(facet.id, values)} + /> + ))} + + {HONEST_FACETS.map((facet) => ( + +
+ +
+
+ ))} +
+ ); +} + +/** + * One live facet: distinct recorded values over the loaded entries, each with + * an occurrence count. Renders the same empty affordance as the honest groups + * when the current window happens to contain no entry carrying the dimension. + */ +function ValueFacetGroup( + { facet, entries, selected, onChange }: { + facet: ValueFacet; + entries: LogEntry[]; + selected: string[]; + onChange: (values: string[]) => void; + }, +) { + const [query, setQuery] = useState(""); + const options = facetValues(entries, facet); + const needle = query.trim().toLowerCase(); + const shown = needle + ? options.filter((option) => option.value.toLowerCase().includes(needle)) + : options; + + const toggle = (value: string, checked: boolean) => { + onChange( + checked + ? [...selected, value] + : selected.filter((entry) => entry !== value), + ); + }; + + return ( + +
+ {facet.searchable && ( +
+
+ )} + {options.length === 0 + ? + : ( +
    + {shown.map((option) => ( +
  • + +
  • + ))} +
+ )} +
+
+ ); +} + +/** A recorded dimension that simply has no values in the current window. */ +function NoneInWindow() { + return ( +

+ None in this range +

+ ); +} + +/** + * Affordance for a group with no filter. `recorded` distinguishes "the gateway + * stores nothing for this" from "it is stored per entry but has no control". + */ +function NotRecorded({ recorded }: { recorded?: boolean }) { + return recorded + ? ( +

+ No filter yet +

+ ) + : ( +

+ Not recorded yet +

+ ); +} diff --git a/klanker-gate/apps/control-ui/src/components/logs/LogsTable.tsx b/klanker-gate/apps/control-ui/src/components/logs/LogsTable.tsx new file mode 100755 index 0000000..b0a3b4e --- /dev/null +++ b/klanker-gate/apps/control-ui/src/components/logs/LogsTable.tsx @@ -0,0 +1,268 @@ +import { RefreshCw } from "lucide-react"; +import type { LogEntry } from "../../api"; +import { Badge } from "../ui/badge"; +import { Button } from "../ui/button"; +import { type Column, DataTable } from "../ui/data-table"; +import { + ALL_COLUMNS, + classifyOutcome, + type ColumnKey, + entryTokens, + formatCostUsd, + formatLatency, + formatTimestamp, + requestType, +} from "./logs-model"; +import { cn } from "../../lib/utils"; + +export type Connection = "connecting" | "streaming" | "disconnected"; + +type Row = LogEntry & { _id: string }; + +const reduceMotion = () => + globalThis.matchMedia?.("(prefers-reduced-motion: reduce)")?.matches ?? false; + +/** + * Honest placeholder for a row that carries no value in this column. Inference + * requests record provider/model/tokens; a health probe or an admin API call + * has none, so those rows keep saying N/A rather than borrowing a value. + */ +function NaCell({ mono }: { mono?: boolean }) { + return ( + + N/A + + ); +} + +/** Tokens cell: total, with the prompt/completion split and cost in the title. */ +function TokensCell({ entry }: { entry: LogEntry }) { + const total = entryTokens(entry); + if (total === null) { + return ; + } + const parts = [ + `${entry.promptTokens ?? 0} prompt`, + `${entry.completionTokens ?? 0} completion`, + ]; + if (typeof entry.costMicroUsd === "number") { + parts.push(formatCostUsd(entry.costMicroUsd)); + } + return ( + + {total.toLocaleString()} + + ); +} + +function MessageCell({ entry }: { entry: LogEntry }) { + const head = [entry.method, entry.path].filter(Boolean).join(" "); + return ( +
+ {head && ( +
+ {head} +
+ )} +
+ {entry.message} +
+
+ ); +} + +function StatusCell({ entry }: { entry: LogEntry }) { + const outcome = classifyOutcome(entry); + if (outcome === "success") { + return success; + } + if (outcome === "error") { + return ( + + {typeof entry.status === "number" ? entry.status : "error"} + + ); + } + if (outcome === "cancelled") { + return cancelled; + } + return processing; +} + +const COLUMN_DEFS: Record> = { + time: { + key: "time", + header: "Time", + sortValue: (row) => Date.parse(row.ts) || 0, + cell: (row) => ( + + {formatTimestamp(row.ts)} + + ), + }, + type: { + key: "type", + header: "Type", + sortValue: (row) => requestType(row) ?? "", + cell: (row) => { + const type = requestType(row); + return type + ? {type} + : ; + }, + }, + provider: { + key: "provider", + header: "Provider", + sortValue: (row) => row.provider ?? "", + cell: (row) => + row.provider + ? ( + + {row.provider} + + ) + : , + }, + model: { + key: "model", + header: "Model", + sortValue: (row) => row.model ?? "", + cell: (row) => + row.model + ? ( + + {row.model} + + ) + : , + }, + message: { + key: "message", + header: "Message", + cell: (row) => , + }, + latency: { + key: "latency", + header: "Latency", + sortValue: (row) => row.durationMs ?? -1, + cell: (row) => { + const latency = formatLatency(row.durationMs); + return latency + ? {latency} + : ; + }, + }, + tokens: { + key: "tokens", + header: "Tokens", + sortValue: (row) => entryTokens(row) ?? -1, + cell: (row) => , + }, + status: { + key: "status", + header: "Status", + cell: (row) => , + }, +}; + +export interface LogsTableProps { + entries: LogEntry[]; + visibleColumns: Set; + live: boolean; + connection: Connection; + loading: boolean; + onReconnect: () => void; +} + +export function LogsTable( + { entries, visibleColumns, live, connection, loading, onReconnect }: + LogsTableProps, +) { + const rows: Row[] = entries.map((entry, index) => ({ + ...entry, + _id: `${index}-${entry.ts}-${entry.requestId ?? ""}`, + })); + + const columns = ALL_COLUMNS + .filter((column) => visibleColumns.has(column.key)) + .map((column) => COLUMN_DEFS[column.key]); + + return ( +
+ {live && } + + caption="Request logs" + rows={rows} + columns={columns} + getRowId={(row) => row._id} + pageSize={25} + loading={loading} + initialSort={{ key: "time", dir: "desc" }} + minWidth="60rem" + empty={ +
+

+ No results found +

+

+ Try adjusting your filters and/or time range. +

+
+ } + /> +
+ ); +} + +function LiveBar( + { connection, onReconnect }: { + connection: Connection; + onReconnect: () => void; + }, +) { + const label = connection === "streaming" + ? "Listening for logs" + : connection === "connecting" + ? "Connecting to log stream" + : "Disconnected from log stream"; + + return ( +
+
+ ); +} diff --git a/klanker-gate/apps/control-ui/src/components/logs/logs-model.ts b/klanker-gate/apps/control-ui/src/components/logs/logs-model.ts new file mode 100755 index 0000000..c04c3b2 --- /dev/null +++ b/klanker-gate/apps/control-ui/src/components/logs/logs-model.ts @@ -0,0 +1,257 @@ +import type { LogEntry } from "../../api"; + +export type Outcome = "success" | "error" | "processing" | "cancelled"; + +/** + * Classify a log line into a request outcome using only recorded fields. + * - cancelled: HTTP 499 (client closed request convention) + * - error: level "error", or a status >= 400 + * - success: any other numeric status (< 400) + * - processing: no numeric status yet (in-flight or non-request log line) + * + * Every branch is a real predicate over recorded data; "cancelled" simply + * matches rarely (the gateway seldom emits 499), which is honest, not faked. + */ +export function classifyOutcome(entry: LogEntry): Outcome { + if (entry.status === 499) { + return "cancelled"; + } + if ( + entry.level === "error" || + (typeof entry.status === "number" && entry.status >= 400) + ) { + return "error"; + } + if (typeof entry.status === "number") { + return "success"; + } + return "processing"; +} + +export const OUTCOME_ORDER: Outcome[] = [ + "success", + "error", + "processing", + "cancelled", +]; + +export const OUTCOME_LABEL: Record = { + success: "Success", + error: "Error", + processing: "Processing", + cancelled: "Cancelled", +}; + +export type OutcomeCounts = Record; + +export function emptyCounts(): OutcomeCounts { + return { success: 0, error: 0, processing: 0, cancelled: 0 }; +} + +/** Time-range value -> window length in ms (matches DEFAULT_TIME_RANGES). */ +export const WINDOW_MS: Record = { + "1h": 60 * 60 * 1000, + "24h": 24 * 60 * 60 * 1000, + "7d": 7 * 24 * 60 * 60 * 1000, +}; + +/** Human timestamp; falls back to the raw string when unparseable. */ +export function formatTimestamp(ts: string): string { + const date = new Date(ts); + return Number.isNaN(date.getTime()) ? ts : date.toLocaleString(); +} + +/** "{n}ms" for a recorded duration, or null when latency is not recorded. */ +export function formatLatency(durationMs: number | undefined): string | null { + if (typeof durationMs !== "number" || !Number.isFinite(durationMs)) { + return null; + } + return `${durationMs}ms`; +} + +/* ------------------------- derived request type ------------------------- */ + +const TYPE_BY_PATH: Record = { + "/v1/chat/completions": "chat", + "/v1/completions": "text", + "/v1/responses": "responses", + "/v1/embeddings": "embedding", + "/v1/messages": "messages", + "/v1/images/generations": "image", + "/v1/audio/speech": "speech", + "/v1/audio/transcriptions": "transcription", +}; + +/** + * Request type derived from the recorded path, or null when the path is not an + * inference surface (admin API, static asset, health probe). + */ +export function requestType(entry: LogEntry): string | null { + return entry.path ? TYPE_BY_PATH[entry.path] ?? null : null; +} + +/* ----------------------------- tokens + cost ---------------------------- */ + +/** Total tokens for an entry, or null when no usage was recorded. */ +export function entryTokens(entry: LogEntry): number | null { + if (typeof entry.totalTokens === "number") { + return entry.totalTokens; + } + const prompt = entry.promptTokens; + const completion = entry.completionTokens; + if (typeof prompt !== "number" && typeof completion !== "number") { + return null; + } + return (prompt ?? 0) + (completion ?? 0); +} + +/** + * Formats integer micro-USD as a USD string. Sub-cent costs keep enough + * precision to stay non-zero, which matters because a single small completion + * routinely costs well under a cent. + */ +export function formatCostUsd(costMicroUsd: number): string { + const usd = costMicroUsd / 1_000_000; + if (usd === 0) { + return "$0.00"; + } + if (usd < 0.01) { + return `$${usd.toFixed(6)}`; + } + return `$${usd.toFixed(usd < 1 ? 4 : 2)}`; +} + +/* ----------------------------- table columns ---------------------------- */ + +export type ColumnKey = + | "time" + | "type" + | "provider" + | "model" + | "message" + | "latency" + | "tokens" + | "status"; + +export interface ColumnMeta { + key: ColumnKey; + label: string; + /** true when the column is backed by a recorded field. */ + real: boolean; +} + +export const ALL_COLUMNS: ColumnMeta[] = [ + { key: "time", label: "Time", real: true }, + { key: "type", label: "Type", real: true }, + { key: "provider", label: "Provider", real: true }, + { key: "model", label: "Model", real: true }, + { key: "message", label: "Message", real: true }, + { key: "latency", label: "Latency", real: true }, + { key: "tokens", label: "Tokens", real: true }, + { key: "status", label: "Status", real: true }, +]; + +export const DEFAULT_VISIBLE_COLUMNS: ColumnKey[] = ALL_COLUMNS.map((c) => + c.key +); + +/* -------------------------- honest-empty facets ------------------------- */ + +export interface HonestFacet { + id: string; + label: string; + /** + * false when the gateway records nothing for this dimension; true when it is + * recorded per entry but has no filter control yet. The two cases get + * different affordance text so neither overstates the other. + */ + recorded?: boolean; +} + +export const HONEST_FACETS: HonestFacet[] = [ + { id: "selectedKeys", label: "Selected Keys" }, + { id: "virtualKeys", label: "Virtual Keys" }, + { id: "aliases", label: "Aliases" }, + { id: "routingEngines", label: "Routing Engines" }, + { id: "routingRules", label: "Routing Rules" }, + { id: "user", label: "User" }, + { id: "session", label: "Session" }, + // Recorded per entry (costMicroUsd) but a range filter is not built. + { id: "cost", label: "Cost", recorded: true }, + { id: "stopReason", label: "Stop Reason" }, + { id: "metadata", label: "Metadata" }, +]; + +/* --------------------------- live value facets -------------------------- */ + +/** A recorded dimension the rail can filter on by exact value. */ +export interface ValueFacet { + id: "model" | "provider" | "type"; + label: string; + /** Recorded (or derived) value for an entry, or null when it has none. */ + valueOf: (entry: LogEntry) => string | null; + /** Rendered with a search box above the option list. */ + searchable?: boolean; +} + +export const VALUE_FACETS: ValueFacet[] = [ + { + id: "model", + label: "Models", + valueOf: (entry) => entry.model ?? null, + searchable: true, + }, + { + id: "provider", + label: "Provider", + valueOf: (entry) => entry.provider ?? null, + }, + { id: "type", label: "Type", valueOf: requestType }, +]; + +/** Distinct values of a facet across `entries`, with counts, sorted by value. */ +export function facetValues( + entries: LogEntry[], + facet: ValueFacet, +): Array<{ value: string; count: number }> { + const counts = new Map(); + for (const entry of entries) { + const value = facet.valueOf(entry); + if (value) { + counts.set(value, (counts.get(value) ?? 0) + 1); + } + } + return [...counts.entries()] + .map(([value, count]) => ({ value, count })) + .sort((a, b) => a.value.localeCompare(b.value)); +} + +/** Selected values per live facet id; an empty array means "no constraint". */ +export type FacetSelection = Partial>; + +/** Applies every non-empty live-facet selection (AND across facets). */ +export function applyValueFacets( + entries: LogEntry[], + selection: FacetSelection, +): LogEntry[] { + const active = VALUE_FACETS.filter((facet) => + (selection[facet.id]?.length ?? 0) > 0 + ); + if (active.length === 0) { + return entries; + } + return entries.filter((entry) => + active.every((facet) => { + const value = facet.valueOf(entry); + return value !== null && selection[facet.id]!.includes(value); + }) + ); +} + +export function outcomeCounts(entries: LogEntry[]): OutcomeCounts { + const counts = emptyCounts(); + for (const entry of entries) { + counts[classifyOutcome(entry)] += 1; + } + return counts; +} diff --git a/klanker-gate/apps/control-ui/src/components/providers/AddCustomProviderForm.tsx b/klanker-gate/apps/control-ui/src/components/providers/AddCustomProviderForm.tsx new file mode 100755 index 0000000..c997de5 --- /dev/null +++ b/klanker-gate/apps/control-ui/src/components/providers/AddCustomProviderForm.tsx @@ -0,0 +1,187 @@ +import { type FormEvent, useState } from "react"; +import { Plus } from "lucide-react"; +import type { ProviderAccountConfig } from "../../api"; +import { Button } from "../ui/button"; +import { Input } from "../ui/input"; +import { Field } from "../ui/label"; +import { NativeSelect } from "../ui/select"; +import { Switch } from "../ui/switch"; +import { Banner } from "../ui/banner"; +import { ToggleGridItem } from "../ui/toggle-grid-item"; +import { CUSTOM_BASE_FORMATS, REQUEST_TYPES } from "./constants"; + +type ProviderType = ProviderAccountConfig["type"]; + +export interface AddCustomProviderFormProps { + busy: boolean; + onSubmit: (payload: ProviderAccountConfig) => void; + onCancel: () => void; +} + +function defaultRequestTypes(): Record { + return Object.fromEntries(REQUEST_TYPES.map((r) => [r.key, true])); +} + +/** + * Inline Add Custom Provider form (spec: Name, Base Format, Base URL, an "Is + * Keyless" switch, and a two-column Allowed Request Types grid). Rendered in the + * detail pane like the standard add form rather than a modal. The config + * contract has no per-endpoint path or request-type storage, so the grid is an + * advisory capability picker (labelled as such) - create posts only the fields + * the gateway persists: id, wire type, base URL, and an optional key. + */ +export function AddCustomProviderForm( + { busy, onSubmit, onCancel }: AddCustomProviderFormProps, +) { + const [name, setName] = useState(""); + const [format, setFormat] = useState("openai-compatible"); + const [baseUrl, setBaseUrl] = useState(""); + const [keyless, setKeyless] = useState(false); + const [apiKey, setApiKey] = useState(""); + const [allowed, setAllowed] = useState>( + defaultRequestTypes, + ); + const [error, setError] = useState(null); + + function submit(event: FormEvent) { + event.preventDefault(); + const id = name.trim(); + if (id === "") { + setError("A name is required."); + return; + } + if (baseUrl.trim() === "") { + setError("A base URL is required for a custom provider."); + return; + } + setError(null); + const payload: ProviderAccountConfig = { + id, + type: format, + enabled: true, + models: [], + priority: 0, + baseUrl: baseUrl.trim(), + }; + if (!keyless && apiKey.trim() !== "") { + payload.apiKey = apiKey.trim(); + } + onSubmit(payload); + } + + return ( +
+
+

+ Add custom provider +

+

+ Point the gateway at any OpenAI- or Anthropic-compatible endpoint. + Keys are stored server-side and never shown again. +

+
+ +
+ + setName(e.target.value)} + /> + + + setFormat(e.target.value as ProviderType)} + > + {CUSTOM_BASE_FORMATS.map((f) => ( + + ))} + + + + setBaseUrl(e.target.value)} + /> + +
+ +
+ + +
+ + {!keyless && ( + + setApiKey(e.target.value)} + /> + + )} + +
+
+

+ Allowed Request Types +

+

+ Advisory capability picker. The gateway routes every request type + its wire format supports; per-endpoint path overrides are not + persisted. +

+
+
+ {REQUEST_TYPES.map((rt) => ( + + setAllowed((prev) => ({ ...prev, [rt.key]: checked }))} + /> + ))} +
+
+ + {error && {error}} + +
+ + +
+ + ); +} diff --git a/klanker-gate/apps/control-ui/src/components/providers/AddProviderDialog.tsx b/klanker-gate/apps/control-ui/src/components/providers/AddProviderDialog.tsx new file mode 100755 index 0000000..e9e3668 --- /dev/null +++ b/klanker-gate/apps/control-ui/src/components/providers/AddProviderDialog.tsx @@ -0,0 +1,113 @@ +import { useState } from "react"; +import { Plus, Search } from "lucide-react"; +import { Dialog } from "../ui/dialog"; +import { Input } from "../ui/input"; +import { Button } from "../ui/button"; +import { ProviderIcon } from "../ui/provider-icon"; +import { cn } from "../../lib/utils"; +import { PROVIDER_PRESETS, type ProviderPreset } from "./constants"; + +export interface AddProviderDialogProps { + open: boolean; + onClose: () => void; + /** Pick a vendor preset: prefills the add form with its type + base URL. */ + onPick: (preset: ProviderPreset) => void; + /** "Custom / other" escape hatch: open the blank / custom-provider flow. */ + onCustom: () => void; +} + +/** + * One-click provider gallery. Lists the vendor presets as filterable cards with + * their brand logo; picking one prefills the add form. A trailing "Custom" card + * routes to the bring-your-own flow for anything not in the catalog. + */ +export function AddProviderDialog( + { open, onClose, onPick, onCustom }: AddProviderDialogProps, +) { + const [query, setQuery] = useState(""); + const needle = query.trim().toLowerCase(); + const matches = needle + ? PROVIDER_PRESETS.filter((p) => + p.displayName.toLowerCase().includes(needle) || + p.key.toLowerCase().includes(needle) || + p.type.toLowerCase().includes(needle) + ) + : PROVIDER_PRESETS; + + return ( + +
+
+
+ +
+ {matches.map((preset) => ( + + ))} + {matches.length === 0 && ( +

+ No providers match "{query}". +

+ )} +
+ +
+

+ Cannot find it? Add any OpenAI- or Anthropic-compatible endpoint. +

+ +
+
+
+ ); +} diff --git a/klanker-gate/apps/control-ui/src/components/providers/AddProviderForm.tsx b/klanker-gate/apps/control-ui/src/components/providers/AddProviderForm.tsx new file mode 100755 index 0000000..1e2af0d --- /dev/null +++ b/klanker-gate/apps/control-ui/src/components/providers/AddProviderForm.tsx @@ -0,0 +1,333 @@ +import { type FormEvent, useState } from "react"; +import { Plus } from "lucide-react"; +import type { ProviderAccountConfig } from "../../api"; +import { Field } from "../ui/label"; +import { Input, Textarea } from "../ui/input"; +import { NativeSelect } from "../ui/select"; +import { Button } from "../ui/button"; +import { Banner } from "../ui/banner"; +import { CLOUD_TYPES, PROVIDER_LABELS, PROVIDER_TYPES } from "./constants"; + +type ProviderType = ProviderAccountConfig["type"]; + +interface FormValues { + id: string; + type: ProviderType; + apiKey: string; + baseUrl: string; + endpoint: string; + apiVersion: string; + modelName: string; + deploymentName: string; + awsRegion: string; + awsAccessKeyId: string; + awsSecretAccessKey: string; + awsSessionToken: string; + projectId: string; + location: string; + serviceAccountJson: string; +} + +function empty(): FormValues { + return { + id: "", + type: "openai", + apiKey: "", + baseUrl: "", + endpoint: "", + apiVersion: "", + modelName: "", + deploymentName: "", + awsRegion: "", + awsAccessKeyId: "", + awsSecretAccessKey: "", + awsSessionToken: "", + projectId: "", + location: "", + serviceAccountJson: "", + }; +} + +function assemble(v: FormValues): ProviderAccountConfig { + const cloud = CLOUD_TYPES.has(v.type); + const out: ProviderAccountConfig = { + id: v.id.trim(), + type: v.type, + enabled: true, + models: [], + priority: 0, + }; + if (!cloud && v.apiKey.trim() !== "") { + out.apiKey = v.apiKey.trim(); + } + if (v.baseUrl.trim() !== "") { + out.baseUrl = v.baseUrl.trim(); + } + if (v.type === "azure") { + if (v.endpoint.trim()) out.endpoint = v.endpoint.trim(); + if (v.apiVersion.trim()) out.apiVersion = v.apiVersion.trim(); + // Azure routes on the deployment name (the URL segment the client calls as + // `azure/`); the model name is a catalog alias. Both feed the + // advertised model list so the account is routable once created. + out.models = [ + ...new Set([v.deploymentName.trim(), v.modelName.trim()]), + ].filter((m) => m !== ""); + } + if (v.type === "bedrock") { + if (v.awsRegion.trim()) out.awsRegion = v.awsRegion.trim(); + if (v.awsAccessKeyId.trim()) out.awsAccessKeyId = v.awsAccessKeyId.trim(); + if (v.awsSecretAccessKey) out.awsSecretAccessKey = v.awsSecretAccessKey; + if (v.awsSessionToken) out.awsSessionToken = v.awsSessionToken; + } + if (v.type === "vertex") { + if (v.projectId.trim()) out.projectId = v.projectId.trim(); + if (v.location.trim()) out.location = v.location.trim(); + if (v.serviceAccountJson) out.serviceAccountJson = v.serviceAccountJson; + } + return out; +} + +export interface AddProviderFormProps { + busy: boolean; + onSubmit: (payload: ProviderAccountConfig) => void; + /** Prefill from a gallery preset (id/type/baseUrl). Remount (via `key`) to reset. */ + initial?: Partial; +} + +/** + * Inline add-provider form shown in the detail pane when no provider is + * selected. Covers the common path (id, wire type, key, base URL) plus the + * cloud/Azure credential fields, and posts a ProviderAccountConfig on submit. + */ +export function AddProviderForm( + { busy, onSubmit, initial }: AddProviderFormProps, +) { + const [v, setV] = useState(() => ({ ...empty(), ...initial })); + const [error, setError] = useState(null); + + const set = (key: K, value: FormValues[K]) => + setV((prev) => ({ ...prev, [key]: value })); + + const cloud = CLOUD_TYPES.has(v.type); + + function submit(event: FormEvent) { + event.preventDefault(); + if (v.id.trim() === "") { + setError("Provider ID is required."); + return; + } + if (v.type === "azure") { + if (v.endpoint.trim() === "") { + setError("An endpoint is required for Azure OpenAI."); + return; + } + if (v.deploymentName.trim() === "") { + setError("A deployment name is required for Azure OpenAI."); + return; + } + } + if (v.type === "vertex" && v.serviceAccountJson.trim() !== "") { + try { + JSON.parse(v.serviceAccountJson); + } catch { + setError("Service account JSON must be valid JSON."); + return; + } + } + setError(null); + onSubmit(assemble(v)); + } + + return ( +
+
+

Add provider

+

+ Connect an account the gateway can route inference to. Keys are stored + server-side and never shown again. +

+
+ +
+ + set("id", e.target.value)} + /> + + + set("type", e.target.value as ProviderType)} + > + {PROVIDER_TYPES.map((t) => ( + + ))} + + + + {!cloud && ( + + set("apiKey", e.target.value)} + /> + + )} + {v.type !== "azure" && ( + + set("baseUrl", e.target.value)} + /> + + )} + + {v.type === "azure" && ( + <> + + set("endpoint", e.target.value)} + /> + + + set("apiVersion", e.target.value)} + /> + + + set("deploymentName", e.target.value)} + /> + + + set("modelName", e.target.value)} + /> + + + )} + + {v.type === "bedrock" && ( + <> + + set("awsRegion", e.target.value)} + /> + + + set("awsAccessKeyId", e.target.value)} + /> + + + set("awsSecretAccessKey", e.target.value)} + /> + + + set("awsSessionToken", e.target.value)} + /> + + + )} + + {v.type === "vertex" && ( + <> + + set("projectId", e.target.value)} + /> + + + set("location", e.target.value)} + /> + + + + Monospace textarea for PEM blocks; identity carried by fill, label, and focus ring. +
+ +

Checkbox and switch

+
+ + + +
+
+ + + +
+ +

Badges

+

Soft is the default shape for tables and lists; solid is reserved for the single most important state in a region.

+
+ success + warning + error + info + default + neutral +
+

Application status vocabulary (locked D-CONTRACT strings). ds-r2: default is now a solid primary chip.

+
+ set + missing + default + enabled + disabled + http-sse + streamable-http + auto + read-only + needs confirmation + streaming + disconnected + healthy + unhealthy + ok + error +
+

The pulse dot appears only on streaming (a real live-connection state), at most once per view.

+ +

Chips and tags

+

Model chips (removable) and the CUSTOM provider tag.

+
+ gpt-4o + claude-sonnet + llama-3.3-70b + CUSTOM +
+ +

Stat tiles

+

Small xs label, large 3xl mono value, xs mono delta. Four-up on the dashboard.

+
+
Total requests12,847+312 last hour
+
Success rate98.4%31 errors
+
Avg latency142 msp95 611 ms
+
Total cost$3.824.2M tokens
+
+ +

Chart cards

+

Bar/line toggle, legend keyed to --chart-1..5, and a graceful empty state. Axes and gridlines use --muted-foreground and --border.

+
+
+
+ Requests per hour +
+ + +
+
+ +
00:0006:0012:0018:00
+
requests
+
+ +
+
+ Latency p50 vs p95 +
+ + +
+
+ + + + + + + + + +
-6h-3hnow
+
+ p95 latency + p50 latency +
+
+ +
+
+ Cost by provider +
+ + +
+
+
+ No data available + No requests recorded in this window. +
+
cost
+
+
+ +

Data table (resources)

+

Sortable headers, inline secret reveal and copy, row edit and delete actions, status pills, pagination.

+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
id type keystatusactions
openaiopenai + + sk-............... + + + + default enabled + + +
anthropicanthropic + + sk-............... + + + + enabled + + +
azure-eu CUSTOMazurenot setmissing disabled + + +
+ + + +

Log table

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
timestatusrequestmodellatencytokenscost
07:41:23200POST /v1/chat/completionsopenai/gpt-4o612 ms1,204$0.0038
07:41:19200POST /v1/messagesanthropic/claude-sonnet1,847 ms2,388$0.0121
07:41:12500POST /v1/embeddingsopenai/text-embedding-393 ms0$0.0000
07:41:08processingPOST /v1/chat/completionsgroq/llama-3.3-70b………
+
+ +

Sidebar navigation

+
+ +

+ Top search box carries a Ctrl K / Cmd K affordance and opens the command + palette. Groups are nested-expandable (rotating chevron); an external-link + glyph marks items that leave the app. Active item: sidebar-accent fill, + sidebar-accent-foreground text, 2px sidebar-primary inset bar. Section + labels are 2xs mono muted. +

+
+ +

Command palette

+ +

Opened by Cmd K / Ctrl K. Popover surface, shadow-lg, radius-xl. Highlighted row uses the accent wash; each row may carry a trailing mono shortcut hint.

+ +

Tabs and configuration panel

+
+
+ + + + +
+
+
+
+ + +
+
+ + +
+
+
+ + + +
+
+ +
+

Horizontal tabs over a dense field grid with toggles and a sticky Save/Remove footer on the card surface.

+ +

Filter and facet sidebar

+
+ +

+ Collapsible facet groups with a searchable list, checkbox groups, and mono + counts. Group headers are 2xs mono muted with a rotating chevron. Selected + facets echo as removable soft-neutral chips above the results. +

+
+ +

Dialog

+ + +

Custom-provider modal

+ + +

Sheet (detail panel)

+
+
+ Request details + +
+
+
Request id
req_8f3ka92
+
Model
openai/gpt-4o
+
Latency
612 ms
+
Prompt tokens
1,204
+
Completion tokens
356
+
Cost
$0.0038
+
+
+ +

Toast

+
+
+ Provider added + openai-eu is now serving requests. +
+
+ Sync failed + MCP server weather did not respond within 30s. +
+
+ +

Error banner

+ + +

Empty state

+
+ No providers configured + Add your first provider to start routing requests through the gateway. + +
+ +

Loading skeleton

+
+
+
+
+
+
+ +

Focus ring

+
+ +

+ Every interactive element shows a 2px ring at 2px offset in the accent + blue; the ring passes 3:1 against both canvases and 4.5:1 as link text. +

+
+
+ +
+

Application frame

+

+ Component preview of the app shell built from tokens: sidebar with a search + box and nested nav, content region with a page header, stat tiles, a chart + card, and a paginated resource table. +

+
+
+
+ Frosty + + + + + + + + gateway v0.9.0 on Deno 2.9.2 +
+
+
+ Configured providers + +
+
+
Providers41 disabled
+
Requests today12,84798.4% ok
+
Cost today$3.824.2M tokens
+
+
+
+ Requests per hour +
+ + +
+
+ +
+
+ + + + + + + + + + + + + + + + + + + +
idtypekeystatus
openaiopenaisetdefault enabled
anthropicanthropicsetenabled
azure-euazuremissingdisabled
+ +
+
+
+
+
+ + + +
+ Frosty design system, revision ds-r2 (supersedes ds-r1). Generated from + docs/design/DESIGN.md and tokens.css. 0 contrast failures across 64 checked + pairs in both themes. +
+ + + + diff --git a/klanker-gate/docs/design/tokens.css b/klanker-gate/docs/design/tokens.css new file mode 100755 index 0000000..0470a50 --- /dev/null +++ b/klanker-gate/docs/design/tokens.css @@ -0,0 +1,331 @@ +/* ============================================================================ + Frosty design tokens (canonical) - revision ds-r2 + ---------------------------------------------------------------------------- + Product: Frosty Control Plane, the control plane for a Deno-native LLM + gateway. Dark is the default theme: applications mount with class="dark" on + the root element. Light theme is the :root base per shadcn/ui v4 convention. + "Auto" behavior: toggle the .dark class from prefers-color-scheme in app JS. + + ds-r2 SUPERSEDES ds-r1 (glacier-blue). The register is a dense, near-neutral + monochrome developer console (shadcn "new-york"/neutral). Neutrals carry a + whisper of cool (hue 265, chroma <= 0.006), never pure gray. Primary is the + shadcn-neutral inversion: near-white in dark (ink label), near-black in light + (paper label). A single restrained cool-blue accent (hue ~252 to 255) is used + ONLY for the focus ring, links, and the active-nav indicator, never as a fill. + Semantic status colors (success/warning/destructive/info) stay a functional + vocabulary. A --chart-1..5 family is added for the analytics dashboard. + + Format: shadcn/ui v4 compatible CSS custom properties in OKLCH, plus an + @theme inline mapping block for Tailwind CSS v4. Hex fallbacks are noted in + comments; every text/surface pair is contrast-verified in both themes (see + docs/design/DESIGN.md, Accessibility section: 0 failures, computed WCAG 2.x). + + Source of truth: docs/design/DESIGN.md. Do not hand-edit component CSS to + diverge from these values; change the token here and let it propagate. Token + NAMES are stable across ds-r1 and ds-r2 so the app re-themes by swapping + values, not names. + ========================================================================== */ + +/* ------------------------------------------------------------------------ */ +/* Light theme (base) + theme-independent tokens */ +/* ------------------------------------------------------------------------ */ +:root { + color-scheme: light; + + /* --- color: surfaces ------------------------------------------------- */ + --background: oklch(0.99 0.002 265); /* #fbfcfd canvas */ + --foreground: oklch(0.2 0.006 265); /* #151619 body text */ + --card: oklch(1 0 0); /* #ffffff raised surface */ + --card-foreground: oklch(0.2 0.006 265); /* #151619 */ + --popover: oklch(1 0 0); /* #ffffff dialogs, menus */ + --popover-foreground: oklch(0.2 0.006 265); /* #151619 */ + + /* --- color: primary (shadcn-neutral inversion: near-black fill) ------- */ + --primary: oklch(0.24 0.006 265); /* #1e1f22 ink */ + --primary-foreground: oklch(0.985 0.001 265); /* #fafafb paper label */ + + /* --- color: supporting surfaces --------------------------------------- */ + --secondary: oklch(0.965 0.003 265); /* #f2f3f5 */ + --secondary-foreground: oklch(0.24 0.006 265); /* #1e1f22 */ + --muted: oklch(0.965 0.003 265); /* #f2f3f5 */ + --muted-foreground: oklch(0.475 0.008 265); /* #5a5c61 */ + --accent: oklch(0.965 0.004 265); /* #f2f3f6 hover wash */ + --accent-foreground: oklch(0.24 0.006 265); /* #1e1f22 */ + + /* --- color: semantic status ------------------------------------------- */ + --destructive: oklch(0.52 0.2 25); /* #c21725 */ + --destructive-foreground: oklch(0.985 0.005 25); /* #fdf9f8 */ + --success: oklch(0.48 0.13 155); /* #00723b */ + --success-foreground: oklch(0.985 0.005 155); /* #f8fbf9 */ + --warning: oklch(0.52 0.11 70); /* #8f5d14 */ + --warning-foreground: oklch(0.985 0.005 80); /* #fcfaf6 */ + --info: oklch(0.5 0.15 255); /* #1762b6 */ + --info-foreground: oklch(0.985 0.005 250); /* #f8fafd */ + + /* --- color: lines and focus ------------------------------------------- */ + --border: oklch(0.92 0.004 265); /* #e3e4e7 hairline */ + --input: oklch(0.89 0.004 265); /* #d9dbdd field border */ + --ring: oklch(0.55 0.15 255); /* #2971c6 accent blue */ + + /* --- color: chart family (data marks; AA >= 3:1 non-text on card) ------ */ + --chart-1: oklch(0.52 0.15 255); /* #1f68bc blue */ + --chart-2: oklch(0.52 0.14 155); /* #007f43 green */ + --chart-3: oklch(0.6 0.12 70); /* #ad721c amber */ + --chart-4: oklch(0.52 0.2 25); /* #c21725 red */ + --chart-5: oklch(0.5 0.18 300); /* #7541b8 violet */ + + /* --- color: sidebar family -------------------------------------------- */ + --sidebar: oklch(0.975 0.003 265); /* #f6f7f9 */ + --sidebar-foreground: oklch(0.24 0.006 265); /* #1e1f22 */ + --sidebar-primary: oklch(0.52 0.15 255); /* #1f68bc accent blue */ + --sidebar-primary-foreground: oklch(0.985 0.001 265); /* #fafafb */ + --sidebar-accent: oklch(0.955 0.005 265); /* #eef0f4 active item */ + --sidebar-accent-foreground: oklch(0.24 0.006 265); /* #1e1f22 */ + --sidebar-border: oklch(0.91 0.004 265); /* #e0e1e4 */ + --sidebar-ring: oklch(0.55 0.15 255); /* #2971c6 */ + + /* --- shadow ramp (near-neutral, never glacier) ------------------------- */ + --shadow-sm: 0 1px 2px 0 oklch(0.2 0.01 265 / 0.06); + --shadow-md: 0 2px 8px -1px oklch(0.2 0.01 265 / 0.1), + 0 1px 2px 0 oklch(0.2 0.01 265 / 0.06); + --shadow-lg: 0 8px 24px -4px oklch(0.2 0.01 265 / 0.16), + 0 2px 6px 0 oklch(0.2 0.01 265 / 0.08); + + /* --- typography -------------------------------------------------------- */ + /* JetBrains Mono (OFL-1.1, free) is an optional progressive enhancement: + it renders only where locally installed. No font files are shipped. */ + --font-family-sans: ui-sans-serif, system-ui, -apple-system, "Segoe UI", + Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif; + --font-family-mono: "JetBrains Mono", ui-monospace, "Cascadia Code", + "SF Mono", Menlo, Consolas, "Liberation Mono", monospace; + + /* compact scale, px-snapped; 13.5px body for high dashboard density */ + --font-size-2xs: 0.6875rem; --line-height-2xs: 1rem; /* 11/16 micro */ + --font-size-xs: 0.75rem; --line-height-xs: 1rem; /* 12/16 caption */ + --font-size-sm: 0.8125rem; --line-height-sm: 1.125rem; /* 13/18 secondary */ + --font-size-base: 0.84375rem; --line-height-base: 1.25rem; /* 13.5/20 body */ + --font-size-lg: 1rem; --line-height-lg: 1.375rem; /* 16/22 emphasis */ + --font-size-xl: 1.125rem; --line-height-xl: 1.5rem; /* 18/24 section */ + --font-size-2xl: 1.3125rem; --line-height-2xl: 1.625rem; /* 21/26 page title */ + --font-size-3xl: 1.6875rem; --line-height-3xl: 2rem; /* 27/32 display, stat */ + + --font-weight-regular: 400; + --font-weight-medium: 500; + --font-weight-semibold: 600; + + --tracking-tight: -0.01em; /* titles 2xl and up */ + --tracking-wide: 0.02em; /* tiny mono labels */ + + /* --- spacing (base unit 4px) ------------------------------------------- */ + --space-1: 0.25rem; /* 4px */ + --space-2: 0.5rem; /* 8px */ + --space-3: 0.75rem; /* 12px */ + --space-4: 1rem; /* 16px */ + --space-5: 1.25rem; /* 20px */ + --space-6: 1.5rem; /* 24px */ + --space-8: 2rem; /* 32px */ + --space-10: 2.5rem; /* 40px */ + --space-12: 3rem; /* 48px */ + --space-16: 4rem; /* 64px */ + + /* --- radius (one family, derived from a single 6px base) --------------- */ + --radius: 0.375rem; /* 6px base */ + --radius-sm: calc(var(--radius) - 2px); /* 4px badges, small controls */ + --radius-md: var(--radius); /* 6px buttons, inputs */ + --radius-lg: calc(var(--radius) + 2px); /* 8px cards, panels */ + --radius-xl: calc(var(--radius) + 6px); /* 12px dialogs, sheets */ + --radius-full: 9999px; /* pills, switch */ + + /* --- layout and control metrics (denser than ds-r1) -------------------- */ + --border-w: 1px; + --ring-w: 2px; + --ring-offset: 2px; + --control-h-sm: 1.875rem; /* 30px dense-row controls */ + --control-h: 2.125rem; /* 34px default control height */ + --control-h-lg: 2.625rem; /* 42px prominent controls */ + --tap-target: 2.75rem; /* 44px minimum hit area (extended if visual < 44) */ + --sidebar-width: 15rem; + --sidebar-width-icon: 3rem; + /* Outer content cap. Was 78rem (1248px), which left ~1250px of a 2560px + monitor unused while tables scrolled horizontally inside it. 110rem + (1760px) is wide enough for dense telemetry tables and the two-pane + layouts without letting a page become a single ungrouped expanse. */ + --container-max: 110rem; + /* Inner cap for prose and single-column forms. A control row stretched to + 1760px puts its label a screen away from its input; text past ~75ch stops + being scannable. Views opt into this for text-heavy panels while their + tables use the full --container-max. */ + --measure-max: 60rem; + /* Page gutter, tightening on small screens so a tablet does not spend a + quarter of its width on padding. */ + --gutter: 1.5rem; + --opacity-disabled: 0.5; + + /* --- motion ------------------------------------------------------------- */ + --motion-fast: 100ms; /* hover, focus, pressed feedback */ + --motion-default: 150ms; /* menus, popovers, toggles, tabs */ + --motion-slow: 220ms; /* dialogs, sheets, page-level */ + --motion-spin: 800ms; /* continuous loading spinner */ + --motion-ease-out: cubic-bezier(0.2, 0, 0, 1); + --motion-ease-in-out: cubic-bezier(0.4, 0, 0.2, 1); + --motion-rise: -2px; /* hover lift distance */ + --motion-enter: 8px; /* enter-toward travel distance */ + + /* --- z-index ------------------------------------------------------------ */ + --z-sticky: 20; + --z-overlay: 40; + --z-modal: 50; + --z-toast: 60; +} + +/* ------------------------------------------------------------------------ */ +/* Dark theme (Frosty default: mount with class="dark") */ +/* ------------------------------------------------------------------------ */ +.dark { + color-scheme: dark; + + --background: oklch(0.15 0.004 265); /* #0a0b0d canvas */ + --foreground: oklch(0.985 0.001 265); /* #fafafb body text */ + --card: oklch(0.185 0.004 265); /* #121314 raised surface */ + --card-foreground: oklch(0.985 0.001 265); /* #fafafb */ + --popover: oklch(0.205 0.004 265); /* #161719 dialogs, menus */ + --popover-foreground: oklch(0.985 0.001 265); /* #fafafb */ + + --primary: oklch(0.92 0.004 265); /* #e3e4e7 near-white */ + --primary-foreground: oklch(0.205 0.006 265); /* #16171a ink label */ + + --secondary: oklch(0.255 0.004 265); /* #222325 */ + --secondary-foreground: oklch(0.985 0.001 265); /* #fafafb */ + --muted: oklch(0.235 0.004 265); /* #1d1e20 */ + --muted-foreground: oklch(0.712 0.008 265); /* #a0a2a7 */ + --accent: oklch(0.255 0.006 265); /* #212326 hover wash */ + --accent-foreground: oklch(0.985 0.001 265); /* #fafafb */ + + --destructive: oklch(0.665 0.19 25); /* #f25855 */ + --destructive-foreground: oklch(0.205 0.04 25); /* #280e0c ink label */ + --success: oklch(0.72 0.15 155); /* #43c07a */ + --success-foreground: oklch(0.18 0.04 155); /* #021709 */ + --warning: oklch(0.8 0.13 82); /* #e7b551 */ + --warning-foreground: oklch(0.24 0.04 82); /* #291d07 */ + --info: oklch(0.68 0.13 250); /* #549de5 */ + --info-foreground: oklch(0.17 0.04 250); /* #021020 */ + + --border: oklch(0.27 0.006 265); /* #252629 */ + --input: oklch(0.3 0.006 265); /* #2c2e31 */ + --ring: oklch(0.62 0.13 252); /* #4589d2 accent blue */ + + --chart-1: oklch(0.66 0.14 252); /* #4b95e5 blue */ + --chart-2: oklch(0.72 0.15 155); /* #43c07a green */ + --chart-3: oklch(0.8 0.13 82); /* #e7b551 amber */ + --chart-4: oklch(0.665 0.19 25); /* #f25855 red */ + --chart-5: oklch(0.62 0.16 300); /* #966cd7 violet */ + + --sidebar: oklch(0.13 0.004 265); /* #070709 */ + --sidebar-foreground: oklch(0.8 0.006 265); /* #bcbec2 */ + --sidebar-primary: oklch(0.62 0.13 252); /* #4589d2 accent blue */ + --sidebar-primary-foreground: oklch(0.985 0.001 265); /* #fafafb */ + --sidebar-accent: oklch(0.235 0.006 265); /* #1d1e21 active item */ + --sidebar-accent-foreground: oklch(0.985 0.001 265); /* #fafafb */ + --sidebar-border: oklch(0.24 0.006 265); /* #1e1f22 */ + --sidebar-ring: oklch(0.62 0.13 252); /* #4589d2 */ + + --shadow-sm: 0 1px 2px 0 oklch(0.03 0.006 265 / 0.5); + --shadow-md: 0 2px 8px -1px oklch(0.03 0.006 265 / 0.6), + 0 1px 2px 0 oklch(0.03 0.006 265 / 0.5); + --shadow-lg: 0 10px 30px -5px oklch(0.03 0.006 265 / 0.7), + 0 4px 8px -2px oklch(0.03 0.006 265 / 0.5); +} + +/* ------------------------------------------------------------------------ */ +/* Reduced motion: durations collapse, travel distances zero out */ +/* ------------------------------------------------------------------------ */ +@media (prefers-reduced-motion: reduce) { + :root { + --motion-fast: 0ms; + --motion-default: 0ms; + --motion-slow: 0ms; + --motion-rise: 0px; + --motion-enter: 0px; + } +} + +/* ------------------------------------------------------------------------ */ +/* Tailwind CSS v4 mapping (shadcn/ui v4 convention) */ +/* Import this file in the app stylesheet after `@import "tailwindcss";`. */ +/* ------------------------------------------------------------------------ */ +@theme inline { + /* colors */ + --color-background: var(--background); + --color-foreground: var(--foreground); + --color-card: var(--card); + --color-card-foreground: var(--card-foreground); + --color-popover: var(--popover); + --color-popover-foreground: var(--popover-foreground); + --color-primary: var(--primary); + --color-primary-foreground: var(--primary-foreground); + --color-secondary: var(--secondary); + --color-secondary-foreground: var(--secondary-foreground); + --color-muted: var(--muted); + --color-muted-foreground: var(--muted-foreground); + --color-accent: var(--accent); + --color-accent-foreground: var(--accent-foreground); + --color-destructive: var(--destructive); + --color-destructive-foreground: var(--destructive-foreground); + --color-success: var(--success); + --color-success-foreground: var(--success-foreground); + --color-warning: var(--warning); + --color-warning-foreground: var(--warning-foreground); + --color-info: var(--info); + --color-info-foreground: var(--info-foreground); + --color-border: var(--border); + --color-input: var(--input); + --color-ring: var(--ring); + --color-chart-1: var(--chart-1); + --color-chart-2: var(--chart-2); + --color-chart-3: var(--chart-3); + --color-chart-4: var(--chart-4); + --color-chart-5: var(--chart-5); + --color-sidebar: var(--sidebar); + --color-sidebar-foreground: var(--sidebar-foreground); + --color-sidebar-primary: var(--sidebar-primary); + --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); + --color-sidebar-accent: var(--sidebar-accent); + --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); + --color-sidebar-border: var(--sidebar-border); + --color-sidebar-ring: var(--sidebar-ring); + + /* typography */ + --font-sans: var(--font-family-sans); + --font-mono: var(--font-family-mono); + --text-2xs: var(--font-size-2xs); + --text-2xs--line-height: var(--line-height-2xs); + --text-xs: var(--font-size-xs); + --text-xs--line-height: var(--line-height-xs); + --text-sm: var(--font-size-sm); + --text-sm--line-height: var(--line-height-sm); + --text-base: var(--font-size-base); + --text-base--line-height: var(--line-height-base); + --text-lg: var(--font-size-lg); + --text-lg--line-height: var(--line-height-lg); + --text-xl: var(--font-size-xl); + --text-xl--line-height: var(--line-height-xl); + --text-2xl: var(--font-size-2xl); + --text-2xl--line-height: var(--line-height-2xl); + --text-3xl: var(--font-size-3xl); + --text-3xl--line-height: var(--line-height-3xl); + + /* radius */ + --radius-sm: calc(var(--radius) - 2px); + --radius-md: var(--radius); + --radius-lg: calc(var(--radius) + 2px); + --radius-xl: calc(var(--radius) + 6px); + + /* shadows */ + --shadow-sm: var(--shadow-sm); + --shadow-md: var(--shadow-md); + --shadow-lg: var(--shadow-lg); + + /* easing */ + --ease-out: var(--motion-ease-out); + --ease-in-out: var(--motion-ease-in-out); +} diff --git a/klanker-gate/docs/design/ui-design.md b/klanker-gate/docs/design/ui-design.md new file mode 100755 index 0000000..050c76e --- /dev/null +++ b/klanker-gate/docs/design/ui-design.md @@ -0,0 +1,1372 @@ +# UI design (control plane) + +This document records the Frosty Deno control-plane SPA (`apps/control-ui`) +**exactly as implemented in the working tree**. It is a reference for the +interface a reader will actually see, not a wishlist. Where the shipped code +diverges from the declared design source [DESIGN.md](DESIGN.md), the code is +authoritative here and the divergence is called out. + +The control plane is served same-origin by the gateway from the same port +(default 8080); it renders API-only until `deno task build-ui` has produced +`apps/control-ui/dist`. See +[../concepts/architectural-overview.md](../concepts/architectural-overview.md) +for how the SPA fits the gateway (one process, or N under `FROSTY_WORKERS`), and +[../../apps/control-ui/CONVENTIONS.md](../../apps/control-ui/CONVENTIONS.md) for +the binding SPA contract. + +## Stack (verified) + +| Concern | Choice | Evidence | +| --- | --- | --- | +| Framework | React 19 (`^19.2.8`) + react-dom, mounted via `ReactDOM.createRoot` inside `React.StrictMode` | `package.json:9-10`, `src/main.tsx:6-10` | +| Styling | Tailwind CSS v4 (`^4.3.3`) CSS-first, wired through `@tailwindcss/vite`; **no `tailwind.config.*`, no `postcss.config.*`** | `vite.config.ts:7`, `package.json:14,22` | +| Class utility | `cn()` = `twMerge(clsx(...))` | `src/lib/utils.ts:4` | +| Icons | `lucide-react ^1.25.0` only (plus a documented brand-SVG exception) | `package.json:8` | +| Router | none - a hand-rolled hash router in `App.tsx` | grep: no router dependency | +| State / component libraries | none - no Radix, no shadcn runtime, no state library; every primitive is hand-written | `package.json`; grep | +| App version | `0.7.0` | `apps/control-ui/package.json` | +| Design revision | `ds-r2` (supersedes ds-r1 "glacier-blue"); dark is the default theme | `tokens.css:2,9`; `index.html:2` | + +Design tokens live in `apps/control-ui/src/styles/tokens.css` (340 lines, +OKLCH). A near-identical mirror ships at [tokens.css](tokens.css) in this folder +(CRLF, 331 lines pre-`deno fmt`); the two carry **zero value differences** and +are synced by hand, with no generator or CI check tying them together. + +--- + +## 1. Design system + +### 1.1 Identity and principles + +`ds-r2` is a dense, near-neutral monochrome developer console, register shadcn +"new-york"/neutral (`DESIGN.md:29-35`). The load-bearing rules, all verified +against code: + +- **Dark is the default and only pre-paint-resolved theme.** The document mounts + with `class="dark"` (`index.html:2`). +- **Neutrals are hue 265, chroma <= 0.006** - a whisper of cool, never pure gray + (`tokens.css:11-12`). +- **`--primary` is an emphasis surface, not a hue.** It is the shadcn-neutral + inversion: near-black in light, near-white in dark (`tokens.css:44,202`). +- **One cool-blue accent (hue 252 dark / 255 light), used only for the focus + ring, links, and the active-nav indicator - never as a fill.** Verified: there + is no `bg-ring` anywhere in the app. +- **Semantic status color (success/warning/destructive/info) is a functional + vocabulary only**, never decorative. +- **lucide-react icons only; same-origin only.** No external CDN, font, script, + or image origin exists in `index.html`, `index.css`, `tokens.css`, or any + component. No `@font-face`, no `` to a font, no font files shipped. + +Declared design dials (informational): DESIGN_VARIANCE 3, MOTION_INTENSITY 2, +VISUAL_DENSITY 8 (`DESIGN.md:37-41`). + +Token counts: **105 custom properties in `:root`**, of which **41 are +re-declared in `.dark`** (38 colors + 3 shadows) and **5 are re-declared under +`prefers-reduced-motion`**. Light values are the `:root` base +(`tokens.css:32-187`); dark is a `.dark` class override (`tokens.css:192-245`); +`color-scheme` is set per theme (`:33,193`) so native widgets follow. + +### 1.2 Color palette + +Every emitted value is OKLCH. The **hex** columns are the fallback hexes written +in the token file's own comments - documentation only, not the rendered value. +Line numbers reference `apps/control-ui/src/styles/tokens.css`. + +#### Surfaces + +| Token | Light (OKLCH) | Light hex | Dark (OKLCH) | Dark hex | Usage | +| --- | --- | --- | --- | --- | --- | +| `--background` | `0.99 0.002 265` (36) | `#fbfcfd` | `0.15 0.004 265` (195) | `#0a0b0d` | `body` fill (`index.css:20`); sticky config-footer fill (`ProviderConfigPanel.tsx:370`) | +| `--foreground` | `0.2 0.006 265` (37) | `#151619` | `0.985 0.001 265` (196) | `#fafafb` | `body` text (`index.css:21`); every modal scrim as `bg-foreground/40` | +| `--card` | `1 0 0` (38) | `#ffffff` | `0.185 0.004 265` (197) | `#121314` | Card surface; every field fill; sticky table header; active tab/segment; Sheet body | +| `--card-foreground` | = foreground (39) | `#151619` | = foreground (198) | `#fafafb` | Card / Sheet text | +| `--popover` | `1 0 0` (40) | `#ffffff` | `0.205 0.004 265` (199) | `#161719` | Dialog, DropdownMenu, Combobox listbox, TimeRangePicker, ColumnPicker, CommandPalette, Toast, chart tooltip (`/95`), skip-link chip | +| `--popover-foreground` | = foreground (41) | `#151619` | = foreground (200) | `#fafafb` | Same set as popover | + +#### Primary (shadcn-neutral inversion - an emphasis surface, not a chromatic hue) + +| Token | Light (OKLCH) | Light hex | Dark (OKLCH) | Dark hex | Usage | +| --- | --- | --- | --- | --- | --- | +| `--primary` | `0.24 0.006 265` (44) | `#1e1f22` | `0.92 0.004 265` (202) | `#e3e4e7` | Button `default` fill; Switch ON track; Checkbox `accent-primary`; Toast action link; Badge `primary` tone | +| `--primary-foreground` | `0.985 0.001 265` (45) | `#fafafb` | `0.205 0.006 265` (203) | `#16171a` | Button `default` label; Switch thumb when ON | + +#### Supporting surfaces + +| Token | Light (OKLCH) | Light hex | Dark (OKLCH) | Dark hex | Usage | +| --- | --- | --- | --- | --- | --- | +| `--secondary` | `0.965 0.003 265` (48) | `#f2f3f5` | `0.255 0.004 265` (205) | `#222325` | Button `secondary`; active pill in NavTabs `pill`; TagInput chips | +| `--secondary-foreground` | = foreground (49) | `#1e1f22` | = foreground (206) | `#fafafb` | Button `secondary` label | +| `--muted` | `0.965 0.003 265` (50) | `#f2f3f5` | `0.235 0.004 265` (207) | `#1d1e20` | Skeleton bar; Switch OFF track; Tabs / SegmentedSelect track; provider-icon tile; Badge `muted`; table row hover (`/40`) | +| `--muted-foreground` | `0.475 0.008 265` (51) | `#5a5c61` | `0.712 0.008 265` (208) | `#a0a2a7` | All secondary text, placeholders, chart axis labels + gridlines, table column headers, icon-button rest color | +| `--accent` | `0.965 0.004 265` (52) | `#f2f3f6` | `0.255 0.006 265` (209) | `#212326` | The single hover/active wash across buttons, menus, combobox, nav tabs, masked-secret, etc. | +| `--accent-foreground` | `0.24 0.006 265` (53) | `#1e1f22` | = foreground (210) | `#fafafb` | Exactly one usage: the highlighted CommandPalette result (`CommandPalette.tsx:160`) | + +#### Semantic status + +| Token | Light (OKLCH) | Light hex | Dark (OKLCH) | Dark hex | Usage | +| --- | --- | --- | --- | --- | --- | +| `--destructive` | `0.52 0.2 25` (56) | `#c21725` | `0.665 0.19 25` (212) | `#f25855` | Destructive buttons; Badge `err`; Banner `error`; required marker; field error text; `aria-invalid` border; Toast error icon; sidebar `denied` token dot | +| `--destructive-foreground` | `0.985 0.005 25` (57) | `#fdf9f8` | `0.205 0.04 25` (213) | `#280e0c` | Destructive button / solid badge label | +| `--success` | `0.48 0.13 155` (58) | `#00723b` | `0.72 0.15 155` (214) | `#43c07a` | Badge `ok`; Toast success icon; copied check; sidebar `ok` token dot | +| `--success-foreground` | `0.985 0.005 155` (59) | `#f8fbf9` | `0.18 0.04 155` (215) | `#021709` | Solid success badge label | +| `--warning` | `0.52 0.11 70` (60) | `#8f5d14` | `0.8 0.13 82` (216) | `#e7b551` | Badge `warn`; Banner `warn`; PEM hint | +| `--warning-foreground` | `0.985 0.005 80` (61) | `#fcfaf6` | `0.24 0.04 82` (217) | `#291d07` | Solid warn badge label | +| `--info` | `0.5 0.15 255` (62) | `#1762b6` | `0.68 0.13 250` (218) | `#549de5` | Badge `info`; Banner `info`; Toast info icon; EmptyState `tone="info"` icon | +| `--info-foreground` | `0.985 0.005 250` (63) | `#f8fafd` | `0.17 0.04 250` (219) | `#021020` | Solid info badge label | + +Deliberate hue note: light `--warning` is hue 70 while `--warning-foreground` is +hue 80; dark uses hue 82 for both (`DESIGN.md:62-63`, "amber 70 to 82"). + +#### Lines and focus + +| Token | Light (OKLCH) | Light hex | Dark (OKLCH) | Dark hex | Usage | +| --- | --- | --- | --- | --- | --- | +| `--border` | `0.92 0.004 265` (66) | `#e3e4e7` | `0.27 0.006 265` (221) | `#252629` | Global `* { border-color: var(--border) }` hairline default (`index.css:8-10`) plus explicit borders | +| `--input` | `0.89 0.004 265` (67) | `#d9dbdd` | `0.3 0.006 265` (222) | `#2c2e31` | Every field border; Button `outline`; Checkbox; Switch OFF border | +| `--ring` | `0.55 0.15 255` (68) | `#2971c6` | `0.62 0.13 252` (223) | `#4589d2` | **Global focus ring only** (`index.css:29-32`). No `bg-ring` anywhere - the accent-blue-is-never-a-fill rule holding | + +#### Chart family + +Consumed only through literal Tailwind classes (`text-chart-1..5`, +`bg-chart-1..5`) in `chart.tsx`; dynamic `text-chart-${n}` is forbidden because +the Tailwind JIT would purge it (verified: no dynamic chart-class construction +exists). Contrast figures are declared in `DESIGN.md` (see 8.3). + +| Token | Light (OKLCH) | Light hex | Dark (OKLCH) | Dark hex | Role | +| --- | --- | --- | --- | --- | --- | +| `--chart-1` | `0.52 0.15 255` (71) | `#1f68bc` | `0.66 0.14 252` (225) | `#4b95e5` | blue | +| `--chart-2` | `0.52 0.14 155` (72) | `#007f43` | `0.72 0.15 155` (226) | `#43c07a` | green | +| `--chart-3` | `0.6 0.12 70` (73) | `#ad721c` | `0.8 0.13 82` (227) | `#e7b551` | amber | +| `--chart-4` | `0.52 0.2 25` (74) | `#c21725` | `0.665 0.19 25` (228) | `#f25855` | red | +| `--chart-5` | `0.5 0.18 300` (75) | `#7541b8` | `0.62 0.16 300` (229) | `#966cd7` | violet | + +#### Sidebar family + +| Token | Light (OKLCH) | Light hex | Dark (OKLCH) | Dark hex | Usage | +| --- | --- | --- | --- | --- | --- | +| `--sidebar` | `0.975 0.003 265` (78) | `#f6f7f9` | `0.13 0.004 265` (231) | `#070709` | Rail surface (`Sidebar.tsx:143`); always the recessed surface (darker than background in dark, lighter in light) | +| `--sidebar-foreground` | `0.24 0.006 265` (79) | `#1e1f22` | `0.8 0.006 265` (232) | `#bcbec2` | Rail text | +| `--sidebar-primary` | `0.52 0.15 255` (80) | `#1f68bc` | `0.62 0.13 252` (233) | `#4589d2` | Brand snowflake (`:154`); search focus border (`:200`); **active-nav left inset bar** `before:bg-sidebar-primary` (`:263`) | +| `--sidebar-primary-foreground` | `0.985 0.001 265` (81) | `#fafafb` | identical value (234) | `#fafafb` | **The only color token with an identical value in both themes; no code usage found** | +| `--sidebar-accent` | `0.955 0.005 265` (82) | `#eef0f4` | `0.235 0.006 265` (235) | `#1d1e21` | Search fill (`/40`); collapse/close hover; active leaf fill; leaf hover (`/60`) | +| `--sidebar-accent-foreground` | `0.24 0.006 265` (83) | `#1e1f22` | `0.985 0.001 265` (236) | `#fafafb` | Active nav label | +| `--sidebar-border` | `0.91 0.004 265` (84) | `#e0e1e4` | `0.24 0.006 265` (237) | `#1e1f22` | Rail borders | +| `--sidebar-ring` | `0.55 0.15 255` (85) | `#2971c6` | `0.62 0.13 252` (238) | `#4589d2` | Mapped to Tailwind; **zero usage in app code** | + +#### Alpha / tint conventions actually used + +- Soft badge: `text- bg-/16 border-/32` (`badge.tsx:7-14`). +- Solid badge: `bg- text--foreground` - the `solid` prop exists but + **no call site passes it** (dead prop, `badge.tsx:27`). +- Banner: `border-/32 bg-/12 text-foreground` - a 12% tint, not the + badge's 16% (`banner.tsx:8-16`). +- Modal scrim `bg-foreground/40`; table row hover `bg-muted/40`; sidebar leaf + hover `bg-sidebar-accent/60`. +- Chart tooltip `bg-popover/95` plus `backdrop-blur-sm` (`chart.tsx:245`) - the + only blur in the app, contradicting `DESIGN.md:300` ("never a blur"). + +### 1.3 Typography + +Families (`tokens.css:97-100`). No font files ship; JetBrains Mono is a +progressive enhancement that renders only where locally installed. + +| Token | Value | +| --- | --- | +| `--font-family-sans` | `ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif` | +| `--font-family-mono` | `"JetBrains Mono", ui-monospace, "Cascadia Code", "SF Mono", Menlo, Consolas, "Liberation Mono", monospace` | + +Size / line-height scale (`tokens.css:103-118`). The token file states "13.5px +body for high dashboard density". + +| Step | font-size | px | line-height | px | Role (token comment) | Weight prescribed (`DESIGN.md:141-150`) | +| --- | --- | --- | --- | --- | --- | --- | +| 2xs | `0.6875rem` | 11 | `1rem` | 16 | micro | 500, tracking 0.02em | +| xs | `0.75rem` | 12 | `1rem` | 16 | caption | 400 | +| sm | `0.8125rem` | 13 | `1.125rem` | 18 | secondary | 400 | +| base | `0.84375rem` | **13.5** | `1.25rem` | 20 | body | 400 | +| lg | `1rem` | 16 | `1.375rem` | 22 | emphasis | 500 | +| xl | `1.125rem` | 18 | `1.5rem` | 24 | section | 600 | +| 2xl | `1.3125rem` | 21 | `1.625rem` | 26 | page title | 600, tracking -0.01em | +| 3xl | `1.6875rem` | 27 | `2rem` | 32 | display, stat | 600, tracking -0.01em | + +Weights: `--font-weight-regular: 400`, `--font-weight-medium: 500`, +`--font-weight-semibold: 600` (`tokens.css:120-122`). Note `--font-weight-regular` +is inert because Tailwind's key is `--font-weight-normal`. +Tracking: `--tracking-tight: -0.01em` (titles 2xl+), `--tracking-wide: 0.02em` +(tiny labels) (`tokens.css:124-125`). + +Where typography is actually applied: + +| Register | Class / rule | Site | +| --- | --- | --- | +| Global body | sans family, 13.5px / 20px, antialiased | `index.css:22-25` | +| Page title | `text-2xl font-semibold tracking-tight` (`h2`) | `page-header.tsx:29` | +| Card title | `text-lg font-semibold` (`h3`); ChartCard downgrades to `text-base` | `card.tsx:37`, `ChartCard.tsx:67` | +| Dialog / Sheet title | `text-lg font-semibold` (`h2`) | `dialog.tsx:50,119`, `sheet.tsx:49` | +| Settings sub-heading | `text-sm font-semibold` (`h4`) | `helpers.tsx:98` | +| Sidebar brand | `text-base font-semibold tracking-tight` (`h1`) | `Sidebar.tsx:158` | +| Table column header | `text-xs font-medium uppercase tracking-wide text-muted-foreground` | `table.tsx:79-80` | +| StatTile value | `font-mono text-2xl font-semibold` | `stat-tile.tsx:20` | +| Sidebar / command-palette group header | `text-2xs ... uppercase tracking-wide text-muted-foreground` | `Sidebar.tsx:226`, `CommandPalette.tsx:139` | +| Form label / Button / Badge | `text-sm font-medium` / `text-sm font-medium` / `text-xs font-medium` | `label.tsx:11`, `button.tsx:60`, `badge.tsx:37` | + +Notes: `font-mono` is used for all telemetry (ids, keys, latency, cost, tokens, +versions), 52 sites app-wide. `text-2xs` (11px) is used at 15 render sites. +`text-xl` (18px) and `text-3xl` (27px) have **no usage in `components/ui`** - +`StatTile` uses `text-2xl`, not the `3xl` the spec calls the "one deliberately +large figure". Because `--tracking-*` / `--font-weight-*` are declared in an +unlayered `:root` block (which beats Tailwind's `@layer theme` defaults), +`tracking-tight`/`tracking-wide` resolve to -0.01em/0.02em and +`font-medium`/`font-semibold` to 500/600. + +### 1.4 Spacing scale + +`tokens.css:128-137`, base unit 4px. + +| Token | Value | px | +| --- | --- | --- | +| `--space-1` | `0.25rem` | 4 | +| `--space-2` | `0.5rem` | 8 | +| `--space-3` | `0.75rem` | 12 | +| `--space-4` | `1rem` | 16 | +| `--space-5` | `1.25rem` | 20 | +| `--space-6` | `1.5rem` | 24 | +| `--space-8` | `2rem` | 32 | +| `--space-10` | `2.5rem` | 40 | +| `--space-12` | `3rem` | 48 | +| `--space-16` | `4rem` | 64 | + +Critical usage fact: only `--space-2` (3 refs) and `--space-4` (1 ref) are +referenced anywhere outside the token file, and all four sit in the single +`.sr-only-focusable:focus-visible` rule (`index.css:142,143,150`). The other +eight `--space-*` tokens have **zero references**. Components use Tailwind's own +`--spacing`-derived utilities (`px-5`, `gap-3`, `py-2`), which coincidentally +share the 4px base but are not driven by these tokens - the `@theme inline` +block does not map `--space-*` onto `--spacing`. De-facto conventions +(`CONVENTIONS.md:145-152`): card padding `px-5 py-4`, section gaps +`gap-4`/`gap-5`, page-section spacing `mb-5`/`mb-6`. + +### 1.5 Border radii + +`tokens.css:140-145`. One family from a single 6px base. + +| Token | Value | px | Role | Utility usage | +| --- | --- | --- | --- | --- | +| `--radius` | `0.375rem` | 6 | base | base only (not a Tailwind key) | +| `--radius-sm` | `calc(base - 2px)` | 4 | badges, small controls | `rounded-sm` (~16 sites) | +| `--radius-md` | `= base` | 6 | buttons, inputs | `rounded-md` (~56 sites) | +| `--radius-lg` | `calc(base + 2px)` | 8 | cards, panels | `rounded-lg` (~12 sites) | +| `--radius-xl` | `calc(base + 6px)` | 12 | dialogs, sheets | `rounded-xl` x4 (`dialog.tsx:44,114`, `CommandPalette.tsx:95`) | +| `--radius-full` | `9999px` | - | pills, switch | **0 direct refs**; `rounded-full` (8 sites) resolves to Tailwind's built-in `calc(infinity * 1px)` because `--radius-full` is deliberately absent from `@theme inline` | + +One outlier: a bare `rounded` at `data-table.tsx:180` (Tailwind's default +0.25rem), outside the declared 6px family. + +### 1.6 Shadows + +| Token | Light | Dark | Usage | +| --- | --- | --- | --- | +| `--shadow-sm` | `0 1px 2px 0 oklch(0.2 0.01 265 / 0.06)` | `0 1px 2px 0 oklch(0.03 0.006 265 / 0.5)` | Card; all fields; Switch thumb; active tab / segment | +| `--shadow-md` | `0 2px 8px -1px .../0.1, 0 1px 2px 0 .../0.06` | `0 2px 8px -1px .../0.6, 0 1px 2px 0 .../0.5` | DropdownMenu, Combobox listbox, TimeRangePicker, ColumnPicker, chart tooltip, skip-link chip | +| `--shadow-lg` | `0 8px 24px -4px .../0.16, 0 2px 6px 0 .../0.08` | `0 10px 30px -5px .../0.7, 0 4px 8px -2px .../0.5` | Dialog, ConfirmDialog, Sheet, CommandPalette, Toast, VK token-reveal dialog | + +`--shadow-lg` is the **only** shadow whose geometry (not just alpha) differs by +theme - the dark ramp is deeper. The self-referential mapping +`--shadow-sm: var(--shadow-sm)` etc. (`tokens.css:331-334`) is the shadcn-v4 +`@theme inline` convention: the literal `var(...)` text is substituted so the +value re-resolves per theme at runtime. + +### 1.7 Layout and control metrics + +`tokens.css:148-170`. + +| Token | Value | px | Where used | +| --- | --- | --- | --- | +| `--border-w` | `1px` | 1 | **0 refs** (components use Tailwind `border`) | +| `--ring-w` | `2px` | 2 | `index.css:30`; `table.tsx:25` | +| `--ring-offset` | `2px` | 2 | `index.css:31` | +| `--control-h-sm` | `1.875rem` | 30 | Button `sm`/`icon-sm`, dense rows | +| `--control-h` | `2.125rem` | 34 | Default control height (Button, Input, Select, Combobox, ...) | +| `--control-h-lg` | `2.625rem` | 42 | **0 refs** (Button has no `lg` size) | +| `--tap-target` | `2.75rem` | 44 | `.hit-target::after` (`index.css:87,88`) | +| `--sidebar-width` | `15rem` | 240 | `Sidebar.tsx:145,148` | +| `--sidebar-width-icon` | `3rem` | 48 | `Sidebar.tsx:148` | +| `--container-max` | `110rem` | 1760 | content wrapper (`App.tsx:315`); raised from 78rem so dense tables stop scrolling horizontally inside unused whitespace | +| `--measure-max` | `60rem` | 960 | `.measure` and `.field-grid > .field-wide` (`index.css:51,74`); caps prose and single-column forms so a label is not a screen from its input | +| `--gutter` | `1.5rem` | 24 | page gutter, `px-(--gutter)` on `
` (`App.tsx:311`); tightens to `1rem` at `<= 48rem` (`index.css:39-43`) | +| `--opacity-disabled` | `0.5` | - | **0 refs**; components hardcode `opacity-50` (value matches, token does not drive it) | + +### 1.8 Motion + +`tokens.css:172-180`. Motion is feedback-only; there is no decorative animation. + +| Token | Value | Refs in code | +| --- | --- | --- | +| `--motion-fast` | `100ms` | ~21 (hover/focus/press feedback on Button, menus, tabs, toggles, ...) | +| `--motion-default` | `150ms` | 5 (Switch, Collapsible, sidebar drawer slide) | +| `--motion-slow` | `220ms` | **0 refs** | +| `--motion-spin` | `800ms` | 1 (`spinner.tsx:9`) | +| `--motion-ease-out` | `cubic-bezier(0.2,0,0,1)` | 0 direct (mapped to Tailwind `--ease-out`, unused) | +| `--motion-ease-in-out` | `cubic-bezier(0.4,0,0.2,1)` | 2 (the two keyframes) | +| `--motion-rise` | `-2px` | **0 refs** | +| `--motion-enter` | `8px` | **0 refs** | + +Reduced motion (`tokens.css:250-258`) collapses fast/default/slow/rise/enter to +`0ms`/`0px`; `--motion-spin` is deliberately not collapsed (the spinner instead +carries `motion-reduce:animate-none`). Two keyframes exist app-wide: +`.skeleton-pulse` (live) and `.stream-pulse` (dead CSS - never applied; the live +indicator is a spinning lucide `RefreshCw`). **Overlays (Dialog, ConfirmDialog, +Sheet, CommandPalette) have no enter/exit animation at all** - they appear +instantly. The only overlay motion is the sidebar drawer slide. + +### 1.9 Z-index layers + +`tokens.css:182-186`. Consumers use the Tailwind arbitrary-variable form +`z-(--z-*)`. + +| Token | Value | Sites (complete) | +| --- | --- | --- | +| `--z-sticky` | 20 | sticky `` (`data-table.tsx:142`); sticky provider config footer (`ProviderConfigPanel.tsx:370`) | +| `--z-overlay` | 40 | DropdownMenu panel, Combobox listbox, TimeRangePicker menu, ColumnPicker popover, mobile nav scrim | +| `--z-modal` | 50 | Dialog, ConfirmDialog, Sheet, CommandPalette, the whole sidebar `