From 9e3aa3aa17cc75e05be8bf232d5d259eaf4e2986 Mon Sep 17 00:00:00 2001 From: Tomas Dvorak Date: Thu, 17 Sep 2026 09:40:08 +0200 Subject: [PATCH] Consolidate backend: drop vendored APwhy, converge on goose + sqlc MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 0 cleanup verified end-to-end: - Delete vendored APwhy runtime (cmd/apwhy, internal/apwhy) and the unwired copy (internal/api/server.go + static bundle + cloned internal/{storage,analytics,auth,rbac}) — ~7.5k lines of dead code. - Keep Containr's native internal/gateway; move APwhy management routes under /api/v1/gateway/* so they stop colliding with the native /services paths. Fix handlers against the real schema: UUID ids via RETURNING, integer enabled flags, plan validation, service validate endpoint, unique-violation mapping. - Port agents.go off GORM onto sqlc (sqlc/queries/agents.sql); drop gorm dependencies; fix sqlc schema drift (services.environment_id). - Replace the SELECT 1 goose baseline with the real consolidated schema (52 tables) minus APwhy's parallel auth/RBAC objects; idempotent via IF NOT EXISTS / OR REPLACE / guarded DO blocks so it replays cleanly over legacy installs. Add a drop migration for the dead objects and delete the legacy filename-tracked migrations/ loader, directory, and Dockerfile copy. Verified via cmd/migrate: fresh install 53 tables, legacy upgrade clean to 20260917000000. - Canvas: add invisible React Flow handles to ServiceNode so inferred auto-connections stop emitting warning #008. - Remove reactflow dep (superseded by @xyflow/react), test harness cmds, and legacy 0001_init.sql backups. Authored By: TDvorak --- app/backend/Dockerfile | 1 - app/backend/README_BACKEND.md | 9 +- app/backend/cmd/apwhy/main.go | 29 - app/backend/cmd/migrate/main.go | 23 +- app/backend/cmd/server/main.go | 2 +- app/backend/cmd/test-build-manager/main.go | 54 - app/backend/cmd/test-railpack/main.go | 32 - app/backend/go.mod | 19 +- app/backend/go.sum | 40 - app/backend/internal/analytics/umami.go | 74 - app/backend/internal/api/agents.go | 651 +- app/backend/internal/api/apwhy_handlers.go | 200 +- app/backend/internal/api/params.go | 16 +- app/backend/internal/api/routes.go | 60 +- app/backend/internal/api/server.go | 2694 --------- ...ex-mono-cyrillic-400-normal-BSMlKf0J.woff2 | Bin 8356 -> 0 bytes ...lex-mono-cyrillic-400-normal-CEL4l2ZJ.woff | Bin 7204 -> 0 bytes ...mono-cyrillic-ext-400-normal-DMdlQ8Kv.woff | Bin 5820 -> 0 bytes ...ono-cyrillic-ext-400-normal-xuaO2J-f.woff2 | Bin 6912 -> 0 bytes ...m-plex-mono-latin-400-normal-CvHOgSBP.woff | Bin 13144 -> 0 bytes ...-plex-mono-latin-400-normal-DMJ8VG8y.woff2 | Bin 14708 -> 0 bytes ...x-mono-latin-ext-400-normal-BmRBH3aV.woff2 | Bin 13348 -> 0 bytes ...ex-mono-latin-ext-400-normal-D3D2R8hC.woff | Bin 11768 -> 0 bytes ...-mono-vietnamese-400-normal-BulugwFq.woff2 | Bin 5868 -> 0 bytes ...x-mono-vietnamese-400-normal-DDuiU_S-.woff | Bin 5620 -> 0 bytes .../api/static/assets/index-DRUelTBf.css | 1 - .../api/static/assets/index-DwfYiTMH.js | 1 - ...manrope-cyrillic-400-normal-BMzJvInZ.woff2 | Bin 7840 -> 0 bytes .../manrope-cyrillic-400-normal-Dvx59UGC.woff | Bin 10032 -> 0 bytes ...manrope-cyrillic-600-normal-DvRl3Mj-.woff2 | Bin 7872 -> 0 bytes .../manrope-cyrillic-600-normal-It4mZcQk.woff | Bin 10060 -> 0 bytes .../manrope-cyrillic-700-normal-7JNVKxyl.woff | Bin 10004 -> 0 bytes ...manrope-cyrillic-700-normal-Dw_fZAg2.woff2 | Bin 7852 -> 0 bytes .../manrope-greek-400-normal-CM4qok81.woff2 | Bin 5280 -> 0 bytes .../manrope-greek-400-normal-DuX9RsAR.woff | Bin 6772 -> 0 bytes .../manrope-greek-600-normal-BoRV6lzK.woff2 | Bin 5232 -> 0 bytes .../manrope-greek-600-normal-CF2i9ZRY.woff | Bin 6740 -> 0 bytes .../manrope-greek-700-normal-CHUG9PD8.woff2 | Bin 5236 -> 0 bytes .../manrope-greek-700-normal-DyfsrCpP.woff | Bin 6744 -> 0 bytes .../manrope-latin-400-normal-8tf8FM3T.woff | Bin 18292 -> 0 bytes .../manrope-latin-400-normal-PaqtzbVb.woff2 | Bin 14108 -> 0 bytes .../manrope-latin-600-normal-4f0koTD-.woff2 | Bin 14172 -> 0 bytes .../manrope-latin-600-normal-BqgrALkZ.woff | Bin 18384 -> 0 bytes .../manrope-latin-700-normal-BZp_XxE4.woff2 | Bin 14212 -> 0 bytes .../manrope-latin-700-normal-DGRFkw-m.woff | Bin 18412 -> 0 bytes ...manrope-latin-ext-400-normal-C-X6QNXX.woff | Bin 11316 -> 0 bytes ...anrope-latin-ext-400-normal-CMDvPJRp.woff2 | Bin 8292 -> 0 bytes ...anrope-latin-ext-600-normal-_gBojHdJ.woff2 | Bin 8304 -> 0 bytes ...manrope-latin-ext-600-normal-u5Pl7hTU.woff | Bin 11248 -> 0 bytes ...anrope-latin-ext-700-normal-DYOwVNan.woff2 | Bin 8276 -> 0 bytes ...manrope-latin-ext-700-normal-eVCcYqtJ.woff | Bin 11352 -> 0 bytes ...anrope-vietnamese-400-normal-D7E_mLGF.woff | Bin 6108 -> 0 bytes ...nrope-vietnamese-400-normal-DHb3EETF.woff2 | Bin 4464 -> 0 bytes ...nrope-vietnamese-600-normal-C1J5PCl_.woff2 | Bin 4468 -> 0 bytes ...anrope-vietnamese-600-normal-lA7a_7Ok.woff | Bin 6140 -> 0 bytes ...nrope-vietnamese-700-normal-CUqMx5-1.woff2 | Bin 4552 -> 0 bytes ...anrope-vietnamese-700-normal-pt65Fn2Z.woff | Bin 6364 -> 0 bytes app/backend/internal/api/static/index.html | 13 - app/backend/internal/api/templates.go | 4 +- app/backend/internal/apwhy/analytics/umami.go | 74 - app/backend/internal/apwhy/api/server.go | 2694 --------- .../apwhy/api/server_integration_test.go | 321 - ...ex-mono-cyrillic-400-normal-BSMlKf0J.woff2 | Bin 8356 -> 0 bytes ...lex-mono-cyrillic-400-normal-CEL4l2ZJ.woff | Bin 7204 -> 0 bytes ...mono-cyrillic-ext-400-normal-DMdlQ8Kv.woff | Bin 5820 -> 0 bytes ...ono-cyrillic-ext-400-normal-xuaO2J-f.woff2 | Bin 6912 -> 0 bytes ...m-plex-mono-latin-400-normal-CvHOgSBP.woff | Bin 13144 -> 0 bytes ...-plex-mono-latin-400-normal-DMJ8VG8y.woff2 | Bin 14708 -> 0 bytes ...x-mono-latin-ext-400-normal-BmRBH3aV.woff2 | Bin 13348 -> 0 bytes ...ex-mono-latin-ext-400-normal-D3D2R8hC.woff | Bin 11768 -> 0 bytes ...-mono-vietnamese-400-normal-BulugwFq.woff2 | Bin 5868 -> 0 bytes ...x-mono-vietnamese-400-normal-DDuiU_S-.woff | Bin 5620 -> 0 bytes .../api/static/assets/index-DRUelTBf.css | 1 - .../apwhy/api/static/assets/index-DwfYiTMH.js | 1 - ...manrope-cyrillic-400-normal-BMzJvInZ.woff2 | Bin 7840 -> 0 bytes .../manrope-cyrillic-400-normal-Dvx59UGC.woff | Bin 10032 -> 0 bytes ...manrope-cyrillic-600-normal-DvRl3Mj-.woff2 | Bin 7872 -> 0 bytes .../manrope-cyrillic-600-normal-It4mZcQk.woff | Bin 10060 -> 0 bytes .../manrope-cyrillic-700-normal-7JNVKxyl.woff | Bin 10004 -> 0 bytes ...manrope-cyrillic-700-normal-Dw_fZAg2.woff2 | Bin 7852 -> 0 bytes .../manrope-greek-400-normal-CM4qok81.woff2 | Bin 5280 -> 0 bytes .../manrope-greek-400-normal-DuX9RsAR.woff | Bin 6772 -> 0 bytes .../manrope-greek-600-normal-BoRV6lzK.woff2 | Bin 5232 -> 0 bytes .../manrope-greek-600-normal-CF2i9ZRY.woff | Bin 6740 -> 0 bytes .../manrope-greek-700-normal-CHUG9PD8.woff2 | Bin 5236 -> 0 bytes .../manrope-greek-700-normal-DyfsrCpP.woff | Bin 6744 -> 0 bytes .../manrope-latin-400-normal-8tf8FM3T.woff | Bin 18292 -> 0 bytes .../manrope-latin-400-normal-PaqtzbVb.woff2 | Bin 14108 -> 0 bytes .../manrope-latin-600-normal-4f0koTD-.woff2 | Bin 14172 -> 0 bytes .../manrope-latin-600-normal-BqgrALkZ.woff | Bin 18384 -> 0 bytes .../manrope-latin-700-normal-BZp_XxE4.woff2 | Bin 14212 -> 0 bytes .../manrope-latin-700-normal-DGRFkw-m.woff | Bin 18412 -> 0 bytes ...manrope-latin-ext-400-normal-C-X6QNXX.woff | Bin 11316 -> 0 bytes ...anrope-latin-ext-400-normal-CMDvPJRp.woff2 | Bin 8292 -> 0 bytes ...anrope-latin-ext-600-normal-_gBojHdJ.woff2 | Bin 8304 -> 0 bytes ...manrope-latin-ext-600-normal-u5Pl7hTU.woff | Bin 11248 -> 0 bytes ...anrope-latin-ext-700-normal-DYOwVNan.woff2 | Bin 8276 -> 0 bytes ...manrope-latin-ext-700-normal-eVCcYqtJ.woff | Bin 11352 -> 0 bytes ...anrope-vietnamese-400-normal-D7E_mLGF.woff | Bin 6108 -> 0 bytes ...nrope-vietnamese-400-normal-DHb3EETF.woff2 | Bin 4464 -> 0 bytes ...nrope-vietnamese-600-normal-C1J5PCl_.woff2 | Bin 4468 -> 0 bytes ...anrope-vietnamese-600-normal-lA7a_7Ok.woff | Bin 6140 -> 0 bytes ...nrope-vietnamese-700-normal-CUqMx5-1.woff2 | Bin 4552 -> 0 bytes ...anrope-vietnamese-700-normal-pt65Fn2Z.woff | Bin 6364 -> 0 bytes .../internal/apwhy/api/static/index.html | 13 - app/backend/internal/apwhy/auth/security.go | 119 - app/backend/internal/apwhy/config/config.go | 127 - app/backend/internal/apwhy/gateway/proxy.go | 85 - app/backend/internal/apwhy/rbac/defaults.go | 49 - app/backend/internal/apwhy/storage/db.go | 236 - app/backend/internal/apwhy/storage/schema.go | 194 - app/backend/internal/auth/security.go | 119 - .../internal/database/goose_migrations.go | 34 +- app/backend/internal/database/migrations.go | 123 - .../internal/database/migrations_test.go | 63 - .../internal/database/sqlcdb/agents.sql.go | 733 +++ .../internal/database/sqlcdb/databases.sql.go | 2 +- app/backend/internal/database/sqlcdb/db.go | 2 +- .../internal/database/sqlcdb/models.go | 105 +- .../internal/database/sqlcdb/projects.sql.go | 2 +- .../internal/database/sqlcdb/querier.go | 21 +- .../internal/database/sqlcdb/templates.sql.go | 6 +- app/backend/internal/logger/logger_test.go | 26 +- app/backend/internal/middleware/ratelimit.go | 24 +- app/backend/internal/rbac/defaults.go | 49 - app/backend/internal/storage/db.go | 433 -- app/backend/internal/storage/schema.go | 194 - app/backend/internal/types/build.go | 158 +- app/backend/migrations/0001_init.sql | 188 - app/backend/migrations/0001_init.sql.bak | 188 - app/backend/migrations/001_initial_schema.sql | 157 - .../migrations/002_git_integration.sql | 136 - app/backend/migrations/003_agent_system.sql | 191 - app/backend/migrations/003_metrics_schema.sql | 350 -- .../migrations/004_database_services.sql | 264 - .../migrations/004_preview_environments.sql | 54 - .../migrations/005_security_features.sql | 247 - .../006_deployment_enhancements.sql | 28 - .../migrations/007_templates_cron_audit.sql | 90 - .../migrations/008_apwhy_integration.sql | 229 - .../018_performance_optimization.sql | 79 - app/backend/migrations/019_builds_table.sql | 35 - .../migrations/020_add_database_templates.sql | 27 - .../021_expand_database_service_types.sql | 8 - .../20260401163000_baseline.sql | 5227 ++++++++++++++++- ...0260917000000_drop_apwhy_parallel_auth.sql | 28 + app/backend/sqlc/queries/agents.sql | 109 + app/backend/sqlc/schema.sql | 97 +- app/frontend/package-lock.json | 451 -- app/frontend/package.json | 1 - .../src/features/workspace/canvas/nodes.tsx | 8 +- docs/developers/onboarding.md | 4 +- docs/guides/README_BACKEND.md | 4 +- 153 files changed, 6996 insertions(+), 11135 deletions(-) delete mode 100644 app/backend/cmd/apwhy/main.go delete mode 100644 app/backend/cmd/test-build-manager/main.go delete mode 100644 app/backend/cmd/test-railpack/main.go delete mode 100644 app/backend/internal/analytics/umami.go delete mode 100644 app/backend/internal/api/server.go delete mode 100644 app/backend/internal/api/static/assets/ibm-plex-mono-cyrillic-400-normal-BSMlKf0J.woff2 delete mode 100644 app/backend/internal/api/static/assets/ibm-plex-mono-cyrillic-400-normal-CEL4l2ZJ.woff delete mode 100644 app/backend/internal/api/static/assets/ibm-plex-mono-cyrillic-ext-400-normal-DMdlQ8Kv.woff delete mode 100644 app/backend/internal/api/static/assets/ibm-plex-mono-cyrillic-ext-400-normal-xuaO2J-f.woff2 delete mode 100644 app/backend/internal/api/static/assets/ibm-plex-mono-latin-400-normal-CvHOgSBP.woff delete mode 100644 app/backend/internal/api/static/assets/ibm-plex-mono-latin-400-normal-DMJ8VG8y.woff2 delete mode 100644 app/backend/internal/api/static/assets/ibm-plex-mono-latin-ext-400-normal-BmRBH3aV.woff2 delete mode 100644 app/backend/internal/api/static/assets/ibm-plex-mono-latin-ext-400-normal-D3D2R8hC.woff delete mode 100644 app/backend/internal/api/static/assets/ibm-plex-mono-vietnamese-400-normal-BulugwFq.woff2 delete mode 100644 app/backend/internal/api/static/assets/ibm-plex-mono-vietnamese-400-normal-DDuiU_S-.woff delete mode 100644 app/backend/internal/api/static/assets/index-DRUelTBf.css delete mode 100644 app/backend/internal/api/static/assets/index-DwfYiTMH.js delete mode 100644 app/backend/internal/api/static/assets/manrope-cyrillic-400-normal-BMzJvInZ.woff2 delete mode 100644 app/backend/internal/api/static/assets/manrope-cyrillic-400-normal-Dvx59UGC.woff delete mode 100644 app/backend/internal/api/static/assets/manrope-cyrillic-600-normal-DvRl3Mj-.woff2 delete mode 100644 app/backend/internal/api/static/assets/manrope-cyrillic-600-normal-It4mZcQk.woff delete mode 100644 app/backend/internal/api/static/assets/manrope-cyrillic-700-normal-7JNVKxyl.woff delete mode 100644 app/backend/internal/api/static/assets/manrope-cyrillic-700-normal-Dw_fZAg2.woff2 delete mode 100644 app/backend/internal/api/static/assets/manrope-greek-400-normal-CM4qok81.woff2 delete mode 100644 app/backend/internal/api/static/assets/manrope-greek-400-normal-DuX9RsAR.woff delete mode 100644 app/backend/internal/api/static/assets/manrope-greek-600-normal-BoRV6lzK.woff2 delete mode 100644 app/backend/internal/api/static/assets/manrope-greek-600-normal-CF2i9ZRY.woff delete mode 100644 app/backend/internal/api/static/assets/manrope-greek-700-normal-CHUG9PD8.woff2 delete mode 100644 app/backend/internal/api/static/assets/manrope-greek-700-normal-DyfsrCpP.woff delete mode 100644 app/backend/internal/api/static/assets/manrope-latin-400-normal-8tf8FM3T.woff delete mode 100644 app/backend/internal/api/static/assets/manrope-latin-400-normal-PaqtzbVb.woff2 delete mode 100644 app/backend/internal/api/static/assets/manrope-latin-600-normal-4f0koTD-.woff2 delete mode 100644 app/backend/internal/api/static/assets/manrope-latin-600-normal-BqgrALkZ.woff delete mode 100644 app/backend/internal/api/static/assets/manrope-latin-700-normal-BZp_XxE4.woff2 delete mode 100644 app/backend/internal/api/static/assets/manrope-latin-700-normal-DGRFkw-m.woff delete mode 100644 app/backend/internal/api/static/assets/manrope-latin-ext-400-normal-C-X6QNXX.woff delete mode 100644 app/backend/internal/api/static/assets/manrope-latin-ext-400-normal-CMDvPJRp.woff2 delete mode 100644 app/backend/internal/api/static/assets/manrope-latin-ext-600-normal-_gBojHdJ.woff2 delete mode 100644 app/backend/internal/api/static/assets/manrope-latin-ext-600-normal-u5Pl7hTU.woff delete mode 100644 app/backend/internal/api/static/assets/manrope-latin-ext-700-normal-DYOwVNan.woff2 delete mode 100644 app/backend/internal/api/static/assets/manrope-latin-ext-700-normal-eVCcYqtJ.woff delete mode 100644 app/backend/internal/api/static/assets/manrope-vietnamese-400-normal-D7E_mLGF.woff delete mode 100644 app/backend/internal/api/static/assets/manrope-vietnamese-400-normal-DHb3EETF.woff2 delete mode 100644 app/backend/internal/api/static/assets/manrope-vietnamese-600-normal-C1J5PCl_.woff2 delete mode 100644 app/backend/internal/api/static/assets/manrope-vietnamese-600-normal-lA7a_7Ok.woff delete mode 100644 app/backend/internal/api/static/assets/manrope-vietnamese-700-normal-CUqMx5-1.woff2 delete mode 100644 app/backend/internal/api/static/assets/manrope-vietnamese-700-normal-pt65Fn2Z.woff delete mode 100644 app/backend/internal/api/static/index.html delete mode 100644 app/backend/internal/apwhy/analytics/umami.go delete mode 100644 app/backend/internal/apwhy/api/server.go delete mode 100644 app/backend/internal/apwhy/api/server_integration_test.go delete mode 100644 app/backend/internal/apwhy/api/static/assets/ibm-plex-mono-cyrillic-400-normal-BSMlKf0J.woff2 delete mode 100644 app/backend/internal/apwhy/api/static/assets/ibm-plex-mono-cyrillic-400-normal-CEL4l2ZJ.woff delete mode 100644 app/backend/internal/apwhy/api/static/assets/ibm-plex-mono-cyrillic-ext-400-normal-DMdlQ8Kv.woff delete mode 100644 app/backend/internal/apwhy/api/static/assets/ibm-plex-mono-cyrillic-ext-400-normal-xuaO2J-f.woff2 delete mode 100644 app/backend/internal/apwhy/api/static/assets/ibm-plex-mono-latin-400-normal-CvHOgSBP.woff delete mode 100644 app/backend/internal/apwhy/api/static/assets/ibm-plex-mono-latin-400-normal-DMJ8VG8y.woff2 delete mode 100644 app/backend/internal/apwhy/api/static/assets/ibm-plex-mono-latin-ext-400-normal-BmRBH3aV.woff2 delete mode 100644 app/backend/internal/apwhy/api/static/assets/ibm-plex-mono-latin-ext-400-normal-D3D2R8hC.woff delete mode 100644 app/backend/internal/apwhy/api/static/assets/ibm-plex-mono-vietnamese-400-normal-BulugwFq.woff2 delete mode 100644 app/backend/internal/apwhy/api/static/assets/ibm-plex-mono-vietnamese-400-normal-DDuiU_S-.woff delete mode 100644 app/backend/internal/apwhy/api/static/assets/index-DRUelTBf.css delete mode 100644 app/backend/internal/apwhy/api/static/assets/index-DwfYiTMH.js delete mode 100644 app/backend/internal/apwhy/api/static/assets/manrope-cyrillic-400-normal-BMzJvInZ.woff2 delete mode 100644 app/backend/internal/apwhy/api/static/assets/manrope-cyrillic-400-normal-Dvx59UGC.woff delete mode 100644 app/backend/internal/apwhy/api/static/assets/manrope-cyrillic-600-normal-DvRl3Mj-.woff2 delete mode 100644 app/backend/internal/apwhy/api/static/assets/manrope-cyrillic-600-normal-It4mZcQk.woff delete mode 100644 app/backend/internal/apwhy/api/static/assets/manrope-cyrillic-700-normal-7JNVKxyl.woff delete mode 100644 app/backend/internal/apwhy/api/static/assets/manrope-cyrillic-700-normal-Dw_fZAg2.woff2 delete mode 100644 app/backend/internal/apwhy/api/static/assets/manrope-greek-400-normal-CM4qok81.woff2 delete mode 100644 app/backend/internal/apwhy/api/static/assets/manrope-greek-400-normal-DuX9RsAR.woff delete mode 100644 app/backend/internal/apwhy/api/static/assets/manrope-greek-600-normal-BoRV6lzK.woff2 delete mode 100644 app/backend/internal/apwhy/api/static/assets/manrope-greek-600-normal-CF2i9ZRY.woff delete mode 100644 app/backend/internal/apwhy/api/static/assets/manrope-greek-700-normal-CHUG9PD8.woff2 delete mode 100644 app/backend/internal/apwhy/api/static/assets/manrope-greek-700-normal-DyfsrCpP.woff delete mode 100644 app/backend/internal/apwhy/api/static/assets/manrope-latin-400-normal-8tf8FM3T.woff delete mode 100644 app/backend/internal/apwhy/api/static/assets/manrope-latin-400-normal-PaqtzbVb.woff2 delete mode 100644 app/backend/internal/apwhy/api/static/assets/manrope-latin-600-normal-4f0koTD-.woff2 delete mode 100644 app/backend/internal/apwhy/api/static/assets/manrope-latin-600-normal-BqgrALkZ.woff delete mode 100644 app/backend/internal/apwhy/api/static/assets/manrope-latin-700-normal-BZp_XxE4.woff2 delete mode 100644 app/backend/internal/apwhy/api/static/assets/manrope-latin-700-normal-DGRFkw-m.woff delete mode 100644 app/backend/internal/apwhy/api/static/assets/manrope-latin-ext-400-normal-C-X6QNXX.woff delete mode 100644 app/backend/internal/apwhy/api/static/assets/manrope-latin-ext-400-normal-CMDvPJRp.woff2 delete mode 100644 app/backend/internal/apwhy/api/static/assets/manrope-latin-ext-600-normal-_gBojHdJ.woff2 delete mode 100644 app/backend/internal/apwhy/api/static/assets/manrope-latin-ext-600-normal-u5Pl7hTU.woff delete mode 100644 app/backend/internal/apwhy/api/static/assets/manrope-latin-ext-700-normal-DYOwVNan.woff2 delete mode 100644 app/backend/internal/apwhy/api/static/assets/manrope-latin-ext-700-normal-eVCcYqtJ.woff delete mode 100644 app/backend/internal/apwhy/api/static/assets/manrope-vietnamese-400-normal-D7E_mLGF.woff delete mode 100644 app/backend/internal/apwhy/api/static/assets/manrope-vietnamese-400-normal-DHb3EETF.woff2 delete mode 100644 app/backend/internal/apwhy/api/static/assets/manrope-vietnamese-600-normal-C1J5PCl_.woff2 delete mode 100644 app/backend/internal/apwhy/api/static/assets/manrope-vietnamese-600-normal-lA7a_7Ok.woff delete mode 100644 app/backend/internal/apwhy/api/static/assets/manrope-vietnamese-700-normal-CUqMx5-1.woff2 delete mode 100644 app/backend/internal/apwhy/api/static/assets/manrope-vietnamese-700-normal-pt65Fn2Z.woff delete mode 100644 app/backend/internal/apwhy/api/static/index.html delete mode 100644 app/backend/internal/apwhy/auth/security.go delete mode 100644 app/backend/internal/apwhy/config/config.go delete mode 100644 app/backend/internal/apwhy/gateway/proxy.go delete mode 100644 app/backend/internal/apwhy/rbac/defaults.go delete mode 100644 app/backend/internal/apwhy/storage/db.go delete mode 100644 app/backend/internal/apwhy/storage/schema.go delete mode 100644 app/backend/internal/auth/security.go delete mode 100644 app/backend/internal/database/migrations_test.go create mode 100644 app/backend/internal/database/sqlcdb/agents.sql.go delete mode 100644 app/backend/internal/rbac/defaults.go delete mode 100644 app/backend/internal/storage/db.go delete mode 100644 app/backend/internal/storage/schema.go delete mode 100644 app/backend/migrations/0001_init.sql delete mode 100644 app/backend/migrations/0001_init.sql.bak delete mode 100644 app/backend/migrations/001_initial_schema.sql delete mode 100644 app/backend/migrations/002_git_integration.sql delete mode 100644 app/backend/migrations/003_agent_system.sql delete mode 100644 app/backend/migrations/003_metrics_schema.sql delete mode 100644 app/backend/migrations/004_database_services.sql delete mode 100644 app/backend/migrations/004_preview_environments.sql delete mode 100644 app/backend/migrations/005_security_features.sql delete mode 100644 app/backend/migrations/006_deployment_enhancements.sql delete mode 100644 app/backend/migrations/007_templates_cron_audit.sql delete mode 100644 app/backend/migrations/008_apwhy_integration.sql delete mode 100644 app/backend/migrations/018_performance_optimization.sql delete mode 100644 app/backend/migrations/019_builds_table.sql delete mode 100644 app/backend/migrations/020_add_database_templates.sql delete mode 100644 app/backend/migrations/021_expand_database_service_types.sql create mode 100644 app/backend/migrations_goose/20260917000000_drop_apwhy_parallel_auth.sql create mode 100644 app/backend/sqlc/queries/agents.sql diff --git a/app/backend/Dockerfile b/app/backend/Dockerfile index c65bec0..e9cb550 100644 --- a/app/backend/Dockerfile +++ b/app/backend/Dockerfile @@ -32,7 +32,6 @@ RUN addgroup -g 1001 -S appgroup && \ WORKDIR /app COPY --from=go-builder /app/main ./main -COPY --from=go-builder /app/migrations ./migrations COPY --from=go-builder /app/migrations_goose ./migrations_goose COPY --from=auth-builder /auth ./auth diff --git a/app/backend/README_BACKEND.md b/app/backend/README_BACKEND.md index 95d6535..e889063 100644 --- a/app/backend/README_BACKEND.md +++ b/app/backend/README_BACKEND.md @@ -170,8 +170,7 @@ internal/ ├── config/ - Configuration management ├── database/ - Database connections and migrations └── middleware/ - HTTP middleware -migrations/ - SQL migration files -migrations_goose/ - Goose-managed migrations for new schema changes +migrations_goose/ - Goose-managed migrations (single migration path) ``` ### Adding New Endpoints @@ -179,9 +178,9 @@ migrations_goose/ - Goose-managed migrations for new schema changes 1. Create handler functions in `internal/api/` 2. Add routes in `internal/api/routes.go` 3. Update database schema: - - Legacy chain lives in `migrations/` and is executed first. - - New migrations must be added to `migrations_goose/` using goose format (`-- +goose Up/Down`). - - Keep legacy files immutable after they are shipped. + - All migrations live in `migrations_goose/` using goose format (`-- +goose Up/Down`). + - `20260401163000_baseline.sql` carries the consolidated schema; append new files after it. + - Never edit the baseline in place; always add a new migration file. ### SQLC (Type-Safe Queries) diff --git a/app/backend/cmd/apwhy/main.go b/app/backend/cmd/apwhy/main.go deleted file mode 100644 index 852e0c7..0000000 --- a/app/backend/cmd/apwhy/main.go +++ /dev/null @@ -1,29 +0,0 @@ -package main - -import ( - "fmt" - "log" - "net/http" - - "containr/internal/apwhy/api" - "containr/internal/apwhy/config" - "containr/internal/apwhy/storage" -) - -func main() { - cfg := config.Load() - - store, err := storage.Open(cfg) - if err != nil { - log.Fatalf("failed to initialize storage: %v", err) - } - defer store.Close() - - server := api.NewServer(store, cfg) - - addr := fmt.Sprintf(":%d", cfg.Port) - log.Printf("APwhy server listening on http://localhost%s", addr) - if err := http.ListenAndServe(addr, server.Handler()); err != nil { - log.Fatalf("server exited: %v", err) - } -} diff --git a/app/backend/cmd/migrate/main.go b/app/backend/cmd/migrate/main.go index 63aabc2..b57c0a5 100644 --- a/app/backend/cmd/migrate/main.go +++ b/app/backend/cmd/migrate/main.go @@ -25,11 +25,6 @@ func main() { } defer db.Close() - legacyDir := os.Getenv("LEGACY_MIGRATIONS_DIR") - if legacyDir == "" { - legacyDir = "migrations" - } - gooseDir := os.Getenv("GOOSE_MIGRATIONS_DIR") if gooseDir == "" { gooseDir = "migrations_goose" @@ -41,24 +36,14 @@ func main() { } switch command { - case "up": + case "up", "goose-up": migrationCtx, migrationCancel := context.WithTimeout(context.Background(), cfg.MigrationLockTimeout) - if err := db.MigrateAllWithLock(migrationCtx, legacyDir, gooseDir); err != nil { + if err := db.MigrateAllWithLock(migrationCtx, gooseDir); err != nil { migrationCancel() log.Fatalf("Migration failed: %v", err) } migrationCancel() - log.Println("Legacy + goose migrations completed successfully") - case "legacy-up": - if err := db.Migrate(legacyDir); err != nil { - log.Fatalf("Legacy migration failed: %v", err) - } - log.Println("Legacy migrations completed successfully") - case "goose-up": - if err := db.MigrateGoose(gooseDir); err != nil { - log.Fatalf("Goose migration failed: %v", err) - } - log.Println("Goose migrations completed successfully") + log.Println("Migrations completed successfully") case "goose-status": if err := db.GooseStatus(gooseDir); err != nil { log.Fatalf("Goose status failed: %v", err) @@ -73,6 +58,6 @@ func main() { } log.Printf("Created goose migration %q in %q", name, gooseDir) default: - log.Fatalf("Unknown command %q. Supported commands: up | legacy-up | goose-up | goose-status | goose-create", command) + log.Fatalf("Unknown command %q. Supported commands: up | goose-status | goose-create", command) } } diff --git a/app/backend/cmd/server/main.go b/app/backend/cmd/server/main.go index fbebca7..97e0cdb 100644 --- a/app/backend/cmd/server/main.go +++ b/app/backend/cmd/server/main.go @@ -39,7 +39,7 @@ func main() { // Run startup migrations unless explicitly disabled. if cfg.AutoMigrate { migrationCtx, migrationCancel := context.WithTimeout(context.Background(), cfg.MigrationLockTimeout) - if err := db.MigrateAllWithLock(migrationCtx, "migrations", "migrations_goose"); err != nil { + if err := db.MigrateAllWithLock(migrationCtx, "migrations_goose"); err != nil { migrationCancel() log.Fatalf("Failed to run database migrations: %v", err) } diff --git a/app/backend/cmd/test-build-manager/main.go b/app/backend/cmd/test-build-manager/main.go deleted file mode 100644 index 21435b6..0000000 --- a/app/backend/cmd/test-build-manager/main.go +++ /dev/null @@ -1,54 +0,0 @@ -package main - -import ( - "context" - "fmt" - - "containr/internal/build" -) - -func main() { - fmt.Println("🧪 Testing Build Manager Detection...") - - // Test build type detection on the current project - fmt.Println("\n📁 Testing on current project (has package.json)...") - - // Note: We can't fully test BuildManager without a docker client, - // but we can test the detection logic - - // Create mock scenarios - testCases := []struct { - name string - expected string - }{ - {"Node.js project (package.json)", "railpack"}, - {"Python project (requirements.txt)", "railpack"}, - {"Go project (go.mod)", "railpack"}, - {"Dockerfile project", "dockerfile"}, - {"Unknown project", "nixpacks"}, - } - - fmt.Println("\n🎯 Expected detection priorities:") - for _, tc := range testCases { - fmt.Printf(" • %s → %s\n", tc.name, tc.expected) - } - - // Test Railpack builder directly - builder := build.NewRailpackBuilder("/tmp/test", nil) - - fmt.Println("\n🔍 Testing Railpack detection on current project:") - err := builder.DetectRailpack(context.Background(), "/home/tdvorak/Desktop/PROG+HTML/Containr") - if err != nil { - fmt.Printf("❌ Detection failed: %v\n", err) - } else { - fmt.Println("✅ Railpack can build this project!") - } - - fmt.Println("\n📋 Build Priority Order:") - fmt.Println(" 1. Dockerfile (if present)") - fmt.Println(" 2. Railpack (primary choice)") - fmt.Println(" 3. Nixpacks (fallback)") - fmt.Println(" 4. Prebuilt (manual)") - - fmt.Println("\n🚀 Build Manager Integration Test Complete!") -} diff --git a/app/backend/cmd/test-railpack/main.go b/app/backend/cmd/test-railpack/main.go deleted file mode 100644 index 59c0667..0000000 --- a/app/backend/cmd/test-railpack/main.go +++ /dev/null @@ -1,32 +0,0 @@ -package main - -import ( - "context" - "fmt" - - "containr/internal/build" -) - -func main() { - // Create a RailpackBuilder - builder := build.NewRailpackBuilder("/tmp/containr-build-test", nil) - - // Test detection on the current project (has package.json) - fmt.Println("Testing Railpack detection on current project...") - - err := builder.DetectRailpack(context.Background(), "/home/tdvorak/Desktop/PROG+HTML/Containr") - if err != nil { - fmt.Printf("❌ Railpack detection failed: %v\n", err) - } else { - fmt.Println("✅ Railpack can build this project!") - } - - // Show supported frameworks - fmt.Println("\nSupported frameworks:") - frameworks := builder.GetSupportedFrameworks() - for _, fw := range frameworks { - fmt.Printf(" - %s\n", fw) - } - - fmt.Println("\n✅ Railpack integration test completed successfully!") -} diff --git a/app/backend/go.mod b/app/backend/go.mod index 7fcbb54..ca785d9 100644 --- a/app/backend/go.mod +++ b/app/backend/go.mod @@ -8,11 +8,9 @@ require ( github.com/gin-gonic/gin v1.9.1 github.com/go-playground/validator/v10 v10.30.1 github.com/go-redis/redis/v8 v8.11.5 - github.com/go-sql-driver/mysql v1.9.3 github.com/golang-jwt/jwt/v5 v5.0.0 github.com/google/uuid v1.6.0 github.com/gorilla/websocket v1.5.3 - github.com/jackc/pgx/v5 v5.7.1 github.com/lib/pq v1.10.9 github.com/miekg/dns v1.1.72 github.com/pressly/goose/v3 v3.24.1 @@ -22,13 +20,9 @@ require ( github.com/sqlc-dev/pqtype v0.3.0 github.com/stretchr/testify v1.11.1 golang.org/x/crypto v0.47.0 - gorm.io/driver/postgres v1.6.0 - gorm.io/gorm v1.31.1 - modernc.org/sqlite v1.39.0 ) require ( - filippo.io/edwards25519 v1.1.0 // indirect github.com/Microsoft/go-winio v0.6.2 // indirect github.com/bytedance/sonic v1.9.1 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect @@ -40,7 +34,6 @@ require ( github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect github.com/distribution/reference v0.6.0 // indirect github.com/docker/go-units v0.5.0 // indirect - github.com/dustin/go-humanize v1.0.1 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/gabriel-vasile/mimetype v1.4.12 // indirect @@ -52,11 +45,6 @@ require ( github.com/go-viper/mapstructure/v2 v2.4.0 // indirect github.com/goccy/go-json v0.10.2 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect - github.com/jackc/pgpassfile v1.0.0 // indirect - github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect - github.com/jackc/puddle/v2 v2.2.2 // indirect - github.com/jinzhu/inflection v1.0.0 // indirect - github.com/jinzhu/now v1.1.5 // indirect github.com/json-iterator/go v1.1.12 // indirect github.com/klauspost/cpuid/v2 v2.2.4 // indirect github.com/leodido/go-urn v1.4.0 // indirect @@ -68,13 +56,11 @@ require ( github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect github.com/morikuni/aec v1.1.0 // indirect - github.com/ncruces/go-strftime v0.1.9 // indirect github.com/opencontainers/go-digest v1.0.0 // indirect github.com/opencontainers/image-spec v1.1.1 // indirect github.com/pelletier/go-toml/v2 v2.2.4 // indirect github.com/pkg/errors v0.9.1 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect - github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/sagikazarmark/locafero v0.11.0 // indirect github.com/sethvargo/go-retry v0.3.0 // indirect github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect @@ -95,7 +81,6 @@ require ( go.uber.org/multierr v1.11.0 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect golang.org/x/arch v0.3.0 // indirect - golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect golang.org/x/mod v0.31.0 // indirect golang.org/x/net v0.49.0 // indirect golang.org/x/sync v0.19.0 // indirect @@ -106,7 +91,5 @@ require ( google.golang.org/protobuf v1.36.11 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect gotest.tools/v3 v3.5.2 // indirect - modernc.org/libc v1.66.3 // indirect - modernc.org/mathutil v1.7.1 // indirect - modernc.org/memory v1.11.0 // indirect + modernc.org/sqlite v1.39.0 // indirect ) diff --git a/app/backend/go.sum b/app/backend/go.sum index 22eb2f5..c009c2a 100644 --- a/app/backend/go.sum +++ b/app/backend/go.sum @@ -1,5 +1,3 @@ -filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= -filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg= github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= @@ -63,8 +61,6 @@ github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy0 github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM= github.com/go-redis/redis/v8 v8.11.5 h1:AcZZR7igkdvfVmQTPnu9WE37LRrO/YrBH5zWyjDC0oI= github.com/go-redis/redis/v8 v8.11.5/go.mod h1:gREzHqY1hg6oD9ngVRbLStwAWKhA0FEgq8Jd4h5lpwo= -github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo= -github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU= github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= @@ -74,8 +70,6 @@ github.com/golang-jwt/jwt/v5 v5.0.0/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVI github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= -github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= -github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= @@ -84,18 +78,6 @@ github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.7 h1:X+2YciYSxvMQK0UZ7sg45ZVabVZ github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.7/go.mod h1:lW34nIZuQ8UDPdkon5fmfp2l3+ZkQ2me/+oecHYLOII= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= -github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= -github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= -github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= -github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= -github.com/jackc/pgx/v5 v5.7.1 h1:x7SYsPBYDkHDksogeSmZZ5xzThcTgRz++I5E+ePFUcs= -github.com/jackc/pgx/v5 v5.7.1/go.mod h1:e7O26IywZZ+naJtWWos6i6fvWK+29etgITqrqHLfoZA= -github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= -github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= -github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= -github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= -github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= -github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= @@ -259,36 +241,14 @@ gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -gorm.io/driver/postgres v1.6.0 h1:2dxzU8xJ+ivvqTRph34QX+WrRaJlmfyPqXmoGVjMBa4= -gorm.io/driver/postgres v1.6.0/go.mod h1:vUw0mrGgrTK+uPHEhAdV4sfFELrByKVGnaVRkXDhtWo= -gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg= -gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs= gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q= gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA= -modernc.org/cc/v4 v4.26.2 h1:991HMkLjJzYBIfha6ECZdjrIYz2/1ayr+FL8GN+CNzM= -modernc.org/cc/v4 v4.26.2/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0= -modernc.org/ccgo/v4 v4.28.0 h1:rjznn6WWehKq7dG4JtLRKxb52Ecv8OUGah8+Z/SfpNU= -modernc.org/ccgo/v4 v4.28.0/go.mod h1:JygV3+9AV6SmPhDasu4JgquwU81XAKLd3OKTUDNOiKE= -modernc.org/fileutil v1.3.8 h1:qtzNm7ED75pd1C7WgAGcK4edm4fvhtBsEiI/0NQ54YM= -modernc.org/fileutil v1.3.8/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc= -modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= -modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= -modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks= -modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= modernc.org/libc v1.66.3 h1:cfCbjTUcdsKyyZZfEUKfoHcP3S0Wkvz3jgSzByEWVCQ= modernc.org/libc v1.66.3/go.mod h1:XD9zO8kt59cANKvHPXpx7yS2ELPheAey0vjIuZOhOU8= modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= -modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8= -modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= -modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= -modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= modernc.org/sqlite v1.39.0 h1:6bwu9Ooim0yVYA7IZn9demiQk/Ejp0BtTjBWFLymSeY= modernc.org/sqlite v1.39.0/go.mod h1:cPTJYSlgg3Sfg046yBShXENNtPrWrDX8bsbAQBzgQ5E= -modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= -modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= -modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= -modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4= diff --git a/app/backend/internal/analytics/umami.go b/app/backend/internal/analytics/umami.go deleted file mode 100644 index 5ed8d56..0000000 --- a/app/backend/internal/analytics/umami.go +++ /dev/null @@ -1,74 +0,0 @@ -package analytics - -import ( - "context" - "encoding/json" - "fmt" - "net/http" - "net/url" - "time" -) - -type Client struct { - BaseURL string - APIKey string - WebsiteID string - HTTP *http.Client -} - -func NewClient(baseURL, apiKey, websiteID string) *Client { - return &Client{ - BaseURL: baseURL, - APIKey: apiKey, - WebsiteID: websiteID, - HTTP: &http.Client{ - Timeout: 10 * time.Second, - }, - } -} - -func (c *Client) Enabled() bool { - return c.BaseURL != "" && c.APIKey != "" && c.WebsiteID != "" -} - -func (c *Client) FetchTraffic(ctx context.Context, from, to time.Time) (map[string]any, error) { - if !c.Enabled() { - return map[string]any{ - "enabled": false, - }, nil - } - - u, err := url.Parse(fmt.Sprintf("%s/api/websites/%s/stats", c.BaseURL, c.WebsiteID)) - if err != nil { - return nil, err - } - - query := u.Query() - query.Set("startAt", fmt.Sprintf("%d", from.UnixMilli())) - query.Set("endAt", fmt.Sprintf("%d", to.UnixMilli())) - u.RawQuery = query.Encode() - - req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil) - if err != nil { - return nil, err - } - req.Header.Set("Authorization", "Bearer "+c.APIKey) - req.Header.Set("Accept", "application/json") - - res, err := c.HTTP.Do(req) - if err != nil { - return nil, err - } - defer res.Body.Close() - - if res.StatusCode >= 400 { - return nil, fmt.Errorf("umami returned %d", res.StatusCode) - } - - var payload map[string]any - if err := json.NewDecoder(res.Body).Decode(&payload); err != nil { - return nil, err - } - payload["enabled"] = true - return payload, nil -} diff --git a/app/backend/internal/api/agents.go b/app/backend/internal/api/agents.go index dd79fc4..0d8e901 100644 --- a/app/backend/internal/api/agents.go +++ b/app/backend/internal/api/agents.go @@ -1,7 +1,10 @@ package api import ( + "context" "crypto/subtle" + "database/sql" + "encoding/json" "fmt" "math" "net/http" @@ -9,26 +12,29 @@ import ( "strings" "time" + "containr/internal/database" + "containr/internal/database/sqlcdb" + "github.com/gin-gonic/gin" "github.com/google/uuid" - "gorm.io/gorm" + "github.com/sqlc-dev/pqtype" ) // NodeAgent represents a container orchestration agent type NodeAgent struct { - ID string `json:"id" gorm:"primaryKey"` - Name string `json:"name" gorm:"not null"` - Hostname string `json:"hostname" gorm:"not null"` - IPAddress string `json:"ip_address" gorm:"not null"` - Port int `json:"port" gorm:"not null"` - Status string `json:"status" gorm:"default:'offline'"` + ID string `json:"id"` + Name string `json:"name"` + Hostname string `json:"hostname"` + IPAddress string `json:"ip_address"` + Port int `json:"port"` + Status string `json:"status"` Version string `json:"version"` - Capabilities AgentCapabilities `json:"capabilities" gorm:"serializer:json"` - Resources NodeResources `json:"resources" gorm:"serializer:json"` + Capabilities AgentCapabilities `json:"capabilities"` + Resources NodeResources `json:"resources"` LastHeartbeat time.Time `json:"last_heartbeat"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` - Metadata map[string]interface{} `json:"metadata" gorm:"serializer:json"` + Metadata map[string]interface{} `json:"metadata"` } // AgentCapabilities defines what the agent can do @@ -89,20 +95,20 @@ type BandwidthInfo struct { // ContainerInstance represents a container running on an agent type ContainerInstance struct { - ID string `json:"id" gorm:"primaryKey"` - Name string `json:"name" gorm:"not null"` - Image string `json:"image" gorm:"not null"` - ProjectID string `json:"project_id" gorm:"not null"` - ServiceID string `json:"service_id" gorm:"not null"` - NodeAgentID string `json:"node_agent_id" gorm:"not null"` - Status ContainerStatus `json:"status" gorm:"serializer:json"` - Resources ContainerResources `json:"resources" gorm:"serializer:json"` - Ports []PortMapping `json:"ports" gorm:"serializer:json"` - Environment map[string]string `json:"environment" gorm:"serializer:json"` - Volumes []VolumeMount `json:"volumes" gorm:"serializer:json"` - Networks []string `json:"networks" gorm:"serializer:json"` - RestartPolicy RestartPolicy `json:"restart_policy" gorm:"serializer:json"` - HealthCheck *HealthCheck `json:"health_check" gorm:"serializer:json"` + ID string `json:"id"` + Name string `json:"name"` + Image string `json:"image"` + ProjectID string `json:"project_id"` + ServiceID string `json:"service_id"` + NodeAgentID string `json:"node_agent_id"` + Status ContainerStatus `json:"status"` + Resources ContainerResources `json:"resources"` + Ports []PortMapping `json:"ports"` + Environment map[string]string `json:"environment"` + Volumes []VolumeMount `json:"volumes"` + Networks []string `json:"networks"` + RestartPolicy RestartPolicy `json:"restart_policy"` + HealthCheck *HealthCheck `json:"health_check"` CreatedAt time.Time `json:"created_at"` StartedAt *time.Time `json:"started_at"` UpdatedAt time.Time `json:"updated_at"` @@ -155,12 +161,12 @@ type HealthCheck struct { // AgentCommand represents a command sent to an agent type AgentCommand struct { - ID string `json:"id" gorm:"primaryKey"` - Type string `json:"type" gorm:"not null"` - NodeAgentID string `json:"node_agent_id" gorm:"not null"` + ID string `json:"id"` + Type string `json:"type"` + NodeAgentID string `json:"node_agent_id"` ContainerID *string `json:"container_id"` - Payload map[string]interface{} `json:"payload" gorm:"serializer:json"` - Status string `json:"status" gorm:"default:'pending'"` + Payload map[string]interface{} `json:"payload"` + Status string `json:"status"` Result *string `json:"result"` Error *string `json:"error"` CreatedAt time.Time `json:"created_at"` @@ -181,23 +187,6 @@ type AgentHeartbeat struct { Version string `json:"version"` } -type AgentHeartbeatRecord struct { - ID string `json:"id" gorm:"primaryKey"` - NodeAgentID string `json:"node_agent_id" gorm:"index;not null"` - Timestamp time.Time `json:"timestamp" gorm:"index;not null"` - Status string `json:"status"` - Resources NodeResources `json:"resources" gorm:"serializer:json"` - ContainerCount int `json:"container_count"` - SystemLoad SystemLoad `json:"system_load" gorm:"serializer:json"` - Uptime int64 `json:"uptime"` - Version string `json:"version"` - CreatedAt time.Time `json:"created_at"` -} - -func (AgentHeartbeatRecord) TableName() string { - return "agent_heartbeats" -} - type SystemLoad struct { Load1M float64 `json:"load_1m"` Load5M float64 `json:"load_5m"` @@ -206,13 +195,125 @@ type SystemLoad struct { // NodeAgentHandler handles agent-related endpoints type NodeAgentHandler struct { - db *gorm.DB + q *sqlcdb.Queries } -func NewNodeAgentHandler(db *gorm.DB) *NodeAgentHandler { - return &NodeAgentHandler{db: db} +func NewNodeAgentHandler(db *database.DB) *NodeAgentHandler { + return &NodeAgentHandler{q: sqlcdb.New(db.DB)} } +// --- row <-> API type conversion helpers --- + +func rawJSON(v interface{}) pqtype.NullRawMessage { + if v == nil { + return pqtype.NullRawMessage{} + } + b, err := json.Marshal(v) + if err != nil { + return pqtype.NullRawMessage{} + } + return pqtype.NullRawMessage{RawMessage: b, Valid: true} +} + +func unmarshalRaw(raw pqtype.NullRawMessage, dst interface{}) { + if raw.Valid && len(raw.RawMessage) > 0 { + _ = json.Unmarshal(raw.RawMessage, dst) + } +} + +func agentFromRow(row sqlcdb.NodeAgent) NodeAgent { + agent := NodeAgent{ + ID: row.ID, + Name: row.Name, + Hostname: row.Hostname, + IPAddress: row.IpAddress, + Port: int(row.Port), + Status: row.Status.String, + Version: row.Version.String, + Metadata: map[string]interface{}{}, + } + if row.LastHeartbeat.Valid { + agent.LastHeartbeat = row.LastHeartbeat.Time + } + if row.CreatedAt.Valid { + agent.CreatedAt = row.CreatedAt.Time + } + if row.UpdatedAt.Valid { + agent.UpdatedAt = row.UpdatedAt.Time + } + unmarshalRaw(row.Capabilities, &agent.Capabilities) + unmarshalRaw(row.Resources, &agent.Resources) + unmarshalRaw(row.Metadata, &agent.Metadata) + return agent +} + +func containerFromRow(row sqlcdb.ContainerInstance) ContainerInstance { + container := ContainerInstance{ + ID: row.ID, + Name: row.Name, + Image: row.Image, + ProjectID: row.ProjectID, + ServiceID: row.ServiceID, + NodeAgentID: row.NodeAgentID, + } + if row.CreatedAt.Valid { + container.CreatedAt = row.CreatedAt.Time + } + if row.UpdatedAt.Valid { + container.UpdatedAt = row.UpdatedAt.Time + } + if row.StartedAt.Valid { + t := row.StartedAt.Time + container.StartedAt = &t + } + unmarshalRaw(row.Status, &container.Status) + unmarshalRaw(row.Resources, &container.Resources) + unmarshalRaw(row.Ports, &container.Ports) + unmarshalRaw(row.Environment, &container.Environment) + unmarshalRaw(row.Volumes, &container.Volumes) + unmarshalRaw(row.Networks, &container.Networks) + unmarshalRaw(row.RestartPolicy, &container.RestartPolicy) + if row.HealthCheck.Valid { + var hc HealthCheck + if err := json.Unmarshal(row.HealthCheck.RawMessage, &hc); err == nil { + container.HealthCheck = &hc + } + } + return container +} + +func commandFromRow(row sqlcdb.AgentCommand) AgentCommand { + cmd := AgentCommand{ + ID: row.ID, + Type: row.Type, + NodeAgentID: row.NodeAgentID, + Status: row.Status.String, + } + if row.ContainerID.Valid { + cmd.ContainerID = &row.ContainerID.String + } + if row.Result.Valid { + cmd.Result = &row.Result.String + } + if row.Error.Valid { + cmd.Error = &row.Error.String + } + if row.CreatedAt.Valid { + cmd.CreatedAt = row.CreatedAt.Time + } + if row.UpdatedAt.Valid { + cmd.UpdatedAt = row.UpdatedAt.Time + } + if row.CompletedAt.Valid { + t := row.CompletedAt.Time + cmd.CompletedAt = &t + } + unmarshalRaw(row.Payload, &cmd.Payload) + return cmd +} + +// --- handlers --- + // RegisterAgent handles agent registration func (h *NodeAgentHandler) RegisterAgent(c *gin.Context) { var req struct { @@ -235,85 +336,83 @@ func (h *NodeAgentHandler) RegisterAgent(c *gin.Context) { return } - // Check if agent already exists - var existingAgent NodeAgent - if err := h.db.Where("hostname = ? AND ip_address = ?", req.Hostname, req.IPAddress).First(&existingAgent).Error; err == nil { - // Update existing agent - existingAgent.Name = req.Name - existingAgent.Port = req.Port - existingAgent.Capabilities = req.Capabilities - existingAgent.Status = "connecting" - existingAgent.LastHeartbeat = time.Now() + ctx := c.Request.Context() - if err := h.db.Save(&existingAgent).Error; err != nil { + // Re-registering an existing host updates it in place. + if existing, err := h.q.GetAgentByHostAndIP(ctx, sqlcdb.GetAgentByHostAndIPParams{ + Hostname: req.Hostname, + IpAddress: req.IPAddress, + }); err == nil { + updated, err := h.q.UpdateAgent(ctx, sqlcdb.UpdateAgentParams{ + ID: existing.ID, + Name: req.Name, + Hostname: existing.Hostname, + IpAddress: existing.IpAddress, + Port: int32(req.Port), + Status: sql.NullString{String: "connecting", Valid: true}, + Version: existing.Version, + Capabilities: rawJSON(req.Capabilities), + Resources: existing.Resources, + LastHeartbeat: sql.NullTime{Time: time.Now(), Valid: true}, + Metadata: existing.Metadata, + }) + if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update agent"}) return } c.JSON(http.StatusOK, gin.H{ - "agent_id": existingAgent.ID, + "agent_id": updated.ID, "auth_token": authToken, "status": "updated", }) return } - // Create new agent - agent := NodeAgent{ - ID: uuid.New().String(), - Name: req.Name, - Hostname: req.Hostname, - IPAddress: req.IPAddress, - Port: req.Port, - Status: "connecting", - Capabilities: req.Capabilities, - Resources: NodeResources{ - CPU: CPUResources{ - Cores: 4, - Allocation: 0, - Usage: 0, - }, - Memory: MemoryResources{ - Total: 8 * 1024 * 1024 * 1024, // 8GB - Allocated: 0, - Used: 0, - Available: 8 * 1024 * 1024 * 1024, - }, - Storage: StorageResources{ - Total: 100 * 1024 * 1024 * 1024, // 100GB - Allocated: 0, - Used: 0, - Available: 100 * 1024 * 1024 * 1024, - }, - Network: NetworkResources{ - Interfaces: []NetworkInterface{ - { - Name: "eth0", - IPAddress: req.IPAddress, - MACAddress: "00:00:00:00:00:00", - Speed: 1000, - Status: "up", - }, - }, - Bandwidth: BandwidthInfo{ - Inbound: 0, - Outbound: 0, + agentID := uuid.New().String() + resources := NodeResources{ + CPU: CPUResources{Cores: 4}, + Memory: MemoryResources{ + Total: 8 * 1024 * 1024 * 1024, // 8GB + Available: 8 * 1024 * 1024 * 1024, + }, + Storage: StorageResources{ + Total: 100 * 1024 * 1024 * 1024, // 100GB + Available: 100 * 1024 * 1024 * 1024, + }, + Network: NetworkResources{ + Interfaces: []NetworkInterface{ + { + Name: "eth0", + IPAddress: req.IPAddress, + MACAddress: "00:00:00:00:00:00", + Speed: 1000, + Status: "up", }, }, }, - LastHeartbeat: time.Now(), - CreatedAt: time.Now(), - UpdatedAt: time.Now(), - Metadata: make(map[string]interface{}), } - if err := h.db.Create(&agent).Error; err != nil { + _, err := h.q.CreateAgent(ctx, sqlcdb.CreateAgentParams{ + ID: agentID, + Name: req.Name, + Hostname: req.Hostname, + IpAddress: req.IPAddress, + Port: int32(req.Port), + Status: sql.NullString{String: "connecting", Valid: true}, + Version: sql.NullString{}, + Capabilities: rawJSON(req.Capabilities), + Resources: rawJSON(resources), + LastHeartbeat: sql.NullTime{Time: time.Now(), Valid: true}, + Metadata: rawJSON(map[string]interface{}{}), + }) + if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create agent"}) return } c.JSON(http.StatusCreated, gin.H{ - "agent_id": agent.ID, + "agent_id": agentID, "auth_token": authToken, "status": "registered", }) @@ -321,22 +420,25 @@ func (h *NodeAgentHandler) RegisterAgent(c *gin.Context) { // GetAgents returns all registered agents func (h *NodeAgentHandler) GetAgents(c *gin.Context) { - var agents []NodeAgent - if err := h.db.Find(&agents).Error; err != nil { + rows, err := h.q.ListAgents(c.Request.Context()) + if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch agents"}) return } + agents := make([]NodeAgent, 0, len(rows)) + for _, row := range rows { + agents = append(agents, agentFromRow(row)) + } + c.JSON(http.StatusOK, gin.H{"agents": agents}) } // GetAgent returns a specific agent func (h *NodeAgentHandler) GetAgent(c *gin.Context) { - id := c.Param("id") - - var agent NodeAgent - if err := h.db.First(&agent, "id = ?", id).Error; err != nil { - if err == gorm.ErrRecordNotFound { + row, err := h.q.GetAgent(c.Request.Context(), c.Param("id")) + if err != nil { + if err == sql.ErrNoRows { c.JSON(http.StatusNotFound, gin.H{"error": "Agent not found"}) return } @@ -344,16 +446,17 @@ func (h *NodeAgentHandler) GetAgent(c *gin.Context) { return } - c.JSON(http.StatusOK, gin.H{"agent": agent}) + c.JSON(http.StatusOK, gin.H{"agent": agentFromRow(row)}) } // UpdateAgent updates an agent's information func (h *NodeAgentHandler) UpdateAgent(c *gin.Context) { id := c.Param("id") + ctx := c.Request.Context() - var agent NodeAgent - if err := h.db.First(&agent, "id = ?", id).Error; err != nil { - if err == gorm.ErrRecordNotFound { + row, err := h.q.GetAgent(ctx, id) + if err != nil { + if err == sql.ErrNoRows { c.JSON(http.StatusNotFound, gin.H{"error": "Agent not found"}) return } @@ -367,19 +470,53 @@ func (h *NodeAgentHandler) UpdateAgent(c *gin.Context) { return } - if err := h.db.Model(&agent).Updates(updates).Error; err != nil { + agent := agentFromRow(row) + applyAgentUpdates(&agent, updates) + + updated, err := h.q.UpdateAgent(ctx, sqlcdb.UpdateAgentParams{ + ID: agent.ID, + Name: agent.Name, + Hostname: agent.Hostname, + IpAddress: agent.IPAddress, + Port: int32(agent.Port), + Status: sql.NullString{String: agent.Status, Valid: agent.Status != ""}, + Version: sql.NullString{String: agent.Version, Valid: agent.Version != ""}, + Capabilities: rawJSON(agent.Capabilities), + Resources: rawJSON(agent.Resources), + LastHeartbeat: sql.NullTime{Time: agent.LastHeartbeat, Valid: !agent.LastHeartbeat.IsZero()}, + Metadata: rawJSON(agent.Metadata), + }) + if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update agent"}) return } - c.JSON(http.StatusOK, gin.H{"agent": agent}) + c.JSON(http.StatusOK, gin.H{"agent": agentFromRow(updated)}) +} + +// applyAgentUpdates merges whitelisted keys from an arbitrary update map. +func applyAgentUpdates(agent *NodeAgent, updates map[string]interface{}) { + remarshal := func(key string, dst interface{}) { + if v, ok := updates[key]; ok { + if b, err := json.Marshal(v); err == nil { + _ = json.Unmarshal(b, dst) + } + } + } + remarshal("name", &agent.Name) + remarshal("hostname", &agent.Hostname) + remarshal("ip_address", &agent.IPAddress) + remarshal("port", &agent.Port) + remarshal("status", &agent.Status) + remarshal("version", &agent.Version) + remarshal("capabilities", &agent.Capabilities) + remarshal("resources", &agent.Resources) + remarshal("metadata", &agent.Metadata) } // DeleteAgent removes an agent func (h *NodeAgentHandler) DeleteAgent(c *gin.Context) { - id := c.Param("id") - - if err := h.db.Delete(&NodeAgent{}, "id = ?", id).Error; err != nil { + if err := h.q.DeleteAgent(c.Request.Context(), c.Param("id")); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete agent"}) return } @@ -403,9 +540,9 @@ func (h *NodeAgentHandler) SendHeartbeat(c *gin.Context) { heartbeat.Timestamp = time.Now() } - var agent NodeAgent - if err := h.db.First(&agent, "id = ?", heartbeat.NodeAgentID).Error; err != nil { - if err == gorm.ErrRecordNotFound { + ctx := c.Request.Context() + if _, err := h.q.GetAgent(ctx, heartbeat.NodeAgentID); err != nil { + if err == sql.ErrNoRows { c.JSON(http.StatusNotFound, gin.H{"error": "Agent not found"}) return } @@ -413,35 +550,31 @@ func (h *NodeAgentHandler) SendHeartbeat(c *gin.Context) { return } - // Update agent status and resources - agent.Status = heartbeat.Status - agent.Resources = heartbeat.Resources - agent.LastHeartbeat = heartbeat.Timestamp - agent.UpdatedAt = time.Now() - - if err := h.db.Save(&agent).Error; err != nil { + if err := h.q.UpdateAgentHeartbeat(ctx, sqlcdb.UpdateAgentHeartbeatParams{ + ID: heartbeat.NodeAgentID, + Status: sql.NullString{String: heartbeat.Status, Valid: heartbeat.Status != ""}, + Resources: rawJSON(heartbeat.Resources), + LastHeartbeat: sql.NullTime{Time: heartbeat.Timestamp, Valid: true}, + }); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update agent"}) return } - record := AgentHeartbeatRecord{ + err := h.q.InsertAgentHeartbeat(ctx, sqlcdb.InsertAgentHeartbeatParams{ ID: uuid.New().String(), NodeAgentID: heartbeat.NodeAgentID, Timestamp: heartbeat.Timestamp, Status: heartbeat.Status, - Resources: heartbeat.Resources, - ContainerCount: heartbeat.ContainerCount, - SystemLoad: heartbeat.SystemLoad, + Resources: rawJSON(heartbeat.Resources).RawMessage, + ContainerCount: int32(heartbeat.ContainerCount), + SystemLoad: rawJSON(heartbeat.SystemLoad).RawMessage, Uptime: heartbeat.Uptime, Version: heartbeat.Version, - CreatedAt: time.Now(), - } - if err := h.db.Create(&record).Error; err != nil { + }) + if err != nil && !isMissingTableError(err) { // Keep heartbeat endpoint available even if history table is not yet migrated. - if !isMissingTableError(err) { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to persist heartbeat"}) - return - } + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to persist heartbeat"}) + return } c.Status(http.StatusOK) @@ -454,17 +587,17 @@ func (h *NodeAgentHandler) GetPendingCommandsForAgent(c *gin.Context) { return } - agentID := c.Param("id") - var commands []AgentCommand - if err := h.db. - Where("node_agent_id = ? AND status = ?", agentID, "pending"). - Order("created_at ASC"). - Limit(25). - Find(&commands).Error; err != nil { + rows, err := h.q.ListPendingCommands(c.Request.Context(), c.Param("id")) + if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch commands"}) return } + commands := make([]AgentCommand, 0, len(rows)) + for _, row := range rows { + commands = append(commands, commandFromRow(row)) + } + c.JSON(http.StatusOK, gin.H{"commands": commands}) } @@ -491,45 +624,39 @@ func (h *NodeAgentHandler) CompleteCommand(c *gin.Context) { return } - var command AgentCommand - if err := h.db.First(&command, "id = ? AND node_agent_id = ?", commandID, agentID).Error; err != nil { - if err == gorm.ErrRecordNotFound { + ctx := c.Request.Context() + command, err := h.q.CompleteCommand(ctx, sqlcdb.CompleteCommandParams{ + ID: commandID, + NodeAgentID: agentID, + Status: sql.NullString{String: req.Status, Valid: true}, + Result: sql.NullString{String: req.Result, Valid: req.Result != ""}, + Error: sql.NullString{String: req.Error, Valid: req.Error != ""}, + }) + if err != nil { + if err == sql.ErrNoRows { c.JSON(http.StatusNotFound, gin.H{"error": "Command not found"}) return } - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch command"}) - return - } - - now := time.Now() - command.Status = req.Status - command.UpdatedAt = now - command.CompletedAt = &now - if req.Result != "" { - command.Result = &req.Result - } - if req.Error != "" { - command.Error = &req.Error - } - - if err := h.db.Save(&command).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update command"}) return } - if command.ContainerID != nil { - h.updateContainerStatusAfterCommand(command) + out := commandFromRow(command) + if out.ContainerID != nil { + h.updateContainerStatusAfterCommand(ctx, out) } - c.JSON(http.StatusOK, gin.H{"command": command}) + c.JSON(http.StatusOK, gin.H{"command": out}) } -func (h *NodeAgentHandler) updateContainerStatusAfterCommand(command AgentCommand) { - var container ContainerInstance - if err := h.db.First(&container, "id = ?", *command.ContainerID).Error; err != nil { +func (h *NodeAgentHandler) updateContainerStatusAfterCommand(ctx context.Context, command AgentCommand) { + row, err := h.q.GetContainer(ctx, *command.ContainerID) + if err != nil { return } + container := containerFromRow(row) + if command.Status == "failed" { errorMessage := "" if command.Error != nil { @@ -537,7 +664,10 @@ func (h *NodeAgentHandler) updateContainerStatusAfterCommand(command AgentComman } container.Status.State = "error" container.Status.Error = &errorMessage - _ = h.db.Save(&container).Error + _ = h.q.UpdateContainerStatus(ctx, sqlcdb.UpdateContainerStatusParams{ + ID: container.ID, + Status: rawJSON(container.Status), + }) return } @@ -555,20 +685,25 @@ func (h *NodeAgentHandler) updateContainerStatusAfterCommand(command AgentComman container.Status.State = "removed" container.Status.FinishedAt = &now } - container.UpdatedAt = now - _ = h.db.Save(&container).Error + _ = h.q.UpdateContainerStatus(ctx, sqlcdb.UpdateContainerStatusParams{ + ID: container.ID, + Status: rawJSON(container.Status), + }) } // GetAgentContainers returns containers running on a specific agent func (h *NodeAgentHandler) GetAgentContainers(c *gin.Context) { - agentID := c.Param("id") - - var containers []ContainerInstance - if err := h.db.Where("node_agent_id = ?", agentID).Find(&containers).Error; err != nil { + rows, err := h.q.ListContainersForAgent(c.Request.Context(), c.Param("id")) + if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch containers"}) return } + containers := make([]ContainerInstance, 0, len(rows)) + for _, row := range rows { + containers = append(containers, containerFromRow(row)) + } + c.JSON(http.StatusOK, gin.H{"containers": containers}) } @@ -595,10 +730,11 @@ func (h *NodeAgentHandler) CreateContainer(c *gin.Context) { return } + ctx := c.Request.Context() + // Verify agent exists - var agent NodeAgent - if err := h.db.First(&agent, "id = ?", agentID).Error; err != nil { - if err == gorm.ErrRecordNotFound { + if _, err := h.q.GetAgent(ctx, agentID); err != nil { + if err == sql.ErrNoRows { c.JSON(http.StatusNotFound, gin.H{"error": "Agent not found"}) return } @@ -606,17 +742,15 @@ func (h *NodeAgentHandler) CreateContainer(c *gin.Context) { return } + containerID := uuid.New().String() container := ContainerInstance{ - ID: uuid.New().String(), - Name: req.Name, - Image: req.Image, - ProjectID: req.ProjectID, - ServiceID: req.ServiceID, - NodeAgentID: agentID, - Status: ContainerStatus{ - State: "created", - Health: "none", - }, + ID: containerID, + Name: req.Name, + Image: req.Image, + ProjectID: req.ProjectID, + ServiceID: req.ServiceID, + NodeAgentID: agentID, + Status: ContainerStatus{State: "created", Health: "none"}, Resources: req.Resources, Ports: req.Ports, Environment: req.Environment, @@ -628,26 +762,36 @@ func (h *NodeAgentHandler) CreateContainer(c *gin.Context) { UpdatedAt: time.Now(), } - if err := h.db.Create(&container).Error; err != nil { + _, err := h.q.CreateContainer(ctx, sqlcdb.CreateContainerParams{ + ID: container.ID, + Name: container.Name, + Image: container.Image, + ProjectID: container.ProjectID, + ServiceID: container.ServiceID, + NodeAgentID: container.NodeAgentID, + Status: rawJSON(container.Status), + Resources: rawJSON(container.Resources), + Ports: rawJSON(container.Ports), + Environment: rawJSON(container.Environment), + Volumes: rawJSON(container.Volumes), + Networks: rawJSON(container.Networks), + RestartPolicy: rawJSON(container.RestartPolicy), + HealthCheck: rawJSON(container.HealthCheck), + }) + if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create container"}) return } // Create command to start container on agent - command := AgentCommand{ + _, err = h.q.CreateCommand(ctx, sqlcdb.CreateCommandParams{ ID: uuid.New().String(), Type: "create_container", NodeAgentID: agentID, - ContainerID: &container.ID, - Payload: map[string]interface{}{ - "container": container, - }, - Status: "pending", - CreatedAt: time.Now(), - UpdatedAt: time.Now(), - } - - if err := h.db.Create(&command).Error; err != nil { + ContainerID: sql.NullString{String: container.ID, Valid: true}, + Payload: rawJSON(map[string]interface{}{"container": container}), + }) + if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create container command"}) return } @@ -669,45 +813,44 @@ func (h *NodeAgentHandler) ExecuteCommand(c *gin.Context) { return } - command := AgentCommand{ + command, err := h.q.CreateCommand(c.Request.Context(), sqlcdb.CreateCommandParams{ ID: uuid.New().String(), Type: req.Type, NodeAgentID: agentID, - Payload: req.Payload, - Status: "pending", - CreatedAt: time.Now(), - UpdatedAt: time.Now(), - } - - if err := h.db.Create(&command).Error; err != nil { + Payload: rawJSON(req.Payload), + }) + if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create command"}) return } - c.JSON(http.StatusCreated, gin.H{"command": command}) + c.JSON(http.StatusCreated, gin.H{"command": commandFromRow(command)}) } // GetAgentCommands returns commands for an agent func (h *NodeAgentHandler) GetAgentCommands(c *gin.Context) { - agentID := c.Param("id") - - var commands []AgentCommand - if err := h.db.Where("node_agent_id = ?", agentID).Order("created_at DESC").Find(&commands).Error; err != nil { + rows, err := h.q.ListCommandsForAgent(c.Request.Context(), c.Param("id")) + if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch commands"}) return } + commands := make([]AgentCommand, 0, len(rows)) + for _, row := range rows { + commands = append(commands, commandFromRow(row)) + } + c.JSON(http.StatusOK, gin.H{"commands": commands}) } // GetCommandStatus returns the status of a specific command func (h *NodeAgentHandler) GetCommandStatus(c *gin.Context) { - agentID := c.Param("id") - commandID := c.Param("commandId") - - var command AgentCommand - if err := h.db.First(&command, "id = ? AND node_agent_id = ?", commandID, agentID).Error; err != nil { - if err == gorm.ErrRecordNotFound { + command, err := h.q.GetCommandForAgent(c.Request.Context(), sqlcdb.GetCommandForAgentParams{ + ID: c.Param("commandId"), + NodeAgentID: c.Param("id"), + }) + if err != nil { + if err == sql.ErrNoRows { c.JSON(http.StatusNotFound, gin.H{"error": "Command not found"}) return } @@ -715,7 +858,7 @@ func (h *NodeAgentHandler) GetCommandStatus(c *gin.Context) { return } - c.JSON(http.StatusOK, gin.H{"command": command}) + c.JSON(http.StatusOK, gin.H{"command": commandFromRow(command)}) } // ContainerAction handles container lifecycle actions @@ -739,10 +882,15 @@ func (h *NodeAgentHandler) ContainerAction(c *gin.Context) { return } + ctx := c.Request.Context() + // Verify container exists - var container ContainerInstance - if err := h.db.First(&container, "id = ? AND node_agent_id = ?", containerID, agentID).Error; err != nil { - if err == gorm.ErrRecordNotFound { + containerRow, err := h.q.GetContainerForAgent(ctx, sqlcdb.GetContainerForAgentParams{ + ID: containerID, + NodeAgentID: agentID, + }) + if err != nil { + if err == sql.ErrNoRows { c.JSON(http.StatusNotFound, gin.H{"error": "Container not found"}) return } @@ -751,22 +899,18 @@ func (h *NodeAgentHandler) ContainerAction(c *gin.Context) { } // Create command for the action - command := AgentCommand{ + _, err = h.q.CreateCommand(ctx, sqlcdb.CreateCommandParams{ ID: uuid.New().String(), Type: fmt.Sprintf("%s_container", action), NodeAgentID: agentID, - ContainerID: &container.ID, - Payload: map[string]interface{}{ + ContainerID: sql.NullString{String: containerRow.ID, Valid: true}, + Payload: rawJSON(map[string]interface{}{ "container_id": containerID, - "container_name": container.Name, - "docker_name": container.Name, - }, - Status: "pending", - CreatedAt: time.Now(), - UpdatedAt: time.Now(), - } - - if err := h.db.Create(&command).Error; err != nil { + "container_name": containerRow.Name, + "docker_name": containerRow.Name, + }), + }) + if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create command"}) return } @@ -794,22 +938,23 @@ func (h *NodeAgentHandler) GetAgentMetrics(c *gin.Context) { return } - var agent NodeAgent - if err := h.db.First(&agent, "id = ?", agentID).Error; err != nil { - if err == gorm.ErrRecordNotFound { + ctx := c.Request.Context() + agentRow, err := h.q.GetAgent(ctx, agentID) + if err != nil { + if err == sql.ErrNoRows { c.JSON(http.StatusNotFound, gin.H{"error": "Agent not found"}) return } c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch agent"}) return } + agent := agentFromRow(agentRow) from := time.Now().Add(-duration) - var records []AgentHeartbeatRecord - queryErr := h.db. - Where("node_agent_id = ? AND timestamp >= ?", agentID, from). - Order("timestamp ASC"). - Find(&records).Error + records, queryErr := h.q.ListAgentHeartbeatsSince(ctx, sqlcdb.ListAgentHeartbeatsSinceParams{ + NodeAgentID: agentID, + Timestamp: from, + }) if queryErr != nil && !isMissingTableError(queryErr) { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch agent metrics"}) return @@ -817,7 +962,11 @@ func (h *NodeAgentHandler) GetAgentMetrics(c *gin.Context) { metrics := make([]map[string]interface{}, 0, len(records)) for _, record := range records { - metrics = append(metrics, buildMetricPoint(record.Timestamp, record.Resources, record.SystemLoad, record.ContainerCount)) + var resources NodeResources + var load SystemLoad + _ = json.Unmarshal(record.Resources, &resources) + _ = json.Unmarshal(record.SystemLoad, &load) + metrics = append(metrics, buildMetricPoint(record.Timestamp, resources, load, int(record.ContainerCount))) } if len(metrics) == 0 { diff --git a/app/backend/internal/api/apwhy_handlers.go b/app/backend/internal/api/apwhy_handlers.go index 8ae564e..5ca696a 100644 --- a/app/backend/internal/api/apwhy_handlers.go +++ b/app/backend/internal/api/apwhy_handlers.go @@ -6,6 +6,7 @@ import ( "crypto/rand" "database/sql" "encoding/hex" + "errors" "fmt" "net/http" "strings" @@ -13,21 +14,14 @@ import ( "github.com/gin-gonic/gin" "github.com/go-playground/validator/v10" + "github.com/lib/pq" "golang.org/x/crypto/bcrypt" ) -// Constants for validation and limits +// Default quota limits applied when the request omits them. const ( - MaxServiceNameLength = 100 - MaxRoutePrefixLength = 200 - MaxUpstreamURLLength = 500 - MaxAPIKeyNameLength = 100 - MinAPIKeyLength = 20 - MaxAPIKeyLength = 100 - DefaultRPMLimit = 60 - DefaultMonthlyQuota = 1000 - MaxRPMLimit = 10000 - MaxMonthlyQuota = 10000000 + DefaultRPMLimit = 60 + DefaultMonthlyQuota = 1000 ) // Validator instance for request validation @@ -75,15 +69,6 @@ type APIKeyRequest struct { MonthlyQuota *int `json:"monthlyQuota,omitempty" validate:"omitempty,min=1,max=10000000"` } -// generateServiceID generates a cryptographically secure service ID -func generateServiceID() (string, error) { - bytes := make([]byte, 16) - if _, err := rand.Read(bytes); err != nil { - return "", fmt.Errorf("failed to generate random bytes: %w", err) - } - return "svc_" + hex.EncodeToString(bytes), nil -} - // generateAPIKey generates a cryptographically secure API key func generateAPIKey() (string, error) { bytes := make([]byte, 32) @@ -162,6 +147,84 @@ func validateRequest(c *gin.Context, req interface{}) error { return nil } +// isUniqueViolation reports whether err is a Postgres unique-constraint violation. +func isUniqueViolation(err error) bool { + var pqErr *pq.Error + return errors.As(err, &pqErr) && pqErr.Code == "23505" +} + +// handleAPwhyServiceValidate probes the upstream health path and records the +// result on the service row. Ported from the standalone APwhy server. +func handleAPwhyServiceValidate(c *gin.Context) { + serviceID := c.Param("id") + db := c.MustGet("db").(*database.DB) + ctx := c.Request.Context() + + var upstreamURL, healthPath string + var authHeader, authValue sql.NullString + var timeoutMs sql.NullInt64 + err := db.QueryRowContext(ctx, ` + SELECT upstream_url, health_path, upstream_auth_header, upstream_auth_value, request_timeout_ms + FROM api_services WHERE id = $1 + `, serviceID).Scan(&upstreamURL, &healthPath, &authHeader, &authValue, &timeoutMs) + if err != nil { + sendErrorResponse(c, http.StatusNotFound, "SERVICE_NOT_FOUND", "Service not found", nil) + return + } + + timeout := 8000 + if timeoutMs.Valid && timeoutMs.Int64 > 0 { + timeout = int(timeoutMs.Int64) + } + target := strings.TrimRight(upstreamURL, "/") + "/" + strings.TrimLeft(healthPath, "/") + + req, _ := http.NewRequestWithContext(ctx, http.MethodGet, target, nil) + if authHeader.Valid && authHeader.String != "" { + req.Header.Set(authHeader.String, authValue.String) + } + + client := &http.Client{Timeout: time.Duration(timeout) * time.Millisecond} + res, probeErr := client.Do(req) + + status := http.StatusBadGateway + message := "" + ok := false + if probeErr != nil { + message = probeErr.Error() + } else { + defer res.Body.Close() + status = res.StatusCode + message = fmt.Sprintf("HTTP %d", res.StatusCode) + ok = res.StatusCode >= 200 && res.StatusCode < 300 + } + + validationStatus := "failed" + if ok { + validationStatus = "healthy" + } + + _, _ = db.ExecContext(ctx, ` + UPDATE api_services + SET last_validation_at = NOW(), last_validation_status = $1, last_validation_message = $2, updated_at = NOW() + WHERE id = $3 + `, validationStatus, message, serviceID) + + if !ok { + _, _ = db.ExecContext(ctx, ` + INSERT INTO incident_events (service_id, code, message, severity, http_status, occurred_at) + VALUES ($1, 'SERVICE_VALIDATION_FAILED', $2, 'medium', $3, NOW()) + `, serviceID, message, status) + } + + sendSuccessResponse(c, http.StatusOK, map[string]interface{}{ + "validation": map[string]interface{}{ + "ok": ok, + "status": status, + "message": message, + }, + }) +} + // handleAPwhyServicesList returns a list of API services func handleAPwhyServicesList(c *gin.Context) { db := c.MustGet("db").(*database.DB) @@ -170,7 +233,7 @@ func handleAPwhyServicesList(c *gin.Context) { // Query services from database rows, err := db.QueryContext(ctx, ` SELECT id, name, slug, upstream_url, route_prefix, enabled, created_at, updated_at - FROM api_services + FROM api_services ORDER BY created_at DESC `) if err != nil { @@ -180,10 +243,10 @@ func handleAPwhyServicesList(c *gin.Context) { } defer rows.Close() - var services []map[string]interface{} + services := make([]map[string]interface{}, 0) for rows.Next() { var id, name, slug, upstreamURL, routePrefix, createdAt, updatedAt string - var enabled bool + var enabled int err := rows.Scan(&id, &name, &slug, &upstreamURL, &routePrefix, &enabled, &createdAt, &updatedAt) if err != nil { continue // Skip malformed rows @@ -195,7 +258,7 @@ func handleAPwhyServicesList(c *gin.Context) { "slug": slug, "upstreamUrl": upstreamURL, "routePrefix": routePrefix, - "enabled": enabled, + "enabled": enabled != 0, "createdAt": createdAt, "updatedAt": updatedAt, }) @@ -220,24 +283,8 @@ func handleAPwhyServicesCreate(c *gin.Context) { db := c.MustGet("db").(*database.DB) ctx := context.Background() - // Generate slug and ID - id, err := generateServiceID() - if err != nil { - sendErrorResponse(c, http.StatusInternalServerError, "GENERATION_ERROR", - "Failed to generate service ID", err.Error()) - return - } - slug := strings.ToLower(strings.ReplaceAll(req.Name, " ", "-")) - // Insert service into database - query := ` - INSERT INTO api_services ( - id, name, slug, upstream_url, route_prefix, health_path, - enabled, rpm_limit, monthly_quota, created_at, updated_at - ) VALUES ($1, $2, $3, $4, $5, '/health', true, $6, $7, NOW(), NOW()) - ` - var rpmLimit, monthlyQuota int if req.RPMLimit != nil { rpmLimit = *req.RPMLimit @@ -251,10 +298,22 @@ func handleAPwhyServicesCreate(c *gin.Context) { monthlyQuota = DefaultMonthlyQuota } - _, err = db.ExecContext(ctx, query, id, req.Name, slug, req.UpstreamURL, - req.RoutePrefix, rpmLimit, monthlyQuota) + // id defaults to gen_random_uuid(); enabled defaults to 1. + var id string + err := db.QueryRowContext(ctx, ` + INSERT INTO api_services ( + name, slug, upstream_url, route_prefix, health_path, + rpm_limit, monthly_quota, created_at, updated_at + ) VALUES ($1, $2, $3, $4, '/health', $5, $6, NOW(), NOW()) + RETURNING id + `, req.Name, slug, req.UpstreamURL, req.RoutePrefix, rpmLimit, monthlyQuota).Scan(&id) if err != nil { + if isUniqueViolation(err) { + sendErrorResponse(c, http.StatusConflict, "CONFLICT", + "A service with this slug or route prefix already exists", err.Error()) + return + } sendErrorResponse(c, http.StatusInternalServerError, "DATABASE_ERROR", "Failed to create service", err.Error()) return @@ -297,9 +356,13 @@ func handleAPwhyServicesPatch(c *gin.Context) { db := c.MustGet("db").(*database.DB) if input.Enabled != nil { + enabled := 0 + if *input.Enabled { + enabled = 1 + } _, err := db.ExecContext(context.Background(), "UPDATE api_services SET enabled = $1, updated_at = NOW() WHERE id = $2", - *input.Enabled, serviceID) + enabled, serviceID) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{ @@ -334,10 +397,10 @@ func handleAPwhyKeysList(c *gin.Context) { } defer rows.Close() - var keys []map[string]interface{} + keys := make([]map[string]interface{}, 0) for rows.Next() { var id, name, keyPrefix, plan, createdAt, updatedAt string - var enabled bool + var enabled int var rpmLimit, monthlyQuota sql.NullInt64 err := rows.Scan(&id, &name, &keyPrefix, &plan, &enabled, &rpmLimit, &monthlyQuota, &createdAt, &updatedAt) @@ -350,7 +413,7 @@ func handleAPwhyKeysList(c *gin.Context) { "name": name, "keyPrefix": keyPrefix, "plan": plan, - "enabled": enabled, + "enabled": enabled != 0, "createdAt": createdAt, "updatedAt": updatedAt, } @@ -407,36 +470,25 @@ func handleAPwhyKeysCreate(c *gin.Context) { return } - // Generate ID and prefix - id, err := generateServiceID() - if err != nil { - sendErrorResponse(c, http.StatusInternalServerError, "GENERATION_ERROR", - "Failed to generate key ID", err.Error()) - return - } - keyPrefix := apiKey[:8] // Set default limits based on plan - var rpmLimit, monthlyQuota int - switch input.Plan { - case "free": - rpmLimit, monthlyQuota = 60, 1000 - case "pro": - rpmLimit, monthlyQuota = 600, 50000 - case "business": - rpmLimit, monthlyQuota = 3000, 300000 - default: - rpmLimit, monthlyQuota = 60, 1000 + plan, rpmLimit, monthlyQuota, err := validateAPIKeyPlan(input.Plan) + if err != nil { + sendErrorResponse(c, http.StatusBadRequest, "VALIDATION_ERROR", + "Invalid plan", err.Error()) + return } - // Insert key into database - _, err = db.ExecContext(context.Background(), ` + // id defaults to gen_random_uuid(); enabled defaults to 1. + var id string + err = db.QueryRowContext(context.Background(), ` INSERT INTO api_keys ( - id, name, key_hash, key_prefix, plan, allowed_service_ids, - enabled, rpm_limit, monthly_quota, created_at, updated_at - ) VALUES ($1, $2, $3, $4, $5, '[]', true, $6, $7, NOW(), NOW()) - `, id, input.Name, keyHash, keyPrefix, input.Plan, rpmLimit, monthlyQuota) + name, key_hash, key_prefix, plan, allowed_service_ids, + rpm_limit, monthly_quota, created_at, updated_at + ) VALUES ($1, $2, $3, $4, '[]', $5, $6, NOW(), NOW()) + RETURNING id + `, input.Name, keyHash, keyPrefix, plan, rpmLimit, monthlyQuota).Scan(&id) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{ @@ -451,7 +503,7 @@ func handleAPwhyKeysCreate(c *gin.Context) { "data": gin.H{ "id": id, "name": input.Name, - "plan": input.Plan, + "plan": plan, "key": apiKey, // Only return the actual key once "keyPrefix": keyPrefix, "enabled": true, @@ -480,9 +532,13 @@ func handleAPwhyKeysPatch(c *gin.Context) { db := c.MustGet("db").(*database.DB) if input.Enabled != nil { + enabled := 0 + if *input.Enabled { + enabled = 1 + } _, err := db.ExecContext(context.Background(), "UPDATE api_keys SET enabled = $1, updated_at = NOW() WHERE id = $2", - *input.Enabled, keyID) + enabled, keyID) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{ diff --git a/app/backend/internal/api/params.go b/app/backend/internal/api/params.go index 0266fde..04505c7 100644 --- a/app/backend/internal/api/params.go +++ b/app/backend/internal/api/params.go @@ -1,6 +1,10 @@ package api -import "github.com/gin-gonic/gin" +import ( + "strings" + + "github.com/gin-gonic/gin" +) // firstPathParam returns the first non-empty route param from the provided names. func firstPathParam(c *gin.Context, names ...string) string { @@ -11,3 +15,13 @@ func firstPathParam(c *gin.Context, names ...string) string { } return "" } + +// firstNonEmpty returns the first non-blank string, trimmed. +func firstNonEmpty(values ...string) string { + for _, value := range values { + if strings.TrimSpace(value) != "" { + return strings.TrimSpace(value) + } + } + return "" +} diff --git a/app/backend/internal/api/routes.go b/app/backend/internal/api/routes.go index 0f19221..72847a6 100644 --- a/app/backend/internal/api/routes.go +++ b/app/backend/internal/api/routes.go @@ -17,8 +17,6 @@ import ( "containr/internal/scaling" "github.com/gin-gonic/gin" - "gorm.io/driver/postgres" - "gorm.io/gorm" ) func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg *config.Config) { @@ -56,14 +54,8 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg // Initialize scaling handler scalingHandler := NewScalingHandler(autoScaler) - // Initialize GORM for agent system - gormDB, err := gorm.Open(postgres.Open(cfg.DatabaseURL), &gorm.Config{}) - if err != nil { - panic("Failed to initialize GORM: " + err.Error()) - } - - // Initialize agent handler - agentHandler := NewNodeAgentHandler(gormDB) + // Initialize agent handler (sqlc-backed) + agentHandler := NewNodeAgentHandler(db) // Initialize database handler databaseHandler := NewDatabaseHandler(db.DB, dockerClient) @@ -90,7 +82,6 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg c.Set("auto_scaler", autoScaler) c.Set("ha_manager", haManager) c.Set("scaling_handler", scalingHandler) - c.Set("gorm_db", gormDB) c.Next() }) @@ -298,42 +289,23 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg // Audit Logs routes protected.GET("/audit-logs", handleGetAuditLogs) protected.GET("/audit-logs/:resource/:id", handleGetResourceAuditLogs) - } - // APwhy Gateway routes - apwhy := router.Group("/api/v1") - { - // Health check (no auth required) - apwhy.GET("/health", func(c *gin.Context) { - c.JSON(200, gin.H{ - "ok": true, - "data": gin.H{ - "status": "ok", - "name": "Containr + APwhy", - "database": "postgresql", - "generatedAt": time.Now().UTC().Format(time.RFC3339), - }, - }) - }) - } + // API Gateway routes (merged APwhy) - namespaced to avoid + // colliding with Containr's own /services paths. + gateway := protected.Group("/gateway") + { + gateway.GET("/services", handleAPwhyServicesList) + gateway.POST("/services", handleAPwhyServicesCreate) + gateway.PATCH("/services/:id", handleAPwhyServicesPatch) + gateway.GET("/services/:id/validate", handleAPwhyServiceValidate) - // Protected APwhy routes (authentication required) - protectedAPwhy := router.Group("/api/v1") - protectedAPwhy.Use(middleware.Auth(cfg.JWTSecret)) - { - // Service management - protectedAPwhy.GET("/services", handleAPwhyServicesList) - protectedAPwhy.POST("/services", handleAPwhyServicesCreate) - protectedAPwhy.PATCH("/services/:id", handleAPwhyServicesPatch) + gateway.GET("/keys", handleAPwhyKeysList) + gateway.POST("/keys", handleAPwhyKeysCreate) + gateway.PATCH("/keys/:id", handleAPwhyKeysPatch) - // API Keys - protectedAPwhy.GET("/keys", handleAPwhyKeysList) - protectedAPwhy.POST("/keys", handleAPwhyKeysCreate) - protectedAPwhy.PATCH("/keys/:id", handleAPwhyKeysPatch) - - // Analytics - protectedAPwhy.GET("/analytics/ops", handleAPwhyAnalyticsOps) - protectedAPwhy.GET("/analytics/traffic", handleAPwhyAnalyticsTraffic) + gateway.GET("/analytics/ops", handleAPwhyAnalyticsOps) + gateway.GET("/analytics/traffic", handleAPwhyAnalyticsTraffic) + } } } } diff --git a/app/backend/internal/api/server.go b/app/backend/internal/api/server.go deleted file mode 100644 index 3a9419e..0000000 --- a/app/backend/internal/api/server.go +++ /dev/null @@ -1,2694 +0,0 @@ -package api - -import ( - "context" - "database/sql" - "embed" - "encoding/json" - "errors" - "fmt" - "io" - "io/fs" - "mime" - "net/http" - "net/url" - "os" - "path" - "path/filepath" - "sort" - "strconv" - "strings" - "sync" - "time" - - "containr/internal/analytics" - "containr/internal/auth" - "containr/internal/config" - "containr/internal/gateway" - "containr/internal/storage" -) - -//go:embed static/* -var staticFS embed.FS - -type Server struct { - Store *storage.Store - Cfg config.Config - Umami *analytics.Client - proxyClient *http.Client - - rpmMu sync.Mutex - rpmUsage map[string]rpmWindow - - loginMu sync.Mutex - loginAttempts map[string]loginWindow -} - -type rpmWindow struct { - WindowStart time.Time - Count int -} - -type loginWindow struct { - WindowStart time.Time - Count int -} - -type authContext struct { - UserID string - Email string - Enabled bool - ForcePasswordReset bool - Roles []string - Permissions map[string]bool - SessionID string -} - -func NewServer(store *storage.Store, cfg config.Config) *Server { - return &Server{ - Store: store, - Cfg: cfg, - Umami: analytics.NewClient(cfg.UmamiBaseURL, cfg.UmamiAPIKey, cfg.UmamiWebsiteID), - proxyClient: &http.Client{ - Timeout: cfg.DefaultServiceTimeout, - }, - rpmUsage: map[string]rpmWindow{}, - loginAttempts: map[string]loginWindow{}, - } -} - -func (s *Server) Handler() http.Handler { - mux := http.NewServeMux() - - mux.HandleFunc("GET /health", s.handleHealth) - - mux.HandleFunc("GET /api/v1/bootstrap/status", s.handleBootstrapStatus) - mux.HandleFunc("POST /api/v1/bootstrap/register-owner", s.handleBootstrapRegisterOwner) - - mux.HandleFunc("POST /api/v1/auth/login", s.handleAuthLogin) - mux.HandleFunc("POST /api/v1/auth/logout", s.handleAuthLogout) - mux.HandleFunc("POST /api/v1/auth/refresh", s.handleAuthRefresh) - mux.HandleFunc("GET /api/v1/auth/me", s.withAuth("", true, s.handleAuthMe)) - mux.HandleFunc("POST /api/v1/auth/reset-password", s.withAuth("", true, s.handleAuthResetPassword)) - - mux.HandleFunc("GET /api/v1/users", s.withAuth("users.read", false, s.handleUsersList)) - mux.HandleFunc("POST /api/v1/users", s.withAuth("users.write", false, s.handleUsersCreate)) - mux.HandleFunc("PATCH /api/v1/users/{id}", s.withAuth("users.write", false, s.handleUsersPatch)) - - mux.HandleFunc("GET /api/v1/roles", s.withAuth("roles.read", false, s.handleRolesList)) - mux.HandleFunc("POST /api/v1/roles", s.withAuth("roles.write", false, s.handleRolesCreate)) - mux.HandleFunc("PATCH /api/v1/roles/{id}", s.withAuth("roles.write", false, s.handleRolesPatch)) - mux.HandleFunc("GET /api/v1/permissions", s.withAuth("roles.read", false, s.handlePermissionsList)) - - mux.HandleFunc("GET /api/v1/services", s.withAuth("services.read", false, s.handleServicesList)) - mux.HandleFunc("POST /api/v1/services", s.withAuth("services.write", false, s.handleServicesCreate)) - mux.HandleFunc("PATCH /api/v1/services/{id}", s.withAuth("services.write", false, s.handleServicesPatch)) - mux.HandleFunc("POST /api/v1/services/{id}/validate", s.withAuth("services.write", false, s.handleServicesValidate)) - - mux.HandleFunc("GET /api/v1/databases", s.withAuth("databases.read", false, s.handleDatabasesList)) - mux.HandleFunc("POST /api/v1/databases", s.withAuth("databases.write", false, s.handleDatabasesCreate)) - mux.HandleFunc("PATCH /api/v1/databases/{id}", s.withAuth("databases.write", false, s.handleDatabasesPatch)) - mux.HandleFunc("POST /api/v1/databases/{id}/validate", s.withAuth("databases.write", false, s.handleDatabasesValidate)) - - mux.HandleFunc("GET /api/v1/keys", s.withAuth("keys.read", false, s.handleKeysList)) - mux.HandleFunc("POST /api/v1/keys", s.withAuth("keys.write", false, s.handleKeysCreate)) - mux.HandleFunc("PATCH /api/v1/keys/{id}", s.withAuth("keys.write", false, s.handleKeysPatch)) - - mux.HandleFunc("GET /api/v1/analytics/ops", s.withAuth("analytics.read", false, s.handleAnalyticsOps)) - mux.HandleFunc("GET /api/v1/analytics/traffic", s.withAuth("analytics.read", false, s.handleAnalyticsTraffic)) - mux.HandleFunc("POST /api/v1/analytics/events", s.withAuth("", true, s.handleAnalyticsEvents)) - - mux.HandleFunc("/", s.handleGatewayOrUI) - - return s.withSecurityHeaders(s.withLogging(mux)) -} - -func (s *Server) withSecurityHeaders(next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("X-Frame-Options", "DENY") - w.Header().Set("X-Content-Type-Options", "nosniff") - w.Header().Set("Referrer-Policy", "strict-origin-when-cross-origin") - next.ServeHTTP(w, r) - }) -} - -func (s *Server) withLogging(next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - start := time.Now() - next.ServeHTTP(w, r) - _ = s.logMetric("http_request", 1, map[string]any{ - "method": r.Method, - "path": r.URL.Path, - "ms": time.Since(start).Milliseconds(), - }) - }) -} - -func (s *Server) withAuth(requiredPermission string, allowForceReset bool, handler func(http.ResponseWriter, *http.Request, *authContext)) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - ctx, err := s.resolveAuth(r) - if err != nil { - writeError(w, http.StatusUnauthorized, "UNAUTHORIZED", "Authentication required") - return - } - if !ctx.Enabled { - writeError(w, http.StatusForbidden, "USER_DISABLED", "User account is disabled") - return - } - if ctx.ForcePasswordReset && !allowForceReset { - writeError(w, http.StatusPreconditionRequired, "PASSWORD_RESET_REQUIRED", "Password reset required") - return - } - if requiredPermission != "" && !ctx.Permissions[requiredPermission] { - writeError(w, http.StatusForbidden, "FORBIDDEN", "Insufficient permissions") - return - } - handler(w, r, ctx) - } -} - -func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) { - writeJSON(w, http.StatusOK, map[string]any{ - "ok": true, - "data": map[string]any{ - "status": "ok", - "name": "APwhy", - "database": "sqlite", - "generatedAt": time.Now().UTC().Format(time.RFC3339), - }, - }) -} - -func (s *Server) handleBootstrapStatus(w http.ResponseWriter, r *http.Request) { - hasUsers := s.hasUsers(r.Context()) - writeJSON(w, http.StatusOK, map[string]any{ - "ok": true, - "data": map[string]any{ - "hasUsers": hasUsers, - "registrationOpen": !hasUsers, - }, - }) -} - -func (s *Server) handleBootstrapRegisterOwner(w http.ResponseWriter, r *http.Request) { - if s.hasUsers(r.Context()) { - writeError(w, http.StatusForbidden, "BOOTSTRAP_CLOSED", "Owner registration is closed") - return - } - - var input struct { - Email string `json:"email"` - Password string `json:"password"` - } - if err := decodeJSON(r.Body, &input); err != nil { - writeError(w, http.StatusBadRequest, "INVALID_BODY", err.Error()) - return - } - - input.Email = strings.ToLower(strings.TrimSpace(input.Email)) - if input.Email == "" || !strings.Contains(input.Email, "@") { - writeError(w, http.StatusBadRequest, "INVALID_EMAIL", "Valid email is required") - return - } - - hash, err := auth.HashPassword(input.Password) - if err != nil { - writeError(w, http.StatusBadRequest, "INVALID_PASSWORD", err.Error()) - return - } - - now := storage.NowISO() - userID, _ := auth.RandomID("usr") - - tx, err := s.Store.DB.BeginTx(r.Context(), nil) - if err != nil { - writeError(w, http.StatusInternalServerError, "DB_ERROR", "Failed to start transaction") - return - } - defer tx.Rollback() - - _, err = tx.ExecContext(r.Context(), ` - INSERT INTO users (id, email, password_hash, enabled, force_password_reset, created_at, updated_at) - VALUES (?, ?, ?, 1, 0, ?, ?) - `, userID, input.Email, hash, now, now) - if err != nil { - writeError(w, http.StatusBadRequest, "USER_CREATE_FAILED", "Email already exists") - return - } - - ownerRoleID := "" - if err := tx.QueryRowContext(r.Context(), `SELECT id FROM roles WHERE slug = 'owner'`).Scan(&ownerRoleID); err != nil { - writeError(w, http.StatusInternalServerError, "ROLE_MISSING", "Owner role not found") - return - } - - _, err = tx.ExecContext(r.Context(), `INSERT INTO user_roles (user_id, role_id, created_at) VALUES (?, ?, ?)`, userID, ownerRoleID, now) - if err != nil { - writeError(w, http.StatusInternalServerError, "ROLE_ASSIGN_FAILED", "Failed to assign owner role") - return - } - - if err := tx.Commit(); err != nil { - writeError(w, http.StatusInternalServerError, "DB_ERROR", "Failed to commit owner registration") - return - } - - s.audit(r.Context(), userID, "owner.registered", "user", userID, map[string]any{"email": input.Email}) - - writeJSON(w, http.StatusCreated, map[string]any{ - "ok": true, - "data": map[string]any{ - "id": userID, - "email": input.Email, - "roles": []string{"owner"}, - }, - }) -} - -func (s *Server) handleAuthLogin(w http.ResponseWriter, r *http.Request) { - var input struct { - Email string `json:"email"` - Password string `json:"password"` - } - if err := decodeJSON(r.Body, &input); err != nil { - writeError(w, http.StatusBadRequest, "INVALID_BODY", err.Error()) - return - } - - email := strings.ToLower(strings.TrimSpace(input.Email)) - if email == "" || input.Password == "" { - writeError(w, http.StatusBadRequest, "INVALID_CREDENTIALS", "Email and password are required") - return - } - - if s.isLoginRateLimited(r, email) { - writeError(w, http.StatusTooManyRequests, "LOGIN_RATE_LIMITED", "Too many login attempts") - return - } - - var user struct { - ID string - Email string - PasswordHash string - Enabled int - ForcePasswordReset int - } - err := s.Store.DB.QueryRowContext(r.Context(), ` - SELECT id, email, password_hash, enabled, force_password_reset - FROM users - WHERE email = ? - `, email).Scan(&user.ID, &user.Email, &user.PasswordHash, &user.Enabled, &user.ForcePasswordReset) - if err != nil { - s.markLoginAttempt(r, email) - writeError(w, http.StatusUnauthorized, "INVALID_CREDENTIALS", "Invalid credentials") - return - } - if user.Enabled != 1 { - writeError(w, http.StatusForbidden, "USER_DISABLED", "User account is disabled") - return - } - - ok, err := auth.VerifyPassword(user.PasswordHash, input.Password) - if err != nil || !ok { - s.markLoginAttempt(r, email) - writeError(w, http.StatusUnauthorized, "INVALID_CREDENTIALS", "Invalid credentials") - return - } - - s.clearLoginAttempts(r, email) - - sessionID, accessToken, refreshToken, err := s.createSession(r.Context(), user.ID) - if err != nil { - writeError(w, http.StatusInternalServerError, "SESSION_CREATE_FAILED", "Failed to create login session") - return - } - - now := storage.NowISO() - _, _ = s.Store.DB.ExecContext(r.Context(), `UPDATE users SET last_login_at = ?, updated_at = ? WHERE id = ?`, now, now, user.ID) - _ = sessionID - - s.setAuthCookies(w, accessToken, refreshToken) - - ctx, _ := s.resolveAuthFromTokenHash(r.Context(), auth.HashToken(accessToken)) - if ctx == nil { - writeError(w, http.StatusInternalServerError, "SESSION_RESOLVE_FAILED", "Failed to load user profile") - return - } - - s.audit(r.Context(), user.ID, "auth.login", "session", sessionID, map[string]any{"email": user.Email}) - - writeJSON(w, http.StatusOK, map[string]any{ - "ok": true, - "data": map[string]any{ - "user": map[string]any{ - "id": ctx.UserID, - "email": ctx.Email, - "roles": ctx.Roles, - "permissions": keysOfMap(ctx.Permissions), - "forcePasswordReset": ctx.ForcePasswordReset, - }, - }, - }) -} - -func (s *Server) handleAuthLogout(w http.ResponseWriter, r *http.Request) { - accessCookie, _ := r.Cookie(s.Cfg.SessionAccessCookie) - refreshCookie, _ := r.Cookie(s.Cfg.SessionRefreshCookie) - - now := storage.NowISO() - if accessCookie != nil { - _, _ = s.Store.DB.ExecContext(r.Context(), ` - UPDATE sessions SET revoked_at = ?, updated_at = ? WHERE access_token_hash = ? - `, now, now, auth.HashToken(accessCookie.Value)) - } - if refreshCookie != nil { - _, _ = s.Store.DB.ExecContext(r.Context(), ` - UPDATE sessions SET revoked_at = ?, updated_at = ? WHERE refresh_token_hash = ? - `, now, now, auth.HashToken(refreshCookie.Value)) - } - - s.clearAuthCookies(w) - writeJSON(w, http.StatusOK, map[string]any{"ok": true}) -} - -func (s *Server) handleAuthRefresh(w http.ResponseWriter, r *http.Request) { - refreshCookie, err := r.Cookie(s.Cfg.SessionRefreshCookie) - if err != nil || strings.TrimSpace(refreshCookie.Value) == "" { - writeError(w, http.StatusUnauthorized, "REFRESH_MISSING", "Refresh token is missing") - return - } - - refreshHash := auth.HashToken(refreshCookie.Value) - - var session struct { - ID string - UserID string - ExpiresAt string - RevokedAt sql.NullString - } - err = s.Store.DB.QueryRowContext(r.Context(), ` - SELECT id, user_id, refresh_expires_at, revoked_at - FROM sessions - WHERE refresh_token_hash = ? - `, refreshHash).Scan(&session.ID, &session.UserID, &session.ExpiresAt, &session.RevokedAt) - if err != nil { - writeError(w, http.StatusUnauthorized, "INVALID_REFRESH", "Refresh token is invalid") - return - } - if session.RevokedAt.Valid { - writeError(w, http.StatusUnauthorized, "INVALID_REFRESH", "Refresh token is revoked") - return - } - expiresAt, _ := time.Parse(time.RFC3339, session.ExpiresAt) - if time.Now().After(expiresAt) { - writeError(w, http.StatusUnauthorized, "REFRESH_EXPIRED", "Refresh token has expired") - return - } - - accessToken, _ := auth.RandomToken(32) - newRefreshToken, _ := auth.RandomToken(48) - accessHash := auth.HashToken(accessToken) - refreshHashNew := auth.HashToken(newRefreshToken) - - accessExp := time.Now().Add(s.Cfg.AccessTokenTTL).UTC().Format(time.RFC3339) - refreshExp := time.Now().Add(s.Cfg.RefreshTokenTTL).UTC().Format(time.RFC3339) - - _, err = s.Store.DB.ExecContext(r.Context(), ` - UPDATE sessions - SET access_token_hash = ?, refresh_token_hash = ?, access_expires_at = ?, refresh_expires_at = ?, updated_at = ? - WHERE id = ? - `, accessHash, refreshHashNew, accessExp, refreshExp, storage.NowISO(), session.ID) - if err != nil { - writeError(w, http.StatusInternalServerError, "REFRESH_FAILED", "Failed to refresh session") - return - } - - s.setAuthCookies(w, accessToken, newRefreshToken) - writeJSON(w, http.StatusOK, map[string]any{"ok": true}) -} - -func (s *Server) handleAuthMe(w http.ResponseWriter, r *http.Request, ctx *authContext) { - writeJSON(w, http.StatusOK, map[string]any{ - "ok": true, - "data": map[string]any{ - "id": ctx.UserID, - "email": ctx.Email, - "roles": ctx.Roles, - "permissions": keysOfMap(ctx.Permissions), - "forcePasswordReset": ctx.ForcePasswordReset, - }, - }) -} - -func (s *Server) handleAuthResetPassword(w http.ResponseWriter, r *http.Request, ctx *authContext) { - var input struct { - NewPassword string `json:"newPassword"` - } - if err := decodeJSON(r.Body, &input); err != nil { - writeError(w, http.StatusBadRequest, "INVALID_BODY", err.Error()) - return - } - hash, err := auth.HashPassword(input.NewPassword) - if err != nil { - writeError(w, http.StatusBadRequest, "INVALID_PASSWORD", err.Error()) - return - } - - now := storage.NowISO() - _, err = s.Store.DB.ExecContext(r.Context(), ` - UPDATE users SET password_hash = ?, force_password_reset = 0, updated_at = ? WHERE id = ? - `, hash, now, ctx.UserID) - if err != nil { - writeError(w, http.StatusInternalServerError, "PASSWORD_UPDATE_FAILED", "Failed to update password") - return - } - - s.audit(r.Context(), ctx.UserID, "auth.reset_password", "user", ctx.UserID, nil) - writeJSON(w, http.StatusOK, map[string]any{"ok": true}) -} - -func (s *Server) handleUsersList(w http.ResponseWriter, r *http.Request, ctx *authContext) { - rows, err := s.Store.DB.QueryContext(r.Context(), ` - SELECT u.id, u.email, u.enabled, u.force_password_reset, u.created_at, u.updated_at, - COALESCE(GROUP_CONCAT(r.slug), '') AS role_slugs - FROM users u - LEFT JOIN user_roles ur ON ur.user_id = u.id - LEFT JOIN roles r ON r.id = ur.role_id - GROUP BY u.id - ORDER BY u.created_at DESC - `) - if err != nil { - writeError(w, http.StatusInternalServerError, "DB_ERROR", "Failed to list users") - return - } - defer rows.Close() - - items := make([]map[string]any, 0) - for rows.Next() { - var id, email, createdAt, updatedAt, roleSlugs string - var enabled, forceReset int - if err := rows.Scan(&id, &email, &enabled, &forceReset, &createdAt, &updatedAt, &roleSlugs); err != nil { - continue - } - roles := []string{} - if strings.TrimSpace(roleSlugs) != "" { - roles = strings.Split(roleSlugs, ",") - } - items = append(items, map[string]any{ - "id": id, - "email": email, - "enabled": enabled == 1, - "forcePasswordReset": forceReset == 1, - "roles": roles, - "createdAt": createdAt, - "updatedAt": updatedAt, - }) - } - writeJSON(w, http.StatusOK, map[string]any{"ok": true, "data": items}) - _ = ctx -} - -func (s *Server) handleUsersCreate(w http.ResponseWriter, r *http.Request, ctx *authContext) { - var input struct { - Email string `json:"email"` - RoleIDs []string `json:"roleIds"` - RoleSlugs []string `json:"roleSlugs"` - } - if err := decodeJSON(r.Body, &input); err != nil { - writeError(w, http.StatusBadRequest, "INVALID_BODY", err.Error()) - return - } - email := strings.ToLower(strings.TrimSpace(input.Email)) - if email == "" || !strings.Contains(email, "@") { - writeError(w, http.StatusBadRequest, "INVALID_EMAIL", "Valid email is required") - return - } - - roleIDs, err := s.resolveRoleIDs(r.Context(), input.RoleIDs, input.RoleSlugs) - if err != nil { - writeError(w, http.StatusBadRequest, "INVALID_ROLE", err.Error()) - return - } - if len(roleIDs) == 0 { - viewerID, err := s.roleIDBySlug(r.Context(), "viewer") - if err == nil { - roleIDs = []string{viewerID} - } - } - - tempPassword, _ := auth.RandomPassword(16) - passwordHash, err := auth.HashPassword(tempPassword) - if err != nil { - writeError(w, http.StatusInternalServerError, "PASSWORD_ERROR", "Failed to generate temporary password") - return - } - - inviteToken, _ := auth.RandomToken(32) - inviteHash := auth.HashToken(inviteToken) - now := storage.NowISO() - expiresAt := time.Now().Add(72 * time.Hour).UTC().Format(time.RFC3339) - - userID, _ := auth.RandomID("usr") - inviteID, _ := auth.RandomID("inv") - - tx, err := s.Store.DB.BeginTx(r.Context(), nil) - if err != nil { - writeError(w, http.StatusInternalServerError, "DB_ERROR", "Failed to start transaction") - return - } - defer tx.Rollback() - - _, err = tx.ExecContext(r.Context(), ` - INSERT INTO users (id, email, password_hash, enabled, force_password_reset, created_at, updated_at) - VALUES (?, ?, ?, 1, 1, ?, ?) - `, userID, email, passwordHash, now, now) - if err != nil { - writeError(w, http.StatusBadRequest, "USER_CREATE_FAILED", "Email already exists") - return - } - - for _, roleID := range roleIDs { - _, err = tx.ExecContext(r.Context(), `INSERT INTO user_roles (user_id, role_id, created_at) VALUES (?, ?, ?)`, userID, roleID, now) - if err != nil { - writeError(w, http.StatusInternalServerError, "ROLE_ASSIGN_FAILED", "Failed to assign roles") - return - } - } - - _, err = tx.ExecContext(r.Context(), ` - INSERT INTO invites (id, user_id, email, token_hash, expires_at, created_by, created_at) - VALUES (?, ?, ?, ?, ?, ?, ?) - `, inviteID, userID, email, inviteHash, expiresAt, ctx.UserID, now) - if err != nil { - writeError(w, http.StatusInternalServerError, "INVITE_FAILED", "Failed to create invite") - return - } - - if err := tx.Commit(); err != nil { - writeError(w, http.StatusInternalServerError, "DB_ERROR", "Failed to commit user creation") - return - } - - s.audit(r.Context(), ctx.UserID, "users.create", "user", userID, map[string]any{"email": email, "roleCount": len(roleIDs)}) - - writeJSON(w, http.StatusCreated, map[string]any{ - "ok": true, - "data": map[string]any{ - "id": userID, - "email": email, - "inviteToken": inviteToken, - "temporaryPass": tempPassword, - "expiresAt": expiresAt, - }, - }) -} - -func (s *Server) handleUsersPatch(w http.ResponseWriter, r *http.Request, ctx *authContext) { - id := r.PathValue("id") - if id == "" { - writeError(w, http.StatusBadRequest, "INVALID_ID", "User ID is required") - return - } - - var input struct { - Enabled *bool `json:"enabled"` - RoleIDs []string `json:"roleIds"` - RoleSlugs []string `json:"roleSlugs"` - ResetPassword bool `json:"resetPassword"` - } - if err := decodeJSON(r.Body, &input); err != nil { - writeError(w, http.StatusBadRequest, "INVALID_BODY", err.Error()) - return - } - - now := storage.NowISO() - tx, err := s.Store.DB.BeginTx(r.Context(), nil) - if err != nil { - writeError(w, http.StatusInternalServerError, "DB_ERROR", "Failed to start transaction") - return - } - defer tx.Rollback() - - if input.Enabled != nil { - _, err = tx.ExecContext(r.Context(), `UPDATE users SET enabled = ?, updated_at = ? WHERE id = ?`, boolToInt(*input.Enabled), now, id) - if err != nil { - writeError(w, http.StatusInternalServerError, "USER_UPDATE_FAILED", "Failed to update user") - return - } - } - - if len(input.RoleIDs) > 0 || len(input.RoleSlugs) > 0 { - roleIDs, err := s.resolveRoleIDs(r.Context(), input.RoleIDs, input.RoleSlugs) - if err != nil { - writeError(w, http.StatusBadRequest, "INVALID_ROLE", err.Error()) - return - } - _, err = tx.ExecContext(r.Context(), `DELETE FROM user_roles WHERE user_id = ?`, id) - if err != nil { - writeError(w, http.StatusInternalServerError, "ROLE_UPDATE_FAILED", "Failed to update user roles") - return - } - for _, roleID := range roleIDs { - _, err = tx.ExecContext(r.Context(), `INSERT INTO user_roles (user_id, role_id, created_at) VALUES (?, ?, ?)`, id, roleID, now) - if err != nil { - writeError(w, http.StatusInternalServerError, "ROLE_UPDATE_FAILED", "Failed to assign role") - return - } - } - } - - response := map[string]any{} - if input.ResetPassword { - tempPassword, _ := auth.RandomPassword(16) - hash, err := auth.HashPassword(tempPassword) - if err != nil { - writeError(w, http.StatusInternalServerError, "PASSWORD_ERROR", "Failed to generate password") - return - } - _, err = tx.ExecContext(r.Context(), ` - UPDATE users SET password_hash = ?, force_password_reset = 1, updated_at = ? WHERE id = ? - `, hash, now, id) - if err != nil { - writeError(w, http.StatusInternalServerError, "PASSWORD_RESET_FAILED", "Failed to reset password") - return - } - response["temporaryPass"] = tempPassword - } - - if err := tx.Commit(); err != nil { - writeError(w, http.StatusInternalServerError, "DB_ERROR", "Failed to commit user patch") - return - } - - s.audit(r.Context(), ctx.UserID, "users.patch", "user", id, input) - writeJSON(w, http.StatusOK, map[string]any{"ok": true, "data": response}) -} - -func (s *Server) handleRolesList(w http.ResponseWriter, r *http.Request, ctx *authContext) { - rows, err := s.Store.DB.QueryContext(r.Context(), ` - SELECT r.id, r.name, r.slug, r.description, r.is_system, r.enabled, - COALESCE(GROUP_CONCAT(p.code), '') AS permission_codes, - r.created_at, r.updated_at - FROM roles r - LEFT JOIN role_permissions rp ON rp.role_id = r.id - LEFT JOIN permissions p ON p.id = rp.permission_id - GROUP BY r.id - ORDER BY r.created_at ASC - `) - if err != nil { - writeError(w, http.StatusInternalServerError, "DB_ERROR", "Failed to list roles") - return - } - defer rows.Close() - - roles := []map[string]any{} - for rows.Next() { - var id, name, slug, description, permissionCodes, createdAt, updatedAt string - var isSystem, enabled int - if err := rows.Scan(&id, &name, &slug, &description, &isSystem, &enabled, &permissionCodes, &createdAt, &updatedAt); err != nil { - continue - } - codes := []string{} - if strings.TrimSpace(permissionCodes) != "" { - codes = strings.Split(permissionCodes, ",") - sort.Strings(codes) - } - roles = append(roles, map[string]any{ - "id": id, - "name": name, - "slug": slug, - "description": description, - "isSystem": isSystem == 1, - "enabled": enabled == 1, - "permissionCodes": codes, - "createdAt": createdAt, - "updatedAt": updatedAt, - }) - } - writeJSON(w, http.StatusOK, map[string]any{"ok": true, "data": roles}) - _ = ctx -} - -func (s *Server) handleRolesCreate(w http.ResponseWriter, r *http.Request, ctx *authContext) { - var input struct { - Name string `json:"name"` - Slug string `json:"slug"` - Description string `json:"description"` - PermissionCodes []string `json:"permissionCodes"` - } - if err := decodeJSON(r.Body, &input); err != nil { - writeError(w, http.StatusBadRequest, "INVALID_BODY", err.Error()) - return - } - - name := strings.TrimSpace(input.Name) - if name == "" { - writeError(w, http.StatusBadRequest, "INVALID_NAME", "Role name is required") - return - } - slug := slugify(firstNonEmpty(input.Slug, input.Name), "role") - id, _ := auth.RandomID("role") - now := storage.NowISO() - - tx, err := s.Store.DB.BeginTx(r.Context(), nil) - if err != nil { - writeError(w, http.StatusInternalServerError, "DB_ERROR", "Failed to start transaction") - return - } - defer tx.Rollback() - - _, err = tx.ExecContext(r.Context(), ` - INSERT INTO roles (id, name, slug, description, is_system, enabled, created_at, updated_at) - VALUES (?, ?, ?, ?, 0, 1, ?, ?) - `, id, name, slug, strings.TrimSpace(input.Description), now, now) - if err != nil { - writeError(w, http.StatusBadRequest, "ROLE_CREATE_FAILED", "Role slug already exists") - return - } - - if err := s.replaceRolePermissionsTx(r.Context(), tx, id, input.PermissionCodes); err != nil { - writeError(w, http.StatusBadRequest, "PERMISSION_UPDATE_FAILED", err.Error()) - return - } - - if err := tx.Commit(); err != nil { - writeError(w, http.StatusInternalServerError, "DB_ERROR", "Failed to commit role creation") - return - } - - s.audit(r.Context(), ctx.UserID, "roles.create", "role", id, input) - writeJSON(w, http.StatusCreated, map[string]any{"ok": true, "data": map[string]any{"id": id, "slug": slug}}) -} - -func (s *Server) handleRolesPatch(w http.ResponseWriter, r *http.Request, ctx *authContext) { - id := r.PathValue("id") - if id == "" { - writeError(w, http.StatusBadRequest, "INVALID_ID", "Role ID is required") - return - } - - var input struct { - Name *string `json:"name"` - Description *string `json:"description"` - Enabled *bool `json:"enabled"` - PermissionCodes []string `json:"permissionCodes"` - } - if err := decodeJSON(r.Body, &input); err != nil { - writeError(w, http.StatusBadRequest, "INVALID_BODY", err.Error()) - return - } - - isSystem := 0 - if err := s.Store.DB.QueryRowContext(r.Context(), `SELECT is_system FROM roles WHERE id = ?`, id).Scan(&isSystem); err != nil { - writeError(w, http.StatusNotFound, "ROLE_NOT_FOUND", "Role not found") - return - } - - now := storage.NowISO() - tx, err := s.Store.DB.BeginTx(r.Context(), nil) - if err != nil { - writeError(w, http.StatusInternalServerError, "DB_ERROR", "Failed to start transaction") - return - } - defer tx.Rollback() - - if input.Name != nil { - _, err = tx.ExecContext(r.Context(), `UPDATE roles SET name = ?, updated_at = ? WHERE id = ?`, strings.TrimSpace(*input.Name), now, id) - if err != nil { - writeError(w, http.StatusInternalServerError, "ROLE_UPDATE_FAILED", "Failed to update role name") - return - } - } - if input.Description != nil { - _, err = tx.ExecContext(r.Context(), `UPDATE roles SET description = ?, updated_at = ? WHERE id = ?`, strings.TrimSpace(*input.Description), now, id) - if err != nil { - writeError(w, http.StatusInternalServerError, "ROLE_UPDATE_FAILED", "Failed to update role description") - return - } - } - if input.Enabled != nil { - _, err = tx.ExecContext(r.Context(), `UPDATE roles SET enabled = ?, updated_at = ? WHERE id = ?`, boolToInt(*input.Enabled), now, id) - if err != nil { - writeError(w, http.StatusInternalServerError, "ROLE_UPDATE_FAILED", "Failed to update role status") - return - } - } - - if input.PermissionCodes != nil { - if isSystem == 1 { - writeError(w, http.StatusBadRequest, "SYSTEM_ROLE_LOCKED", "System role permissions are locked") - return - } - if err := s.replaceRolePermissionsTx(r.Context(), tx, id, input.PermissionCodes); err != nil { - writeError(w, http.StatusBadRequest, "PERMISSION_UPDATE_FAILED", err.Error()) - return - } - } - - if err := tx.Commit(); err != nil { - writeError(w, http.StatusInternalServerError, "DB_ERROR", "Failed to commit role patch") - return - } - - s.audit(r.Context(), ctx.UserID, "roles.patch", "role", id, input) - writeJSON(w, http.StatusOK, map[string]any{"ok": true}) -} - -func (s *Server) handlePermissionsList(w http.ResponseWriter, r *http.Request, ctx *authContext) { - rows, err := s.Store.DB.QueryContext(r.Context(), `SELECT id, code, name, description FROM permissions ORDER BY code ASC`) - if err != nil { - writeError(w, http.StatusInternalServerError, "DB_ERROR", "Failed to list permissions") - return - } - defer rows.Close() - - items := []map[string]any{} - for rows.Next() { - var id, code, name, description string - if err := rows.Scan(&id, &code, &name, &description); err != nil { - continue - } - items = append(items, map[string]any{"id": id, "code": code, "name": name, "description": description}) - } - writeJSON(w, http.StatusOK, map[string]any{"ok": true, "data": items}) - _ = ctx -} - -func (s *Server) handleServicesList(w http.ResponseWriter, r *http.Request, ctx *authContext) { - rows, err := s.Store.DB.QueryContext(r.Context(), `SELECT id, name, slug, upstream_url, route_prefix, health_path, upstream_auth_header, upstream_auth_value, internal_token, enabled, rpm_limit, monthly_quota, request_timeout_ms, last_validation_at, last_validation_status, last_validation_message, created_at, updated_at FROM services ORDER BY created_at DESC`) - if err != nil { - writeError(w, http.StatusInternalServerError, "DB_ERROR", "Failed to list services") - return - } - defer rows.Close() - items := []map[string]any{} - for rows.Next() { - item, err := scanService(rows) - if err == nil { - items = append(items, item) - } - } - writeJSON(w, http.StatusOK, map[string]any{"ok": true, "data": items}) - _ = ctx -} - -func (s *Server) handleServicesCreate(w http.ResponseWriter, r *http.Request, ctx *authContext) { - var input serviceInput - if err := decodeJSON(r.Body, &input); err != nil { - writeError(w, http.StatusBadRequest, "INVALID_BODY", err.Error()) - return - } - if strings.TrimSpace(input.Name) == "" { - writeError(w, http.StatusBadRequest, "INVALID_NAME", "Service name is required") - return - } - if strings.TrimSpace(input.UpstreamURL) == "" { - writeError(w, http.StatusBadRequest, "INVALID_UPSTREAM", "Service upstream URL is required") - return - } - routePrefix := normalizePathPrefix(input.RoutePrefix, "/"+slugify(input.Name, "service")) - if routePrefix == "/" && !s.Cfg.AllowRootRoutePrefix { - writeError(w, http.StatusBadRequest, "ROOT_ROUTE_DISABLED", "Route prefix '/' is disabled") - return - } - if strings.HasPrefix(routePrefix, "/api/") { - writeError(w, http.StatusBadRequest, "ROUTE_CONFLICT", "Route prefix conflicts with internal API") - return - } - - now := storage.NowISO() - id, _ := auth.RandomID("svc") - slug := slugify(firstNonEmpty(input.Slug, input.Name), "service") - - _, err := s.Store.DB.ExecContext(r.Context(), ` - INSERT INTO services ( - id, name, slug, upstream_url, route_prefix, health_path, - upstream_auth_header, upstream_auth_value, internal_token, - enabled, rpm_limit, monthly_quota, request_timeout_ms, - created_at, updated_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - `, - id, - strings.TrimSpace(input.Name), - slug, - strings.TrimSpace(input.UpstreamURL), - routePrefix, - normalizePathPrefix(input.HealthPath, "/health"), - nullIfEmpty(input.UpstreamAuthHeader), - nullIfEmpty(input.UpstreamAuthValue), - nullIfEmpty(input.InternalToken), - boolToInt(defaultBool(input.Enabled, true)), - nullInt(input.RPMLimit), - nullInt(input.MonthlyQuota), - nullInt(input.RequestTimeoutMS), - now, - now, - ) - if err != nil { - writeError(w, http.StatusBadRequest, "SERVICE_CREATE_FAILED", "Service slug or route already exists") - return - } - - s.audit(r.Context(), ctx.UserID, "services.create", "service", id, input) - writeJSON(w, http.StatusCreated, map[string]any{"ok": true, "data": map[string]any{"id": id, "slug": slug}}) -} - -func (s *Server) handleServicesPatch(w http.ResponseWriter, r *http.Request, ctx *authContext) { - id := r.PathValue("id") - if id == "" { - writeError(w, http.StatusBadRequest, "INVALID_ID", "Service ID is required") - return - } - - current, err := s.getServiceByID(r.Context(), id) - if err != nil { - writeError(w, http.StatusNotFound, "SERVICE_NOT_FOUND", "Service not found") - return - } - - var patch serviceInput - if err := decodeJSON(r.Body, &patch); err != nil { - writeError(w, http.StatusBadRequest, "INVALID_BODY", err.Error()) - return - } - - name := firstNonEmpty(patch.Name, asString(current["name"])) - routePrefix := normalizePathPrefix(firstNonEmpty(patch.RoutePrefix, asString(current["routePrefix"])), "/") - if routePrefix == "/" && !s.Cfg.AllowRootRoutePrefix { - writeError(w, http.StatusBadRequest, "ROOT_ROUTE_DISABLED", "Route prefix '/' is disabled") - return - } - - _, err = s.Store.DB.ExecContext(r.Context(), ` - UPDATE services SET - name = ?, slug = ?, upstream_url = ?, route_prefix = ?, health_path = ?, - upstream_auth_header = ?, upstream_auth_value = ?, internal_token = ?, enabled = ?, - rpm_limit = ?, monthly_quota = ?, request_timeout_ms = ?, updated_at = ? - WHERE id = ? - `, - name, - slugify(firstNonEmpty(patch.Slug, asString(current["slug"])), "service"), - firstNonEmpty(patch.UpstreamURL, asString(current["upstreamUrl"])), - routePrefix, - normalizePathPrefix(firstNonEmpty(patch.HealthPath, asString(current["healthPath"])), "/health"), - nullIfEmpty(firstNonEmpty(patch.UpstreamAuthHeader, asString(current["upstreamAuthHeader"]))), - nullIfEmpty(firstNonEmpty(patch.UpstreamAuthValue, asString(current["upstreamAuthValue"]))), - nullIfEmpty(firstNonEmpty(patch.InternalToken, asString(current["internalToken"]))), - boolToInt(defaultBool(patch.Enabled, asBool(current["enabled"]))), - nullInt(patch.RPMLimitOr(current["rpmLimit"])), - nullInt(patch.MonthlyQuotaOr(current["monthlyQuota"])), - nullInt(patch.TimeoutOr(current["requestTimeoutMs"])), - storage.NowISO(), - id, - ) - if err != nil { - writeError(w, http.StatusBadRequest, "SERVICE_UPDATE_FAILED", "Failed to update service") - return - } - writeJSON(w, http.StatusOK, map[string]any{"ok": true}) - _ = ctx -} - -func (s *Server) handleServicesValidate(w http.ResponseWriter, r *http.Request, ctx *authContext) { - id := r.PathValue("id") - service, err := s.getServiceByID(r.Context(), id) - if err != nil { - writeError(w, http.StatusNotFound, "SERVICE_NOT_FOUND", "Service not found") - return - } - - timeoutMS := intFromAny(service["requestTimeoutMs"], int(s.Cfg.DefaultServiceTimeout.Milliseconds())) - client := &http.Client{Timeout: time.Duration(timeoutMS) * time.Millisecond} - target := strings.TrimRight(asString(service["upstreamUrl"]), "/") + normalizePathPrefix(asString(service["healthPath"]), "/health") - - req, _ := http.NewRequestWithContext(r.Context(), http.MethodGet, target, nil) - if header := asString(service["upstreamAuthHeader"]); header != "" { - req.Header.Set(header, asString(service["upstreamAuthValue"])) - } - if token := asString(service["internalToken"]); token != "" { - req.Header.Set(s.Cfg.ServiceTokenHeader, token) - } - res, err := client.Do(req) - status := 0 - message := "" - ok := false - if err != nil { - status = http.StatusBadGateway - message = err.Error() - } else { - defer res.Body.Close() - status = res.StatusCode - message = fmt.Sprintf("HTTP %d", res.StatusCode) - ok = res.StatusCode >= 200 && res.StatusCode < 300 - } - - validationStatus := "failed" - if ok { - validationStatus = "healthy" - } - - _, _ = s.Store.DB.ExecContext(r.Context(), ` - UPDATE services - SET last_validation_at = ?, last_validation_status = ?, last_validation_message = ?, updated_at = ? - WHERE id = ? - `, storage.NowISO(), validationStatus, message, storage.NowISO(), id) - - if !ok { - s.logIncident(r.Context(), sql.NullString{String: id, Valid: true}, sql.NullString{}, "SERVICE_VALIDATION_FAILED", message, "medium", sql.NullInt64{Int64: int64(status), Valid: true}) - } - - writeJSON(w, http.StatusOK, map[string]any{"ok": true, "validation": map[string]any{"ok": ok, "status": status, "message": message}}) - _ = ctx -} - -func (s *Server) handleDatabasesList(w http.ResponseWriter, r *http.Request, ctx *authContext) { - rows, err := s.Store.DB.QueryContext(r.Context(), ` - SELECT id, name, slug, provider, connection_url, enabled, last_validation_at, last_validation_status, last_validation_message, created_at, updated_at - FROM database_connections - ORDER BY created_at DESC - `) - if err != nil { - writeError(w, http.StatusInternalServerError, "DB_ERROR", "Failed to list database connections") - return - } - defer rows.Close() - - items := []map[string]any{} - for rows.Next() { - var id, name, slug, provider, connURL, createdAt, updatedAt string - var enabled int - var lastAt, lastStatus, lastMessage sql.NullString - if err := rows.Scan(&id, &name, &slug, &provider, &connURL, &enabled, &lastAt, &lastStatus, &lastMessage, &createdAt, &updatedAt); err != nil { - continue - } - items = append(items, map[string]any{ - "id": id, - "name": name, - "slug": slug, - "provider": provider, - "target": extractDatabaseTarget(provider, connURL), - "maskedConnectionUrl": maskDatabaseURL(provider, connURL), - "enabled": enabled == 1, - "lastValidationAt": nullString(lastAt), - "lastValidationStatus": nullString(lastStatus), - "lastValidationMessage": nullString(lastMessage), - "createdAt": createdAt, - "updatedAt": updatedAt, - }) - } - writeJSON(w, http.StatusOK, map[string]any{"ok": true, "data": items}) - _ = ctx -} - -func (s *Server) handleDatabasesCreate(w http.ResponseWriter, r *http.Request, ctx *authContext) { - var input struct { - Name string `json:"name"` - Slug string `json:"slug"` - Provider string `json:"provider"` - ConnectionURL string `json:"connectionUrl"` - Enabled *bool `json:"enabled"` - } - if err := decodeJSON(r.Body, &input); err != nil { - writeError(w, http.StatusBadRequest, "INVALID_BODY", err.Error()) - return - } - - provider := normalizeProvider(input.Provider) - if provider == "" { - writeError(w, http.StatusBadRequest, "INVALID_PROVIDER", "Provider must be sqlite, postgres, or mysql") - return - } - if strings.TrimSpace(input.ConnectionURL) == "" { - writeError(w, http.StatusBadRequest, "INVALID_CONNECTION", "Connection URL is required") - return - } - - now := storage.NowISO() - id, _ := auth.RandomID("dbc") - slug := slugify(firstNonEmpty(input.Slug, input.Name+"-"+provider), "database") - - _, err := s.Store.DB.ExecContext(r.Context(), ` - INSERT INTO database_connections (id, name, slug, provider, connection_url, enabled, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?) - `, id, strings.TrimSpace(input.Name), slug, provider, strings.TrimSpace(input.ConnectionURL), boolToInt(defaultBool(input.Enabled, true)), now, now) - if err != nil { - writeError(w, http.StatusBadRequest, "DATABASE_CREATE_FAILED", "Database connection slug already exists") - return - } - writeJSON(w, http.StatusCreated, map[string]any{"ok": true, "data": map[string]any{"id": id, "slug": slug}}) - _ = ctx -} - -func (s *Server) handleDatabasesPatch(w http.ResponseWriter, r *http.Request, ctx *authContext) { - id := r.PathValue("id") - if id == "" { - writeError(w, http.StatusBadRequest, "INVALID_ID", "Database connection ID is required") - return - } - - var input struct { - Name *string `json:"name"` - Slug *string `json:"slug"` - Provider *string `json:"provider"` - ConnectionURL *string `json:"connectionUrl"` - Enabled *bool `json:"enabled"` - } - if err := decodeJSON(r.Body, &input); err != nil { - writeError(w, http.StatusBadRequest, "INVALID_BODY", err.Error()) - return - } - - current, err := s.getDatabaseByID(r.Context(), id) - if err != nil { - writeError(w, http.StatusNotFound, "DATABASE_NOT_FOUND", "Database connection not found") - return - } - - provider := asString(current["provider"]) - if input.Provider != nil { - norm := normalizeProvider(*input.Provider) - if norm == "" { - writeError(w, http.StatusBadRequest, "INVALID_PROVIDER", "Provider must be sqlite, postgres, or mysql") - return - } - provider = norm - } - - _, err = s.Store.DB.ExecContext(r.Context(), ` - UPDATE database_connections - SET name = ?, slug = ?, provider = ?, connection_url = ?, enabled = ?, updated_at = ? - WHERE id = ? - `, - firstNonEmptyPtr(input.Name, asString(current["name"])), - slugify(firstNonEmptyPtr(input.Slug, asString(current["slug"])), "database"), - provider, - firstNonEmptyPtr(input.ConnectionURL, asString(current["connectionUrl"])), - boolToInt(defaultBool(input.Enabled, asBool(current["enabled"]))), - storage.NowISO(), - id, - ) - if err != nil { - writeError(w, http.StatusBadRequest, "DATABASE_UPDATE_FAILED", "Failed to update database connection") - return - } - - writeJSON(w, http.StatusOK, map[string]any{"ok": true}) - _ = ctx -} - -func (s *Server) handleDatabasesValidate(w http.ResponseWriter, r *http.Request, ctx *authContext) { - id := r.PathValue("id") - database, err := s.getDatabaseByID(r.Context(), id) - if err != nil { - writeError(w, http.StatusNotFound, "DATABASE_NOT_FOUND", "Database connection not found") - return - } - - provider := asString(database["provider"]) - connectionURL := asString(database["connectionUrl"]) - ok, status, message := s.validateDatabaseConnection(r.Context(), provider, connectionURL) - state := "failed" - if ok { - state = "healthy" - } - _, _ = s.Store.DB.ExecContext(r.Context(), ` - UPDATE database_connections - SET last_validation_at = ?, last_validation_status = ?, last_validation_message = ?, updated_at = ? - WHERE id = ? - `, storage.NowISO(), state, message, storage.NowISO(), id) - - if !ok { - s.logIncident(r.Context(), sql.NullString{}, sql.NullString{}, "DATABASE_VALIDATION_FAILED", message, "medium", sql.NullInt64{Int64: int64(status), Valid: true}) - } - - writeJSON(w, http.StatusOK, map[string]any{"ok": true, "validation": map[string]any{"ok": ok, "status": status, "message": message}}) - _ = ctx -} - -func (s *Server) handleKeysList(w http.ResponseWriter, r *http.Request, ctx *authContext) { - rows, err := s.Store.DB.QueryContext(r.Context(), ` - SELECT id, name, key_prefix, plan, allowed_service_ids, enabled, rpm_limit, monthly_quota, created_at, updated_at, last_used_at - FROM api_keys - ORDER BY created_at DESC - `) - if err != nil { - writeError(w, http.StatusInternalServerError, "DB_ERROR", "Failed to list API keys") - return - } - defer rows.Close() - - items := []map[string]any{} - for rows.Next() { - var id, name, keyPrefix, plan, allowed, createdAt, updatedAt string - var enabled int - var rpm, monthly sql.NullInt64 - var lastUsed sql.NullString - if err := rows.Scan(&id, &name, &keyPrefix, &plan, &allowed, &enabled, &rpm, &monthly, &createdAt, &updatedAt, &lastUsed); err != nil { - continue - } - allowedServices := []string{} - _ = json.Unmarshal([]byte(allowed), &allowedServices) - items = append(items, map[string]any{ - "id": id, - "name": name, - "keyPrefix": keyPrefix, - "plan": plan, - "allowedServiceIds": allowedServices, - "enabled": enabled == 1, - "rpmLimit": nullIntValue(rpm), - "monthlyQuota": nullIntValue(monthly), - "createdAt": createdAt, - "updatedAt": updatedAt, - "lastUsedAt": nullString(lastUsed), - }) - } - writeJSON(w, http.StatusOK, map[string]any{"ok": true, "data": items}) - _ = ctx -} - -func (s *Server) handleKeysCreate(w http.ResponseWriter, r *http.Request, ctx *authContext) { - var input struct { - Name string `json:"name"` - Plan string `json:"plan"` - AllowedServiceIDs []string `json:"allowedServiceIds"` - RPMLimit *int `json:"rpmLimit"` - MonthlyQuota *int `json:"monthlyQuota"` - Enabled *bool `json:"enabled"` - } - if err := decodeJSON(r.Body, &input); err != nil { - writeError(w, http.StatusBadRequest, "INVALID_BODY", err.Error()) - return - } - name := strings.TrimSpace(input.Name) - if name == "" { - writeError(w, http.StatusBadRequest, "INVALID_NAME", "API key name is required") - return - } - plan := strings.ToLower(strings.TrimSpace(firstNonEmpty(input.Plan, "pro"))) - rpmLimit, monthlyQuota := s.planDefaults(plan) - if input.RPMLimit != nil { - rpmLimit = input.RPMLimit - } - if input.MonthlyQuota != nil { - monthlyQuota = input.MonthlyQuota - } - - rawKey, _ := auth.RandomToken(28) - rawKey = "apy_" + rawKey - keyHash := auth.HashToken(rawKey) - id, _ := auth.RandomID("key") - now := storage.NowISO() - - _, err := s.Store.DB.ExecContext(r.Context(), ` - INSERT INTO api_keys (id, name, key_hash, key_prefix, plan, allowed_service_ids, enabled, rpm_limit, monthly_quota, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - `, - id, - name, - keyHash, - rawKey[:12], - plan, - mustJSON(input.AllowedServiceIDs), - boolToInt(defaultBool(input.Enabled, true)), - nullInt(rpmLimit), - nullInt(monthlyQuota), - now, - now, - ) - if err != nil { - writeError(w, http.StatusBadRequest, "KEY_CREATE_FAILED", "Failed to create API key") - return - } - - writeJSON(w, http.StatusCreated, map[string]any{ - "ok": true, - "data": map[string]any{ - "key": rawKey, - "item": map[string]any{"id": id, "name": name, "plan": plan}, - }, - }) - _ = ctx -} - -func (s *Server) handleKeysPatch(w http.ResponseWriter, r *http.Request, ctx *authContext) { - id := r.PathValue("id") - if id == "" { - writeError(w, http.StatusBadRequest, "INVALID_ID", "API key ID is required") - return - } - var input struct { - Name *string `json:"name"` - Plan *string `json:"plan"` - AllowedServiceIDs []string `json:"allowedServiceIds"` - RPMLimit *int `json:"rpmLimit"` - MonthlyQuota *int `json:"monthlyQuota"` - Enabled *bool `json:"enabled"` - } - if err := decodeJSON(r.Body, &input); err != nil { - writeError(w, http.StatusBadRequest, "INVALID_BODY", err.Error()) - return - } - - current := struct { - Name string - Plan string - Allowed string - Enabled int - RPM sql.NullInt64 - Monthly sql.NullInt64 - }{} - err := s.Store.DB.QueryRowContext(r.Context(), ` - SELECT name, plan, allowed_service_ids, enabled, rpm_limit, monthly_quota - FROM api_keys WHERE id = ? - `, id).Scan(¤t.Name, ¤t.Plan, ¤t.Allowed, ¤t.Enabled, ¤t.RPM, ¤t.Monthly) - if err != nil { - writeError(w, http.StatusNotFound, "KEY_NOT_FOUND", "API key not found") - return - } - - plan := current.Plan - if input.Plan != nil { - plan = strings.ToLower(strings.TrimSpace(*input.Plan)) - } - - allowed := current.Allowed - if input.AllowedServiceIDs != nil { - allowed = mustJSON(input.AllowedServiceIDs) - } - - _, err = s.Store.DB.ExecContext(r.Context(), ` - UPDATE api_keys - SET name = ?, plan = ?, allowed_service_ids = ?, enabled = ?, rpm_limit = ?, monthly_quota = ?, updated_at = ? - WHERE id = ? - `, - firstNonEmptyPtr(input.Name, current.Name), - plan, - allowed, - boolToInt(defaultBool(input.Enabled, current.Enabled == 1)), - nullInt(coalesceIntPtr(input.RPMLimit, nullIntValue(current.RPM))), - nullInt(coalesceIntPtr(input.MonthlyQuota, nullIntValue(current.Monthly))), - storage.NowISO(), - id, - ) - if err != nil { - writeError(w, http.StatusBadRequest, "KEY_UPDATE_FAILED", "Failed to update API key") - return - } - - writeJSON(w, http.StatusOK, map[string]any{"ok": true}) - _ = ctx -} - -func (s *Server) handleAnalyticsOps(w http.ResponseWriter, r *http.Request, ctx *authContext) { - counts := map[string]int{} - for _, table := range []string{"users", "services", "database_connections", "api_keys"} { - var count int - _ = s.Store.DB.QueryRowContext(r.Context(), `SELECT COUNT(*) FROM `+table).Scan(&count) - counts[table] = count - } - - period := time.Now().UTC().Format("2006-01") - var monthlyRequests int - _ = s.Store.DB.QueryRowContext(r.Context(), `SELECT COALESCE(SUM(request_count), 0) FROM usage_counters WHERE period_month = ?`, period).Scan(&monthlyRequests) - - severityRows, _ := s.Store.DB.QueryContext(r.Context(), ` - SELECT severity, COUNT(*) FROM incident_events - WHERE occurred_at >= datetime('now', '-24 hours') - GROUP BY severity - `) - incidentsBySeverity := map[string]int{} - if severityRows != nil { - defer severityRows.Close() - for severityRows.Next() { - var severity string - var count int - if err := severityRows.Scan(&severity, &count); err == nil { - incidentsBySeverity[severity] = count - } - } - } - - healthRows, _ := s.Store.DB.QueryContext(r.Context(), ` - SELECT COALESCE(last_validation_status, 'unknown') AS status, COUNT(*) - FROM services - GROUP BY status - `) - healthCounts := map[string]int{} - if healthRows != nil { - defer healthRows.Close() - for healthRows.Next() { - var status string - var count int - if err := healthRows.Scan(&status, &count); err == nil { - healthCounts[status] = count - } - } - } - - tsRows, _ := s.Store.DB.QueryContext(r.Context(), ` - SELECT strftime('%Y-%m-%dT%H:00:00Z', occurred_at) AS bucket, COUNT(*) - FROM metrics_timeseries - WHERE metric = 'request_total' AND occurred_at >= datetime('now', '-24 hours') - GROUP BY bucket - ORDER BY bucket ASC - `) - hourly := []map[string]any{} - if tsRows != nil { - defer tsRows.Close() - for tsRows.Next() { - var bucket string - var count int - if err := tsRows.Scan(&bucket, &count); err == nil { - hourly = append(hourly, map[string]any{"bucket": bucket, "value": count}) - } - } - } - - writeJSON(w, http.StatusOK, map[string]any{ - "ok": true, - "data": map[string]any{ - "counts": map[string]any{ - "users": counts["users"], - "services": counts["services"], - "databases": counts["database_connections"], - "keys": counts["api_keys"], - }, - "requests": map[string]any{ - "period": period, - "total": monthlyRequests, - "hourly": hourly, - }, - "incidentsBySeverity": incidentsBySeverity, - "serviceHealth": healthCounts, - }, - }) - _ = ctx -} - -func (s *Server) handleAnalyticsTraffic(w http.ResponseWriter, r *http.Request, ctx *authContext) { - from := time.Now().Add(-7 * 24 * time.Hour) - to := time.Now() - if value := strings.TrimSpace(r.URL.Query().Get("from")); value != "" { - if parsed, err := time.Parse(time.RFC3339, value); err == nil { - from = parsed - } - } - if value := strings.TrimSpace(r.URL.Query().Get("to")); value != "" { - if parsed, err := time.Parse(time.RFC3339, value); err == nil { - to = parsed - } - } - - traffic, err := s.Umami.FetchTraffic(r.Context(), from, to) - if err == nil { - _, _ = s.Store.DB.ExecContext(r.Context(), ` - INSERT INTO umami_sync_cache (cache_key, payload_json, updated_at) - VALUES ('traffic', ?, ?) - ON CONFLICT(cache_key) DO UPDATE SET payload_json = excluded.payload_json, updated_at = excluded.updated_at - `, mustJSON(traffic), storage.NowISO()) - } else { - var payload string - _ = s.Store.DB.QueryRowContext(r.Context(), `SELECT payload_json FROM umami_sync_cache WHERE cache_key = 'traffic'`).Scan(&payload) - if payload != "" { - _ = json.Unmarshal([]byte(payload), &traffic) - } - if traffic == nil { - traffic = map[string]any{"enabled": false, "error": err.Error()} - } - } - - eventRows, _ := s.Store.DB.QueryContext(r.Context(), ` - SELECT COALESCE(json_extract(labels_json, '$.path'), 'unknown') AS path, COUNT(*) - FROM metrics_timeseries - WHERE metric = 'client_event' AND occurred_at >= datetime('now', '-7 days') - GROUP BY path - ORDER BY COUNT(*) DESC - LIMIT 20 - `) - events := []map[string]any{} - if eventRows != nil { - defer eventRows.Close() - for eventRows.Next() { - var page string - var count int - if err := eventRows.Scan(&page, &count); err == nil { - events = append(events, map[string]any{"path": page, "count": count}) - } - } - } - - writeJSON(w, http.StatusOK, map[string]any{ - "ok": true, - "data": map[string]any{ - "umami": traffic, - "clientEvents": events, - }, - }) - _ = ctx -} - -func (s *Server) handleAnalyticsEvents(w http.ResponseWriter, r *http.Request, ctx *authContext) { - var input struct { - Event string `json:"event"` - Path string `json:"path"` - Meta map[string]any `json:"meta"` - } - if err := decodeJSON(r.Body, &input); err != nil { - writeError(w, http.StatusBadRequest, "INVALID_BODY", err.Error()) - return - } - labels := map[string]any{ - "event": firstNonEmpty(input.Event, "event"), - "path": firstNonEmpty(input.Path, "unknown"), - "meta": input.Meta, - } - _ = s.logMetric("client_event", 1, labels) - writeJSON(w, http.StatusCreated, map[string]any{"ok": true}) - _ = ctx -} - -func (s *Server) handleGatewayOrUI(w http.ResponseWriter, r *http.Request) { - if strings.HasPrefix(r.URL.Path, "/api/") { - writeError(w, http.StatusNotFound, "NOT_FOUND", "Route not found") - return - } - - uiBasePath := normalizePathPrefix(s.Cfg.DashboardUIBasePath, "/") - uiRequestPath, uiPathMatched := webPathForRequest(r.URL.Path, uiBasePath) - if uiBasePath != "/" && uiPathMatched { - s.serveWeb(w, uiRequestPath) - return - } - - service, ok := s.matchService(r.Context(), r.URL.Path) - if ok { - s.handleProxy(w, r, service) - return - } - - if uiBasePath == "/" { - s.serveWeb(w, uiRequestPath) - return - } - - writeError(w, http.StatusNotFound, "NOT_FOUND", "Route not found") -} - -func (s *Server) serveWeb(w http.ResponseWriter, requestPath string) { - sub, err := fs.Sub(staticFS, "static") - if err != nil { - writeError(w, http.StatusNotFound, "FRONTEND_NOT_BUILT", "Frontend build is not available") - return - } - - reqPath := strings.TrimPrefix(path.Clean("/"+strings.TrimSpace(requestPath)), "/") - if reqPath == "." { - reqPath = "" - } - if reqPath == "" { - reqPath = "index.html" - } - if _, err := fs.Stat(sub, reqPath); err != nil { - reqPath = "index.html" - } - - data, err := fs.ReadFile(sub, reqPath) - if err != nil { - writeError(w, http.StatusNotFound, "FRONTEND_NOT_BUILT", "Frontend build is not available") - return - } - - if contentType := mime.TypeByExtension(path.Ext(reqPath)); contentType != "" { - w.Header().Set("Content-Type", contentType) - } - _, _ = w.Write(data) -} - -func (s *Server) handleProxy(w http.ResponseWriter, r *http.Request, service map[string]any) { - secret := strings.TrimSpace(r.Header.Get(s.Cfg.APIKeyHeader)) - if secret == "" { - writeError(w, http.StatusUnauthorized, "API_KEY_MISSING", fmt.Sprintf("Missing API key header '%s'", s.Cfg.APIKeyHeader)) - return - } - - apiKey, err := s.getAPIKeyBySecret(r.Context(), secret) - if err != nil { - writeError(w, http.StatusUnauthorized, "API_KEY_INVALID", "Invalid API key") - _ = s.logIncident(r.Context(), nullStringValue(service["id"]), sql.NullString{}, "API_KEY_INVALID", "Invalid API key", "medium", sql.NullInt64{Int64: 401, Valid: true}) - return - } - if !asBool(apiKey["enabled"]) { - writeError(w, http.StatusForbidden, "API_KEY_DISABLED", "API key is disabled") - return - } - if !asBool(service["enabled"]) { - writeError(w, http.StatusServiceUnavailable, "SERVICE_DISABLED", "Service is disabled") - return - } - - if !serviceAllowed(apiKey, asString(service["id"]), asString(service["slug"]), asString(service["routePrefix"])) { - writeError(w, http.StatusForbidden, "SERVICE_SCOPE_DENIED", "API key is not allowed for this service") - _ = s.logIncident(r.Context(), nullStringValue(service["id"]), nullStringValue(apiKey["id"]), "SERVICE_SCOPE_DENIED", "API key attempted unauthorized service access", "medium", sql.NullInt64{Int64: 403, Valid: true}) - return - } - - period := time.Now().UTC().Format("2006-01") - serviceLimitRPM := nullIntValueFromAny(service["rpmLimit"]) - keyLimitRPM := nullIntValueFromAny(apiKey["rpmLimit"]) - serviceLimitMonthly := nullIntValueFromAny(service["monthlyQuota"]) - keyLimitMonthly := nullIntValueFromAny(apiKey["monthlyQuota"]) - - effectiveRPM := minPositive(serviceLimitRPM, keyLimitRPM) - effectiveMonthly := minPositive(serviceLimitMonthly, keyLimitMonthly) - - if effectiveMonthly != nil { - used := s.usageCount(r.Context(), asString(apiKey["id"]), asString(service["id"]), period) - if used >= *effectiveMonthly { - writeError(w, http.StatusTooManyRequests, "MONTHLY_QUOTA_EXCEEDED", "Monthly quota exceeded") - _ = s.logIncident(r.Context(), nullStringValue(service["id"]), nullStringValue(apiKey["id"]), "MONTHLY_QUOTA_EXCEEDED", "Monthly quota exceeded", "high", sql.NullInt64{Int64: 429, Valid: true}) - return - } - } - - if effectiveRPM != nil { - if ok, retryAfter := s.allowRPM(asString(apiKey["id"])+":"+asString(service["id"]), *effectiveRPM); !ok { - w.Header().Set("Retry-After", strconv.Itoa(retryAfter)) - writeError(w, http.StatusTooManyRequests, "RATE_LIMIT_EXCEEDED", "Rate limit exceeded") - _ = s.logIncident(r.Context(), nullStringValue(service["id"]), nullStringValue(apiKey["id"]), "RATE_LIMIT_EXCEEDED", "Rate limit exceeded", "medium", sql.NullInt64{Int64: 429, Valid: true}) - return - } - } - - targetURL, err := gateway.BuildTargetURL(asString(service["upstreamUrl"]), asString(service["routePrefix"]), r.URL.Path, r.URL.RawQuery) - if err != nil { - writeError(w, http.StatusBadGateway, "UPSTREAM_INVALID", "Invalid upstream URL") - return - } - - headers := map[string]string{} - if upstreamHeader := asString(service["upstreamAuthHeader"]); upstreamHeader != "" { - headers[upstreamHeader] = asString(service["upstreamAuthValue"]) - } - if token := asString(service["internalToken"]); token != "" { - headers[s.Cfg.ServiceTokenHeader] = token - } - - r.Header.Del(s.Cfg.APIKeyHeader) - - timeout := time.Duration(intFromAny(service["requestTimeoutMs"], int(s.Cfg.DefaultServiceTimeout.Milliseconds()))) * time.Millisecond - client := &http.Client{Timeout: timeout} - - statusCode, _, proxyErr := gateway.ProxyRequest(client, w, r, targetURL, headers) - if proxyErr != nil { - status := http.StatusBadGateway - if errors.Is(proxyErr, context.DeadlineExceeded) || strings.Contains(strings.ToLower(proxyErr.Error()), "timeout") { - status = http.StatusGatewayTimeout - } - writeError(w, status, "UPSTREAM_ERROR", proxyErr.Error()) - _ = s.logIncident(r.Context(), nullStringValue(service["id"]), nullStringValue(apiKey["id"]), "UPSTREAM_ERROR", proxyErr.Error(), "high", sql.NullInt64{Int64: int64(status), Valid: true}) - return - } - - s.incrementUsage(r.Context(), asString(apiKey["id"]), asString(service["id"]), period) - _, _ = s.Store.DB.ExecContext(r.Context(), `UPDATE api_keys SET last_used_at = ?, updated_at = ? WHERE id = ?`, storage.NowISO(), storage.NowISO(), asString(apiKey["id"])) - _ = s.logMetric("request_total", 1, map[string]any{"service": asString(service["slug"]), "status": statusCode}) - - if statusCode >= 500 { - _ = s.logIncident(r.Context(), nullStringValue(service["id"]), nullStringValue(apiKey["id"]), "UPSTREAM_SERVER_ERROR", fmt.Sprintf("Upstream returned %d", statusCode), "high", sql.NullInt64{Int64: int64(statusCode), Valid: true}) - } -} - -func (s *Server) resolveAuth(r *http.Request) (*authContext, error) { - accessCookie, err := r.Cookie(s.Cfg.SessionAccessCookie) - if err != nil || strings.TrimSpace(accessCookie.Value) == "" { - return nil, errors.New("access cookie missing") - } - return s.resolveAuthFromTokenHash(r.Context(), auth.HashToken(accessCookie.Value)) -} - -func (s *Server) resolveAuthFromTokenHash(ctx context.Context, accessTokenHash string) (*authContext, error) { - var user struct { - ID string - Email string - Enabled int - ForcePasswordReset int - SessionID string - } - err := s.Store.DB.QueryRowContext(ctx, ` - SELECT u.id, u.email, u.enabled, u.force_password_reset, sess.id - FROM sessions sess - JOIN users u ON u.id = sess.user_id - WHERE sess.access_token_hash = ? - AND sess.revoked_at IS NULL - AND sess.access_expires_at > ? - `, accessTokenHash, storage.NowISO()).Scan(&user.ID, &user.Email, &user.Enabled, &user.ForcePasswordReset, &user.SessionID) - if err != nil { - return nil, err - } - - roles, permissions, err := s.userAccess(ctx, user.ID) - if err != nil { - return nil, err - } - - return &authContext{ - UserID: user.ID, - Email: user.Email, - Enabled: user.Enabled == 1, - ForcePasswordReset: user.ForcePasswordReset == 1, - Roles: roles, - Permissions: permissions, - SessionID: user.SessionID, - }, nil -} - -func (s *Server) userAccess(ctx context.Context, userID string) ([]string, map[string]bool, error) { - roles := []string{} - roleRows, err := s.Store.DB.QueryContext(ctx, ` - SELECT r.slug - FROM user_roles ur - JOIN roles r ON r.id = ur.role_id - WHERE ur.user_id = ? AND r.enabled = 1 - `, userID) - if err != nil { - return nil, nil, err - } - for roleRows.Next() { - var slug string - if err := roleRows.Scan(&slug); err == nil { - roles = append(roles, slug) - } - } - roleRows.Close() - - permissions := map[string]bool{} - permRows, err := s.Store.DB.QueryContext(ctx, ` - SELECT DISTINCT p.code - FROM user_roles ur - JOIN role_permissions rp ON rp.role_id = ur.role_id - JOIN permissions p ON p.id = rp.permission_id - JOIN roles r ON r.id = ur.role_id - WHERE ur.user_id = ? AND r.enabled = 1 - `, userID) - if err != nil { - return roles, permissions, err - } - for permRows.Next() { - var code string - if err := permRows.Scan(&code); err == nil { - permissions[code] = true - } - } - permRows.Close() - - return roles, permissions, nil -} - -func (s *Server) createSession(ctx context.Context, userID string) (string, string, string, error) { - sessionID, _ := auth.RandomID("ses") - accessToken, _ := auth.RandomToken(32) - refreshToken, _ := auth.RandomToken(48) - accessHash := auth.HashToken(accessToken) - refreshHash := auth.HashToken(refreshToken) - - now := storage.NowISO() - accessExp := time.Now().Add(s.Cfg.AccessTokenTTL).UTC().Format(time.RFC3339) - refreshExp := time.Now().Add(s.Cfg.RefreshTokenTTL).UTC().Format(time.RFC3339) - - _, err := s.Store.DB.ExecContext(ctx, ` - INSERT INTO sessions (id, user_id, access_token_hash, refresh_token_hash, access_expires_at, refresh_expires_at, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?) - `, sessionID, userID, accessHash, refreshHash, accessExp, refreshExp, now, now) - if err != nil { - return "", "", "", err - } - - return sessionID, accessToken, refreshToken, nil -} - -func (s *Server) setAuthCookies(w http.ResponseWriter, accessToken, refreshToken string) { - accessCookie := &http.Cookie{ - Name: s.Cfg.SessionAccessCookie, - Value: accessToken, - Path: "/", - HttpOnly: true, - Secure: s.Cfg.CookieSecure, - SameSite: http.SameSiteLaxMode, - Expires: time.Now().Add(s.Cfg.AccessTokenTTL), - } - if s.Cfg.CookieDomain != "" { - accessCookie.Domain = s.Cfg.CookieDomain - } - - refreshCookie := &http.Cookie{ - Name: s.Cfg.SessionRefreshCookie, - Value: refreshToken, - Path: "/", - HttpOnly: true, - Secure: s.Cfg.CookieSecure, - SameSite: http.SameSiteLaxMode, - Expires: time.Now().Add(s.Cfg.RefreshTokenTTL), - } - if s.Cfg.CookieDomain != "" { - refreshCookie.Domain = s.Cfg.CookieDomain - } - - http.SetCookie(w, accessCookie) - http.SetCookie(w, refreshCookie) -} - -func (s *Server) clearAuthCookies(w http.ResponseWriter) { - expired := time.Unix(0, 0) - for _, cookieName := range []string{s.Cfg.SessionAccessCookie, s.Cfg.SessionRefreshCookie} { - cookie := &http.Cookie{ - Name: cookieName, - Value: "", - Path: "/", - HttpOnly: true, - Secure: s.Cfg.CookieSecure, - SameSite: http.SameSiteLaxMode, - Expires: expired, - MaxAge: -1, - } - if s.Cfg.CookieDomain != "" { - cookie.Domain = s.Cfg.CookieDomain - } - http.SetCookie(w, cookie) - } -} - -func (s *Server) hasUsers(ctx context.Context) bool { - var count int - _ = s.Store.DB.QueryRowContext(ctx, `SELECT COUNT(*) FROM users`).Scan(&count) - return count > 0 -} - -func (s *Server) isLoginRateLimited(r *http.Request, email string) bool { - key := r.RemoteAddr + ":" + email - s.loginMu.Lock() - defer s.loginMu.Unlock() - entry := s.loginAttempts[key] - now := time.Now() - if now.Sub(entry.WindowStart) > 15*time.Minute { - entry = loginWindow{WindowStart: now, Count: 0} - } - return entry.Count >= 10 -} - -func (s *Server) markLoginAttempt(r *http.Request, email string) { - key := r.RemoteAddr + ":" + email - s.loginMu.Lock() - defer s.loginMu.Unlock() - entry := s.loginAttempts[key] - now := time.Now() - if now.Sub(entry.WindowStart) > 15*time.Minute { - entry = loginWindow{WindowStart: now, Count: 0} - } - entry.Count++ - s.loginAttempts[key] = entry -} - -func (s *Server) clearLoginAttempts(r *http.Request, email string) { - key := r.RemoteAddr + ":" + email - s.loginMu.Lock() - defer s.loginMu.Unlock() - delete(s.loginAttempts, key) -} - -func (s *Server) resolveRoleIDs(ctx context.Context, roleIDs []string, roleSlugs []string) ([]string, error) { - resolved := []string{} - if len(roleIDs) > 0 { - for _, roleID := range roleIDs { - id := strings.TrimSpace(roleID) - if id == "" { - continue - } - var exists int - if err := s.Store.DB.QueryRowContext(ctx, `SELECT COUNT(*) FROM roles WHERE id = ?`, id).Scan(&exists); err != nil || exists == 0 { - return nil, fmt.Errorf("role %s not found", id) - } - resolved = append(resolved, id) - } - return uniqueStrings(resolved), nil - } - for _, slug := range roleSlugs { - id, err := s.roleIDBySlug(ctx, slug) - if err != nil { - return nil, err - } - resolved = append(resolved, id) - } - return uniqueStrings(resolved), nil -} - -func (s *Server) roleIDBySlug(ctx context.Context, slug string) (string, error) { - id := "" - err := s.Store.DB.QueryRowContext(ctx, `SELECT id FROM roles WHERE slug = ?`, strings.ToLower(strings.TrimSpace(slug))).Scan(&id) - if err != nil { - return "", fmt.Errorf("role %s not found", slug) - } - return id, nil -} - -func (s *Server) replaceRolePermissionsTx(ctx context.Context, tx *sql.Tx, roleID string, permissionCodes []string) error { - if _, err := tx.ExecContext(ctx, `DELETE FROM role_permissions WHERE role_id = ?`, roleID); err != nil { - return err - } - now := storage.NowISO() - for _, code := range permissionCodes { - permCode := strings.TrimSpace(code) - if permCode == "" { - continue - } - permID := "" - if err := tx.QueryRowContext(ctx, `SELECT id FROM permissions WHERE code = ?`, permCode).Scan(&permID); err != nil { - return fmt.Errorf("permission not found: %s", permCode) - } - if _, err := tx.ExecContext(ctx, `INSERT INTO role_permissions (role_id, permission_id, created_at) VALUES (?, ?, ?)`, roleID, permID, now); err != nil { - return err - } - } - return nil -} - -func (s *Server) getServiceByID(ctx context.Context, id string) (map[string]any, error) { - row := s.Store.DB.QueryRowContext(ctx, ` - SELECT id, name, slug, upstream_url, route_prefix, health_path, upstream_auth_header, upstream_auth_value, internal_token, enabled, rpm_limit, monthly_quota, request_timeout_ms, last_validation_at, last_validation_status, last_validation_message, created_at, updated_at - FROM services WHERE id = ? - `, id) - return scanServiceRow(row) -} - -func (s *Server) getDatabaseByID(ctx context.Context, id string) (map[string]any, error) { - var item struct { - ID, Name, Slug, Provider, ConnectionURL, CreatedAt, UpdatedAt string - Enabled int - LastAt, LastStatus, LastMessage sql.NullString - } - err := s.Store.DB.QueryRowContext(ctx, ` - SELECT id, name, slug, provider, connection_url, enabled, last_validation_at, last_validation_status, last_validation_message, created_at, updated_at - FROM database_connections - WHERE id = ? - `, id).Scan(&item.ID, &item.Name, &item.Slug, &item.Provider, &item.ConnectionURL, &item.Enabled, &item.LastAt, &item.LastStatus, &item.LastMessage, &item.CreatedAt, &item.UpdatedAt) - if err != nil { - return nil, err - } - return map[string]any{ - "id": item.ID, - "name": item.Name, - "slug": item.Slug, - "provider": item.Provider, - "connectionUrl": item.ConnectionURL, - "target": extractDatabaseTarget(item.Provider, item.ConnectionURL), - "maskedConnectionUrl": maskDatabaseURL(item.Provider, item.ConnectionURL), - "enabled": item.Enabled == 1, - "lastValidationAt": nullString(item.LastAt), - "lastValidationStatus": nullString(item.LastStatus), - "lastValidationMessage": nullString(item.LastMessage), - "createdAt": item.CreatedAt, - "updatedAt": item.UpdatedAt, - }, nil -} - -func (s *Server) validateDatabaseConnection(ctx context.Context, provider, connectionURL string) (bool, int, string) { - provider = normalizeProvider(provider) - if provider == "" { - return false, http.StatusBadRequest, "Unsupported database provider" - } - - timeoutCtx, cancel := context.WithTimeout(ctx, s.Cfg.DefaultServiceTimeout) - defer cancel() - - sqlDriver := "" - driverConn := connectionURL - switch provider { - case "sqlite": - sqlDriver = "sqlite" - if !strings.HasPrefix(driverConn, "file:") && driverConn != ":memory:" { - if abs, err := pathAbs(driverConn); err == nil { - driverConn = "file:" + abs - } - } - case "postgres": - sqlDriver = "pgx" - case "mysql": - sqlDriver = "mysql" - } - - db, err := sql.Open(sqlDriver, driverConn) - if err != nil { - return false, http.StatusBadGateway, err.Error() - } - defer db.Close() - - db.SetConnMaxLifetime(2 * time.Minute) - db.SetMaxOpenConns(1) - db.SetMaxIdleConns(1) - - if err := db.PingContext(timeoutCtx); err != nil { - return false, http.StatusBadGateway, err.Error() - } - return true, http.StatusOK, fmt.Sprintf("%s connection validated", strings.Title(provider)) -} - -func (s *Server) getAPIKeyBySecret(ctx context.Context, secret string) (map[string]any, error) { - hash := auth.HashToken(secret) - var item struct { - ID, Name, KeyPrefix, Plan, AllowedServiceIDs, CreatedAt, UpdatedAt string - Enabled int - RPMLimit, MonthlyQuota sql.NullInt64 - } - err := s.Store.DB.QueryRowContext(ctx, ` - SELECT id, name, key_prefix, plan, allowed_service_ids, enabled, rpm_limit, monthly_quota, created_at, updated_at - FROM api_keys - WHERE key_hash = ? - `, hash).Scan(&item.ID, &item.Name, &item.KeyPrefix, &item.Plan, &item.AllowedServiceIDs, &item.Enabled, &item.RPMLimit, &item.MonthlyQuota, &item.CreatedAt, &item.UpdatedAt) - if err != nil { - return nil, err - } - allowed := []string{} - _ = json.Unmarshal([]byte(item.AllowedServiceIDs), &allowed) - return map[string]any{ - "id": item.ID, - "name": item.Name, - "keyPrefix": item.KeyPrefix, - "plan": item.Plan, - "allowedServiceIds": allowed, - "enabled": item.Enabled == 1, - "rpmLimit": nullIntValue(item.RPMLimit), - "monthlyQuota": nullIntValue(item.MonthlyQuota), - }, nil -} - -func (s *Server) planDefaults(plan string) (*int, *int) { - p := strings.ToLower(strings.TrimSpace(plan)) - switch p { - case "free": - return intPtr(s.Cfg.FreeRPM), intPtr(s.Cfg.FreeMonthlyQuota) - case "business": - return intPtr(s.Cfg.BusinessRPM), intPtr(s.Cfg.BusinessMonthlyQuota) - case "enterprise": - return nil, nil - default: - return intPtr(s.Cfg.ProRPM), intPtr(s.Cfg.ProMonthlyQuota) - } -} - -func (s *Server) matchService(ctx context.Context, requestPath string) (map[string]any, bool) { - rows, err := s.Store.DB.QueryContext(ctx, ` - SELECT id, name, slug, upstream_url, route_prefix, health_path, upstream_auth_header, upstream_auth_value, internal_token, enabled, rpm_limit, monthly_quota, request_timeout_ms, last_validation_at, last_validation_status, last_validation_message, created_at, updated_at - FROM services - WHERE enabled = 1 - ORDER BY LENGTH(route_prefix) DESC, route_prefix DESC - `) - if err != nil { - return nil, false - } - defer rows.Close() - - for rows.Next() { - item, err := scanService(rows) - if err != nil { - continue - } - prefix := asString(item["routePrefix"]) - if prefix == "/" || requestPath == prefix || strings.HasPrefix(requestPath, prefix+"/") { - return item, true - } - } - return nil, false -} - -func (s *Server) usageCount(ctx context.Context, apiKeyID, serviceID, period string) int { - var count int - _ = s.Store.DB.QueryRowContext(ctx, ` - SELECT COALESCE(request_count, 0) - FROM usage_counters - WHERE api_key_id = ? AND service_id = ? AND period_month = ? - `, apiKeyID, serviceID, period).Scan(&count) - return count -} - -func (s *Server) incrementUsage(ctx context.Context, apiKeyID, serviceID, period string) { - _, _ = s.Store.DB.ExecContext(ctx, ` - INSERT INTO usage_counters (api_key_id, service_id, period_month, request_count, updated_at) - VALUES (?, ?, ?, 1, ?) - ON CONFLICT(api_key_id, service_id, period_month) - DO UPDATE SET request_count = request_count + 1, updated_at = excluded.updated_at - `, apiKeyID, serviceID, period, storage.NowISO()) -} - -func (s *Server) allowRPM(bucket string, limit int) (bool, int) { - if limit <= 0 { - return true, 0 - } - now := time.Now().UTC() - windowStart := now.Truncate(time.Minute) - key := bucket + ":" + windowStart.Format(time.RFC3339) - - s.rpmMu.Lock() - defer s.rpmMu.Unlock() - - for k, v := range s.rpmUsage { - if now.Sub(v.WindowStart) > 2*time.Minute { - delete(s.rpmUsage, k) - } - } - - entry := s.rpmUsage[key] - if entry.WindowStart.IsZero() { - entry.WindowStart = windowStart - } - if entry.Count >= limit { - retry := int(windowStart.Add(time.Minute).Sub(now).Seconds()) - if retry < 1 { - retry = 1 - } - return false, retry - } - entry.Count++ - s.rpmUsage[key] = entry - return true, 0 -} - -func (s *Server) logIncident(ctx context.Context, serviceID, apiKeyID sql.NullString, code, message, severity string, httpStatus sql.NullInt64) error { - _, err := s.Store.DB.ExecContext(ctx, ` - INSERT INTO incident_events (service_id, api_key_id, code, message, severity, http_status, count, occurred_at) - VALUES (?, ?, ?, ?, ?, ?, 1, ?) - `, serviceID, apiKeyID, code, message, severity, httpStatus, storage.NowISO()) - return err -} - -func (s *Server) logMetric(metric string, value float64, labels map[string]any) error { - _, err := s.Store.DB.Exec(` - INSERT INTO metrics_timeseries (metric, value, labels_json, occurred_at) - VALUES (?, ?, ?, ?) - `, metric, value, mustJSON(labels), storage.NowISO()) - return err -} - -func (s *Server) audit(ctx context.Context, actorUserID, action, targetType, targetID string, payload any) { - _, _ = s.Store.DB.ExecContext(ctx, ` - INSERT INTO audit_log (actor_user_id, action, target_type, target_id, payload_json, occurred_at) - VALUES (?, ?, ?, ?, ?, ?) - `, nullIfEmpty(actorUserID), action, nullIfEmpty(targetType), nullIfEmpty(targetID), mustJSON(payload), storage.NowISO()) -} - -func scanService(rows *sql.Rows) (map[string]any, error) { - var id, name, slug, upstreamURL, routePrefix, healthPath, createdAt, updatedAt string - var upstreamAuthHeader, upstreamAuthValue, internalToken, lastValidationAt, lastValidationStatus, lastValidationMessage sql.NullString - var enabled int - var rpmLimit, monthlyQuota, requestTimeoutMS sql.NullInt64 - if err := rows.Scan( - &id, &name, &slug, &upstreamURL, &routePrefix, &healthPath, &upstreamAuthHeader, &upstreamAuthValue, &internalToken, - &enabled, &rpmLimit, &monthlyQuota, &requestTimeoutMS, - &lastValidationAt, &lastValidationStatus, &lastValidationMessage, - &createdAt, &updatedAt, - ); err != nil { - return nil, err - } - return map[string]any{ - "id": id, - "name": name, - "slug": slug, - "upstreamUrl": upstreamURL, - "routePrefix": routePrefix, - "healthPath": healthPath, - "upstreamAuthHeader": nullString(upstreamAuthHeader), - "upstreamAuthValue": nullString(upstreamAuthValue), - "internalToken": nullString(internalToken), - "enabled": enabled == 1, - "rpmLimit": nullIntValue(rpmLimit), - "monthlyQuota": nullIntValue(monthlyQuota), - "requestTimeoutMs": nullIntValue(requestTimeoutMS), - "lastValidationAt": nullString(upstreamOr(lastValidationAt, sql.NullString{})), - "lastValidationStatus": nullString(lastValidationStatus), - "lastValidationMessage": nullString(lastValidationMessage), - "createdAt": createdAt, - "updatedAt": updatedAt, - }, nil -} - -func scanServiceRow(row *sql.Row) (map[string]any, error) { - var id, name, slug, upstreamURL, routePrefix, healthPath, createdAt, updatedAt string - var upstreamAuthHeader, upstreamAuthValue, internalToken, lastValidationAt, lastValidationStatus, lastValidationMessage sql.NullString - var enabled int - var rpmLimit, monthlyQuota, requestTimeoutMS sql.NullInt64 - if err := row.Scan( - &id, &name, &slug, &upstreamURL, &routePrefix, &healthPath, &upstreamAuthHeader, &upstreamAuthValue, &internalToken, - &enabled, &rpmLimit, &monthlyQuota, &requestTimeoutMS, - &lastValidationAt, &lastValidationStatus, &lastValidationMessage, - &createdAt, &updatedAt, - ); err != nil { - return nil, err - } - return map[string]any{ - "id": id, - "name": name, - "slug": slug, - "upstreamUrl": upstreamURL, - "routePrefix": routePrefix, - "healthPath": healthPath, - "upstreamAuthHeader": nullString(upstreamAuthHeader), - "upstreamAuthValue": nullString(upstreamAuthValue), - "internalToken": nullString(internalToken), - "enabled": enabled == 1, - "rpmLimit": nullIntValue(rpmLimit), - "monthlyQuota": nullIntValue(monthlyQuota), - "requestTimeoutMs": nullIntValue(requestTimeoutMS), - "lastValidationAt": nullString(lastValidationAt), - "lastValidationStatus": nullString(lastValidationStatus), - "lastValidationMessage": nullString(lastValidationMessage), - "createdAt": createdAt, - "updatedAt": updatedAt, - }, nil -} - -func serviceAllowed(apiKey map[string]any, serviceID, slug, prefix string) bool { - allowed, ok := apiKey["allowedServiceIds"].([]string) - if !ok || len(allowed) == 0 { - return true - } - for _, candidate := range allowed { - if candidate == serviceID || candidate == slug || candidate == prefix { - return true - } - } - return false -} - -func writeJSON(w http.ResponseWriter, status int, payload map[string]any) { - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(status) - _ = json.NewEncoder(w).Encode(payload) -} - -func writeError(w http.ResponseWriter, status int, code, message string) { - writeJSON(w, status, map[string]any{ - "ok": false, - "error": map[string]any{ - "code": code, - "message": message, - }, - }) -} - -func decodeJSON(body io.Reader, target any) error { - decoder := json.NewDecoder(io.LimitReader(body, 1<<20)) - decoder.DisallowUnknownFields() - if err := decoder.Decode(target); err != nil { - return err - } - return nil -} - -func mustJSON(value any) string { - bytes, _ := json.Marshal(value) - return string(bytes) -} - -func boolToInt(value bool) int { - if value { - return 1 - } - return 0 -} - -func intPtr(value int) *int { - v := value - return &v -} - -func nullInt(value *int) any { - if value == nil || *value <= 0 { - return nil - } - return *value -} - -func nullIntValue(value sql.NullInt64) *int { - if !value.Valid { - return nil - } - v := int(value.Int64) - return &v -} - -func nullIntValueFromAny(value any) *int { - if value == nil { - return nil - } - switch typed := value.(type) { - case *int: - return typed - case int: - v := typed - return &v - case int64: - v := int(typed) - return &v - case float64: - v := int(typed) - return &v - default: - return nil - } -} - -func nullString(value sql.NullString) any { - if !value.Valid { - return nil - } - return value.String -} - -func nullStringValue(value any) sql.NullString { - if value == nil { - return sql.NullString{} - } - text := fmt.Sprintf("%v", value) - if strings.TrimSpace(text) == "" { - return sql.NullString{} - } - return sql.NullString{String: text, Valid: true} -} - -func nullIfEmpty(value string) any { - v := strings.TrimSpace(value) - if v == "" { - return nil - } - return v -} - -func coalesceIntPtr(value *int, fallback *int) *int { - if value != nil { - return value - } - return fallback -} - -func normalizePathPrefix(value, fallback string) string { - v := strings.TrimSpace(value) - if v == "" { - v = fallback - } - if !strings.HasPrefix(v, "/") { - v = "/" + v - } - if len(v) > 1 && strings.HasSuffix(v, "/") { - v = strings.TrimSuffix(v, "/") - } - return v -} - -func slugify(value, fallback string) string { - v := strings.ToLower(strings.TrimSpace(value)) - if v == "" { - v = fallback - } - out := strings.Builder{} - lastDash := false - for _, r := range v { - if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') { - out.WriteRune(r) - lastDash = false - continue - } - if !lastDash { - out.WriteRune('-') - lastDash = true - } - } - result := strings.Trim(out.String(), "-") - if result == "" { - return fallback - } - return result -} - -func firstNonEmpty(values ...string) string { - for _, value := range values { - if strings.TrimSpace(value) != "" { - return strings.TrimSpace(value) - } - } - return "" -} - -func firstNonEmptyPtr(value *string, fallback string) string { - if value != nil { - if strings.TrimSpace(*value) == "" { - return fallback - } - return strings.TrimSpace(*value) - } - return fallback -} - -func defaultBool(value *bool, fallback bool) bool { - if value == nil { - return fallback - } - return *value -} - -func asString(value any) string { - if value == nil { - return "" - } - if v, ok := value.(string); ok { - return v - } - return fmt.Sprintf("%v", value) -} - -func asBool(value any) bool { - if value == nil { - return false - } - switch v := value.(type) { - case bool: - return v - case int: - return v == 1 - case int64: - return v == 1 - case float64: - return int(v) == 1 - default: - return false - } -} - -func intFromAny(value any, fallback int) int { - if value == nil { - return fallback - } - switch v := value.(type) { - case int: - return v - case int64: - return int(v) - case float64: - return int(v) - case *int: - if v == nil { - return fallback - } - return *v - default: - return fallback - } -} - -func minPositive(a, b *int) *int { - if a == nil && b == nil { - return nil - } - if a == nil { - return b - } - if b == nil { - return a - } - if *a < *b { - return a - } - return b -} - -func keysOfMap(values map[string]bool) []string { - keys := make([]string, 0, len(values)) - for key, enabled := range values { - if enabled { - keys = append(keys, key) - } - } - sort.Strings(keys) - return keys -} - -func uniqueStrings(values []string) []string { - seen := map[string]bool{} - output := []string{} - for _, value := range values { - v := strings.TrimSpace(value) - if v == "" || seen[v] { - continue - } - seen[v] = true - output = append(output, v) - } - return output -} - -func normalizeProvider(value string) string { - v := strings.ToLower(strings.TrimSpace(value)) - switch v { - case "sqlite", "sqlite3", "file": - return "sqlite" - case "postgres", "postgresql", "pg": - return "postgres" - case "mysql", "mariadb": - return "mysql" - default: - return "" - } -} - -func maskDatabaseURL(provider, raw string) string { - if normalizeProvider(provider) == "sqlite" { - return raw - } - u, err := netUrlParse(raw) - if err != nil { - if len(raw) <= 8 { - return "***" - } - return raw[:4] + "***" + raw[len(raw)-2:] - } - if u.User != nil { - username := u.User.Username() - password, hasPassword := u.User.Password() - if username != "" { - if len(username) > 2 { - username = username[:2] + "***" - } else { - username = "***" - } - } - if hasPassword && password != "" { - u.User = url.UserPassword(username, "***") - } else { - u.User = url.User(username) - } - } - q := u.Query() - for _, key := range []string{"password", "pass", "pwd", "token", "secret"} { - if q.Has(key) { - q.Set(key, "***") - } - } - u.RawQuery = q.Encode() - return u.String() -} - -func extractDatabaseTarget(provider, raw string) string { - if normalizeProvider(provider) == "sqlite" { - return raw - } - u, err := netUrlParse(raw) - if err != nil { - return "-" - } - name := strings.TrimPrefix(u.Path, "/") - if name == "" { - name = "-" - } - return name -} - -func pathAbs(value string) (string, error) { - if strings.HasPrefix(value, "file:") { - return strings.TrimPrefix(value, "file:"), nil - } - if filepath.IsAbs(value) { - return value, nil - } - wd, err := os.Getwd() - if err != nil { - return "", err - } - return filepath.Join(wd, value), nil -} - -func netUrlParse(value string) (*url.URL, error) { - return url.Parse(value) -} - -func webPathForRequest(requestPath string, uiBasePath string) (string, bool) { - reqPath := path.Clean("/" + strings.TrimSpace(requestPath)) - if reqPath == "." { - reqPath = "/" - } - - base := normalizePathPrefix(uiBasePath, "/") - if base == "/" { - return strings.TrimPrefix(reqPath, "/"), true - } - - if reqPath == base { - return "", true - } - - prefix := base + "/" - if strings.HasPrefix(reqPath, prefix) { - return strings.TrimPrefix(reqPath, prefix), true - } - - return "", false -} - -func upstreamOr(a, b sql.NullString) sql.NullString { - if a.Valid { - return a - } - return b -} - -type serviceInput struct { - Name string `json:"name"` - Slug string `json:"slug"` - UpstreamURL string `json:"upstreamUrl"` - RoutePrefix string `json:"routePrefix"` - HealthPath string `json:"healthPath"` - UpstreamAuthHeader string `json:"upstreamAuthHeader"` - UpstreamAuthValue string `json:"upstreamAuthValue"` - InternalToken string `json:"internalToken"` - Enabled *bool `json:"enabled"` - RPMLimit *int `json:"rpmLimit"` - MonthlyQuota *int `json:"monthlyQuota"` - RequestTimeoutMS *int `json:"requestTimeoutMs"` -} - -func (s serviceInput) RPMLimitOr(value any) *int { - if s.RPMLimit != nil { - return s.RPMLimit - } - return nullIntValueFromAny(value) -} - -func (s serviceInput) MonthlyQuotaOr(value any) *int { - if s.MonthlyQuota != nil { - return s.MonthlyQuota - } - return nullIntValueFromAny(value) -} - -func (s serviceInput) TimeoutOr(value any) *int { - if s.RequestTimeoutMS != nil { - return s.RequestTimeoutMS - } - return nullIntValueFromAny(value) -} diff --git a/app/backend/internal/api/static/assets/ibm-plex-mono-cyrillic-400-normal-BSMlKf0J.woff2 b/app/backend/internal/api/static/assets/ibm-plex-mono-cyrillic-400-normal-BSMlKf0J.woff2 deleted file mode 100644 index 20e72b01579543fe0cb4aed955a146b6b949709f..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 8356 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 diff --git a/app/backend/internal/api/static/assets/ibm-plex-mono-cyrillic-ext-400-normal-DMdlQ8Kv.woff b/app/backend/internal/api/static/assets/ibm-plex-mono-cyrillic-ext-400-normal-DMdlQ8Kv.woff deleted file mode 100644 index eff6edd7ebf1249b57421ff033388b1b12a882fa..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 5820 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 diff --git a/app/backend/internal/api/static/assets/ibm-plex-mono-cyrillic-ext-400-normal-xuaO2J-f.woff2 b/app/backend/internal/api/static/assets/ibm-plex-mono-cyrillic-ext-400-normal-xuaO2J-f.woff2 deleted file mode 100644 index b54e556623a2593ff7e029b3e1bbbc3f305e5303..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 6912 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 G0002bawYu$ diff --git a/app/backend/internal/api/static/assets/ibm-plex-mono-latin-400-normal-CvHOgSBP.woff b/app/backend/internal/api/static/assets/ibm-plex-mono-latin-400-normal-CvHOgSBP.woff deleted file mode 100644 index 8c83fdc0351f0d6451158a64bd5b638d951a8983..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 13144 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 BN96zj diff --git a/app/backend/internal/api/static/assets/ibm-plex-mono-latin-400-normal-DMJ8VG8y.woff2 b/app/backend/internal/api/static/assets/ibm-plex-mono-latin-400-normal-DMJ8VG8y.woff2 deleted file mode 100644 index 0804aaff92260b5359330f63e05b9ac25ccabeee..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 14708 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 diff --git a/app/backend/internal/api/static/assets/ibm-plex-mono-latin-ext-400-normal-D3D2R8hC.woff b/app/backend/internal/api/static/assets/ibm-plex-mono-latin-ext-400-normal-D3D2R8hC.woff deleted file mode 100644 index 338fc76a84ce2f71519547ec38f882c15c5cf48b..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 11768 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 X>`C_q>a1IlwqC#+!XQ6?r=9-;0%~Ut diff --git a/app/backend/internal/api/static/assets/ibm-plex-mono-vietnamese-400-normal-BulugwFq.woff2 b/app/backend/internal/api/static/assets/ibm-plex-mono-vietnamese-400-normal-BulugwFq.woff2 deleted file mode 100644 index 21612f479ed33f94abf24d978f4e7c1613155b34..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 5868 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 C5-ZFA diff --git a/app/backend/internal/api/static/assets/ibm-plex-mono-vietnamese-400-normal-DDuiU_S-.woff b/app/backend/internal/api/static/assets/ibm-plex-mono-vietnamese-400-normal-DDuiU_S-.woff deleted file mode 100644 index dfa68b0a6ec90690c57fe74baf850fe248e5ee36..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 5620 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:not([hidden])~:not([hidden]){--tw-space-x-reverse: 0;margin-right:calc(.5rem * var(--tw-space-x-reverse));margin-left:calc(.5rem * calc(1 - var(--tw-space-x-reverse)))}.space-y-1>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(.25rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.25rem * var(--tw-space-y-reverse))}.space-y-2>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(.5rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.5rem * var(--tw-space-y-reverse))}.space-y-3>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(.75rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.75rem * var(--tw-space-y-reverse))}.space-y-4>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(1rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(1rem * var(--tw-space-y-reverse))}.divide-y>:not([hidden])~:not([hidden]){--tw-divide-y-reverse: 0;border-top-width:calc(1px * calc(1 - var(--tw-divide-y-reverse)));border-bottom-width:calc(1px * var(--tw-divide-y-reverse))}.divide-border>:not([hidden])~:not([hidden]){border-color:hsl(var(--border))}.overflow-auto{overflow:auto}.break-all{word-break:break-all}.rounded{border-radius:.25rem}.rounded-full{border-radius:9999px}.rounded-md{border-radius:.6rem}.rounded-xl{border-radius:.75rem}.border{border-width:1px}.border-t{border-top-width:1px}.border-amber-500\/45{border-color:#f59e0b73}.border-amber-500\/50{border-color:#f59e0b80}.border-border{border-color:hsl(var(--border))}.border-destructive{border-color:hsl(var(--destructive))}.border-emerald-500\/50{border-color:#10b98180}.border-input{border-color:hsl(var(--input))}.border-primary{border-color:hsl(var(--primary))}.border-primary\/40{border-color:hsl(var(--primary) / .4)}.border-primary\/50{border-color:hsl(var(--primary) / .5)}.border-rose-500\/50{border-color:#f43f5e80}.bg-amber-500\/10{background-color:#f59e0b1a}.bg-amber-500\/15{background-color:#f59e0b26}.bg-background{background-color:hsl(var(--background))}.bg-card{background-color:hsl(var(--card))}.bg-card\/90{background-color:hsl(var(--card) / .9)}.bg-destructive{background-color:hsl(var(--destructive))}.bg-emerald-500\/15{background-color:#10b98126}.bg-primary{background-color:hsl(var(--primary))}.bg-primary\/10{background-color:hsl(var(--primary) / .1)}.bg-primary\/15{background-color:hsl(var(--primary) / .15)}.bg-primary\/20{background-color:hsl(var(--primary) / .2)}.bg-rose-500\/10{background-color:#f43f5e1a}.bg-rose-500\/15{background-color:#f43f5e26}.bg-secondary{background-color:hsl(var(--secondary))}.bg-secondary\/35{background-color:hsl(var(--secondary) / .35)}.bg-secondary\/40{background-color:hsl(var(--secondary) / .4)}.bg-secondary\/60{background-color:hsl(var(--secondary) / .6)}.bg-secondary\/70{background-color:hsl(var(--secondary) / .7)}.bg-transparent{background-color:transparent}.p-10{padding:2.5rem}.p-2{padding:.5rem}.p-3{padding:.75rem}.p-4{padding:1rem}.px-2{padding-left:.5rem;padding-right:.5rem}.px-3{padding-left:.75rem;padding-right:.75rem}.py-0\.5{padding-top:.125rem;padding-bottom:.125rem}.py-1{padding-top:.25rem;padding-bottom:.25rem}.py-1\.5{padding-top:.375rem;padding-bottom:.375rem}.py-2{padding-top:.5rem;padding-bottom:.5rem}.text-left{text-align:left}.text-center{text-align:center}.text-right{text-align:right}.align-top{vertical-align:top}.font-mono{font-family:IBM Plex Mono,ui-monospace,SFMono-Regular,monospace}.text-2xl{font-size:1.5rem;line-height:2rem}.text-lg{font-size:1.125rem;line-height:1.75rem}.text-sm{font-size:.875rem;line-height:1.25rem}.text-xl{font-size:1.25rem;line-height:1.75rem}.text-xs{font-size:.75rem;line-height:1rem}.font-bold{font-weight:700}.font-semibold{font-weight:600}.uppercase{text-transform:uppercase}.tracking-\[0\.18em\]{letter-spacing:.18em}.tracking-\[0\.2em\]{letter-spacing:.2em}.tracking-wide{letter-spacing:.025em}.text-amber-100{--tw-text-opacity: 1;color:rgb(254 243 199 / var(--tw-text-opacity, 1))}.text-amber-200{--tw-text-opacity: 1;color:rgb(253 230 138 / var(--tw-text-opacity, 1))}.text-amber-200\/90{color:#fde68ae6}.text-destructive-foreground{color:hsl(var(--destructive-foreground))}.text-emerald-200{--tw-text-opacity: 1;color:rgb(167 243 208 / var(--tw-text-opacity, 1))}.text-foreground{color:hsl(var(--foreground))}.text-muted-foreground{color:hsl(var(--muted-foreground))}.text-primary{color:hsl(var(--primary))}.text-primary-foreground{color:hsl(var(--primary-foreground))}.text-rose-200{--tw-text-opacity: 1;color:rgb(254 205 211 / var(--tw-text-opacity, 1))}.text-secondary-foreground{color:hsl(var(--secondary-foreground))}.shadow-\[0_8px_24px_rgba\(0\,0\,0\,0\.24\)\]{--tw-shadow: 0 8px 24px rgba(0,0,0,.24);--tw-shadow-colored: 0 8px 24px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow, 0 0 #0000),var(--tw-ring-shadow, 0 0 #0000),var(--tw-shadow)}.outline-none{outline:2px solid transparent;outline-offset:2px}.outline{outline-style:solid}.ring-0{--tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow: var(--tw-ring-inset) 0 0 0 calc(0px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow, 0 0 #0000)}.filter{filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.transition{transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}:root{--background: 147 32% 9%;--foreground: 152 28% 94%;--card: 148 30% 12%;--card-foreground: 152 28% 93%;--primary: 142 73% 45%;--primary-foreground: 144 35% 8%;--secondary: 145 32% 18%;--secondary-foreground: 145 28% 92%;--muted: 146 26% 17%;--muted-foreground: 148 18% 74%;--accent: 136 66% 40%;--accent-foreground: 150 38% 95%;--destructive: 1 74% 56%;--destructive-foreground: 0 0% 98%;--border: 146 24% 24%;--input: 147 25% 20%;--ring: 142 73% 45%}*{border-color:hsl(var(--border))}body{background-color:hsl(var(--background));color:hsl(var(--foreground));-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale;font-family:Manrope,ui-sans-serif,system-ui,sans-serif}code,pre{font-family:IBM Plex Mono,ui-monospace,SFMono-Regular,monospace}.placeholder\:text-muted-foreground::-moz-placeholder{color:hsl(var(--muted-foreground))}.placeholder\:text-muted-foreground::placeholder{color:hsl(var(--muted-foreground))}.hover\:bg-secondary:hover{background-color:hsl(var(--secondary))}.hover\:opacity-95:hover{opacity:.95}.focus\:border-primary:focus{border-color:hsl(var(--primary))}@media(min-width:768px){.md\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.md\:flex-row{flex-direction:row}.md\:items-center{align-items:center}.md\:justify-between{justify-content:space-between}.md\:p-6{padding:1.5rem}}@media(min-width:1024px){.lg\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.lg\:p-8{padding:2rem}}@media(min-width:1280px){.xl\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.xl\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}.xl\:grid-cols-\[360px_1fr\]{grid-template-columns:360px 1fr}.xl\:grid-cols-\[420px_1fr\]{grid-template-columns:420px 1fr}} diff --git a/app/backend/internal/api/static/assets/index-DwfYiTMH.js b/app/backend/internal/api/static/assets/index-DwfYiTMH.js deleted file mode 100644 index 8bab5af..0000000 --- a/app/backend/internal/api/static/assets/index-DwfYiTMH.js +++ /dev/null @@ -1 +0,0 @@ -(function(){const t=document.createElement("link").relList;if(t&&t.supports&&t.supports("modulepreload"))return;for(const s of document.querySelectorAll('link[rel="modulepreload"]'))n(s);new MutationObserver(s=>{for(const i of s)if(i.type==="childList")for(const o of i.addedNodes)o.tagName==="LINK"&&o.rel==="modulepreload"&&n(o)}).observe(document,{childList:!0,subtree:!0});function r(s){const i={};return s.integrity&&(i.integrity=s.integrity),s.referrerPolicy&&(i.referrerPolicy=s.referrerPolicy),s.crossOrigin==="use-credentials"?i.credentials="include":s.crossOrigin==="anonymous"?i.credentials="omit":i.credentials="same-origin",i}function n(s){if(s.ep)return;s.ep=!0;const i=r(s);fetch(s.href,i)}})();const Ge=!1,We=(e,t)=>e===t,Se=Symbol("solid-proxy"),Qe=typeof Proxy=="function",Xe=Symbol("solid-track"),de={equals:We};let je=Fe;const J=1,fe=2,Ve={owned:null,cleanups:null,context:null,owner:null},ve={};var S=null;let xe=null,Ye=null,_=null,I=null,q=null,he=0;function ce(e,t){const r=_,n=S,s=e.length===0,i=t===void 0?n:t,o=s?Ve:{owned:null,cleanups:null,context:i?i.context:null,owner:i},a=s?e:()=>e(()=>j(()=>re(o)));S=o,_=null;try{return H(a,!0)}finally{_=r,S=n}}function y(e,t){t=t?Object.assign({},de,t):de;const r={value:e,observers:null,observerSlots:null,comparator:t.equals||void 0},n=s=>(typeof s=="function"&&(s=s(r.value)),qe(r,s));return[Be.bind(r),n]}function ze(e,t,r){const n=me(e,t,!0,J);z(n)}function L(e,t,r){const n=me(e,t,!1,J);z(n)}function Ee(e,t,r){je=it;const n=me(e,t,!1,J);n.user=!0,q?q.push(n):z(n)}function R(e,t,r){r=r?Object.assign({},de,r):de;const n=me(e,t,!0,0);return n.observers=null,n.observerSlots=null,n.comparator=r.equals||void 0,z(n),Be.bind(n)}function Ze(e){return e&&typeof e=="object"&&"then"in e}function F(e,t,r){let n,s,i;n=!0,s=e,i={};let o=null,a=ve,h=!1,m="initialValue"in i,d=typeof n=="function"&&R(n);const l=new Set,[f,g]=(i.storage||y)(i.initialValue),[b,x]=y(void 0),[V,U]=y(void 0,{equals:!1}),[T,O]=y(m?"ready":"unresolved");function k(w,$,B,N){return o===w&&(o=null,N!==void 0&&(m=!0),(w===a||$===a)&&i.onHydrated&&queueMicrotask(()=>i.onHydrated(N,{value:$})),a=ve,M($,B)),$}function M(w,$){H(()=>{$===void 0&&g(()=>w),O($!==void 0?"errored":m?"ready":"unresolved"),x($);for(const B of l.keys())B.decrement();l.clear()},!1)}function G(){const w=nt,$=f(),B=b();if(B!==void 0&&!o)throw B;return _&&_.user,$}function Z(w=!0){if(w!==!1&&h)return;h=!1;const $=d?d():n;if($==null||$===!1){k(o,j(f));return}let B;const N=a!==ve?a:j(()=>{try{return s($,{value:f(),refetching:w})}catch(te){B=te}});if(B!==void 0){k(o,void 0,ue(B),$);return}else if(!Ze(N))return k(o,N,void 0,$),N;return o=N,"v"in N?(N.s===1?k(o,N.v,void 0,$):k(o,void 0,ue(N.v),$),N):(h=!0,queueMicrotask(()=>h=!1),H(()=>{O(m?"refreshing":"pending"),U()},!1),N.then(te=>k(N,te,void 0,$),te=>k(N,void 0,ue(te),$)))}Object.defineProperties(G,{state:{get:()=>T()},error:{get:()=>b()},loading:{get(){const w=T();return w==="pending"||w==="refreshing"}},latest:{get(){if(!m)return G();const w=b();if(w&&!o)throw w;return f()}}});let ee=S;return d?ze(()=>(ee=S,Z(!1))):Z(!1),[G,{refetch:w=>tt(ee,()=>Z(w)),mutate:g}]}function j(e){if(_===null)return e();const t=_;_=null;try{return e()}finally{_=t}}function et(e){return S===null||(S.cleanups===null?S.cleanups=[e]:S.cleanups.push(e)),e}function tt(e,t){const r=S,n=_;S=e,_=null;try{return H(t,!0)}catch(s){ke(s)}finally{S=r,_=n}}const[rl,nl]=y(!1);function rt(e){const t=R(e),r=R(()=>Ce(t()));return r.toArray=()=>{const n=r();return Array.isArray(n)?n:n!=null?[n]:[]},r}let nt;function Be(){if(this.sources&&this.state)if(this.state===J)z(this);else{const e=I;I=null,H(()=>ge(this),!1),I=e}if(_){const e=this.observers?this.observers.length:0;_.sources?(_.sources.push(this),_.sourceSlots.push(e)):(_.sources=[this],_.sourceSlots=[e]),this.observers?(this.observers.push(_),this.observerSlots.push(_.sources.length-1)):(this.observers=[_],this.observerSlots=[_.sources.length-1])}return this.value}function qe(e,t,r){let n=e.value;return(!e.comparator||!e.comparator(n,t))&&(e.value=t,e.observers&&e.observers.length&&H(()=>{for(let s=0;s1e6)throw I=[],new Error},!1)),t}function z(e){if(!e.fn)return;re(e);const t=he;lt(e,e.value,t)}function lt(e,t,r){let n;const s=S,i=_;_=S=e;try{n=e.fn(t)}catch(o){return e.pure&&(e.state=J,e.owned&&e.owned.forEach(re),e.owned=null),e.updatedAt=r+1,ke(o)}finally{_=i,S=s}(!e.updatedAt||e.updatedAt<=r)&&(e.updatedAt!=null&&"observers"in e?qe(e,n):e.value=n,e.updatedAt=r)}function me(e,t,r,n=J,s){const i={fn:e,state:n,updatedAt:null,owned:null,sources:null,sourceSlots:null,cleanups:null,value:t,owner:S,context:S?S.context:null,pure:r};return S===null||S!==Ve&&(S.owned?S.owned.push(i):S.owned=[i]),i}function pe(e){if(e.state===0)return;if(e.state===fe)return ge(e);if(e.suspense&&j(e.suspense.inFallback))return e.suspense.effects.push(e);const t=[e];for(;(e=e.owner)&&(!e.updatedAt||e.updatedAt=0;r--)if(e=t[r],e.state===J)z(e);else if(e.state===fe){const n=I;I=null,H(()=>ge(e,t[0]),!1),I=n}}function H(e,t){if(I)return e();let r=!1;t||(I=[]),q?r=!0:q=[],he++;try{const n=e();return st(r),n}catch(n){r||(q=null),I=null,ke(n)}}function st(e){if(I&&(Fe(I),I=null),e)return;const t=q;q=null,t.length&&H(()=>je(t),!1)}function Fe(e){for(let t=0;t=0;t--)re(e.tOwned[t]);delete e.tOwned}if(e.owned){for(t=e.owned.length-1;t>=0;t--)re(e.owned[t]);e.owned=null}if(e.cleanups){for(t=e.cleanups.length-1;t>=0;t--)e.cleanups[t]();e.cleanups=null}e.state=0}function ue(e){return e instanceof Error?e:new Error(typeof e=="string"?e:"Unknown error",{cause:e})}function ke(e,t=S){throw ue(e)}function Ce(e){if(typeof e=="function"&&!e.length)return Ce(e());if(Array.isArray(e)){const t=[];for(let r=0;r1?[]:null;return et(()=>Ie(i)),()=>{let h=e()||[],m=h.length,d,l;return h[Xe],j(()=>{let g,b,x,V,U,T,O,k,M;if(m===0)o!==0&&(Ie(i),i=[],n=[],s=[],o=0,a&&(a=[])),r.fallback&&(n=[at],s[0]=ce(G=>(i[0]=G,r.fallback())),o=1);else if(o===0){for(s=new Array(m),l=0;l=T&&k>=T&&n[O]===h[k];O--,k--)x[k]=s[O],V[k]=i[O],a&&(U[k]=a[O]);for(g=new Map,b=new Array(k+1),l=k;l>=T;l--)M=h[l],d=g.get(M),b[l]=d===void 0?-1:d,g.set(M,l);for(d=T;d<=O;d++)M=n[d],l=g.get(M),l!==void 0&&l!==-1?(x[l]=s[d],V[l]=i[d],a&&(U[l]=a[d]),l=b[l],g.set(M,l)):i[d]();for(l=T;le(t||{}))}function ae(){return!0}const ct={get(e,t,r){return t===Se?r:e.get(t)},has(e,t){return t===Se?!0:e.has(t)},set:ae,deleteProperty:ae,getOwnPropertyDescriptor(e,t){return{configurable:!0,enumerable:!0,get(){return e.get(t)},set:ae,deleteProperty:ae}},ownKeys(e){return e.keys()}};function we(e){return(e=typeof e=="function"?e():e)?e:{}}function ut(){for(let e=0,t=this.length;e=0;a--){const h=we(e[a])[o];if(h!==void 0)return h}},has(o){for(let a=e.length-1;a>=0;a--)if(o in we(e[a]))return!0;return!1},keys(){const o=[];for(let a=0;a=0;o--){const a=e[o];if(!a)continue;const h=Object.getOwnPropertyNames(a);for(let m=h.length-1;m>=0;m--){const d=h[m];if(d==="__proto__"||d==="constructor")continue;const l=Object.getOwnPropertyDescriptor(a,d);if(!n[d])n[d]=l.get?{enumerable:!0,configurable:!0,get:ut.bind(r[d]=[l.get.bind(a)])}:l.value!==void 0?l:void 0;else{const f=r[d];f&&(l.get?f.push(l.get.bind(a)):l.value!==void 0&&f.push(()=>l.value))}}}const s={},i=Object.keys(n);for(let o=i.length-1;o>=0;o--){const a=i[o],h=n[a];h&&h.get?Object.defineProperty(s,a,h):s[a]=h?h.value:void 0}return s}const Je=e=>`Stale read from <${e}>.`;function K(e){const t="fallback"in e&&{fallback:()=>e.fallback};return R(ot(()=>e.each,e.children,t||void 0))}function ye(e){const t=e.keyed,r=R(()=>e.when,void 0,void 0),n=t?r:R(r,void 0,{equals:(s,i)=>!s==!i});return R(()=>{const s=n();if(s){const i=e.children;return typeof i=="function"&&i.length>0?j(()=>i(t?s:()=>{if(!j(n))throw Je("Show");return r()})):i}return e.fallback},void 0,void 0)}function dt(e){const t=rt(()=>e.children),r=R(()=>{const n=t(),s=Array.isArray(n)?n:[n];let i=()=>{};for(let o=0;om()?void 0:h.when,void 0,void 0),l=h.keyed?d:R(d,void 0,{equals:(f,g)=>!f==!g});i=()=>m()||(l()?[a,d,h]:void 0)}return i});return R(()=>{const n=r()();if(!n)return e.fallback;const[s,i,o]=n,a=o.children;return typeof a=="function"&&a.length>0?j(()=>a(o.keyed?i():()=>{if(j(r)()?.[0]!==s)throw Je("Match");return i()})):a},void 0,void 0)}function W(e){return e}const ft=["allowfullscreen","async","alpha","autofocus","autoplay","checked","controls","default","disabled","formnovalidate","hidden","indeterminate","inert","ismap","loop","multiple","muted","nomodule","novalidate","open","playsinline","readonly","required","reversed","seamless","selected","adauctionheaders","browsingtopics","credentialless","defaultchecked","defaultmuted","defaultselected","defer","disablepictureinpicture","disableremoteplayback","preservespitch","shadowrootclonable","shadowrootcustomelementregistry","shadowrootdelegatesfocus","shadowrootserializable","sharedstoragewritable"],pt=new Set(["className","value","readOnly","noValidate","formNoValidate","isMap","noModule","playsInline","adAuctionHeaders","allowFullscreen","browsingTopics","defaultChecked","defaultMuted","defaultSelected","disablePictureInPicture","disableRemotePlayback","preservesPitch","shadowRootClonable","shadowRootCustomElementRegistry","shadowRootDelegatesFocus","shadowRootSerializable","sharedStorageWritable",...ft]),gt=new Set(["innerHTML","textContent","innerText","children"]),ht=Object.assign(Object.create(null),{className:"class",htmlFor:"for"}),mt=Object.assign(Object.create(null),{class:"className",novalidate:{$:"noValidate",FORM:1},formnovalidate:{$:"formNoValidate",BUTTON:1,INPUT:1},ismap:{$:"isMap",IMG:1},nomodule:{$:"noModule",SCRIPT:1},playsinline:{$:"playsInline",VIDEO:1},readonly:{$:"readOnly",INPUT:1,TEXTAREA:1},adauctionheaders:{$:"adAuctionHeaders",IFRAME:1},allowfullscreen:{$:"allowFullscreen",IFRAME:1},browsingtopics:{$:"browsingTopics",IMG:1},defaultchecked:{$:"defaultChecked",INPUT:1},defaultmuted:{$:"defaultMuted",AUDIO:1,VIDEO:1},defaultselected:{$:"defaultSelected",OPTION:1},disablepictureinpicture:{$:"disablePictureInPicture",VIDEO:1},disableremoteplayback:{$:"disableRemotePlayback",AUDIO:1,VIDEO:1},preservespitch:{$:"preservesPitch",AUDIO:1,VIDEO:1},shadowrootclonable:{$:"shadowRootClonable",TEMPLATE:1},shadowrootdelegatesfocus:{$:"shadowRootDelegatesFocus",TEMPLATE:1},shadowrootserializable:{$:"shadowRootSerializable",TEMPLATE:1},sharedstoragewritable:{$:"sharedStorageWritable",IFRAME:1,IMG:1}});function bt(e,t){const r=mt[e];return typeof r=="object"?r[t]?r.$:void 0:r}const yt=new Set(["beforeinput","click","dblclick","contextmenu","focusin","focusout","input","keydown","keyup","mousedown","mousemove","mouseout","mouseover","mouseup","pointerdown","pointermove","pointerout","pointerover","pointerup","touchend","touchmove","touchstart"]),Q=e=>R(()=>e());function $t(e,t,r){let n=r.length,s=t.length,i=n,o=0,a=0,h=t[s-1].nextSibling,m=null;for(;od-a){const b=t[o];for(;a{s=i,t===document?e():u(t,e(),t.firstChild?null:void 0,r)},n.owner),()=>{s(),t.textContent=""}}function p(e,t,r,n){let s;const i=()=>{const a=document.createElement("template");return a.innerHTML=e,a.content.firstChild},o=()=>(s||(s=i())).cloneNode(!0);return o.cloneNode=o,o}function Oe(e,t=window.document){const r=t[Ne]||(t[Ne]=new Set);for(let n=0,s=e.length;ns.call(e,r[1],i))}else e.addEventListener(t,r,typeof r!="function"&&r)}function _t(e,t,r={}){const n=Object.keys(t||{}),s=Object.keys(r);let i,o;for(i=0,o=s.length;is.children=ne(e,t.children,s.children)),L(()=>typeof t.ref=="function"&&Pt(t.ref,e)),L(()=>At(e,t,r,!0,s,!0)),s}function Pt(e,t,r){return j(()=>e(t,r))}function u(e,t,r,n){if(r!==void 0&&!n&&(n=[]),typeof t!="function")return ne(e,t,n,r);L(s=>ne(e,t(),s,r),n)}function At(e,t,r,n,s={},i=!1){t||(t={});for(const o in s)if(!(o in t)){if(o==="children")continue;s[o]=Re(e,o,null,s[o],r,i,t)}for(const o in t){if(o==="children")continue;const a=t[o];s[o]=Re(e,o,a,s[o],r,i,t)}}function kt(e){return e.toLowerCase().replace(/-([a-z])/g,(t,r)=>r.toUpperCase())}function Le(e,t,r){const n=t.trim().split(/\s+/);for(let s=0,i=n.length;sObject.defineProperty(e,"target",{configurable:!0,value:h}),o=()=>{const h=t[r];if(h&&!t.disabled){const m=t[`${r}Data`];if(m!==void 0?h.call(t,m,e):h.call(t,e),e.cancelBubble)return}return t.host&&typeof t.host!="string"&&!t.host._$host&&t.contains(e.target)&&i(t.host),!0},a=()=>{for(;o()&&(t=t._$host||t.parentNode||t.host););};if(Object.defineProperty(e,"currentTarget",{configurable:!0,get(){return t||document}}),e.composedPath){const h=e.composedPath();i(h[0]);for(let m=0;m{let a=t();for(;typeof a=="function";)a=a();r=ne(e,a,r,n)}),()=>r;if(Array.isArray(t)){const a=[],h=r&&Array.isArray(r);if(Ae(a,t,r,s))return L(()=>r=ne(e,a,r,n,!0)),()=>r;if(a.length===0){if(r=Y(e,r,n),o)return r}else h?r.length===0?De(e,a,n):$t(e,r,a):(r&&Y(e),De(e,a));r=a}else if(t.nodeType){if(Array.isArray(r)){if(o)return r=Y(e,r,n,t);Y(e,r,null,t)}else r==null||r===""||!e.firstChild?e.appendChild(t):e.replaceChild(t,e.firstChild);r=t}}return r}function Ae(e,t,r,n){let s=!1;for(let i=0,o=t.length;i=0;o--){const a=t[o];if(s!==a){const h=a.parentNode===e;!i&&!o?h?e.replaceChild(s,a):e.insertBefore(s,r):h&&a.remove()}else i=!0}}else e.insertBefore(s,r);return[s]}async function v(e,t){const r=await fetch(e,{credentials:"include",headers:{"Content-Type":"application/json",...t?.headers||{}},...t}),n=await r.json().catch(()=>({}));if(!r.ok)throw new Error(n?.error?.message||`Request failed (${r.status})`);return n}async function Tt(){return(await v("/api/v1/bootstrap/status")).data}async function Et(e,t){return v("/api/v1/bootstrap/register-owner",{method:"POST",body:JSON.stringify({email:e,password:t})})}async function It(e,t){return(await v("/api/v1/auth/login",{method:"POST",body:JSON.stringify({email:e,password:t})})).data.user}async function Ue(){return(await v("/api/v1/auth/me")).data}async function Nt(){return v("/api/v1/auth/logout",{method:"POST"})}async function Lt(e){return v("/api/v1/auth/reset-password",{method:"POST",body:JSON.stringify({newPassword:e})})}async function Rt(){return(await v("/api/v1/services")).data}async function Dt(e){return v("/api/v1/services",{method:"POST",body:JSON.stringify(e)})}async function Ut(e,t){return v(`/api/v1/services/${e}`,{method:"PATCH",body:JSON.stringify(t)})}async function Mt(e){return v(`/api/v1/services/${e}/validate`,{method:"POST"})}async function jt(){return(await v("/api/v1/databases")).data}async function Vt(e){return v("/api/v1/databases",{method:"POST",body:JSON.stringify(e)})}async function Bt(e,t){return v(`/api/v1/databases/${e}`,{method:"PATCH",body:JSON.stringify(t)})}async function qt(e){return v(`/api/v1/databases/${e}/validate`,{method:"POST"})}async function Ft(){return(await v("/api/v1/keys")).data}async function Kt(e){return v("/api/v1/keys",{method:"POST",body:JSON.stringify(e)})}async function Jt(e,t){return v(`/api/v1/keys/${e}`,{method:"PATCH",body:JSON.stringify(t)})}async function Ht(){return(await v("/api/v1/users")).data}async function Gt(e){return v("/api/v1/users",{method:"POST",body:JSON.stringify(e)})}async function Me(e,t){return v(`/api/v1/users/${e}`,{method:"PATCH",body:JSON.stringify(t)})}async function He(){return(await v("/api/v1/roles")).data}async function Wt(e){return v("/api/v1/roles",{method:"POST",body:JSON.stringify(e)})}async function Qt(e,t){return v(`/api/v1/roles/${e}`,{method:"PATCH",body:JSON.stringify(t)})}async function Xt(){return(await v("/api/v1/permissions")).data}async function Yt(){return v("/api/v1/analytics/ops")}async function zt(){return v("/api/v1/analytics/traffic")}async function Zt(e,t,r){return v("/api/v1/analytics/events",{method:"POST",body:JSON.stringify({event:e,path:t,meta:r})})}var er=p("