From f72ad2769fb36512eaef70c991f4c0ed386b63ff Mon Sep 17 00:00:00 2001 From: Verum Date: Thu, 19 Mar 2026 18:00:46 +0700 Subject: [PATCH] 1111 --- .dockerignore | 147 + .editorconfig | 65 + .env.example | 26 + .gitattributes | 83 + .gitignore | 166 + .pre-commit-config.yaml | 55 + backend/Dockerfile | 21 + backend/package.json | 32 + backend/src/app/create-app.ts | 36 + backend/src/app/health.routes.ts | 12 + backend/src/app/rate-limit.ts | 16 + backend/src/app/routes.ts | 33 + backend/src/config/env.ts | 47 + backend/src/db/control.ts | 11 + backend/src/db/target.ts | 18 + backend/src/index.ts | 12 + backend/src/lib/api-response.ts | 19 + backend/src/lib/errors.ts | 16 + backend/src/lib/http.ts | 6 + backend/src/lib/identifiers.ts | 22 + backend/src/lib/logger.ts | 49 + backend/src/lib/pagination.ts | 9 + backend/src/lib/sql-guard.ts | 64 + backend/src/middleware/auth.ts | 20 + backend/src/middleware/error-handler.ts | 17 + backend/src/middleware/permission.ts | 67 + backend/src/middleware/request-id.ts | 7 + backend/src/middleware/validate.ts | 27 + backend/src/modules/audit/audit.routes.ts | 67 + backend/src/modules/audit/audit.schemas.ts | 10 + backend/src/modules/audit/audit.service.ts | 53 + backend/src/modules/auth/auth.routes.ts | 30 + backend/src/modules/auth/auth.schemas.ts | 6 + backend/src/modules/auth/auth.service.ts | 238 ++ .../modules/connections/connections.routes.ts | 17 + backend/src/modules/indexes/indexes.routes.ts | 40 + .../src/modules/indexes/indexes.schemas.ts | 7 + .../src/modules/indexes/indexes.service.ts | 45 + .../modules/navigation/navigation.routes.ts | 15 + .../modules/navigation/navigation.service.ts | 79 + .../modules/permissions/permissions.routes.ts | 15 + backend/src/modules/records/records.routes.ts | 112 + .../src/modules/records/records.schemas.ts | 10 + .../src/modules/records/records.service.ts | 195 + backend/src/modules/roles/roles.routes.ts | 35 + backend/src/modules/roles/roles.schemas.ts | 16 + backend/src/modules/roles/roles.service.ts | 83 + backend/src/modules/schema/schema.routes.ts | 79 + backend/src/modules/schema/schema.schemas.ts | 15 + backend/src/modules/schema/schema.service.ts | 82 + .../modules/sql-console/sql-console.routes.ts | 34 + .../sql-console/sql-console.schemas.ts | 5 + .../sql-console/sql-console.service.ts | 58 + backend/src/modules/tables/table.schemas.ts | 17 + backend/src/modules/tables/tables.routes.ts | 77 + backend/src/modules/tables/tables.service.ts | 148 + backend/src/modules/users/users.routes.ts | 40 + backend/src/modules/users/users.schemas.ts | 13 + backend/src/modules/users/users.service.ts | 63 + backend/src/types/auth.ts | 34 + backend/src/types/express.d.ts | 13 + backend/tests/identifiers.test.ts | 15 + backend/tests/sql-guard.test.ts | 27 + backend/tsconfig.json | 20 + docker-compose.yml | 45 + docker/nginx/default.conf | 24 + docs/architecture.md | 16 + frontend/Dockerfile | 15 + frontend/index.html | 18 + frontend/package.json | 26 + frontend/src/app/App.tsx | 6 + frontend/src/app/providers.tsx | 8 + frontend/src/app/router.tsx | 33 + frontend/src/app/styles.css | 297 ++ frontend/src/features/auth/use-session.ts | 22 + frontend/src/main.tsx | 10 + frontend/src/pages/AuditPage.tsx | 50 + frontend/src/pages/DashboardPage.tsx | 184 + frontend/src/pages/LoginPage.tsx | 54 + frontend/src/pages/RolesPage.tsx | 69 + frontend/src/pages/UsersPage.tsx | 69 + frontend/src/shared/api/client.ts | 55 + frontend/src/shared/types.ts | 41 + frontend/src/widgets/AuditTable.tsx | 32 + frontend/src/widgets/DataGrid.tsx | 44 + frontend/src/widgets/LogViewer.tsx | 27 + frontend/src/widgets/SchemaEditor.tsx | 93 + frontend/src/widgets/Sidebar.tsx | 65 + frontend/src/widgets/SqlConsole.tsx | 87 + frontend/src/widgets/Topbar.tsx | 33 + frontend/tsconfig.json | 21 + frontend/vite.config.ts | 19 + index.html | 1452 ++++++++ infra/init/001-control.sql | 137 + infra/init/002-seed-root.sql | 25 + infra/init/010-target.sql | 43 + package-lock.json | 3247 +++++++++++++++++ package.json | 16 + 98 files changed, 9299 insertions(+) create mode 100644 .dockerignore create mode 100644 .editorconfig create mode 100644 .env.example create mode 100644 .gitattributes create mode 100644 .gitignore create mode 100644 .pre-commit-config.yaml create mode 100644 backend/Dockerfile create mode 100644 backend/package.json create mode 100644 backend/src/app/create-app.ts create mode 100644 backend/src/app/health.routes.ts create mode 100644 backend/src/app/rate-limit.ts create mode 100644 backend/src/app/routes.ts create mode 100644 backend/src/config/env.ts create mode 100644 backend/src/db/control.ts create mode 100644 backend/src/db/target.ts create mode 100644 backend/src/index.ts create mode 100644 backend/src/lib/api-response.ts create mode 100644 backend/src/lib/errors.ts create mode 100644 backend/src/lib/http.ts create mode 100644 backend/src/lib/identifiers.ts create mode 100644 backend/src/lib/logger.ts create mode 100644 backend/src/lib/pagination.ts create mode 100644 backend/src/lib/sql-guard.ts create mode 100644 backend/src/middleware/auth.ts create mode 100644 backend/src/middleware/error-handler.ts create mode 100644 backend/src/middleware/permission.ts create mode 100644 backend/src/middleware/request-id.ts create mode 100644 backend/src/middleware/validate.ts create mode 100644 backend/src/modules/audit/audit.routes.ts create mode 100644 backend/src/modules/audit/audit.schemas.ts create mode 100644 backend/src/modules/audit/audit.service.ts create mode 100644 backend/src/modules/auth/auth.routes.ts create mode 100644 backend/src/modules/auth/auth.schemas.ts create mode 100644 backend/src/modules/auth/auth.service.ts create mode 100644 backend/src/modules/connections/connections.routes.ts create mode 100644 backend/src/modules/indexes/indexes.routes.ts create mode 100644 backend/src/modules/indexes/indexes.schemas.ts create mode 100644 backend/src/modules/indexes/indexes.service.ts create mode 100644 backend/src/modules/navigation/navigation.routes.ts create mode 100644 backend/src/modules/navigation/navigation.service.ts create mode 100644 backend/src/modules/permissions/permissions.routes.ts create mode 100644 backend/src/modules/records/records.routes.ts create mode 100644 backend/src/modules/records/records.schemas.ts create mode 100644 backend/src/modules/records/records.service.ts create mode 100644 backend/src/modules/roles/roles.routes.ts create mode 100644 backend/src/modules/roles/roles.schemas.ts create mode 100644 backend/src/modules/roles/roles.service.ts create mode 100644 backend/src/modules/schema/schema.routes.ts create mode 100644 backend/src/modules/schema/schema.schemas.ts create mode 100644 backend/src/modules/schema/schema.service.ts create mode 100644 backend/src/modules/sql-console/sql-console.routes.ts create mode 100644 backend/src/modules/sql-console/sql-console.schemas.ts create mode 100644 backend/src/modules/sql-console/sql-console.service.ts create mode 100644 backend/src/modules/tables/table.schemas.ts create mode 100644 backend/src/modules/tables/tables.routes.ts create mode 100644 backend/src/modules/tables/tables.service.ts create mode 100644 backend/src/modules/users/users.routes.ts create mode 100644 backend/src/modules/users/users.schemas.ts create mode 100644 backend/src/modules/users/users.service.ts create mode 100644 backend/src/types/auth.ts create mode 100644 backend/src/types/express.d.ts create mode 100644 backend/tests/identifiers.test.ts create mode 100644 backend/tests/sql-guard.test.ts create mode 100644 backend/tsconfig.json create mode 100644 docker-compose.yml create mode 100644 docker/nginx/default.conf create mode 100644 docs/architecture.md create mode 100644 frontend/Dockerfile create mode 100644 frontend/index.html create mode 100644 frontend/package.json create mode 100644 frontend/src/app/App.tsx create mode 100644 frontend/src/app/providers.tsx create mode 100644 frontend/src/app/router.tsx create mode 100644 frontend/src/app/styles.css create mode 100644 frontend/src/features/auth/use-session.ts create mode 100644 frontend/src/main.tsx create mode 100644 frontend/src/pages/AuditPage.tsx create mode 100644 frontend/src/pages/DashboardPage.tsx create mode 100644 frontend/src/pages/LoginPage.tsx create mode 100644 frontend/src/pages/RolesPage.tsx create mode 100644 frontend/src/pages/UsersPage.tsx create mode 100644 frontend/src/shared/api/client.ts create mode 100644 frontend/src/shared/types.ts create mode 100644 frontend/src/widgets/AuditTable.tsx create mode 100644 frontend/src/widgets/DataGrid.tsx create mode 100644 frontend/src/widgets/LogViewer.tsx create mode 100644 frontend/src/widgets/SchemaEditor.tsx create mode 100644 frontend/src/widgets/Sidebar.tsx create mode 100644 frontend/src/widgets/SqlConsole.tsx create mode 100644 frontend/src/widgets/Topbar.tsx create mode 100644 frontend/tsconfig.json create mode 100644 frontend/vite.config.ts create mode 100644 index.html create mode 100644 infra/init/001-control.sql create mode 100644 infra/init/002-seed-root.sql create mode 100644 infra/init/010-target.sql create mode 100644 package-lock.json create mode 100644 package.json diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..7866e61 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,147 @@ +# ============================================================================ +# Git & Version Control +# ============================================================================ +.git +.gitea +.github +.gitlab +.gitattributes +.gitignore +.pre-commit-config.yaml + +# ============================================================================ +# IDE & Editor +# ============================================================================ +.vscode/ +.idea/ +*.swp +*.swo +*~ +.DS_Store +Thumbs.db +*.sublime-project +*.sublime-workspace +.editorconfig + +# ============================================================================ +# Documentation & Configuration +# ============================================================================ +README.md +CHANGELOG.md +ARCHITECTURE.md +DEVELOPMENT.md +LICENSE +LICENSE.md +CONTRIBUTING.md +.prettierrc* +.eslintrc* +.stylelintrc* + +# ============================================================================ +# Build & Distribution +# ============================================================================ +dist/ +build/ +out/ +coverage/ +.next/ +.nuxt/ +*.tsbuildinfo + +# ============================================================================ +# Testing +# ============================================================================ +__tests__/ +__test__/ +test/ +tests/ +*.test.js +*.spec.js +.coverage +.nyc_output/ +jest.config.js +karma.conf.js + +# ============================================================================ +# Environment & Secrets +# ============================================================================ +.env +.env.local +.env.*.local +.env.test +.env.production +.envrc +.env-cmdrc.json +.secrets/ +*.pem +*.key +*.crt +*.p12 +*.pfx + +# ============================================================================ +# Temporary & Cache Files +# ============================================================================ +tmp/ +temp/ +.tmp/ +.cache/ +*.tmp +*.temp +.DS_Store +Thumbs.db +ehthumbs.db + +# ============================================================================ +# Logs +# ============================================================================ +*.log +logs/ +log/ + +# ============================================================================ +# Database & Data +# ============================================================================ +postgres_data/ +database_backups/ +*.db +*.sqlite +*.sqlite3 +*.sql +*.sql.gz + +# ============================================================================ +# Docker & Container Files (these files should not be in the container) +# ============================================================================ +Dockerfile +docker-compose*.yml +.dockerignore +.docker/ + +# ============================================================================ +# CI/CD +# ============================================================================ +.github/ +.gitlab-ci.yml +.circleci/ +Jenkinsfile +.travis.yml +.appveyor.yml +azure-pipelines.yml +.drone.yml + +# ============================================================================ +# Misc +# ============================================================================ +tsconfig.json +babel.config.js +webpack.config.js +rollup.config.js +gulpfile.js +Makefile +.nvmrc +.node-version +.npmrc + + +node_modules \ No newline at end of file diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..d30f5f7 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,65 @@ +root = true + +# ============================================================================= +# Global settings +# ============================================================================= +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true +indent_style = space +indent_size = 4 +tab_width = 4 + +# ============================================================================= +# Python +# ============================================================================= +[*.py] +max_line_length = 88 + +# ============================================================================= +# YAML (Docker, CI, compose) +# ============================================================================= +[*.yml] +indent_size = 2 + +[*.yaml] +indent_size = 2 + +# ============================================================================= +# JSON +# ============================================================================= +[*.json] +indent_size = 2 + +# ============================================================================= +# TOML (pyproject.toml, poetry) +# ============================================================================= +[*.toml] +indent_size = 2 + +# ============================================================================= +# Markdown +# ============================================================================= +[*.md] +trim_trailing_whitespace = false +indent_size = 2 + +# ============================================================================= +# Shell scripts +# ============================================================================= +[*.sh] +indent_size = 2 + +# ============================================================================= +# Makefile (tabs required) +# ============================================================================= +[Makefile] +indent_style = tab + +# ============================================================================= +# INI / config files +# ============================================================================= +[*.ini] +indent_size = 2 diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..3fa9926 --- /dev/null +++ b/.env.example @@ -0,0 +1,26 @@ +NODE_ENV=development +APP_PORT=4000 +APP_HOST=0.0.0.0 +FRONTEND_ORIGIN=http://localhost:5173 +SESSION_COOKIE_NAME=pg_admin_sid +SESSION_TTL_HOURS=12 +SESSION_SECRET=change-me +TRUST_PROXY=false +LOG_LEVEL=info +LOG_SOURCE_PATH=/var/log/postgresql/postgresql.log +FEATURE_SQL_CONSOLE=true +FEATURE_LOG_VIEWER=true +FEATURE_SCHEMA_MUTATIONS=true +RATE_LIMIT_WINDOW_MS=60000 +RATE_LIMIT_MAX=120 +AUTH_RATE_LIMIT_MAX=10 +CONTROL_DB_HOST=postgres-control +CONTROL_DB_PORT=5432 +CONTROL_DB_NAME=pg_admin_control +CONTROL_DB_USER=pgadmin +CONTROL_DB_PASSWORD=pgadmin +TARGET_DB_HOST=postgres-target +TARGET_DB_PORT=5432 +TARGET_DB_NAME=appdb +TARGET_DB_USER=postgres +TARGET_DB_PASSWORD=postgres diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..5e5113f --- /dev/null +++ b/.gitattributes @@ -0,0 +1,83 @@ +# ============================================================================= +# Global text normalization +# ============================================================================= +* text=auto eol=lf + +# ============================================================================= +# Shell scripts (must stay LF) +# ============================================================================= +*.sh text eol=lf +*.bash text eol=lf +*.zsh text eol=lf + +# ============================================================================= +# Windows scripts +# ============================================================================= +*.bat text eol=crlf +*.cmd text eol=crlf +*.ps1 text eol=crlf + +# ============================================================================= +# Binary images +# ============================================================================= +*.png binary +*.jpg binary +*.jpeg binary +*.gif binary +*.bmp binary +*.webp binary +*.ico binary + +# SVG is text +*.svg text + +# ============================================================================= +# Media +# ============================================================================= +*.mp3 binary +*.wav binary +*.ogg binary +*.mp4 binary +*.mov binary +*.avi binary +*.mkv binary + +# ============================================================================= +# Fonts +# ============================================================================= +*.eot binary +*.ttf binary +*.woff binary +*.woff2 binary +*.otf binary + +# ============================================================================= +# Documents +# ============================================================================= +*.pdf binary + +# ============================================================================= +# WebAssembly +# ============================================================================= +*.wasm binary + +# ============================================================================= +# Jupyter +# ============================================================================= +*.ipynb binary + +# ============================================================================= +# Git LFS (ML / large artifacts) +# ============================================================================= +*.pt filter=lfs diff=lfs merge=lfs -text +*.pth filter=lfs diff=lfs merge=lfs -text +*.onnx filter=lfs diff=lfs merge=lfs -text +*.ckpt filter=lfs diff=lfs merge=lfs -text +*.safetensors filter=lfs diff=lfs merge=lfs -text + +# ============================================================================= +# GitHub linguist hints +# ============================================================================= +docs/** linguist-documentation +generated/** linguist-generated +vendor/** linguist-vendored diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..41bfca2 --- /dev/null +++ b/.gitignore @@ -0,0 +1,166 @@ +# ============================================================================= +# OS +# ============================================================================= +.DS_Store +Thumbs.db +Desktop.ini + +# ============================================================================= +# IDE / Editors +# ============================================================================= +.idea/ +.vscode/ +*.swp +*.swo +*~ +*.sublime-* +*.code-workspace + +# ============================================================================= +# Logs +# ============================================================================= +*.log +*.logs +*.logs.* +*.log.* +logs/ +log/ + +# ============================================================================= +# Environment / Secrets +# ============================================================================= +.env +.env.* +!.env.example +!.env.sample +!.env.template + +# ============================================================================= +# Security keys +# ============================================================================= +*.pem +*.key +*.crt +*.p12 +*.pfx +secrets/ + +# ============================================================================= +# Python +# ============================================================================= +__pycache__/ +*.py[cod] +*$py.class +*.so + +# Virtual environments +.venv/ +venv/ +env/ +ENV/ + +# Packaging +build/ +dist/ +.eggs/ +*.egg-info/ +pip-wheel-metadata/ + +# Testing / coverage +.coverage +.coverage.* +htmlcov/ +.tox/ +.nox/ + +# Tool caches +.pytest_cache/ +.mypy_cache/ +.ruff_cache/ +.pyre/ +.pytype/ +.pyright/ + +# Jupyter +.ipynb_checkpoints/ + +# ============================================================================= +# Node / Frontend +# ============================================================================= +node_modules/ +.next/ +.nuxt/ +coverage/ +*.tsbuildinfo + +# ============================================================================= +# Java / Kotlin +# ============================================================================= +.gradle/ +out/ +*.class + +# ============================================================================= +# Go +# ============================================================================= +bin/ +*.test + +# ============================================================================= +# Rust +# ============================================================================= +target/ + +# ============================================================================= +# C / C++ / CMake +# ============================================================================= +cmake-build-*/ +CMakeFiles/ +CMakeCache.txt +compile_commands.json + +# ============================================================================= +# Docker +# ============================================================================= +docker-compose.override.yml +*.tar + +# ============================================================================= +# Databases +# ============================================================================= +*.sqlite +*.sqlite3 +*.db + +# ============================================================================= +# ML / Data artifacts +# ============================================================================= +*.pt +*.pth +*.onnx +*.h5 +*.ckpt +*.safetensors +*.npy +*.npz +*.parquet +*.joblib +*.pkl +*.pickle + +# ============================================================================= +# Archives +# ============================================================================= +*.zip +*.tar.* +*.gz +*.7z +*.rar + +# ============================================================================= +# Temporary +# ============================================================================= +tmp/ +temp/ +*.tmp +.cache/ diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..7fc6da8 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,55 @@ +repos: + # ============================================================================= + # Ruff (lint + import sorting + formatting) + # ============================================================================= + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.4.4 + hooks: + - id: ruff + args: [--fix, --exit-non-zero-on-fix] + - id: ruff-format + + # ============================================================================= + # Base repository hygiene + # ============================================================================= + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v4.6.0 + hooks: + - id: check-yaml + args: [--allow-multiple-documents] + - id: check-json + - id: check-toml + - id: end-of-file-fixer + - id: trailing-whitespace + - id: check-merge-conflict + - id: detect-private-key + - id: check-added-large-files + - id: debug-statements + - id: check-executables-have-shebangs + - id: requirements-txt-fixer + + # ============================================================================= + # Static typing + # ============================================================================= + - repo: https://github.com/pre-commit/mirrors-mypy + rev: v1.10.0 + hooks: + - id: mypy + args: [--ignore-missing-imports] + + # ============================================================================= + # Security checks + # ============================================================================= + - repo: https://github.com/PyCQA/bandit + rev: 1.7.8 + hooks: + - id: bandit + args: ["-r", "src"] + + # ============================================================================= + # Secret detection + # ============================================================================= + - repo: https://github.com/Yelp/detect-secrets + rev: v1.5.0 + hooks: + - id: detect-secrets diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..942bb89 --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,21 @@ +FROM node:24-alpine AS base +WORKDIR /app +COPY package.json /app/package.json +COPY backend/package.json /app/backend/package.json +RUN npm install --workspaces --include-workspace-root=false + +FROM base AS build +COPY backend /app/backend +WORKDIR /app/backend +RUN npm run build + +FROM node:24-alpine AS runtime +WORKDIR /app +ENV NODE_ENV=production +COPY --from=base /app/node_modules /app/node_modules +COPY --from=build /app/backend/dist /app/backend/dist +COPY backend/package.json /app/backend/package.json +WORKDIR /app/backend +USER node +EXPOSE 4000 +CMD ["node", "dist/index.js"] diff --git a/backend/package.json b/backend/package.json new file mode 100644 index 0000000..aff9e5d --- /dev/null +++ b/backend/package.json @@ -0,0 +1,32 @@ +{ + "name": "@pg-admin/backend", + "version": "1.0.0", + "private": true, + "type": "module", + "scripts": { + "dev": "tsx watch src/index.ts", + "build": "tsc -p tsconfig.json", + "start": "node dist/index.js", + "test": "tsx --test tests/**/*.test.ts" + }, + "dependencies": { + "argon2": "^0.41.1", + "cookie": "^1.0.2", + "cors": "^2.8.5", + "dotenv": "^16.6.1", + "express": "^4.21.2", + "express-rate-limit": "^7.5.0", + "helmet": "^8.1.0", + "pg": "^8.16.3", + "zod": "^3.25.67" + }, + "devDependencies": { + "@types/cookie": "^1.0.0", + "@types/cors": "^2.8.19", + "@types/express": "^5.0.3", + "@types/node": "^24.0.10", + "@types/pg": "^8.15.5", + "tsx": "^4.20.3", + "typescript": "^5.9.2" + } +} diff --git a/backend/src/app/create-app.ts b/backend/src/app/create-app.ts new file mode 100644 index 0000000..7001d2d --- /dev/null +++ b/backend/src/app/create-app.ts @@ -0,0 +1,36 @@ +import express from "express"; +import helmet from "helmet"; +import cors from "cors"; +import { env } from "../config/env.js"; +import { requestIdMiddleware } from "../middleware/request-id.js"; +import { sessionMiddleware } from "../middleware/auth.js"; +import { errorHandler } from "../middleware/error-handler.js"; +import { apiLimiter, authLimiter } from "./rate-limit.js"; +import { healthRouter } from "./health.routes.js"; +import { apiRouter } from "./routes.js"; + +export function createApp() { + const app = express(); + + if (env.TRUST_PROXY) { + app.set("trust proxy", 1); + } + + app.use(requestIdMiddleware); + app.use( + cors({ + origin: env.FRONTEND_ORIGIN, + credentials: true + }) + ); + app.use(helmet()); + app.use(express.json({ limit: "1mb" })); + app.use(sessionMiddleware); + app.use("/health", healthRouter); + app.use("/api/v1/auth", authLimiter); + app.use("/api/v1", apiLimiter); + app.use("/api/v1", apiRouter); + app.use(errorHandler); + + return app; +} diff --git a/backend/src/app/health.routes.ts b/backend/src/app/health.routes.ts new file mode 100644 index 0000000..ea110de --- /dev/null +++ b/backend/src/app/health.routes.ts @@ -0,0 +1,12 @@ +import { Router } from "express"; +import { ok } from "../lib/api-response.js"; + +export const healthRouter = Router(); + +healthRouter.get("/live", (_req, res) => { + res.json(ok({ status: "live" })); +}); + +healthRouter.get("/ready", (_req, res) => { + res.json(ok({ status: "ready" })); +}); diff --git a/backend/src/app/rate-limit.ts b/backend/src/app/rate-limit.ts new file mode 100644 index 0000000..7fb44e1 --- /dev/null +++ b/backend/src/app/rate-limit.ts @@ -0,0 +1,16 @@ +import rateLimit from "express-rate-limit"; +import { env } from "../config/env.js"; + +export const apiLimiter = rateLimit({ + windowMs: env.RATE_LIMIT_WINDOW_MS, + max: env.RATE_LIMIT_MAX, + standardHeaders: true, + legacyHeaders: false +}); + +export const authLimiter = rateLimit({ + windowMs: env.RATE_LIMIT_WINDOW_MS, + max: env.AUTH_RATE_LIMIT_MAX, + standardHeaders: true, + legacyHeaders: false +}); diff --git a/backend/src/app/routes.ts b/backend/src/app/routes.ts new file mode 100644 index 0000000..2a130cb --- /dev/null +++ b/backend/src/app/routes.ts @@ -0,0 +1,33 @@ +import { Router } from "express"; +import { authRouter } from "../modules/auth/auth.routes.js"; +import { navigationRouter } from "../modules/navigation/navigation.routes.js"; +import { tablesRouter } from "../modules/tables/tables.routes.js"; +import { recordsRouter } from "../modules/records/records.routes.js"; +import { schemaRouter } from "../modules/schema/schema.routes.js"; +import { indexesRouter, globalIndexesRouter } from "../modules/indexes/indexes.routes.js"; +import { sqlConsoleRouter } from "../modules/sql-console/sql-console.routes.js"; +import { auditRouter } from "../modules/audit/audit.routes.js"; +import { usersRouter } from "../modules/users/users.routes.js"; +import { rolesRouter } from "../modules/roles/roles.routes.js"; +import { permissionsRouter } from "../modules/permissions/permissions.routes.js"; +import { connectionsRouter } from "../modules/connections/connections.routes.js"; +import { logsRouter } from "../modules/logs/logs.routes.js"; +import { requireAuth } from "../middleware/auth.js"; + +export const apiRouter = Router(); + +apiRouter.use("/auth", authRouter); +apiRouter.use(requireAuth); +apiRouter.use("/navigation", navigationRouter); +apiRouter.use("/tables", tablesRouter); +apiRouter.use("/tables/:table/records", recordsRouter); +apiRouter.use("/tables/:table/columns", schemaRouter); +apiRouter.use("/tables/:table/indexes", indexesRouter); +apiRouter.use("/indexes", globalIndexesRouter); +apiRouter.use("/sql", sqlConsoleRouter); +apiRouter.use("/audit", auditRouter); +apiRouter.use("/users", usersRouter); +apiRouter.use("/roles", rolesRouter); +apiRouter.use("/permissions", permissionsRouter); +apiRouter.use("/connections", connectionsRouter); +apiRouter.use("/logs", logsRouter); diff --git a/backend/src/config/env.ts b/backend/src/config/env.ts new file mode 100644 index 0000000..55c4593 --- /dev/null +++ b/backend/src/config/env.ts @@ -0,0 +1,47 @@ +import "dotenv/config"; +import { z } from "zod"; + +const envSchema = z.object({ + NODE_ENV: z.enum(["development", "test", "production"]).default("development"), + APP_PORT: z.coerce.number().default(4000), + APP_HOST: z.string().default("0.0.0.0"), + FRONTEND_ORIGIN: z.string().url().default("http://localhost:5173"), + SESSION_COOKIE_NAME: z.string().default("pg_admin_sid"), + SESSION_TTL_HOURS: z.coerce.number().positive().default(12), + SESSION_SECRET: z.string().min(8), + TRUST_PROXY: z + .string() + .transform((value) => value === "true") + .default("false"), + LOG_LEVEL: z.enum(["debug", "info", "warn", "error"]).default("info"), + LOG_SOURCE_PATH: z.string().default("/var/log/postgresql/postgresql.log"), + FEATURE_SQL_CONSOLE: z + .string() + .transform((value) => value !== "false") + .default("true"), + FEATURE_LOG_VIEWER: z + .string() + .transform((value) => value !== "false") + .default("true"), + FEATURE_SCHEMA_MUTATIONS: z + .string() + .transform((value) => value !== "false") + .default("true"), + RATE_LIMIT_WINDOW_MS: z.coerce.number().default(60000), + RATE_LIMIT_MAX: z.coerce.number().default(120), + AUTH_RATE_LIMIT_MAX: z.coerce.number().default(10), + CONTROL_DB_HOST: z.string(), + CONTROL_DB_PORT: z.coerce.number().default(5432), + CONTROL_DB_NAME: z.string(), + CONTROL_DB_USER: z.string(), + CONTROL_DB_PASSWORD: z.string(), + TARGET_DB_HOST: z.string(), + TARGET_DB_PORT: z.coerce.number().default(5432), + TARGET_DB_NAME: z.string(), + TARGET_DB_USER: z.string(), + TARGET_DB_PASSWORD: z.string() +}); + +export const env = envSchema.parse(process.env); + +export const isProduction = env.NODE_ENV === "production"; diff --git a/backend/src/db/control.ts b/backend/src/db/control.ts new file mode 100644 index 0000000..71d34ac --- /dev/null +++ b/backend/src/db/control.ts @@ -0,0 +1,11 @@ +import { Pool } from "pg"; +import { env } from "../config/env.js"; + +export const controlPool = new Pool({ + host: env.CONTROL_DB_HOST, + port: env.CONTROL_DB_PORT, + database: env.CONTROL_DB_NAME, + user: env.CONTROL_DB_USER, + password: env.CONTROL_DB_PASSWORD, + max: 10 +}); diff --git a/backend/src/db/target.ts b/backend/src/db/target.ts new file mode 100644 index 0000000..34bc5a1 --- /dev/null +++ b/backend/src/db/target.ts @@ -0,0 +1,18 @@ +import { Pool } from "pg"; +import { env } from "../config/env.js"; + +let targetPool: Pool | null = null; + +export function getTargetPool() { + if (!targetPool) { + targetPool = new Pool({ + host: env.TARGET_DB_HOST, + port: env.TARGET_DB_PORT, + database: env.TARGET_DB_NAME, + user: env.TARGET_DB_USER, + password: env.TARGET_DB_PASSWORD, + max: 10 + }); + } + return targetPool; +} diff --git a/backend/src/index.ts b/backend/src/index.ts new file mode 100644 index 0000000..25e3cbb --- /dev/null +++ b/backend/src/index.ts @@ -0,0 +1,12 @@ +import { createApp } from "./app/create-app.js"; +import { env } from "./config/env.js"; +import { logger } from "./lib/logger.js"; + +const app = createApp(); + +app.listen(env.APP_PORT, env.APP_HOST, () => { + logger.info("Backend started", { + host: env.APP_HOST, + port: env.APP_PORT + }); +}); diff --git a/backend/src/lib/api-response.ts b/backend/src/lib/api-response.ts new file mode 100644 index 0000000..26aeecb --- /dev/null +++ b/backend/src/lib/api-response.ts @@ -0,0 +1,19 @@ +export function ok(data: T, meta: Record = {}) { + return { + success: true, + data, + meta + }; +} + +export function fail(code: string, message: string, requestId: string, details?: unknown) { + return { + success: false, + error: { + code, + message, + details, + requestId + } + }; +} diff --git a/backend/src/lib/errors.ts b/backend/src/lib/errors.ts new file mode 100644 index 0000000..56015b7 --- /dev/null +++ b/backend/src/lib/errors.ts @@ -0,0 +1,16 @@ +export class AppError extends Error { + statusCode: number; + code: string; + details?: unknown; + + constructor(statusCode: number, code: string, message: string, details?: unknown) { + super(message); + this.statusCode = statusCode; + this.code = code; + this.details = details; + } +} + +export function isAppError(error: unknown): error is AppError { + return error instanceof AppError; +} diff --git a/backend/src/lib/http.ts b/backend/src/lib/http.ts new file mode 100644 index 0000000..825515b --- /dev/null +++ b/backend/src/lib/http.ts @@ -0,0 +1,6 @@ +export function getSingleParam(value: string | string[] | undefined) { + if (Array.isArray(value)) { + return value[0] ?? ""; + } + return value ?? ""; +} diff --git a/backend/src/lib/identifiers.ts b/backend/src/lib/identifiers.ts new file mode 100644 index 0000000..3702229 --- /dev/null +++ b/backend/src/lib/identifiers.ts @@ -0,0 +1,22 @@ +import { AppError } from "./errors.js"; + +const identifierPattern = /^[a-zA-Z_][a-zA-Z0-9_]*$/; + +export function assertSafeIdentifier(value: string, label = "identifier") { + if (!identifierPattern.test(value)) { + throw new AppError(400, "INVALID_IDENTIFIER", `Unsafe ${label}: ${value}`); + } +} + +export function quoteIdentifier(identifier: string) { + assertSafeIdentifier(identifier); + return `"${identifier}"`; +} + +export function quoteQualifiedName(name: string) { + const parts = name.split("."); + if (parts.length > 2) { + throw new AppError(400, "INVALID_IDENTIFIER", `Unsupported qualified name: ${name}`); + } + return parts.map((part) => quoteIdentifier(part)).join("."); +} diff --git a/backend/src/lib/logger.ts b/backend/src/lib/logger.ts new file mode 100644 index 0000000..db92530 --- /dev/null +++ b/backend/src/lib/logger.ts @@ -0,0 +1,49 @@ +type LogLevel = "debug" | "info" | "warn" | "error"; + +const order: Record = { + debug: 10, + info: 20, + warn: 30, + error: 40 +}; + +const currentLevel = (process.env.LOG_LEVEL as LogLevel | undefined) ?? "info"; + +function write(level: LogLevel, message: string, meta?: unknown) { + if (order[level] < order[currentLevel]) { + return; + } + + const payload = { + ts: new Date().toISOString(), + level, + message, + meta + }; + + const line = JSON.stringify(payload); + if (level === "error") { + console.error(line); + return; + } + if (level === "warn") { + console.warn(line); + return; + } + console.log(line); +} + +export const logger = { + debug(message: string, meta?: unknown) { + write("debug", message, meta); + }, + info(message: string, meta?: unknown) { + write("info", message, meta); + }, + warn(message: string, meta?: unknown) { + write("warn", message, meta); + }, + error(message: string, meta?: unknown) { + write("error", message, meta); + } +}; diff --git a/backend/src/lib/pagination.ts b/backend/src/lib/pagination.ts new file mode 100644 index 0000000..17d1860 --- /dev/null +++ b/backend/src/lib/pagination.ts @@ -0,0 +1,9 @@ +export function getPagination(page: number, limit: number) { + const safePage = Math.max(page, 1); + const safeLimit = Math.min(Math.max(limit, 1), 100); + return { + page: safePage, + limit: safeLimit, + offset: (safePage - 1) * safeLimit + }; +} diff --git a/backend/src/lib/sql-guard.ts b/backend/src/lib/sql-guard.ts new file mode 100644 index 0000000..02f10f4 --- /dev/null +++ b/backend/src/lib/sql-guard.ts @@ -0,0 +1,64 @@ +import { AppError } from "./errors.js"; + +const blockedPatterns = [ + /\bdrop\s+database\b/i, + /\balter\s+system\b/i, + /\bcopy\b[\s\S]*\bprogram\b/i, + /\bcreate\s+role\b/i, + /\balter\s+role\b/i, + /\bdrop\s+role\b/i, + /\bcreate\s+extension\b/i +]; + +const mutatingKeywords = /\b(insert|update|delete|alter|create|drop|truncate|grant|revoke)\b/i; +const selectOnlyPattern = /^\s*(with\b[\s\S]+?\bselect\b|select\b)/i; + +export type SqlGuardOptions = { + allowMultiStatement: boolean; + readOnly: boolean; + allowSchemaChanges: boolean; +}; + +export function normalizeSql(sql: string) { + return sql.replace(/\s+/g, " ").trim(); +} + +export function inferStatementType(sql: string) { + const normalized = normalizeSql(sql).toLowerCase(); + return normalized.split(/\s+/)[0] ?? "unknown"; +} + +export function guardSql(sql: string, options: SqlGuardOptions) { + const normalized = normalizeSql(sql); + + if (!normalized) { + throw new AppError(400, "SQL_EMPTY", "SQL query cannot be empty"); + } + + if (!options.allowMultiStatement && normalized.includes(";")) { + const statements = normalized.split(";").filter((part) => part.trim().length > 0); + if (statements.length > 1) { + throw new AppError(403, "SQL_MULTI_STATEMENT_BLOCKED", "Multiple statements are restricted"); + } + } + + for (const pattern of blockedPatterns) { + if (pattern.test(normalized)) { + throw new AppError(403, "SQL_BLOCKED", "SQL contains a blocked operation"); + } + } + + if (options.readOnly && !selectOnlyPattern.test(normalized)) { + throw new AppError(403, "SQL_READ_ONLY", "Read-only access allows SELECT statements only"); + } + + if (!options.allowSchemaChanges && /\b(alter|create|drop|truncate)\b/i.test(normalized)) { + throw new AppError(403, "SQL_SCHEMA_BLOCKED", "Schema-changing statements are not allowed"); + } + + return { + normalized, + statementType: inferStatementType(normalized), + isMutating: mutatingKeywords.test(normalized) + }; +} diff --git a/backend/src/middleware/auth.ts b/backend/src/middleware/auth.ts new file mode 100644 index 0000000..e6a97a2 --- /dev/null +++ b/backend/src/middleware/auth.ts @@ -0,0 +1,20 @@ +import type { RequestHandler } from "express"; +import { AppError } from "../lib/errors.js"; +import { resolveSession } from "../modules/auth/auth.service.js"; + +export const sessionMiddleware: RequestHandler = async (req, _res, next) => { + try { + await resolveSession(req); + next(); + } catch (error) { + next(error); + } +}; + +export const requireAuth: RequestHandler = (req, _res, next) => { + if (!req.user) { + next(new AppError(401, "UNAUTHORIZED", "Authentication is required")); + return; + } + next(); +}; diff --git a/backend/src/middleware/error-handler.ts b/backend/src/middleware/error-handler.ts new file mode 100644 index 0000000..ae28bfb --- /dev/null +++ b/backend/src/middleware/error-handler.ts @@ -0,0 +1,17 @@ +import type { ErrorRequestHandler } from "express"; +import { fail } from "../lib/api-response.js"; +import { isAppError } from "../lib/errors.js"; +import { logger } from "../lib/logger.js"; + +export const errorHandler: ErrorRequestHandler = (error, req, res, _next) => { + if (isAppError(error)) { + return res.status(error.statusCode).json(fail(error.code, error.message, req.requestId, error.details)); + } + + logger.error("Unhandled error", { + requestId: req.requestId, + error: error instanceof Error ? error.message : String(error) + }); + + return res.status(500).json(fail("INTERNAL_ERROR", "Internal server error", req.requestId)); +}; diff --git a/backend/src/middleware/permission.ts b/backend/src/middleware/permission.ts new file mode 100644 index 0000000..f1b9df0 --- /dev/null +++ b/backend/src/middleware/permission.ts @@ -0,0 +1,67 @@ +import type { RequestHandler } from "express"; +import { AppError } from "../lib/errors.js"; +import type { PermissionAction, PermissionResource } from "../types/auth.js"; + +function extractTableGroup(tableName: string) { + if (tableName.includes("__")) { + return tableName.split("__")[0]; + } + return "default"; +} + +export function hasPermission( + req: Express.Request, + resource: PermissionResource, + action: PermissionAction, + scope?: { type: "group" | "table"; value: string } +) { + if (req.user?.isRoot) { + return true; + } + + const permissions = req.user?.permissions ?? []; + return permissions.some((grant) => { + if (grant.resource !== resource || grant.action !== action) { + return false; + } + if (grant.scopeType === "global") { + return true; + } + if (!scope) { + return false; + } + return grant.scopeType === scope.type && grant.scopeValue === scope.value; + }); +} + +export function requirePermission(resource: PermissionResource, action: PermissionAction): RequestHandler { + return (req, _res, next) => { + if (hasPermission(req, resource, action)) { + next(); + return; + } + next(new AppError(403, "FORBIDDEN", "Permission denied")); + }; +} + +export function requireTableAccess(action: PermissionAction): RequestHandler { + return (req, _res, next) => { + const tableName = String(req.params.table ?? ""); + if (!tableName) { + next(new AppError(400, "MISSING_TABLE", "Table parameter is required")); + return; + } + + const group = extractTableGroup(tableName); + if ( + hasPermission(req, "table", action, { type: "table", value: tableName }) || + hasPermission(req, "group", action, { type: "group", value: group }) || + hasPermission(req, "database", action) + ) { + next(); + return; + } + + next(new AppError(403, "FORBIDDEN", "Table access denied")); + }; +} diff --git a/backend/src/middleware/request-id.ts b/backend/src/middleware/request-id.ts new file mode 100644 index 0000000..4c07875 --- /dev/null +++ b/backend/src/middleware/request-id.ts @@ -0,0 +1,7 @@ +import type { RequestHandler } from "express"; +import crypto from "node:crypto"; + +export const requestIdMiddleware: RequestHandler = (req, _res, next) => { + req.requestId = crypto.randomUUID(); + next(); +}; diff --git a/backend/src/middleware/validate.ts b/backend/src/middleware/validate.ts new file mode 100644 index 0000000..6c1f160 --- /dev/null +++ b/backend/src/middleware/validate.ts @@ -0,0 +1,27 @@ +import type { RequestHandler } from "express"; +import type { ZodTypeAny } from "zod"; +import { AppError } from "../lib/errors.js"; + +export function validateBody(schema: ZodTypeAny): RequestHandler { + return (req, _res, next) => { + const parsed = schema.safeParse(req.body); + if (!parsed.success) { + next(new AppError(400, "VALIDATION_ERROR", "Invalid request body", parsed.error.flatten())); + return; + } + req.body = parsed.data; + next(); + }; +} + +export function validateQuery(schema: ZodTypeAny): RequestHandler { + return (req, _res, next) => { + const parsed = schema.safeParse(req.query); + if (!parsed.success) { + next(new AppError(400, "VALIDATION_ERROR", "Invalid query parameters", parsed.error.flatten())); + return; + } + req.query = parsed.data; + next(); + }; +} diff --git a/backend/src/modules/audit/audit.routes.ts b/backend/src/modules/audit/audit.routes.ts new file mode 100644 index 0000000..df3bf9b --- /dev/null +++ b/backend/src/modules/audit/audit.routes.ts @@ -0,0 +1,67 @@ +import { Router } from "express"; +import { controlPool } from "../../db/control.js"; +import { ok } from "../../lib/api-response.js"; +import { getPagination } from "../../lib/pagination.js"; +import { requirePermission } from "../../middleware/permission.js"; +import { validateQuery } from "../../middleware/validate.js"; +import { auditQuerySchema } from "./audit.schemas.js"; + +export const auditRouter = Router(); + +auditRouter.get("/", requirePermission("audit", "read"), validateQuery(auditQuerySchema), async (req, res, next) => { + try { + const query = req.query as unknown as { + action?: string; + resourceType?: string; + status?: string; + userId?: string; + page: number; + limit: number; + }; + const pagination = getPagination(query.page, query.limit); + const values: unknown[] = []; + const where: string[] = []; + + if (query.action) { + values.push(query.action); + where.push(`action = $${values.length}`); + } + if (query.resourceType) { + values.push(query.resourceType); + where.push(`resource_type = $${values.length}`); + } + if (query.status) { + values.push(query.status); + where.push(`status = $${values.length}`); + } + if (query.userId) { + values.push(query.userId); + where.push(`actor_user_id = $${values.length}`); + } + + const whereSql = where.length > 0 ? `where ${where.join(" and ")}` : ""; + const countResult = await controlPool.query(`select count(*)::int as total from audit_events ${whereSql}`, values); + values.push(pagination.limit, pagination.offset); + const dataResult = await controlPool.query( + ` + select * + from audit_events + ${whereSql} + order by created_at desc + limit $${values.length - 1} + offset $${values.length} + `, + values + ); + + res.json( + ok(dataResult.rows, { + page: pagination.page, + limit: pagination.limit, + total: countResult.rows[0]?.total ?? 0 + }) + ); + } catch (error) { + next(error); + } +}); diff --git a/backend/src/modules/audit/audit.schemas.ts b/backend/src/modules/audit/audit.schemas.ts new file mode 100644 index 0000000..9efbfa1 --- /dev/null +++ b/backend/src/modules/audit/audit.schemas.ts @@ -0,0 +1,10 @@ +import { z } from "zod"; + +export const auditQuerySchema = z.object({ + action: z.string().optional(), + resourceType: z.string().optional(), + status: z.string().optional(), + userId: z.string().optional(), + page: z.coerce.number().default(1), + limit: z.coerce.number().default(25) +}); diff --git a/backend/src/modules/audit/audit.service.ts b/backend/src/modules/audit/audit.service.ts new file mode 100644 index 0000000..e30d22b --- /dev/null +++ b/backend/src/modules/audit/audit.service.ts @@ -0,0 +1,53 @@ +import { controlPool } from "../../db/control.js"; + +export type AuditInput = { + actorUserId: string | null; + action: string; + resourceType: string; + resourceName: string | null; + groupId?: string | null; + targetConnectionId?: string | null; + sqlTextMasked?: string | null; + payloadBefore?: unknown; + payloadAfter?: unknown; + ip?: string | null; + userAgent?: string | null; + status: "success" | "failure"; +}; + +export async function createAuditEvent(input: AuditInput) { + await controlPool.query( + ` + insert into audit_events ( + actor_user_id, + action, + resource_type, + resource_name, + group_id, + target_connection_id, + sql_text_masked, + payload_before, + payload_after, + ip, + user_agent, + status + ) values ( + $1, $2, $3, $4, $5, $6, $7, $8::jsonb, $9::jsonb, $10, $11, $12 + ) + `, + [ + input.actorUserId, + input.action, + input.resourceType, + input.resourceName, + input.groupId ?? null, + input.targetConnectionId ?? null, + input.sqlTextMasked ?? null, + input.payloadBefore ? JSON.stringify(input.payloadBefore) : null, + input.payloadAfter ? JSON.stringify(input.payloadAfter) : null, + input.ip ?? null, + input.userAgent ?? null, + input.status + ] + ); +} diff --git a/backend/src/modules/auth/auth.routes.ts b/backend/src/modules/auth/auth.routes.ts new file mode 100644 index 0000000..6b35dc7 --- /dev/null +++ b/backend/src/modules/auth/auth.routes.ts @@ -0,0 +1,30 @@ +import { Router } from "express"; +import { ok } from "../../lib/api-response.js"; +import { requireAuth } from "../../middleware/auth.js"; +import { validateBody } from "../../middleware/validate.js"; +import { loginSchema } from "./auth.schemas.js"; +import { login, logout } from "./auth.service.js"; + +export const authRouter = Router(); + +authRouter.post("/login", validateBody(loginSchema), async (req, res, next) => { + try { + const user = await login(req, res, req.body.username, req.body.password); + res.json(ok({ user })); + } catch (error) { + next(error); + } +}); + +authRouter.post("/logout", requireAuth, async (req, res, next) => { + try { + await logout(req, res); + res.json(ok({ loggedOut: true })); + } catch (error) { + next(error); + } +}); + +authRouter.get("/session", requireAuth, async (req, res) => { + res.json(ok({ user: req.user })); +}); diff --git a/backend/src/modules/auth/auth.schemas.ts b/backend/src/modules/auth/auth.schemas.ts new file mode 100644 index 0000000..3e27041 --- /dev/null +++ b/backend/src/modules/auth/auth.schemas.ts @@ -0,0 +1,6 @@ +import { z } from "zod"; + +export const loginSchema = z.object({ + username: z.string().min(1), + password: z.string().min(1) +}); diff --git a/backend/src/modules/auth/auth.service.ts b/backend/src/modules/auth/auth.service.ts new file mode 100644 index 0000000..79c227e --- /dev/null +++ b/backend/src/modules/auth/auth.service.ts @@ -0,0 +1,238 @@ +import argon2 from "argon2"; +import crypto from "node:crypto"; +import { parse as parseCookie, serialize as serializeCookie } from "cookie"; +import type { Request, Response } from "express"; +import { controlPool } from "../../db/control.js"; +import { env, isProduction } from "../../config/env.js"; +import type { PermissionGrant, SessionUser } from "../../types/auth.js"; +import { AppError } from "../../lib/errors.js"; +import { createAuditEvent } from "../audit/audit.service.js"; + +type UserRow = { + id: string; + username: string; + password_hash: string; + is_active: boolean; + is_locked: boolean; + role_slug: string; + is_root: boolean; +}; + +function buildCookie(token: string) { + return serializeCookie(env.SESSION_COOKIE_NAME, token, { + httpOnly: true, + sameSite: "lax", + secure: isProduction, + path: "/", + maxAge: env.SESSION_TTL_HOURS * 60 * 60 + }); +} + +function hashToken(token: string) { + return crypto.createHash("sha256").update(token + env.SESSION_SECRET).digest("hex"); +} + +async function getPermissions(userId: string): Promise { + const result = await controlPool.query( + ` + select + p.resource, + p.action, + coalesce(rp.scope_type, 'global') as scope_type, + rp.scope_value + from user_roles ur + join roles r on r.id = ur.role_id + join role_permissions rp on rp.role_id = r.id + join permissions p on p.id = rp.permission_id + where ur.user_id = $1 + `, + [userId] + ); + + return result.rows.map((row) => ({ + resource: row.resource, + action: row.action, + scopeType: row.scope_type, + scopeValue: row.scope_value + })); +} + +async function verifyPassword(passwordHash: string, password: string) { + if (passwordHash.startsWith("pbkdf2$")) { + const [, digest, iterationsRaw, salt, expectedHash] = passwordHash.split("$"); + const derived = crypto + .pbkdf2Sync(password, salt, Number(iterationsRaw), expectedHash.length / 2, digest) + .toString("hex"); + return crypto.timingSafeEqual(Buffer.from(derived), Buffer.from(expectedHash)); + } + + return argon2.verify(passwordHash, password); +} + +export async function login(req: Request, res: Response, username: string, password: string) { + const userResult = await controlPool.query( + ` + select + u.id, + u.username, + u.password_hash, + u.is_active, + u.is_locked, + coalesce(r.slug, 'user') as role_slug, + coalesce(bool_or(r.slug = 'root'), false) as is_root + from users u + left join user_roles ur on ur.user_id = u.id + left join roles r on r.id = ur.role_id + where u.username = $1 + group by u.id + limit 1 + `, + [username] + ); + + const user = userResult.rows[0]; + if (!user) { + await createAuditEvent({ + actorUserId: null, + action: "auth.login", + resourceType: "session", + resourceName: username, + ip: req.ip, + userAgent: req.headers["user-agent"] ?? null, + status: "failure" + }); + throw new AppError(401, "INVALID_CREDENTIALS", "Invalid username or password"); + } + + if (!user.is_active || user.is_locked) { + throw new AppError(403, "ACCOUNT_DISABLED", "Account is disabled or locked"); + } + + const valid = await verifyPassword(user.password_hash, password); + if (!valid) { + await createAuditEvent({ + actorUserId: user.id, + action: "auth.login", + resourceType: "session", + resourceName: username, + ip: req.ip, + userAgent: req.headers["user-agent"] ?? null, + status: "failure" + }); + throw new AppError(401, "INVALID_CREDENTIALS", "Invalid username or password"); + } + + const permissions = await getPermissions(user.id); + const token = crypto.randomUUID(); + const tokenHash = hashToken(token); + + const sessionResult = await controlPool.query<{ id: string }>( + ` + insert into sessions (user_id, token_hash, expires_at, ip, user_agent) + values ($1, $2, now() + ($3 || ' hour')::interval, $4, $5) + returning id + `, + [user.id, tokenHash, env.SESSION_TTL_HOURS, req.ip, req.headers["user-agent"] ?? null] + ); + + const sessionUser: SessionUser = { + id: user.id, + username: user.username, + roleSlug: user.role_slug, + isRoot: user.is_root, + permissions + }; + + res.setHeader("Set-Cookie", buildCookie(token)); + req.user = sessionUser; + req.sessionId = sessionResult.rows[0]?.id; + + await createAuditEvent({ + actorUserId: user.id, + action: "auth.login", + resourceType: "session", + resourceName: user.username, + ip: req.ip, + userAgent: req.headers["user-agent"] ?? null, + status: "success" + }); + + return sessionUser; +} + +export async function resolveSession(req: Request) { + const cookies = parseCookie(req.headers.cookie ?? ""); + const token = cookies[env.SESSION_COOKIE_NAME]; + if (!token) { + return null; + } + + const sessionResult = await controlPool.query( + ` + select + s.id as session_id, + u.id, + u.username, + coalesce(bool_or(r.slug = 'root'), false) as is_root, + coalesce(max(r.slug), 'user') as role_slug + from sessions s + join users u on u.id = s.user_id + left join user_roles ur on ur.user_id = u.id + left join roles r on r.id = ur.role_id + where s.token_hash = $1 + and s.expires_at > now() + and u.is_active = true + and u.is_locked = false + group by s.id, u.id + limit 1 + `, + [hashToken(token)] + ); + + const row = sessionResult.rows[0]; + if (!row) { + return null; + } + + const permissions = await getPermissions(row.id); + const sessionUser: SessionUser = { + id: row.id, + username: row.username, + roleSlug: row.role_slug, + isRoot: row.is_root, + permissions + }; + + req.user = sessionUser; + req.sessionId = row.session_id; + return sessionUser; +} + +export async function logout(req: Request, res: Response) { + if (req.sessionId) { + await controlPool.query(`delete from sessions where id = $1`, [req.sessionId]); + } + + res.setHeader( + "Set-Cookie", + serializeCookie(env.SESSION_COOKIE_NAME, "", { + httpOnly: true, + sameSite: "lax", + secure: isProduction, + path: "/", + expires: new Date(0) + }) + ); + + if (req.user) { + await createAuditEvent({ + actorUserId: req.user.id, + action: "auth.logout", + resourceType: "session", + resourceName: req.user.username, + ip: req.ip, + userAgent: req.headers["user-agent"] ?? null, + status: "success" + }); + } +} diff --git a/backend/src/modules/connections/connections.routes.ts b/backend/src/modules/connections/connections.routes.ts new file mode 100644 index 0000000..9c55fe9 --- /dev/null +++ b/backend/src/modules/connections/connections.routes.ts @@ -0,0 +1,17 @@ +import { Router } from "express"; +import { controlPool } from "../../db/control.js"; +import { ok } from "../../lib/api-response.js"; +import { requirePermission } from "../../middleware/permission.js"; + +export const connectionsRouter = Router(); + +connectionsRouter.get("/", requirePermission("database", "read"), async (_req, res, next) => { + try { + const result = await controlPool.query( + `select id, name, host, port, database_name, is_default, created_at from db_connections order by is_default desc, name` + ); + res.json(ok(result.rows)); + } catch (error) { + next(error); + } +}); diff --git a/backend/src/modules/indexes/indexes.routes.ts b/backend/src/modules/indexes/indexes.routes.ts new file mode 100644 index 0000000..048c088 --- /dev/null +++ b/backend/src/modules/indexes/indexes.routes.ts @@ -0,0 +1,40 @@ +import { Router } from "express"; +import { ok } from "../../lib/api-response.js"; +import { getSingleParam } from "../../lib/http.js"; +import { requirePermission, requireTableAccess } from "../../middleware/permission.js"; +import { validateBody } from "../../middleware/validate.js"; +import { createIndexSchema } from "./indexes.schemas.js"; +import { createIndex, dropIndex, listIndexes } from "./indexes.service.js"; + +export const indexesRouter = Router({ mergeParams: true }); +export const globalIndexesRouter = Router(); + +indexesRouter.get("/", requireTableAccess("read"), async (req, res, next) => { + try { + res.json(ok(await listIndexes(getSingleParam(req.params.table)))); + } catch (error) { + next(error); + } +}); + +indexesRouter.post("/", requireTableAccess("schema_change"), validateBody(createIndexSchema), async (req, res, next) => { + try { + await createIndex(getSingleParam(req.params.table), req.body.name, req.body.columns, req.body.unique); + res.status(201).json(ok({ created: true })); + } catch (error) { + next(error); + } +}); + +globalIndexesRouter.delete( + "/:name", + requirePermission("database", "schema_change"), + async (req, res, next) => { + try { + await dropIndex(getSingleParam(req.params.name)); + res.json(ok({ deleted: true })); + } catch (error) { + next(error); + } + } +); diff --git a/backend/src/modules/indexes/indexes.schemas.ts b/backend/src/modules/indexes/indexes.schemas.ts new file mode 100644 index 0000000..48a7683 --- /dev/null +++ b/backend/src/modules/indexes/indexes.schemas.ts @@ -0,0 +1,7 @@ +import { z } from "zod"; + +export const createIndexSchema = z.object({ + name: z.string().min(1), + columns: z.array(z.string().min(1)).min(1), + unique: z.boolean().default(false) +}); diff --git a/backend/src/modules/indexes/indexes.service.ts b/backend/src/modules/indexes/indexes.service.ts new file mode 100644 index 0000000..a8731be --- /dev/null +++ b/backend/src/modules/indexes/indexes.service.ts @@ -0,0 +1,45 @@ +import { getTargetPool } from "../../db/target.js"; +import { assertSafeIdentifier, quoteIdentifier } from "../../lib/identifiers.js"; + +export async function listIndexes(table: string) { + assertSafeIdentifier(table, "table"); + const pool = getTargetPool(); + const result = await pool.query( + ` + select + indexname as name, + indexdef as definition + from pg_indexes + where schemaname = 'public' + and tablename = $1 + order by indexname + `, + [table] + ); + + return result.rows.map((row) => ({ + name: row.name, + definition: row.definition, + unique: /\bunique\b/i.test(row.definition), + type: row.definition.includes(" using ") ? row.definition.split(" using ")[1].split(" ")[0] : "btree" + })); +} + +export async function createIndex(table: string, name: string, columns: string[], unique: boolean) { + assertSafeIdentifier(table, "table"); + assertSafeIdentifier(name, "index"); + const quotedColumns = columns.map((column) => { + assertSafeIdentifier(column, "column"); + return quoteIdentifier(column); + }); + const pool = getTargetPool(); + await pool.query( + `create ${unique ? "unique " : ""}index ${quoteIdentifier(name)} on ${quoteIdentifier(table)} (${quotedColumns.join(", ")})` + ); +} + +export async function dropIndex(name: string) { + assertSafeIdentifier(name, "index"); + const pool = getTargetPool(); + await pool.query(`drop index ${quoteIdentifier(name)}`); +} diff --git a/backend/src/modules/navigation/navigation.routes.ts b/backend/src/modules/navigation/navigation.routes.ts new file mode 100644 index 0000000..7fbd5f8 --- /dev/null +++ b/backend/src/modules/navigation/navigation.routes.ts @@ -0,0 +1,15 @@ +import { Router } from "express"; +import { ok } from "../../lib/api-response.js"; +import { requireAuth } from "../../middleware/auth.js"; +import { getSidebarTree } from "./navigation.service.js"; + +export const navigationRouter = Router(); + +navigationRouter.get("/sidebar", requireAuth, async (_req, res, next) => { + try { + const tree = await getSidebarTree(); + res.json(ok(tree)); + } catch (error) { + next(error); + } +}); diff --git a/backend/src/modules/navigation/navigation.service.ts b/backend/src/modules/navigation/navigation.service.ts new file mode 100644 index 0000000..a662cd0 --- /dev/null +++ b/backend/src/modules/navigation/navigation.service.ts @@ -0,0 +1,79 @@ +import { controlPool } from "../../db/control.js"; +import { getTargetPool } from "../../db/target.js"; + +type SidebarTable = { + name: string; + schema: string; + group_slug: string; + display_name: string; + estimated_rows: number; +}; + +export async function getSidebarTree() { + const groupsResult = await controlPool.query<{ + group_id: string; + group_slug: string; + group_name: string; + table_name: string | null; + }>( + ` + select + g.id as group_id, + g.slug as group_slug, + g.name as group_name, + rgt.table_name + from resource_groups g + left join resource_group_tables rgt on rgt.group_id = g.id + order by g.name, rgt.table_name + ` + ); + + const targetPool = getTargetPool(); + const tablesResult = await targetPool.query( + ` + select + t.table_name as name, + t.table_schema as schema, + coalesce(g.slug, split_part(t.table_name, '__', 1), 'default') as group_slug, + case + when position('__' in t.table_name) > 0 then split_part(t.table_name, '__', 2) + else t.table_name + end as display_name, + coalesce(s.n_live_tup, 0)::bigint as estimated_rows + from information_schema.tables t + left join pg_stat_user_tables s + on s.relname = t.table_name + left join resource_group_tables rgt + on rgt.table_name = t.table_name + left join resource_groups g + on g.id = rgt.group_id + where t.table_schema = 'public' + and t.table_type = 'BASE TABLE' + order by group_slug, display_name + ` + ); + + const mapped = new Map(); + for (const row of groupsResult.rows) { + if (!mapped.has(row.group_slug)) { + mapped.set(row.group_slug, { + slug: row.group_slug, + name: row.group_name, + tables: [] + }); + } + } + + for (const table of tablesResult.rows) { + if (!mapped.has(table.group_slug)) { + mapped.set(table.group_slug, { + slug: table.group_slug, + name: table.group_slug === "default" ? "General" : table.group_slug, + tables: [] + }); + } + mapped.get(table.group_slug)?.tables.push(table); + } + + return Array.from(mapped.values()); +} diff --git a/backend/src/modules/permissions/permissions.routes.ts b/backend/src/modules/permissions/permissions.routes.ts new file mode 100644 index 0000000..80f47a1 --- /dev/null +++ b/backend/src/modules/permissions/permissions.routes.ts @@ -0,0 +1,15 @@ +import { Router } from "express"; +import { controlPool } from "../../db/control.js"; +import { ok } from "../../lib/api-response.js"; +import { requirePermission } from "../../middleware/permission.js"; + +export const permissionsRouter = Router(); + +permissionsRouter.get("/", requirePermission("roles", "manage_roles"), async (_req, res, next) => { + try { + const result = await controlPool.query(`select * from permissions order by resource, action`); + res.json(ok(result.rows)); + } catch (error) { + next(error); + } +}); diff --git a/backend/src/modules/records/records.routes.ts b/backend/src/modules/records/records.routes.ts new file mode 100644 index 0000000..534b09f --- /dev/null +++ b/backend/src/modules/records/records.routes.ts @@ -0,0 +1,112 @@ +import { Router } from "express"; +import { ok } from "../../lib/api-response.js"; +import { getSingleParam } from "../../lib/http.js"; +import { requireTableAccess } from "../../middleware/permission.js"; +import { validateQuery } from "../../middleware/validate.js"; +import { createAuditEvent } from "../audit/audit.service.js"; +import { recordsQuerySchema } from "./records.schemas.js"; +import { createRecord, deleteRecord, getRecordById, listRecords, updateRecord } from "./records.service.js"; + +export const recordsRouter = Router({ mergeParams: true }); + +recordsRouter.get("/", requireTableAccess("read"), validateQuery(recordsQuerySchema), async (req, res, next) => { + try { + const tableName = getSingleParam(req.params.table); + const query = req.query as unknown as { + page: number; + limit: number; + search: string; + sortColumn?: string; + sortDirection?: string; + filters: string; + }; + const result = await listRecords({ + table: tableName, + ...query + }); + res.json(ok(result.data, { + page: result.page, + limit: result.limit, + total: result.total, + totalPages: result.totalPages, + primaryKey: result.primaryKey + })); + } catch (error) { + next(error); + } +}); + +recordsRouter.get("/:id", requireTableAccess("read"), async (req, res, next) => { + try { + res.json(ok(await getRecordById(getSingleParam(req.params.table), getSingleParam(req.params.id)))); + } catch (error) { + next(error); + } +}); + +recordsRouter.post("/", requireTableAccess("write"), async (req, res, next) => { + try { + const tableName = getSingleParam(req.params.table); + await createRecord(tableName, req.body as Record); + await createAuditEvent({ + actorUserId: req.user?.id ?? null, + action: "record.create", + resourceType: "table", + resourceName: tableName, + payloadAfter: req.body, + ip: req.ip, + userAgent: req.headers["user-agent"] ?? null, + status: "success" + }); + res.status(201).json(ok({ created: true })); + } catch (error) { + next(error); + } +}); + +recordsRouter.put("/:id", requireTableAccess("write"), async (req, res, next) => { + try { + const tableName = getSingleParam(req.params.table); + const recordId = getSingleParam(req.params.id); + await updateRecord(tableName, recordId, req.body as Record); + await createAuditEvent({ + actorUserId: req.user?.id ?? null, + action: "record.update", + resourceType: "table", + resourceName: tableName, + payloadAfter: { + id: recordId, + changes: req.body + }, + ip: req.ip, + userAgent: req.headers["user-agent"] ?? null, + status: "success" + }); + res.json(ok({ updated: true })); + } catch (error) { + next(error); + } +}); + +recordsRouter.delete("/:id", requireTableAccess("delete"), async (req, res, next) => { + try { + const tableName = getSingleParam(req.params.table); + const recordId = getSingleParam(req.params.id); + await deleteRecord(tableName, recordId); + await createAuditEvent({ + actorUserId: req.user?.id ?? null, + action: "record.delete", + resourceType: "table", + resourceName: tableName, + payloadAfter: { + id: recordId + }, + ip: req.ip, + userAgent: req.headers["user-agent"] ?? null, + status: "success" + }); + res.json(ok({ deleted: true })); + } catch (error) { + next(error); + } +}); diff --git a/backend/src/modules/records/records.schemas.ts b/backend/src/modules/records/records.schemas.ts new file mode 100644 index 0000000..debbd8a --- /dev/null +++ b/backend/src/modules/records/records.schemas.ts @@ -0,0 +1,10 @@ +import { z } from "zod"; + +export const recordsQuerySchema = z.object({ + page: z.coerce.number().default(1), + limit: z.coerce.number().default(25), + search: z.string().optional().default(""), + sortColumn: z.string().optional(), + sortDirection: z.enum(["asc", "desc", "ASC", "DESC"]).optional().default("ASC"), + filters: z.string().optional().default("{}") +}); diff --git a/backend/src/modules/records/records.service.ts b/backend/src/modules/records/records.service.ts new file mode 100644 index 0000000..e1fd140 --- /dev/null +++ b/backend/src/modules/records/records.service.ts @@ -0,0 +1,195 @@ +import { getTargetPool } from "../../db/target.js"; +import { AppError } from "../../lib/errors.js"; +import { assertSafeIdentifier, quoteIdentifier } from "../../lib/identifiers.js"; +import { getPagination } from "../../lib/pagination.js"; + +type Filters = Record; + +async function getColumns(table: string) { + const pool = getTargetPool(); + const result = await pool.query<{ column_name: string }>( + ` + select column_name + from information_schema.columns + where table_schema = 'public' + and table_name = $1 + order by ordinal_position + `, + [table] + ); + return result.rows.map((row) => row.column_name); +} + +async function getPrimaryKey(table: string) { + const pool = getTargetPool(); + const result = await pool.query<{ column_name: string }>( + ` + select kcu.column_name + from information_schema.table_constraints tc + join information_schema.key_column_usage kcu + on tc.constraint_name = kcu.constraint_name + and tc.table_schema = kcu.table_schema + where tc.constraint_type = 'PRIMARY KEY' + and tc.table_schema = 'public' + and tc.table_name = $1 + limit 1 + `, + [table] + ); + return result.rows[0]?.column_name ?? null; +} + +function parseFilters(filters: string): Filters { + try { + const parsed = JSON.parse(filters); + if (!parsed || typeof parsed !== "object") { + return {}; + } + return parsed as Filters; + } catch { + return {}; + } +} + +export async function listRecords(query: { + table: string; + page: number; + limit: number; + search: string; + sortColumn?: string; + sortDirection?: string; + filters: string; +}) { + assertSafeIdentifier(query.table, "table"); + const columns = await getColumns(query.table); + const primaryKey = await getPrimaryKey(query.table); + const pagination = getPagination(query.page, query.limit); + const parsedFilters = parseFilters(query.filters); + + if (query.sortColumn && !columns.includes(query.sortColumn)) { + throw new AppError(400, "INVALID_SORT", "Invalid sort column"); + } + + const pool = getTargetPool(); + const whereParts: string[] = []; + const values: unknown[] = []; + + if (query.search.trim()) { + const searchValue = `%${query.search.trim()}%`; + const conditions = columns.map((column) => { + values.push(searchValue); + return `cast(${quoteIdentifier(column)} as text) ilike $${values.length}`; + }); + whereParts.push(`(${conditions.join(" or ")})`); + } + + for (const [column, value] of Object.entries(parsedFilters)) { + if (!columns.includes(column)) { + continue; + } + values.push(`%${value}%`); + whereParts.push(`cast(${quoteIdentifier(column)} as text) ilike $${values.length}`); + } + + const whereSql = whereParts.length > 0 ? `where ${whereParts.join(" and ")}` : ""; + const orderSql = query.sortColumn + ? `order by ${quoteIdentifier(query.sortColumn)} ${query.sortDirection?.toUpperCase() === "DESC" ? "DESC" : "ASC"}` + : primaryKey + ? `order by ${quoteIdentifier(primaryKey)} desc` + : ""; + + const countResult = await pool.query( + `select count(*)::int as total from ${quoteIdentifier(query.table)} ${whereSql}`, + values + ); + + values.push(pagination.limit, pagination.offset); + const dataResult = await pool.query( + ` + select * + from ${quoteIdentifier(query.table)} + ${whereSql} + ${orderSql} + limit $${values.length - 1} + offset $${values.length} + `, + values + ); + + const total = countResult.rows[0]?.total ?? 0; + return { + data: dataResult.rows, + page: pagination.page, + limit: pagination.limit, + total, + totalPages: Math.max(1, Math.ceil(total / pagination.limit)), + primaryKey + }; +} + +export async function getRecordById(table: string, id: string) { + assertSafeIdentifier(table, "table"); + const primaryKey = await getPrimaryKey(table); + if (!primaryKey) { + throw new AppError(400, "PRIMARY_KEY_REQUIRED", "Table does not have a primary key"); + } + const pool = getTargetPool(); + const result = await pool.query( + `select * from ${quoteIdentifier(table)} where ${quoteIdentifier(primaryKey)} = $1 limit 1`, + [id] + ); + return result.rows[0] ?? null; +} + +export async function createRecord(table: string, payload: Record) { + assertSafeIdentifier(table, "table"); + const entries = Object.entries(payload).filter(([, value]) => value !== undefined); + if (entries.length === 0) { + throw new AppError(400, "EMPTY_PAYLOAD", "Record payload cannot be empty"); + } + const columns = entries.map(([key]) => { + assertSafeIdentifier(key, "column"); + return quoteIdentifier(key); + }); + const values = entries.map(([, value]) => value); + const placeholders = values.map((_, index) => `$${index + 1}`); + const pool = getTargetPool(); + await pool.query( + `insert into ${quoteIdentifier(table)} (${columns.join(", ")}) values (${placeholders.join(", ")})`, + values + ); +} + +export async function updateRecord(table: string, id: string, payload: Record) { + assertSafeIdentifier(table, "table"); + const primaryKey = await getPrimaryKey(table); + if (!primaryKey) { + throw new AppError(400, "PRIMARY_KEY_REQUIRED", "Table does not have a primary key"); + } + const entries = Object.entries(payload).filter(([key, value]) => key !== primaryKey && value !== undefined); + if (entries.length === 0) { + throw new AppError(400, "EMPTY_PAYLOAD", "Record payload cannot be empty"); + } + const values: unknown[] = []; + const sets = entries.map(([key, value]) => { + assertSafeIdentifier(key, "column"); + values.push(value); + return `${quoteIdentifier(key)} = $${values.length}`; + }); + values.push(id); + const pool = getTargetPool(); + await pool.query( + `update ${quoteIdentifier(table)} set ${sets.join(", ")} where ${quoteIdentifier(primaryKey)} = $${values.length}`, + values + ); +} + +export async function deleteRecord(table: string, id: string) { + assertSafeIdentifier(table, "table"); + const primaryKey = await getPrimaryKey(table); + if (!primaryKey) { + throw new AppError(400, "PRIMARY_KEY_REQUIRED", "Table does not have a primary key"); + } + const pool = getTargetPool(); + await pool.query(`delete from ${quoteIdentifier(table)} where ${quoteIdentifier(primaryKey)} = $1`, [id]); +} diff --git a/backend/src/modules/roles/roles.routes.ts b/backend/src/modules/roles/roles.routes.ts new file mode 100644 index 0000000..7083145 --- /dev/null +++ b/backend/src/modules/roles/roles.routes.ts @@ -0,0 +1,35 @@ +import { Router } from "express"; +import { ok } from "../../lib/api-response.js"; +import { getSingleParam } from "../../lib/http.js"; +import { requirePermission } from "../../middleware/permission.js"; +import { validateBody } from "../../middleware/validate.js"; +import { upsertRoleSchema } from "./roles.schemas.js"; +import { createRole, listRoles, updateRole } from "./roles.service.js"; + +export const rolesRouter = Router(); + +rolesRouter.get("/", requirePermission("roles", "manage_roles"), async (_req, res, next) => { + try { + res.json(ok(await listRoles())); + } catch (error) { + next(error); + } +}); + +rolesRouter.post("/", requirePermission("roles", "manage_roles"), validateBody(upsertRoleSchema), async (req, res, next) => { + try { + const roleId = await createRole(req.body); + res.status(201).json(ok({ id: roleId })); + } catch (error) { + next(error); + } +}); + +rolesRouter.put("/:id", requirePermission("roles", "manage_roles"), validateBody(upsertRoleSchema), async (req, res, next) => { + try { + await updateRole(getSingleParam(req.params.id), req.body); + res.json(ok({ updated: true })); + } catch (error) { + next(error); + } +}); diff --git a/backend/src/modules/roles/roles.schemas.ts b/backend/src/modules/roles/roles.schemas.ts new file mode 100644 index 0000000..bfae7ef --- /dev/null +++ b/backend/src/modules/roles/roles.schemas.ts @@ -0,0 +1,16 @@ +import { z } from "zod"; + +export const upsertRoleSchema = z.object({ + name: z.string().min(1), + slug: z.string().min(1), + description: z.string().optional().default(""), + permissions: z + .array( + z.object({ + permissionId: z.string(), + scopeType: z.enum(["global", "group", "table"]).default("global"), + scopeValue: z.string().nullable().optional() + }) + ) + .default([]) +}); diff --git a/backend/src/modules/roles/roles.service.ts b/backend/src/modules/roles/roles.service.ts new file mode 100644 index 0000000..dfdc2c2 --- /dev/null +++ b/backend/src/modules/roles/roles.service.ts @@ -0,0 +1,83 @@ +import { controlPool } from "../../db/control.js"; + +export async function listRoles() { + const rolesResult = await controlPool.query( + ` + select + r.id, + r.name, + r.slug, + r.description, + coalesce( + json_agg( + json_build_object( + 'permissionId', p.id, + 'resource', p.resource, + 'action', p.action, + 'scopeType', rp.scope_type, + 'scopeValue', rp.scope_value + ) + ) filter (where p.id is not null), + '[]'::json + ) as permissions + 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.name + ` + ); + return rolesResult.rows; +} + +export async function createRole(input: { + name: string; + slug: string; + description: string; + permissions: Array<{ permissionId: string; scopeType: string; scopeValue?: string | null }>; +}) { + const roleResult = await controlPool.query<{ id: string }>( + ` + insert into roles (name, slug, description) + values ($1, $2, $3) + returning id + `, + [input.name, input.slug, input.description] + ); + const roleId = roleResult.rows[0]?.id; + for (const permission of input.permissions) { + await controlPool.query( + ` + insert into role_permissions (role_id, permission_id, scope_type, scope_value) + values ($1, $2, $3, $4) + `, + [roleId, permission.permissionId, permission.scopeType, permission.scopeValue ?? null] + ); + } + return roleId; +} + +export async function updateRole( + id: string, + input: { + name: string; + slug: string; + description: string; + permissions: Array<{ permissionId: string; scopeType: string; scopeValue?: string | null }>; + } +) { + await controlPool.query( + `update roles set name = $2, slug = $3, description = $4 where id = $1`, + [id, input.name, input.slug, input.description] + ); + await controlPool.query(`delete from role_permissions where role_id = $1`, [id]); + for (const permission of input.permissions) { + await controlPool.query( + ` + insert into role_permissions (role_id, permission_id, scope_type, scope_value) + values ($1, $2, $3, $4) + `, + [id, permission.permissionId, permission.scopeType, permission.scopeValue ?? null] + ); + } +} diff --git a/backend/src/modules/schema/schema.routes.ts b/backend/src/modules/schema/schema.routes.ts new file mode 100644 index 0000000..4200608 --- /dev/null +++ b/backend/src/modules/schema/schema.routes.ts @@ -0,0 +1,79 @@ +import { Router } from "express"; +import { ok } from "../../lib/api-response.js"; +import { getSingleParam } from "../../lib/http.js"; +import { requireTableAccess } from "../../middleware/permission.js"; +import { validateBody } from "../../middleware/validate.js"; +import { createAuditEvent } from "../audit/audit.service.js"; +import { addColumn, dropColumn, ensureSchemaMutationsEnabled, updateColumn } from "./schema.service.js"; +import { createColumnSchema, updateColumnSchema } from "./schema.schemas.js"; + +export const schemaRouter = Router({ mergeParams: true }); + +schemaRouter.post("/", requireTableAccess("schema_change"), validateBody(createColumnSchema), async (req, res, next) => { + try { + const tableName = getSingleParam(req.params.table); + await ensureSchemaMutationsEnabled(); + await addColumn(tableName, req.body); + await createAuditEvent({ + actorUserId: req.user?.id ?? null, + action: "schema.column.create", + resourceType: "table", + resourceName: tableName, + payloadAfter: req.body, + ip: req.ip, + userAgent: req.headers["user-agent"] ?? null, + status: "success" + }); + res.status(201).json(ok({ created: true })); + } catch (error) { + next(error); + } +}); + +schemaRouter.put( + "/:column", + requireTableAccess("schema_change"), + validateBody(updateColumnSchema), + async (req, res, next) => { + try { + const tableName = getSingleParam(req.params.table); + const columnName = getSingleParam(req.params.column); + await ensureSchemaMutationsEnabled(); + await updateColumn(tableName, columnName, req.body); + await createAuditEvent({ + actorUserId: req.user?.id ?? null, + action: "schema.column.update", + resourceType: "table", + resourceName: `${tableName}.${columnName}`, + payloadAfter: req.body, + ip: req.ip, + userAgent: req.headers["user-agent"] ?? null, + status: "success" + }); + res.json(ok({ updated: true })); + } catch (error) { + next(error); + } + } +); + +schemaRouter.delete("/:column", requireTableAccess("schema_change"), async (req, res, next) => { + try { + const tableName = getSingleParam(req.params.table); + const columnName = getSingleParam(req.params.column); + await ensureSchemaMutationsEnabled(); + await dropColumn(tableName, columnName); + await createAuditEvent({ + actorUserId: req.user?.id ?? null, + action: "schema.column.delete", + resourceType: "table", + resourceName: `${tableName}.${columnName}`, + ip: req.ip, + userAgent: req.headers["user-agent"] ?? null, + status: "success" + }); + res.json(ok({ deleted: true })); + } catch (error) { + next(error); + } +}); diff --git a/backend/src/modules/schema/schema.schemas.ts b/backend/src/modules/schema/schema.schemas.ts new file mode 100644 index 0000000..1f32db0 --- /dev/null +++ b/backend/src/modules/schema/schema.schemas.ts @@ -0,0 +1,15 @@ +import { z } from "zod"; + +export const createColumnSchema = z.object({ + name: z.string().min(1), + type: z.string().min(1), + nullable: z.boolean().default(true), + defaultValue: z.string().nullable().optional(), + primaryKey: z.boolean().default(false) +}); + +export const updateColumnSchema = z.object({ + type: z.string().optional(), + nullable: z.boolean().optional(), + defaultValue: z.string().nullable().optional() +}); diff --git a/backend/src/modules/schema/schema.service.ts b/backend/src/modules/schema/schema.service.ts new file mode 100644 index 0000000..35cc1d3 --- /dev/null +++ b/backend/src/modules/schema/schema.service.ts @@ -0,0 +1,82 @@ +import { getTargetPool } from "../../db/target.js"; +import { AppError } from "../../lib/errors.js"; +import { assertSafeIdentifier, quoteIdentifier } from "../../lib/identifiers.js"; + +type CreateColumnInput = { + name: string; + type: string; + nullable: boolean; + defaultValue?: string | null; + primaryKey: boolean; +}; + +type UpdateColumnInput = { + type?: string; + nullable?: boolean; + defaultValue?: string | null; +}; + +export async function addColumn(table: string, input: CreateColumnInput) { + assertSafeIdentifier(table, "table"); + assertSafeIdentifier(input.name, "column"); + const pool = getTargetPool(); + await pool.query( + `alter table ${quoteIdentifier(table)} add column ${quoteIdentifier(input.name)} ${input.type}` + ); + if (!input.nullable) { + await pool.query( + `alter table ${quoteIdentifier(table)} alter column ${quoteIdentifier(input.name)} set not null` + ); + } + if (input.defaultValue) { + await pool.query( + `alter table ${quoteIdentifier(table)} alter column ${quoteIdentifier(input.name)} set default ${input.defaultValue}` + ); + } + if (input.primaryKey) { + await pool.query( + `alter table ${quoteIdentifier(table)} add primary key (${quoteIdentifier(input.name)})` + ); + } +} + +export async function updateColumn(table: string, column: string, input: UpdateColumnInput) { + assertSafeIdentifier(table, "table"); + assertSafeIdentifier(column, "column"); + const pool = getTargetPool(); + + if (input.type) { + await pool.query( + `alter table ${quoteIdentifier(table)} alter column ${quoteIdentifier(column)} type ${input.type}` + ); + } + + if (typeof input.nullable === "boolean") { + await pool.query( + `alter table ${quoteIdentifier(table)} alter column ${quoteIdentifier(column)} ${ + input.nullable ? "drop not null" : "set not null" + }` + ); + } + + if (input.defaultValue !== undefined) { + await pool.query( + input.defaultValue === null || input.defaultValue === "" + ? `alter table ${quoteIdentifier(table)} alter column ${quoteIdentifier(column)} drop default` + : `alter table ${quoteIdentifier(table)} alter column ${quoteIdentifier(column)} set default ${input.defaultValue}` + ); + } +} + +export async function dropColumn(table: string, column: string) { + assertSafeIdentifier(table, "table"); + assertSafeIdentifier(column, "column"); + const pool = getTargetPool(); + await pool.query(`alter table ${quoteIdentifier(table)} drop column ${quoteIdentifier(column)} cascade`); +} + +export async function ensureSchemaMutationsEnabled() { + if (process.env.FEATURE_SCHEMA_MUTATIONS === "false") { + throw new AppError(403, "SCHEMA_MUTATIONS_DISABLED", "Schema mutation feature is disabled"); + } +} diff --git a/backend/src/modules/sql-console/sql-console.routes.ts b/backend/src/modules/sql-console/sql-console.routes.ts new file mode 100644 index 0000000..70d93a2 --- /dev/null +++ b/backend/src/modules/sql-console/sql-console.routes.ts @@ -0,0 +1,34 @@ +import { Router } from "express"; +import { ok } from "../../lib/api-response.js"; +import { AppError } from "../../lib/errors.js"; +import { requireAuth } from "../../middleware/auth.js"; +import { requirePermission } from "../../middleware/permission.js"; +import { validateBody } from "../../middleware/validate.js"; +import { executeSqlSchema } from "./sql-console.schemas.js"; +import { executeSql } from "./sql-console.service.js"; + +export const sqlConsoleRouter = Router(); + +sqlConsoleRouter.post( + "/execute", + requireAuth, + requirePermission("sql_console", "execute_sql"), + validateBody(executeSqlSchema), + async (req, res, next) => { + try { + if (!req.user) { + throw new AppError(401, "UNAUTHORIZED", "Authentication is required"); + } + res.json( + ok( + await executeSql(req.body.sql, req.user, { + ip: req.ip, + userAgent: req.headers["user-agent"] as string | undefined + }) + ) + ); + } catch (error) { + next(error); + } + } +); diff --git a/backend/src/modules/sql-console/sql-console.schemas.ts b/backend/src/modules/sql-console/sql-console.schemas.ts new file mode 100644 index 0000000..1a2d204 --- /dev/null +++ b/backend/src/modules/sql-console/sql-console.schemas.ts @@ -0,0 +1,5 @@ +import { z } from "zod"; + +export const executeSqlSchema = z.object({ + sql: z.string().min(1) +}); diff --git a/backend/src/modules/sql-console/sql-console.service.ts b/backend/src/modules/sql-console/sql-console.service.ts new file mode 100644 index 0000000..adbece5 --- /dev/null +++ b/backend/src/modules/sql-console/sql-console.service.ts @@ -0,0 +1,58 @@ +import { createHash } from "node:crypto"; +import { getTargetPool } from "../../db/target.js"; +import { AppError } from "../../lib/errors.js"; +import { guardSql } from "../../lib/sql-guard.js"; +import type { SessionUser } from "../../types/auth.js"; +import { createAuditEvent } from "../audit/audit.service.js"; + +function isReadOnly(user: SessionUser) { + if (user.isRoot) { + return false; + } + + const hasWrite = user.permissions.some((grant) => grant.action === "write" || grant.action === "schema_change"); + return !hasWrite; +} + +export async function executeSql(sql: string, user: SessionUser, context: { ip?: string; userAgent?: string }) { + if (process.env.FEATURE_SQL_CONSOLE === "false") { + throw new AppError(403, "SQL_CONSOLE_DISABLED", "SQL console feature is disabled"); + } + + const guard = guardSql(sql, { + allowMultiStatement: user.isRoot, + readOnly: isReadOnly(user), + allowSchemaChanges: user.isRoot || user.permissions.some((grant) => grant.action === "schema_change") + }); + + const pool = getTargetPool(); + const startedAt = Date.now(); + const result = await pool.query(guard.normalized); + const durationMs = Date.now() - startedAt; + const maskedSql = guard.normalized.slice(0, 4000); + + await createAuditEvent({ + actorUserId: user.id, + action: "sql.execute", + resourceType: "sql_console", + resourceName: guard.statementType, + sqlTextMasked: maskedSql, + payloadAfter: { + rowCount: result.rowCount, + durationMs, + statementHash: createHash("sha256").update(guard.normalized).digest("hex") + }, + ip: context.ip ?? null, + userAgent: context.userAgent ?? null, + status: "success" + }); + + return { + rows: result.rows, + fields: result.fields.map((field) => ({ name: field.name, dataTypeId: field.dataTypeID })), + rowCount: result.rowCount ?? 0, + durationMs, + statementType: guard.statementType, + notice: guard.isMutating ? "Mutation executed" : "Query executed" + }; +} diff --git a/backend/src/modules/tables/table.schemas.ts b/backend/src/modules/tables/table.schemas.ts new file mode 100644 index 0000000..d76f343 --- /dev/null +++ b/backend/src/modules/tables/table.schemas.ts @@ -0,0 +1,17 @@ +import { z } from "zod"; + +export const createTableSchema = z.object({ + name: z.string().min(1), + columns: z + .array( + z.object({ + name: z.string().min(1), + type: z.string().min(1), + nullable: z.boolean().default(true), + primaryKey: z.boolean().default(false), + defaultValue: z.string().nullable().optional() + }) + ) + .min(1), + groupSlug: z.string().min(1).optional() +}); diff --git a/backend/src/modules/tables/tables.routes.ts b/backend/src/modules/tables/tables.routes.ts new file mode 100644 index 0000000..26800f9 --- /dev/null +++ b/backend/src/modules/tables/tables.routes.ts @@ -0,0 +1,77 @@ +import { Router } from "express"; +import { ok } from "../../lib/api-response.js"; +import { getSingleParam } from "../../lib/http.js"; +import { requirePermission, requireTableAccess } from "../../middleware/permission.js"; +import { validateBody } from "../../middleware/validate.js"; +import { createAuditEvent } from "../audit/audit.service.js"; +import { createTableSchema } from "./table.schemas.js"; +import { createTable, dropTable, getTableRelations, getTableStructure, listTables } from "./tables.service.js"; + +export const tablesRouter = Router(); + +tablesRouter.get("/", async (_req, res, next) => { + try { + res.json(ok(await listTables())); + } catch (error) { + next(error); + } +}); + +tablesRouter.post( + "/", + requirePermission("database", "schema_change"), + validateBody(createTableSchema), + async (req, res, next) => { + try { + await createTable(req.body); + await createAuditEvent({ + actorUserId: req.user?.id ?? null, + action: "schema.table.create", + resourceType: "table", + resourceName: req.body.name, + payloadAfter: req.body, + ip: req.ip, + userAgent: req.headers["user-agent"] ?? null, + status: "success" + }); + res.status(201).json(ok({ created: true })); + } catch (error) { + next(error); + } + } +); + +tablesRouter.delete("/:table", requireTableAccess("schema_change"), async (req, res, next) => { + try { + const tableName = getSingleParam(req.params.table); + await dropTable(tableName); + await createAuditEvent({ + actorUserId: req.user?.id ?? null, + action: "schema.table.delete", + resourceType: "table", + resourceName: tableName, + ip: req.ip, + userAgent: req.headers["user-agent"] ?? null, + status: "success" + }); + res.json(ok({ deleted: true })); + } catch (error) { + next(error); + } +}); + +tablesRouter.get("/:table/structure", requireTableAccess("read"), async (req, res, next) => { + try { + res.json(ok(await getTableStructure(getSingleParam(req.params.table)))); + } catch (error) { + next(error); + } +}); + +tablesRouter.get("/:table/relations", requireTableAccess("read"), async (req, res, next) => { + try { + res.json(ok(await getTableRelations(getSingleParam(req.params.table)))); + } catch (error) { + next(error); + } +}); diff --git a/backend/src/modules/tables/tables.service.ts b/backend/src/modules/tables/tables.service.ts new file mode 100644 index 0000000..15d4541 --- /dev/null +++ b/backend/src/modules/tables/tables.service.ts @@ -0,0 +1,148 @@ +import { getTargetPool } from "../../db/target.js"; +import { controlPool } from "../../db/control.js"; +import { AppError } from "../../lib/errors.js"; +import { assertSafeIdentifier, quoteIdentifier } from "../../lib/identifiers.js"; + +type CreateTableInput = { + name: string; + columns: Array<{ + name: string; + type: string; + nullable: boolean; + primaryKey: boolean; + defaultValue?: string | null; + }>; + groupSlug?: string; +}; + +export async function listTables() { + const pool = getTargetPool(); + const result = await pool.query( + ` + select + t.table_name as name, + t.table_schema as schema, + coalesce(s.n_live_tup, 0)::bigint as rows, + coalesce(g.slug, split_part(t.table_name, '__', 1), 'default') as group_slug + from information_schema.tables t + left join pg_stat_user_tables s + on s.relname = t.table_name + left join resource_group_tables rgt + on rgt.table_name = t.table_name + left join resource_groups g + on g.id = rgt.group_id + where t.table_schema = 'public' + and t.table_type = 'BASE TABLE' + order by t.table_name + ` + ); + + return result.rows; +} + +export async function getTableStructure(table: string) { + assertSafeIdentifier(table, "table"); + const pool = getTargetPool(); + const result = await pool.query( + ` + select + c.column_name as name, + c.data_type as type, + c.is_nullable = 'YES' as nullable, + c.column_default as default_value, + exists ( + select 1 + from information_schema.table_constraints tc + join information_schema.key_column_usage kcu + on tc.constraint_name = kcu.constraint_name + and tc.table_schema = kcu.table_schema + where tc.constraint_type = 'PRIMARY KEY' + and tc.table_schema = c.table_schema + and tc.table_name = c.table_name + and kcu.column_name = c.column_name + ) as is_primary + from information_schema.columns c + where c.table_schema = 'public' + and c.table_name = $1 + order by c.ordinal_position + `, + [table] + ); + + return result.rows; +} + +export async function getTableRelations(table: string) { + assertSafeIdentifier(table, "table"); + const pool = getTargetPool(); + const result = await pool.query( + ` + select + tc.constraint_name, + kcu.column_name, + ccu.table_name as foreign_table_name, + ccu.column_name as foreign_column_name + from information_schema.table_constraints tc + join information_schema.key_column_usage kcu + on tc.constraint_name = kcu.constraint_name + join information_schema.constraint_column_usage ccu + on ccu.constraint_name = tc.constraint_name + where tc.constraint_type = 'FOREIGN KEY' + and tc.table_schema = 'public' + and tc.table_name = $1 + order by tc.constraint_name + `, + [table] + ); + return result.rows; +} + +export async function createTable(input: CreateTableInput) { + assertSafeIdentifier(input.name, "table"); + const pool = getTargetPool(); + + const primaryKeys = input.columns.filter((column) => column.primaryKey); + if (primaryKeys.length > 1) { + throw new AppError(400, "INVALID_PRIMARY_KEY", "Composite primary keys are not supported in v1"); + } + + const definitions = input.columns.map((column) => { + assertSafeIdentifier(column.name, "column"); + const pieces = [`${quoteIdentifier(column.name)} ${column.type}`]; + if (!column.nullable) { + pieces.push("NOT NULL"); + } + if (column.defaultValue) { + pieces.push(`DEFAULT ${column.defaultValue}`); + } + if (column.primaryKey) { + pieces.push("PRIMARY KEY"); + } + return pieces.join(" "); + }); + + await pool.query(`create table ${quoteIdentifier(input.name)} (${definitions.join(", ")})`); + + if (input.groupSlug) { + const groupResult = await controlPool.query<{ id: string }>( + `select id from resource_groups where slug = $1 limit 1`, + [input.groupSlug] + ); + if (groupResult.rows[0]) { + await controlPool.query( + ` + insert into resource_group_tables (group_id, table_name) + values ($1, $2) + on conflict (group_id, table_name) do nothing + `, + [groupResult.rows[0].id, input.name] + ); + } + } +} + +export async function dropTable(table: string) { + assertSafeIdentifier(table, "table"); + const pool = getTargetPool(); + await pool.query(`drop table ${quoteIdentifier(table)} cascade`); +} diff --git a/backend/src/modules/users/users.routes.ts b/backend/src/modules/users/users.routes.ts new file mode 100644 index 0000000..9bab3ec --- /dev/null +++ b/backend/src/modules/users/users.routes.ts @@ -0,0 +1,40 @@ +import { Router } from "express"; +import { ok } from "../../lib/api-response.js"; +import { getSingleParam } from "../../lib/http.js"; +import { requirePermission } from "../../middleware/permission.js"; +import { validateBody } from "../../middleware/validate.js"; +import { createUserSchema, updateUserSchema } from "./users.schemas.js"; +import { createUser, listUsers, updateUser } from "./users.service.js"; + +export const usersRouter = Router(); + +usersRouter.get("/", requirePermission("users", "manage_users"), async (_req, res, next) => { + try { + res.json(ok(await listUsers())); + } catch (error) { + next(error); + } +}); + +usersRouter.post("/", requirePermission("users", "manage_users"), validateBody(createUserSchema), async (req, res, next) => { + try { + const userId = await createUser(req.body); + res.status(201).json(ok({ id: userId })); + } catch (error) { + next(error); + } +}); + +usersRouter.put( + "/:id", + requirePermission("users", "manage_users"), + validateBody(updateUserSchema), + async (req, res, next) => { + try { + await updateUser(getSingleParam(req.params.id), req.body); + res.json(ok({ updated: true })); + } catch (error) { + next(error); + } + } +); diff --git a/backend/src/modules/users/users.schemas.ts b/backend/src/modules/users/users.schemas.ts new file mode 100644 index 0000000..c55c013 --- /dev/null +++ b/backend/src/modules/users/users.schemas.ts @@ -0,0 +1,13 @@ +import { z } from "zod"; + +export const createUserSchema = z.object({ + username: z.string().min(1), + password: z.string().min(8), + roleIds: z.array(z.string()).default([]) +}); + +export const updateUserSchema = z.object({ + isActive: z.boolean().optional(), + isLocked: z.boolean().optional(), + roleIds: z.array(z.string()).optional() +}); diff --git a/backend/src/modules/users/users.service.ts b/backend/src/modules/users/users.service.ts new file mode 100644 index 0000000..82578c7 --- /dev/null +++ b/backend/src/modules/users/users.service.ts @@ -0,0 +1,63 @@ +import argon2 from "argon2"; +import { controlPool } from "../../db/control.js"; + +export async function listUsers() { + const result = await controlPool.query( + ` + select + u.id, + u.username, + u.is_active, + u.is_locked, + u.created_at, + coalesce(json_agg(json_build_object('id', r.id, 'slug', r.slug, 'name', r.name)) + filter (where r.id is not null), '[]'::json) as roles + 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 + ` + ); + return result.rows; +} + +export async function createUser(input: { username: string; password: string; roleIds: string[] }) { + const passwordHash = await argon2.hash(input.password); + const userResult = await controlPool.query<{ id: string }>( + ` + insert into users (username, password_hash, is_active, is_locked) + values ($1, $2, true, false) + returning id + `, + [input.username, passwordHash] + ); + const userId = userResult.rows[0]?.id; + for (const roleId of input.roleIds) { + await controlPool.query( + `insert into user_roles (user_id, role_id) values ($1, $2) on conflict do nothing`, + [userId, roleId] + ); + } + return userId; +} + +export async function updateUser(id: string, input: { isActive?: boolean; isLocked?: boolean; roleIds?: string[] }) { + await controlPool.query( + ` + update users + set + is_active = coalesce($2, is_active), + is_locked = coalesce($3, is_locked) + where id = $1 + `, + [id, input.isActive ?? null, input.isLocked ?? null] + ); + + if (input.roleIds) { + await controlPool.query(`delete from user_roles where user_id = $1`, [id]); + for (const roleId of input.roleIds) { + await controlPool.query(`insert into user_roles (user_id, role_id) values ($1, $2)`, [id, roleId]); + } + } +} diff --git a/backend/src/types/auth.ts b/backend/src/types/auth.ts new file mode 100644 index 0000000..e03c38d --- /dev/null +++ b/backend/src/types/auth.ts @@ -0,0 +1,34 @@ +export type PermissionAction = + | "read" + | "write" + | "delete" + | "schema_change" + | "execute_sql" + | "view_logs" + | "manage_users" + | "manage_roles"; + +export type PermissionResource = + | "database" + | "group" + | "table" + | "sql_console" + | "logs" + | "users" + | "roles" + | "audit"; + +export type PermissionGrant = { + resource: PermissionResource; + action: PermissionAction; + scopeType: "global" | "group" | "table"; + scopeValue: string | null; +}; + +export type SessionUser = { + id: string; + username: string; + roleSlug: string; + isRoot: boolean; + permissions: PermissionGrant[]; +}; diff --git a/backend/src/types/express.d.ts b/backend/src/types/express.d.ts new file mode 100644 index 0000000..f0b3c44 --- /dev/null +++ b/backend/src/types/express.d.ts @@ -0,0 +1,13 @@ +import type { SessionUser } from "./auth.js"; + +declare global { + namespace Express { + interface Request { + requestId: string; + user?: SessionUser; + sessionId?: string; + } + } +} + +export {}; diff --git a/backend/tests/identifiers.test.ts b/backend/tests/identifiers.test.ts new file mode 100644 index 0000000..fe295d0 --- /dev/null +++ b/backend/tests/identifiers.test.ts @@ -0,0 +1,15 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { assertSafeIdentifier, quoteQualifiedName } from "../src/lib/identifiers.js"; + +test("safe identifier accepts public_table", () => { + assert.doesNotThrow(() => assertSafeIdentifier("finance_table")); +}); + +test("safe identifier rejects SQL injection attempts", () => { + assert.throws(() => assertSafeIdentifier("users; drop table users")); +}); + +test("quoteQualifiedName supports schema-qualified names", () => { + assert.equal(quoteQualifiedName("public.users"), "\"public\".\"users\""); +}); diff --git a/backend/tests/sql-guard.test.ts b/backend/tests/sql-guard.test.ts new file mode 100644 index 0000000..1955945 --- /dev/null +++ b/backend/tests/sql-guard.test.ts @@ -0,0 +1,27 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { guardSql } from "../src/lib/sql-guard.js"; + +test("guardSql blocks DROP DATABASE", () => { + assert.throws( + () => + guardSql("DROP DATABASE appdb", { + allowMultiStatement: false, + readOnly: false, + allowSchemaChanges: true + }), + /blocked/i + ); +}); + +test("guardSql blocks writes for read-only users", () => { + assert.throws( + () => + guardSql("update users set name = 'x'", { + allowMultiStatement: false, + readOnly: true, + allowSchemaChanges: false + }), + /Read-only/i + ); +}); diff --git a/backend/tsconfig.json b/backend/tsconfig.json new file mode 100644 index 0000000..60d0d59 --- /dev/null +++ b/backend/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "outDir": "dist", + "rootDir": "src", + "strict": true, + "skipLibCheck": true, + "esModuleInterop": true, + "resolveJsonModule": true, + "types": [ + "node" + ] + }, + "include": [ + "src/**/*.ts", + "src/**/*.d.ts" + ] +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..4d72c5d --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,45 @@ +services: + postgres-control: + image: postgres:17-alpine + environment: + POSTGRES_DB: pg_admin_control + POSTGRES_USER: pgadmin + POSTGRES_PASSWORD: pgadmin + ports: + - "5433:5432" + volumes: + - ./infra/init/001-control.sql:/docker-entrypoint-initdb.d/001-control.sql:ro + - ./infra/init/002-seed-root.sql:/docker-entrypoint-initdb.d/002-seed-root.sql:ro + + postgres-target: + image: postgres:17-alpine + environment: + POSTGRES_DB: appdb + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + ports: + - "5432:5432" + volumes: + - ./infra/init/010-target.sql:/docker-entrypoint-initdb.d/010-target.sql:ro + - ./docker/postgres/postgresql.log:/var/log/postgresql/postgresql.log + + backend: + build: + context: . + dockerfile: backend/Dockerfile + env_file: + - .env.example + depends_on: + - postgres-control + - postgres-target + ports: + - "4000:4000" + + frontend: + build: + context: . + dockerfile: frontend/Dockerfile + depends_on: + - backend + ports: + - "8080:80" diff --git a/docker/nginx/default.conf b/docker/nginx/default.conf new file mode 100644 index 0000000..413105f --- /dev/null +++ b/docker/nginx/default.conf @@ -0,0 +1,24 @@ +server { + listen 80; + server_name _; + + root /usr/share/nginx/html; + index index.html; + + location /api/ { + proxy_pass http://backend:4000; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + location /health/ { + proxy_pass http://backend:4000; + } + + location / { + try_files $uri /index.html; + } +} diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..726ce2e --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,16 @@ +# PostgreSQL Admin Control Plane + +## Overview +This repository contains a production-oriented PostgreSQL admin panel with: + +- `backend`: Express + TypeScript API, RBAC, audit, session management, SQL guard +- `frontend`: React + TypeScript admin console +- `infra`: SQL bootstrap for control and target databases +- `docker`: production-style images and reverse proxy + +## Key Design Decisions +- Metadata, sessions, RBAC, and audit live in a dedicated control database. +- Managed PostgreSQL access is mediated by the backend only. +- Dynamic schema operations use `pg` with validated identifiers instead of an ORM. +- Permissions are enforced on the backend for every sensitive route. +- The UI preserves the visual direction of the original `index.html`, but is split into modular React components. diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..1b71650 --- /dev/null +++ b/frontend/Dockerfile @@ -0,0 +1,15 @@ +FROM node:24-alpine AS base +WORKDIR /app +COPY package.json /app/package.json +COPY frontend/package.json /app/frontend/package.json +RUN npm install --workspaces --include-workspace-root=false + +FROM base AS build +COPY frontend /app/frontend +WORKDIR /app/frontend +RUN npm run build + +FROM nginx:1.29-alpine AS runtime +COPY docker/nginx/default.conf /etc/nginx/conf.d/default.conf +COPY --from=build /app/frontend/dist /usr/share/nginx/html +EXPOSE 80 diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..59e5aea --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,18 @@ + + + + + + PostgreSQL SensoLab Panel + + + + + + +
+ + diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..f2f2fa4 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,26 @@ +{ + "name": "@pg-admin/frontend", + "private": true, + "version": "1.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "preview": "vite preview", + "test": "node -e \"console.log('frontend tests are scaffolded for future setup')\"" + }, + "dependencies": { + "@tanstack/react-query": "^5.83.0", + "lucide-react": "^0.542.0", + "react": "^19.1.1", + "react-dom": "^19.1.1", + "react-router-dom": "^7.8.0" + }, + "devDependencies": { + "@types/react": "^19.1.8", + "@types/react-dom": "^19.1.6", + "@vitejs/plugin-react": "^5.0.0", + "typescript": "^5.9.2", + "vite": "^7.1.3" + } +} diff --git a/frontend/src/app/App.tsx b/frontend/src/app/App.tsx new file mode 100644 index 0000000..35f624d --- /dev/null +++ b/frontend/src/app/App.tsx @@ -0,0 +1,6 @@ +import { RouterProvider } from "react-router-dom"; +import { router } from "./router"; + +export function App() { + return ; +} diff --git a/frontend/src/app/providers.tsx b/frontend/src/app/providers.tsx new file mode 100644 index 0000000..b47e418 --- /dev/null +++ b/frontend/src/app/providers.tsx @@ -0,0 +1,8 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import type { PropsWithChildren } from "react"; + +const queryClient = new QueryClient(); + +export function AppProviders({ children }: PropsWithChildren) { + return {children}; +} diff --git a/frontend/src/app/router.tsx b/frontend/src/app/router.tsx new file mode 100644 index 0000000..a702afe --- /dev/null +++ b/frontend/src/app/router.tsx @@ -0,0 +1,33 @@ +import { createBrowserRouter, Navigate } from "react-router-dom"; +import { DashboardPage } from "../pages/DashboardPage"; +import { LoginPage } from "../pages/LoginPage"; +import { UsersPage } from "../pages/UsersPage"; +import { RolesPage } from "../pages/RolesPage"; +import { AuditPage } from "../pages/AuditPage"; + +export const router = createBrowserRouter([ + { + path: "/login", + element: + }, + { + path: "/", + element: + }, + { + path: "/users", + element: + }, + { + path: "/roles", + element: + }, + { + path: "/audit", + element: + }, + { + path: "*", + element: + } +]); diff --git a/frontend/src/app/styles.css b/frontend/src/app/styles.css new file mode 100644 index 0000000..073e590 --- /dev/null +++ b/frontend/src/app/styles.css @@ -0,0 +1,297 @@ +:root { + color-scheme: light; + font-family: "Inter", sans-serif; + --bg: #f4f7fb; + --panel: rgba(255, 255, 255, 0.96); + --panel-solid: #ffffff; + --sidebar: #0f172a; + --sidebar-muted: #94a3b8; + --line: #dbe3ef; + --text: #1e293b; + --muted: #64748b; + --primary: #2563eb; + --primary-soft: #dbeafe; + --success: #16a34a; + --danger: #dc2626; + --shadow: 0 30px 70px rgba(15, 23, 42, 0.12); + --radius: 18px; +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + background: + radial-gradient(circle at top right, rgba(37, 99, 235, 0.18), transparent 28%), + linear-gradient(180deg, #f8fbff 0%, #eef4fb 100%); + color: var(--text); +} + +button, +input, +select, +textarea { + font: inherit; +} + +#root { + min-height: 100vh; +} + +.app-shell { + display: grid; + grid-template-columns: 280px 1fr; + min-height: 100vh; +} + +.sidebar { + background: linear-gradient(180deg, #0f172a 0%, #111827 100%); + color: white; + padding: 24px; + display: flex; + flex-direction: column; + gap: 20px; +} + +.brand { + display: flex; + align-items: center; + gap: 12px; + font-weight: 700; +} + +.badge { + background: rgba(37, 99, 235, 0.18); + color: #bfdbfe; + border: 1px solid rgba(191, 219, 254, 0.24); + padding: 4px 10px; + border-radius: 999px; + font-size: 12px; +} + +.sidebar-group { + display: flex; + flex-direction: column; + gap: 10px; +} + +.sidebar-title { + color: var(--sidebar-muted); + text-transform: uppercase; + letter-spacing: 0.12em; + font-size: 11px; + font-weight: 700; +} + +.sidebar-item, +.nav-link { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + color: rgba(255, 255, 255, 0.92); + text-decoration: none; + padding: 12px 14px; + border-radius: 14px; + transition: 0.2s ease; + border: 1px solid transparent; +} + +.sidebar-item:hover, +.nav-link:hover, +.sidebar-item.active, +.nav-link.active { + background: rgba(30, 41, 59, 0.9); + border-color: rgba(59, 130, 246, 0.25); +} + +.main-area { + padding: 24px; + display: flex; + flex-direction: column; + gap: 20px; +} + +.topbar, +.panel, +.card { + background: var(--panel); + border: 1px solid rgba(219, 227, 239, 0.9); + box-shadow: var(--shadow); + backdrop-filter: blur(18px); + border-radius: var(--radius); +} + +.topbar { + padding: 18px 22px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; +} + +.content-grid { + display: grid; + grid-template-columns: minmax(0, 1fr) 340px; + gap: 20px; +} + +.panel { + padding: 20px; +} + +.section-title { + margin: 0 0 14px; + font-size: 22px; +} + +.muted { + color: var(--muted); +} + +.row { + display: flex; + gap: 12px; + flex-wrap: wrap; +} + +.input, +.select, +.textarea { + width: 100%; + border: 1px solid var(--line); + background: white; + border-radius: 14px; + padding: 12px 14px; + outline: none; +} + +.textarea { + min-height: 180px; + resize: vertical; + font-family: "JetBrains Mono", monospace; +} + +.button { + border: 0; + border-radius: 14px; + padding: 12px 16px; + cursor: pointer; + font-weight: 600; + transition: 0.2s ease; +} + +.button.primary { + background: var(--primary); + color: white; +} + +.button.secondary { + background: #e2e8f0; + color: var(--text); +} + +.button.danger { + background: #fee2e2; + color: var(--danger); +} + +.button:hover { + transform: translateY(-1px); +} + +.table-wrap { + overflow: auto; + border: 1px solid var(--line); + border-radius: 16px; +} + +table { + width: 100%; + border-collapse: collapse; +} + +th, +td { + text-align: left; + padding: 12px 14px; + border-bottom: 1px solid #edf2f7; + vertical-align: top; +} + +thead { + background: #f8fafc; +} + +.tabs { + display: flex; + gap: 10px; + flex-wrap: wrap; + margin-bottom: 16px; +} + +.tab { + border: 1px solid var(--line); + background: white; + border-radius: 999px; + padding: 10px 14px; + cursor: pointer; + font-weight: 600; +} + +.tab.active { + background: var(--primary); + color: white; + border-color: var(--primary); +} + +.login-page { + min-height: 100vh; + display: grid; + place-items: center; + padding: 24px; +} + +.login-card { + width: min(100%, 420px); + padding: 36px; +} + +.stack { + display: grid; + gap: 14px; +} + +.status-pill { + display: inline-flex; + align-items: center; + gap: 8px; + background: #eff6ff; + color: #1d4ed8; + border-radius: 999px; + padding: 8px 12px; + font-size: 13px; + font-weight: 600; +} + +.code { + font-family: "JetBrains Mono", monospace; +} + +.empty-state { + padding: 48px 20px; + text-align: center; + color: var(--muted); +} + +@media (max-width: 1080px) { + .app-shell { + grid-template-columns: 1fr; + } + + .content-grid { + grid-template-columns: 1fr; + } +} diff --git a/frontend/src/features/auth/use-session.ts b/frontend/src/features/auth/use-session.ts new file mode 100644 index 0000000..b46f61d --- /dev/null +++ b/frontend/src/features/auth/use-session.ts @@ -0,0 +1,22 @@ +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { api } from "../../shared/api/client"; +import type { SessionUser } from "../../shared/types"; + +export function useSession() { + return useQuery({ + queryKey: ["session"], + queryFn: async () => { + const response = await api.get<{ user: SessionUser }>("/auth/session"); + return response.data.user; + }, + retry: false + }); +} + +export function useLogout() { + const queryClient = useQueryClient(); + return async () => { + await api.post("/auth/logout"); + queryClient.clear(); + }; +} diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx new file mode 100644 index 0000000..37e6213 --- /dev/null +++ b/frontend/src/main.tsx @@ -0,0 +1,10 @@ +import ReactDOM from "react-dom/client"; +import { App } from "./app/App"; +import { AppProviders } from "./app/providers"; +import "./app/styles.css"; + +ReactDOM.createRoot(document.getElementById("root")!).render( + + + +); diff --git a/frontend/src/pages/AuditPage.tsx b/frontend/src/pages/AuditPage.tsx new file mode 100644 index 0000000..f2ade43 --- /dev/null +++ b/frontend/src/pages/AuditPage.tsx @@ -0,0 +1,50 @@ +import { Navigate, useNavigate } from "react-router-dom"; +import { useQuery } from "@tanstack/react-query"; +import { api } from "../shared/api/client"; +import { useLogout, useSession } from "../features/auth/use-session"; +import { Sidebar } from "../widgets/Sidebar"; +import { Topbar } from "../widgets/Topbar"; +import { AuditTable } from "../widgets/AuditTable"; + +export function AuditPage() { + const navigate = useNavigate(); + const session = useSession(); + const logout = useLogout(); + const auditQuery = useQuery({ + queryKey: ["audit"], + queryFn: async () => { + const response = await api.get>>("/audit?page=1&limit=50"); + return response.data; + }, + enabled: session.isSuccess + }); + + if (session.isError) { + return ; + } + + if (session.isLoading || !session.data) { + return
Loading audit log...
; + } + + return ( +
+ undefined} /> +
+ navigate("/")} + onLogout={async () => { + await logout(); + navigate("/login"); + }} + /> +
+

Audit Trail

+

Authentication events, SQL executions, and administrative changes are collected here.

+ +
+
+
+ ); +} diff --git a/frontend/src/pages/DashboardPage.tsx b/frontend/src/pages/DashboardPage.tsx new file mode 100644 index 0000000..2b0ccb3 --- /dev/null +++ b/frontend/src/pages/DashboardPage.tsx @@ -0,0 +1,184 @@ +import { useMemo, useState } from "react"; +import { Navigate, useNavigate } from "react-router-dom"; +import { useQuery } from "@tanstack/react-query"; +import { api } from "../shared/api/client"; +import type { SidebarGroup, TableColumn, TableIndex } from "../shared/types"; +import { useLogout, useSession } from "../features/auth/use-session"; +import { Sidebar } from "../widgets/Sidebar"; +import { Topbar } from "../widgets/Topbar"; +import { DataGrid } from "../widgets/DataGrid"; +import { SchemaEditor } from "../widgets/SchemaEditor"; +import { SqlConsole } from "../widgets/SqlConsole"; +import { LogViewer } from "../widgets/LogViewer"; + +type RecordResponse = { + data: Array>; + meta: { + page: number; + limit: number; + total: number; + totalPages: number; + }; +}; + +export function DashboardPage() { + const navigate = useNavigate(); + const session = useSession(); + const logout = useLogout(); + const [currentTable, setCurrentTable] = useState(null); + const [activeTab, setActiveTab] = useState<"data" | "structure" | "sql" | "logs">("data"); + + const groupsQuery = useQuery({ + queryKey: ["sidebar"], + queryFn: async () => { + const response = await api.get("/navigation/sidebar"); + return response.data; + }, + enabled: session.isSuccess + }); + + const columnsQuery = useQuery({ + queryKey: ["structure", currentTable], + queryFn: async () => { + const response = await api.get(`/tables/${currentTable}/structure`); + return response.data; + }, + enabled: Boolean(currentTable) + }); + + const indexesQuery = useQuery({ + queryKey: ["indexes", currentTable], + queryFn: async () => { + const response = await api.get(`/tables/${currentTable}/indexes`); + return response.data; + }, + enabled: Boolean(currentTable) + }); + + const relationsQuery = useQuery({ + queryKey: ["relations", currentTable], + queryFn: async () => { + const response = await api.get>>(`/tables/${currentTable}/relations`); + return response.data; + }, + enabled: Boolean(currentTable) + }); + + const recordsQuery = useQuery({ + queryKey: ["records", currentTable], + queryFn: async () => { + const response = await api.get>>( + `/tables/${currentTable}/records?page=1&limit=25&search=&filters={}` + ); + return { + data: response.data, + meta: response.meta as RecordResponse["meta"] + }; + }, + enabled: Boolean(currentTable) + }); + + const logsQuery = useQuery({ + queryKey: ["logs"], + queryFn: async () => { + const response = await api.get("/logs?q=&severity=&page=1&limit=50"); + return response.data; + }, + enabled: session.isSuccess + }); + + const tableCount = useMemo( + () => groupsQuery.data?.reduce((sum, group) => sum + group.tables.length, 0) ?? 0, + [groupsQuery.data] + ); + + if (session.isError) { + return ; + } + + if (session.isLoading || !session.data) { + return
Loading session...
; + } + + return ( +
+ +
+ setActiveTab("sql")} + onLogout={async () => { + await logout(); + navigate("/login"); + }} + /> + +
+
+
+

{currentTable ?? "Choose a table from the sidebar"}

+
+ {tableCount} tables across managed groups. Current role: {session.data.roleSlug} +
+
+
Control DB + target DB mediated through backend API
+
+
+ +
+ {[ + ["data", "Data"], + ["structure", "Structure"], + ["sql", "SQL"], + ["logs", "Logs"] + ].map(([value, label]) => ( + + ))} +
+ +
+
+ {activeTab === "data" ? : null} + {activeTab === "structure" ? ( + + ) : null} + + {activeTab === "logs" ? : null} +
+ + +
+
+
+ ); +} diff --git a/frontend/src/pages/LoginPage.tsx b/frontend/src/pages/LoginPage.tsx new file mode 100644 index 0000000..c6208bc --- /dev/null +++ b/frontend/src/pages/LoginPage.tsx @@ -0,0 +1,54 @@ +import { useNavigate } from "react-router-dom"; +import { useState } from "react"; +import { Database } from "lucide-react"; +import { api } from "../shared/api/client"; + +export function LoginPage() { + const navigate = useNavigate(); + const [username, setUsername] = useState("root"); + const [password, setPassword] = useState("root12345"); + const [error, setError] = useState(""); + + return ( +
+
+
+
+ +
PostgreSQL SensoLab
+
+

Secure PostgreSQL administration with RBAC, audit, and SQL guardrails.

+
+ + + {error ?
{error}
: null} + +
+
+ ); +} diff --git a/frontend/src/pages/RolesPage.tsx b/frontend/src/pages/RolesPage.tsx new file mode 100644 index 0000000..97e623a --- /dev/null +++ b/frontend/src/pages/RolesPage.tsx @@ -0,0 +1,69 @@ +import { Navigate, useNavigate } from "react-router-dom"; +import { useQuery } from "@tanstack/react-query"; +import { api } from "../shared/api/client"; +import { useLogout, useSession } from "../features/auth/use-session"; +import { Sidebar } from "../widgets/Sidebar"; +import { Topbar } from "../widgets/Topbar"; + +export function RolesPage() { + const navigate = useNavigate(); + const session = useSession(); + const logout = useLogout(); + const rolesQuery = useQuery({ + queryKey: ["roles"], + queryFn: async () => { + const response = await api.get>>("/roles"); + return response.data; + }, + enabled: session.isSuccess + }); + + if (session.isError) { + return ; + } + + if (session.isLoading || !session.data) { + return
Loading roles...
; + } + + return ( +
+ undefined} /> +
+ navigate("/")} + onLogout={async () => { + await logout(); + navigate("/login"); + }} + /> +
+

Roles and Permissions

+
+ + + + + + + + + + + {(rolesQuery.data ?? []).map((row) => ( + + + + + + + ))} + +
NameSlugDescriptionPermissions
{String(row.name ?? "")}{String(row.slug ?? "")}{String(row.description ?? "")}{JSON.stringify(row.permissions ?? [])}
+
+
+
+
+ ); +} diff --git a/frontend/src/pages/UsersPage.tsx b/frontend/src/pages/UsersPage.tsx new file mode 100644 index 0000000..0798944 --- /dev/null +++ b/frontend/src/pages/UsersPage.tsx @@ -0,0 +1,69 @@ +import { Navigate, useNavigate } from "react-router-dom"; +import { useQuery } from "@tanstack/react-query"; +import { api } from "../shared/api/client"; +import { useLogout, useSession } from "../features/auth/use-session"; +import { Sidebar } from "../widgets/Sidebar"; +import { Topbar } from "../widgets/Topbar"; + +export function UsersPage() { + const navigate = useNavigate(); + const session = useSession(); + const logout = useLogout(); + const usersQuery = useQuery({ + queryKey: ["users"], + queryFn: async () => { + const response = await api.get>>("/users"); + return response.data; + }, + enabled: session.isSuccess + }); + + if (session.isError) { + return ; + } + + if (session.isLoading || !session.data) { + return
Loading users...
; + } + + return ( +
+ undefined} /> +
+ navigate("/")} + onLogout={async () => { + await logout(); + navigate("/login"); + }} + /> +
+

User Management

+
+ + + + + + + + + + + {(usersQuery.data ?? []).map((row) => ( + + + + + + + ))} + +
UsernameActiveLockedRoles
{String(row.username ?? "")}{String(row.is_active ?? "")}{String(row.is_locked ?? "")}{JSON.stringify(row.roles ?? [])}
+
+
+
+
+ ); +} diff --git a/frontend/src/shared/api/client.ts b/frontend/src/shared/api/client.ts new file mode 100644 index 0000000..6a5998f --- /dev/null +++ b/frontend/src/shared/api/client.ts @@ -0,0 +1,55 @@ +export type ApiSuccess = { + success: true; + data: T; + meta: Record; +}; + +export type ApiError = { + success: false; + error: { + code: string; + message: string; + details?: unknown; + requestId: string; + }; +}; + +export type ApiResponse = ApiSuccess | ApiError; + +const API_BASE = "/api/v1"; + +async function request(path: string, init?: RequestInit): Promise> { + const response = await fetch(`${API_BASE}${path}`, { + credentials: "include", + headers: { + "Content-Type": "application/json", + ...(init?.headers ?? {}) + }, + ...init + }); + + const payload = (await response.json()) as ApiResponse; + if (!response.ok || !payload.success) { + throw new Error(payload.success ? "Request failed" : payload.error.message); + } + + return payload; +} + +export const api = { + get: (path: string) => request(path), + post: (path: string, body?: unknown) => + request(path, { + method: "POST", + body: body ? JSON.stringify(body) : undefined + }), + put: (path: string, body?: unknown) => + request(path, { + method: "PUT", + body: body ? JSON.stringify(body) : undefined + }), + delete: (path: string) => + request(path, { + method: "DELETE" + }) +}; diff --git a/frontend/src/shared/types.ts b/frontend/src/shared/types.ts new file mode 100644 index 0000000..58f27f0 --- /dev/null +++ b/frontend/src/shared/types.ts @@ -0,0 +1,41 @@ +export type PermissionGrant = { + resource: string; + action: string; + scopeType: string; + scopeValue: string | null; +}; + +export type SessionUser = { + id: string; + username: string; + roleSlug: string; + isRoot: boolean; + permissions: PermissionGrant[]; +}; + +export type SidebarGroup = { + slug: string; + name: string; + tables: Array<{ + name: string; + schema: string; + group_slug: string; + display_name: string; + estimated_rows: number; + }>; +}; + +export type TableColumn = { + name: string; + type: string; + nullable: boolean; + default_value: string | null; + is_primary: boolean; +}; + +export type TableIndex = { + name: string; + definition: string; + unique: boolean; + type: string; +}; diff --git a/frontend/src/widgets/AuditTable.tsx b/frontend/src/widgets/AuditTable.tsx new file mode 100644 index 0000000..39f8f47 --- /dev/null +++ b/frontend/src/widgets/AuditTable.tsx @@ -0,0 +1,32 @@ +type AuditTableProps = { + rows: Array>; +}; + +export function AuditTable({ rows }: AuditTableProps) { + return ( +
+ + + + + + + + + + + + {rows.map((row) => ( + + + + + + + + ))} + +
TimeActionResourceStatusActor
{String(row.created_at ?? "")}{String(row.action ?? "")}{String(row.resource_type ?? "")}{String(row.status ?? "")}{String(row.actor_user_id ?? "")}
+
+ ); +} diff --git a/frontend/src/widgets/DataGrid.tsx b/frontend/src/widgets/DataGrid.tsx new file mode 100644 index 0000000..ad3656c --- /dev/null +++ b/frontend/src/widgets/DataGrid.tsx @@ -0,0 +1,44 @@ +type DataGridProps = { + rows: Array>; +}; + +export function DataGrid({ rows }: DataGridProps) { + if (rows.length === 0) { + return
No rows found for the current query.
; + } + + const columns = Object.keys(rows[0]); + + return ( +
+ + + + {columns.map((column) => ( + + ))} + + + + {rows.map((row, index) => ( + + {columns.map((column) => ( + + ))} + + ))} + +
{column}
{renderValue(row[column])}
+
+ ); +} + +function renderValue(value: unknown) { + if (value === null || value === undefined) { + return null; + } + if (typeof value === "object") { + return {JSON.stringify(value)}; + } + return String(value); +} diff --git a/frontend/src/widgets/LogViewer.tsx b/frontend/src/widgets/LogViewer.tsx new file mode 100644 index 0000000..f0b41b3 --- /dev/null +++ b/frontend/src/widgets/LogViewer.tsx @@ -0,0 +1,27 @@ +type LogViewerProps = { + logs: string[]; +}; + +export function LogViewer({ logs }: LogViewerProps) { + return ( +
+

PostgreSQL Logs

+
+ + + + + + + + {logs.map((line, index) => ( + + + + ))} + +
Entry
{line}
+
+
+ ); +} diff --git a/frontend/src/widgets/SchemaEditor.tsx b/frontend/src/widgets/SchemaEditor.tsx new file mode 100644 index 0000000..fccae30 --- /dev/null +++ b/frontend/src/widgets/SchemaEditor.tsx @@ -0,0 +1,93 @@ +import type { TableColumn, TableIndex } from "../shared/types"; + +type SchemaEditorProps = { + columns: TableColumn[]; + indexes: TableIndex[]; + relations: Array>; +}; + +export function SchemaEditor({ columns, indexes, relations }: SchemaEditorProps) { + return ( +
+
+

Columns

+
+ + + + + + + + + + + + {columns.map((column) => ( + + + + + + + + ))} + +
NameTypeNullableDefaultPK
{column.name}{column.type}{column.nullable ? "Yes" : "No"}{column.default_value ?? "-"}{column.is_primary ? "Yes" : "No"}
+
+
+ +
+

Indexes

+
+ + + + + + + + + + + {indexes.map((index) => ( + + + + + + + ))} + +
NameTypeUniqueDefinition
{index.name}{index.type}{index.unique ? "Yes" : "No"}{index.definition}
+
+
+ +
+

Relations

+
+ + + + + + + + + + + {relations.map((relation, index) => ( + + + + + + + ))} + +
ConstraintColumnForeign TableForeign Column
{String(relation.constraint_name ?? "")}{String(relation.column_name ?? "")}{String(relation.foreign_table_name ?? "")}{String(relation.foreign_column_name ?? "")}
+
+
+
+ ); +} diff --git a/frontend/src/widgets/Sidebar.tsx b/frontend/src/widgets/Sidebar.tsx new file mode 100644 index 0000000..2444279 --- /dev/null +++ b/frontend/src/widgets/Sidebar.tsx @@ -0,0 +1,65 @@ +import { Database, FileClock, Shield, Users } from "lucide-react"; +import { NavLink } from "react-router-dom"; +import type { SidebarGroup } from "../shared/types"; + +type SidebarProps = { + groups: SidebarGroup[]; + currentTable: string | null; + onSelectTable: (tableName: string) => void; +}; + +export function Sidebar({ groups, currentTable, onSelectTable }: SidebarProps) { + return ( + + ); +} diff --git a/frontend/src/widgets/SqlConsole.tsx b/frontend/src/widgets/SqlConsole.tsx new file mode 100644 index 0000000..bfd9ea5 --- /dev/null +++ b/frontend/src/widgets/SqlConsole.tsx @@ -0,0 +1,87 @@ +import { useState } from "react"; +import { api } from "../shared/api/client"; + +type SqlConsoleProps = { + visible: boolean; +}; + +export function SqlConsole({ visible }: SqlConsoleProps) { + const [sql, setSql] = useState("SELECT table_name FROM information_schema.tables WHERE table_schema = 'public';"); + const [result, setResult] = useState[]>([]); + const [meta, setMeta] = useState<{ + rowCount: number; + durationMs: number; + statementType: string; + } | null>(null); + const [error, setError] = useState(""); + + if (!visible) { + return null; + } + + return ( +
+

SQL Console

+
+ +
+
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + diff --git a/infra/init/001-control.sql b/infra/init/001-control.sql new file mode 100644 index 0000000..ebe7c40 --- /dev/null +++ b/infra/init/001-control.sql @@ -0,0 +1,137 @@ +create extension if not exists "pgcrypto"; + +create table if not exists users ( + id uuid primary key default gen_random_uuid(), + username text unique not null, + password_hash text not null, + is_active boolean not null default true, + is_locked boolean not null default false, + created_at timestamptz not null default now() +); + +create table if not exists roles ( + id uuid primary key default gen_random_uuid(), + slug text unique not null, + name text not null, + description text not null default '', + created_at timestamptz not null default now() +); + +create table if not exists permissions ( + id uuid primary key default gen_random_uuid(), + resource text not null, + action text not null, + unique (resource, action) +); + +create table if not exists role_permissions ( + role_id uuid not null references roles(id) on delete cascade, + permission_id uuid not null references permissions(id) on delete cascade, + scope_type text not null default 'global', + scope_value text null +); + +create unique index if not exists role_permissions_scope_uidx + on role_permissions (role_id, permission_id, scope_type, coalesce(scope_value, '')); + +create table if not exists user_roles ( + user_id uuid not null references users(id) on delete cascade, + role_id uuid not null references roles(id) on delete cascade, + primary key (user_id, role_id) +); + +create table if not exists resource_groups ( + id uuid primary key default gen_random_uuid(), + slug text unique not null, + name text not null +); + +create table if not exists resource_group_tables ( + group_id uuid not null references resource_groups(id) on delete cascade, + table_name text not null, + primary key (group_id, table_name) +); + +create table if not exists sessions ( + id uuid primary key default gen_random_uuid(), + user_id uuid not null references users(id) on delete cascade, + token_hash text not null unique, + expires_at timestamptz not null, + ip text null, + user_agent text null, + created_at timestamptz not null default now() +); + +create table if not exists audit_events ( + id uuid primary key default gen_random_uuid(), + created_at timestamptz not null default now(), + actor_user_id uuid null references users(id) on delete set null, + action text not null, + resource_type text not null, + resource_name text null, + group_id text null, + target_connection_id text null, + sql_text_masked text null, + payload_before jsonb null, + payload_after jsonb null, + ip text null, + user_agent text null, + status text not null +); + +create table if not exists db_connections ( + id uuid primary key default gen_random_uuid(), + name text not null, + host text not null, + port integer not null, + database_name text not null, + is_default boolean not null default false, + created_at timestamptz not null default now() +); + +insert into permissions (resource, action) values + ('database', 'read'), + ('database', 'write'), + ('database', 'delete'), + ('database', 'schema_change'), + ('group', 'read'), + ('group', 'write'), + ('group', 'delete'), + ('group', 'schema_change'), + ('table', 'read'), + ('table', 'write'), + ('table', 'delete'), + ('table', 'schema_change'), + ('sql_console', 'execute_sql'), + ('logs', 'view_logs'), + ('users', 'manage_users'), + ('roles', 'manage_roles'), + ('audit', 'read') +on conflict do nothing; + +insert into roles (slug, name, description) values + ('root', 'Root', 'Full access to the control plane'), + ('folder_admin', 'Folder Admin', 'Admin for selected groups'), + ('user', 'User', 'Read-only or restricted access') +on conflict do nothing; + +insert into resource_groups (slug, name) values + ('finance', 'Finance'), + ('users', 'Users'), + ('logs', 'Logs') +on conflict do nothing; + +insert into resource_group_tables (group_id, table_name) +select g.id, x.table_name +from ( + values + ('finance', 'finance__invoices'), + ('users', 'users__accounts'), + ('logs', 'logs__events') +) as x(group_slug, table_name) +join resource_groups g on g.slug = x.group_slug +on conflict do nothing; + +insert into db_connections (name, host, port, database_name, is_default) +values ('Default Target DB', 'postgres-target', 5432, 'appdb', true) +on conflict do nothing; diff --git a/infra/init/002-seed-root.sql b/infra/init/002-seed-root.sql new file mode 100644 index 0000000..7bc45a9 --- /dev/null +++ b/infra/init/002-seed-root.sql @@ -0,0 +1,25 @@ +do $$ +declare + root_role_id uuid; + root_user_id uuid; +begin + select id into root_role_id from roles where slug = 'root'; + + insert into users (username, password_hash) + values ( + 'root', + 'pbkdf2$sha256$210000$pgadmin-root-seed$2dd2c2adeb3a7f89d8dac01f5f991bdf8d674231ac200f4281552e506452df95' + ) + on conflict (username) do nothing; + + select id into root_user_id from users where username = 'root'; + + insert into user_roles (user_id, role_id) + values (root_user_id, root_role_id) + on conflict do nothing; + + insert into role_permissions (role_id, permission_id, scope_type, scope_value) + select root_role_id, id, 'global', null + from permissions + on conflict do nothing; +end $$; diff --git a/infra/init/010-target.sql b/infra/init/010-target.sql new file mode 100644 index 0000000..9fcb166 --- /dev/null +++ b/infra/init/010-target.sql @@ -0,0 +1,43 @@ +create extension if not exists "pgcrypto"; + +create table if not exists finance__invoices ( + id uuid primary key default gen_random_uuid(), + customer_name text not null, + amount numeric(12,2) not null, + status text not null default 'draft', + created_at timestamptz not null default now() +); + +create table if not exists users__accounts ( + id uuid primary key default gen_random_uuid(), + email text not null unique, + full_name text not null, + is_active boolean not null default true, + created_at timestamptz not null default now() +); + +create table if not exists logs__events ( + id uuid primary key default gen_random_uuid(), + source text not null, + level text not null, + message text not null, + created_at timestamptz not null default now() +); + +insert into finance__invoices (customer_name, amount, status) +values + ('Acme Corp', 1200.50, 'paid'), + ('Northwind', 340.00, 'draft') +on conflict do nothing; + +insert into users__accounts (email, full_name, is_active) +values + ('root@example.com', 'Root Operator', true), + ('analyst@example.com', 'Financial Analyst', true) +on conflict do nothing; + +insert into logs__events (source, level, message) +values + ('system', 'info', 'Bootstrap completed'), + ('postgres', 'warn', 'Autovacuum threshold reached') +on conflict do nothing; diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..3a33d59 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,3247 @@ +{ + "name": "postgres-admin-control-plane", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "postgres-admin-control-plane", + "version": "1.0.0", + "workspaces": [ + "backend", + "frontend" + ] + }, + "backend": { + "name": "@pg-admin/backend", + "version": "1.0.0", + "dependencies": { + "argon2": "^0.41.1", + "cookie": "^1.0.2", + "cors": "^2.8.5", + "dotenv": "^16.6.1", + "express": "^4.21.2", + "express-rate-limit": "^7.5.0", + "helmet": "^8.1.0", + "pg": "^8.16.3", + "zod": "^3.25.67" + }, + "devDependencies": { + "@types/cookie": "^1.0.0", + "@types/cors": "^2.8.19", + "@types/express": "^5.0.3", + "@types/node": "^24.0.10", + "@types/pg": "^8.15.5", + "tsx": "^4.20.3", + "typescript": "^5.9.2" + } + }, + "frontend": { + "name": "@pg-admin/frontend", + "version": "1.0.0", + "dependencies": { + "@tanstack/react-query": "^5.83.0", + "lucide-react": "^0.542.0", + "react": "^19.1.1", + "react-dom": "^19.1.1", + "react-router-dom": "^7.8.0" + }, + "devDependencies": { + "@types/react": "^19.1.8", + "@types/react-dom": "^19.1.6", + "@vitejs/plugin-react": "^5.0.0", + "typescript": "^5.9.2", + "vite": "^7.1.3" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz", + "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", + "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helpers": "^7.28.6", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.1", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", + "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", + "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.28.6", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", + "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", + "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-validator-identifier": "^7.28.5", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz", + "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.2.tgz", + "integrity": "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz", + "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz", + "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz", + "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", + "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", + "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.4.tgz", + "integrity": "sha512-cQPwL2mp2nSmHHJlCyoXgHGhbEPMrEEU5xhkcy3Hs/O7nGZqEpZ2sUtLaL9MORLtDfRvVl2/3PAuEkYZH0Ty8Q==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.4.tgz", + "integrity": "sha512-X9bUgvxiC8CHAGKYufLIHGXPJWnr0OCdR0anD2e21vdvgCI8lIfqFbnoeOz7lBjdrAGUhqLZLcQo6MLhTO2DKQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.4.tgz", + "integrity": "sha512-gdLscB7v75wRfu7QSm/zg6Rx29VLdy9eTr2t44sfTW7CxwAtQghZ4ZnqHk3/ogz7xao0QAgrkradbBzcqFPasw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.4.tgz", + "integrity": "sha512-PzPFnBNVF292sfpfhiyiXCGSn9HZg5BcAz+ivBuSsl6Rk4ga1oEXAamhOXRFyMcjwr2DVtm40G65N3GLeH1Lvw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.4.tgz", + "integrity": "sha512-b7xaGIwdJlht8ZFCvMkpDN6uiSmnxxK56N2GDTMYPr2/gzvfdQN8rTfBsvVKmIVY/X7EM+/hJKEIbbHs9oA4tQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.4.tgz", + "integrity": "sha512-sR+OiKLwd15nmCdqpXMnuJ9W2kpy0KigzqScqHI3Hqwr7IXxBp3Yva+yJwoqh7rE8V77tdoheRYataNKL4QrPw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.4.tgz", + "integrity": "sha512-jnfpKe+p79tCnm4GVav68A7tUFeKQwQyLgESwEAUzyxk/TJr4QdGog9sqWNcUbr/bZt/O/HXouspuQDd9JxFSw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.4.tgz", + "integrity": "sha512-2kb4ceA/CpfUrIcTUl1wrP/9ad9Atrp5J94Lq69w7UwOMolPIGrfLSvAKJp0RTvkPPyn6CIWrNy13kyLikZRZQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.4.tgz", + "integrity": "sha512-aBYgcIxX/wd5n2ys0yESGeYMGF+pv6g0DhZr3G1ZG4jMfruU9Tl1i2Z+Wnj9/KjGz1lTLCcorqE2viePZqj4Eg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.4.tgz", + "integrity": "sha512-7nQOttdzVGth1iz57kxg9uCz57dxQLHWxopL6mYuYthohPKEK0vU0C3O21CcBK6KDlkYVcnDXY099HcCDXd9dA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.4.tgz", + "integrity": "sha512-oPtixtAIzgvzYcKBQM/qZ3R+9TEUd1aNJQu0HhGyqtx6oS7qTpvjheIWBbes4+qu1bNlo2V4cbkISr8q6gRBFA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.4.tgz", + "integrity": "sha512-8mL/vh8qeCoRcFH2nM8wm5uJP+ZcVYGGayMavi8GmRJjuI3g1v6Z7Ni0JJKAJW+m0EtUuARb6Lmp4hMjzCBWzA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.4.tgz", + "integrity": "sha512-1RdrWFFiiLIW7LQq9Q2NES+HiD4NyT8Itj9AUeCl0IVCA459WnPhREKgwrpaIfTOe+/2rdntisegiPWn/r/aAw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.4.tgz", + "integrity": "sha512-tLCwNG47l3sd9lpfyx9LAGEGItCUeRCWeAx6x2Jmbav65nAwoPXfewtAdtbtit/pJFLUWOhpv0FpS6GQAmPrHA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.4.tgz", + "integrity": "sha512-BnASypppbUWyqjd1KIpU4AUBiIhVr6YlHx/cnPgqEkNoVOhHg+YiSVxM1RLfiy4t9cAulbRGTNCKOcqHrEQLIw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.4.tgz", + "integrity": "sha512-+eUqgb/Z7vxVLezG8bVB9SfBie89gMueS+I0xYh2tJdw3vqA/0ImZJ2ROeWwVJN59ihBeZ7Tu92dF/5dy5FttA==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.4.tgz", + "integrity": "sha512-S5qOXrKV8BQEzJPVxAwnryi2+Iq5pB40gTEIT69BQONqR7JH1EPIcQ/Uiv9mCnn05jff9umq/5nqzxlqTOg9NA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.4.tgz", + "integrity": "sha512-xHT8X4sb0GS8qTqiwzHqpY00C95DPAq7nAwX35Ie/s+LO9830hrMd3oX0ZMKLvy7vsonee73x0lmcdOVXFzd6Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.4.tgz", + "integrity": "sha512-RugOvOdXfdyi5Tyv40kgQnI0byv66BFgAqjdgtAKqHoZTbTF2QqfQrFwa7cHEORJf6X2ht+l9ABLMP0dnKYsgg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.4.tgz", + "integrity": "sha512-2MyL3IAaTX+1/qP0O1SwskwcwCoOI4kV2IBX1xYnDDqthmq5ArrW94qSIKCAuRraMgPOmG0RDTA74mzYNQA9ow==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.4.tgz", + "integrity": "sha512-u8fg/jQ5aQDfsnIV6+KwLOf1CmJnfu1ShpwqdwC0uA7ZPwFws55Ngc12vBdeUdnuWoQYx/SOQLGDcdlfXhYmXQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.4.tgz", + "integrity": "sha512-JkTZrl6VbyO8lDQO3yv26nNr2RM2yZzNrNHEsj9bm6dOwwu9OYN28CjzZkH57bh4w0I2F7IodpQvUAEd1mbWXg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.4.tgz", + "integrity": "sha512-/gOzgaewZJfeJTlsWhvUEmUG4tWEY2Spp5M20INYRg2ZKl9QPO3QEEgPeRtLjEWSW8FilRNacPOg8R1uaYkA6g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.4.tgz", + "integrity": "sha512-Z9SExBg2y32smoDQdf1HRwHRt6vAHLXcxD2uGgO/v2jK7Y718Ix4ndsbNMU/+1Qiem9OiOdaqitioZwxivhXYg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.4.tgz", + "integrity": "sha512-DAyGLS0Jz5G5iixEbMHi5KdiApqHBWMGzTtMiJ72ZOLhbu/bzxgAe8Ue8CTS3n3HbIUHQz/L51yMdGMeoxXNJw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.4.tgz", + "integrity": "sha512-+knoa0BDoeXgkNvvV1vvbZX4+hizelrkwmGJBdT17t8FNPwG2lKemmuMZlmaNQ3ws3DKKCxpb4zRZEIp3UxFCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@pg-admin/backend": { + "resolved": "backend", + "link": true + }, + "node_modules/@pg-admin/frontend": { + "resolved": "frontend", + "link": true + }, + "node_modules/@phc/format": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@phc/format/-/format-1.0.0.tgz", + "integrity": "sha512-m7X9U6BG2+J+R1lSOdCiITLLrxm+cWlNI3HUFA92oLO77ObGNzaKdh8pMLqdZcshtkKuV84olNNXDfMc4FezBQ==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-rc.3", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.3.tgz", + "integrity": "sha512-eybk3TjzzzV97Dlj5c+XrBFW57eTNhzod66y9HrBlzJ6NsCrWCp/2kaPS3K9wJmurBC0Tdw4yPjXKZqlznim3Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz", + "integrity": "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.59.0.tgz", + "integrity": "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.59.0.tgz", + "integrity": "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.59.0.tgz", + "integrity": "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.59.0.tgz", + "integrity": "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.59.0.tgz", + "integrity": "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.59.0.tgz", + "integrity": "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.59.0.tgz", + "integrity": "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.59.0.tgz", + "integrity": "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.59.0.tgz", + "integrity": "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.59.0.tgz", + "integrity": "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.59.0.tgz", + "integrity": "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.59.0.tgz", + "integrity": "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.59.0.tgz", + "integrity": "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.59.0.tgz", + "integrity": "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.59.0.tgz", + "integrity": "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.59.0.tgz", + "integrity": "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.59.0.tgz", + "integrity": "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.59.0.tgz", + "integrity": "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.59.0.tgz", + "integrity": "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.59.0.tgz", + "integrity": "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.59.0.tgz", + "integrity": "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.59.0.tgz", + "integrity": "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.59.0.tgz", + "integrity": "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.59.0.tgz", + "integrity": "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@tanstack/query-core": { + "version": "5.91.2", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.91.2.tgz", + "integrity": "sha512-Uz2pTgPC1mhqrrSGg18RKCWT/pkduAYtxbcyIyKBhw7dTWjXZIzqmpzO2lBkyWr4hlImQgpu1m1pei3UnkFRWw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/react-query": { + "version": "5.91.2", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.91.2.tgz", + "integrity": "sha512-GClLPzbM57iFXv+FlvOUL56XVe00PxuTaVEyj1zAObhRiKF008J5vedmaq7O6ehs+VmPHe8+PUQhMuEyv8d9wQ==", + "license": "MIT", + "dependencies": { + "@tanstack/query-core": "5.91.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^18 || ^19" + } + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/body-parser": { + "version": "1.19.6", + "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", + "integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/connect": "*", + "@types/node": "*" + } + }, + "node_modules/@types/connect": { + "version": "3.4.38", + "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", + "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/cookie": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-1.0.0.tgz", + "integrity": "sha512-mGFXbkDQJ6kAXByHS7QAggRXgols0mAdP4MuXgloGY1tXokvzaFFM4SMqWvf7AH0oafI7zlFJwoGWzmhDqTZ9w==", + "deprecated": "This is a stub types definition. cookie provides its own type definitions, so you do not need this installed.", + "dev": true, + "license": "MIT", + "dependencies": { + "cookie": "*" + } + }, + "node_modules/@types/cors": { + "version": "2.8.19", + "resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.19.tgz", + "integrity": "sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/express": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.6.tgz", + "integrity": "sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/body-parser": "*", + "@types/express-serve-static-core": "^5.0.0", + "@types/serve-static": "^2" + } + }, + "node_modules/@types/express-serve-static-core": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-5.1.1.tgz", + "integrity": "sha512-v4zIMr/cX7/d2BpAEX3KNKL/JrT1s43s96lLvvdTmza1oEvDudCqK9aF/djc/SWgy8Yh0h30TZx5VpzqFCxk5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@types/qs": "*", + "@types/range-parser": "*", + "@types/send": "*" + } + }, + "node_modules/@types/http-errors": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz", + "integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "24.12.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.12.0.tgz", + "integrity": "sha512-GYDxsZi3ChgmckRT9HPU0WEhKLP08ev/Yfcq2AstjrDASOYCSXeyjDsHg4v5t4jOj7cyDX3vmprafKlWIG9MXQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.16.0" + } + }, + "node_modules/@types/pg": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.18.0.tgz", + "integrity": "sha512-gT+oueVQkqnj6ajGJXblFR4iavIXWsGAFCk3dP4Kki5+a9R4NMt0JARdk6s8cUKcfUoqP5dAtDSLU8xYUTFV+Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "pg-protocol": "*", + "pg-types": "^2.2.0" + } + }, + "node_modules/@types/qs": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.0.tgz", + "integrity": "sha512-JawvT8iBVWpzTrz3EGw9BTQFg3BQNmwERdKE22vlTxawwtbyUSlMppvZYKLZzB5zgACXdXxbD3m1bXaMqP/9ow==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/range-parser": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", + "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "19.2.14", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", + "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", + "dev": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/@types/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz", + "integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/serve-static": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-2.2.0.tgz", + "integrity": "sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/http-errors": "*", + "@types/node": "*" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.2.0.tgz", + "integrity": "sha512-YmKkfhOAi3wsB1PhJq5Scj3GXMn3WvtQ/JC0xoopuHoXSdmtdStOpFrYaT1kie2YgFBcIe64ROzMYRjCrYOdYw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.29.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-rc.3", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.18.0" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/argon2": { + "version": "0.41.1", + "resolved": "https://registry.npmjs.org/argon2/-/argon2-0.41.1.tgz", + "integrity": "sha512-dqCW8kJXke8Ik+McUcMDltrbuAWETPyU6iq+4AhxqKphWi7pChB/Zgd/Tp/o8xRLbg8ksMj46F/vph9wnxpTzQ==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "@phc/format": "^1.0.0", + "node-addon-api": "^8.1.0", + "node-gyp-build": "^4.8.1" + }, + "engines": { + "node": ">=16.17.0" + } + }, + "node_modules/array-flatten": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", + "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", + "license": "MIT" + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.8", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.8.tgz", + "integrity": "sha512-PCLz/LXGBsNTErbtB6i5u4eLpHeMfi93aUv5duMmj6caNu6IphS4q6UevDnL36sZQv9lrP11dbPKGMaXPwMKfQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/body-parser": { + "version": "1.20.4", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz", + "integrity": "sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "content-type": "~1.0.5", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "~1.2.0", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "on-finished": "~2.4.1", + "qs": "~6.14.0", + "raw-body": "~2.5.3", + "type-is": "~1.6.18", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/body-parser/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/body-parser/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/browserslist": { + "version": "4.28.1", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", + "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.9.0", + "caniuse-lite": "^1.0.30001759", + "electron-to-chromium": "^1.5.263", + "node-releases": "^2.0.27", + "update-browserslist-db": "^1.2.0" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001780", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001780.tgz", + "integrity": "sha512-llngX0E7nQci5BPJDqoZSbuZ5Bcs9F5db7EtgfwBerX9XGtkkiO4NwfDDIRzHTTwcYC8vC7bmeUEPGrKlR/TkQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/content-disposition": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/cookie-signature": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", + "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", + "license": "MIT" + }, + "node_modules/cors": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "license": "MIT", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/dotenv": { + "version": "16.6.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", + "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/electron-to-chromium": { + "version": "1.5.321", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.321.tgz", + "integrity": "sha512-L2C7Q279W2D/J4PLZLk7sebOILDSWos7bMsMNN06rK482umHUrh/3lM8G7IlHFOYip2oAg5nha1rCMxr/rs6ZQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.4.tgz", + "integrity": "sha512-Rq4vbHnYkK5fws5NF7MYTU68FPRE1ajX7heQ/8QXXWqNgqqJ/GkmmyxIzUnf2Sr/bakf8l54716CcMGHYhMrrQ==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.4", + "@esbuild/android-arm": "0.27.4", + "@esbuild/android-arm64": "0.27.4", + "@esbuild/android-x64": "0.27.4", + "@esbuild/darwin-arm64": "0.27.4", + "@esbuild/darwin-x64": "0.27.4", + "@esbuild/freebsd-arm64": "0.27.4", + "@esbuild/freebsd-x64": "0.27.4", + "@esbuild/linux-arm": "0.27.4", + "@esbuild/linux-arm64": "0.27.4", + "@esbuild/linux-ia32": "0.27.4", + "@esbuild/linux-loong64": "0.27.4", + "@esbuild/linux-mips64el": "0.27.4", + "@esbuild/linux-ppc64": "0.27.4", + "@esbuild/linux-riscv64": "0.27.4", + "@esbuild/linux-s390x": "0.27.4", + "@esbuild/linux-x64": "0.27.4", + "@esbuild/netbsd-arm64": "0.27.4", + "@esbuild/netbsd-x64": "0.27.4", + "@esbuild/openbsd-arm64": "0.27.4", + "@esbuild/openbsd-x64": "0.27.4", + "@esbuild/openharmony-arm64": "0.27.4", + "@esbuild/sunos-x64": "0.27.4", + "@esbuild/win32-arm64": "0.27.4", + "@esbuild/win32-ia32": "0.27.4", + "@esbuild/win32-x64": "0.27.4" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/express": { + "version": "4.22.1", + "resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz", + "integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==", + "license": "MIT", + "dependencies": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "~1.20.3", + "content-disposition": "~0.5.4", + "content-type": "~1.0.4", + "cookie": "~0.7.1", + "cookie-signature": "~1.0.6", + "debug": "2.6.9", + "depd": "2.0.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "~1.3.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.0", + "merge-descriptors": "1.0.3", + "methods": "~1.1.2", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "path-to-regexp": "~0.1.12", + "proxy-addr": "~2.0.7", + "qs": "~6.14.0", + "range-parser": "~1.2.1", + "safe-buffer": "5.2.1", + "send": "~0.19.0", + "serve-static": "~1.16.2", + "setprototypeof": "1.2.0", + "statuses": "~2.0.1", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express-rate-limit": { + "version": "7.5.1", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-7.5.1.tgz", + "integrity": "sha512-7iN8iPMDzOMHPUYllBEsQdWVB6fPDMPqwjBaFrgr4Jgr/+okjvzAy+UHlYYL/Vs0OsOrMkwS6PJDkFlJwoxUnw==", + "license": "MIT", + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, + "node_modules/express/node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/express/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/express/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/finalhandler": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", + "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "statuses": "~2.0.2", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/finalhandler/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/finalhandler/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/get-tsconfig": { + "version": "4.13.6", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.6.tgz", + "integrity": "sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/helmet": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/helmet/-/helmet-8.1.0.tgz", + "integrity": "sha512-jOiHyAZsmnr8LqoPGmCjYAaiuWwjAPLgY8ZX2XrmHawt99/u1y6RgrZMTeoPfpUbV96HOalYgz1qzkRbw54Pmg==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/lucide-react": { + "version": "0.542.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.542.0.tgz", + "integrity": "sha512-w3hD8/SQB7+lzU2r4VdFyzzOzKnUjTZIF/MQJGSSvni7Llewni4vuViRppfRAa2guOsY5k4jZyxw/i9DQHv+dw==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/merge-descriptors": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", + "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/node-addon-api": { + "version": "8.6.0", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.6.0.tgz", + "integrity": "sha512-gBVjCaqDlRUk0EwoPNKzIr9KkS9041G/q31IBShPs1Xz6UTA+EXdZADbzqAJQrpDRq71CIMnOP5VMut3SL0z5Q==", + "license": "MIT", + "engines": { + "node": "^18 || ^20 || >= 21" + } + }, + "node_modules/node-gyp-build": { + "version": "4.8.4", + "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz", + "integrity": "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==", + "license": "MIT", + "bin": { + "node-gyp-build": "bin.js", + "node-gyp-build-optional": "optional.js", + "node-gyp-build-test": "build-test.js" + } + }, + "node_modules/node-releases": { + "version": "2.0.36", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.36.tgz", + "integrity": "sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA==", + "dev": true, + "license": "MIT" + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-to-regexp": { + "version": "0.1.12", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz", + "integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==", + "license": "MIT" + }, + "node_modules/pg": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/pg/-/pg-8.20.0.tgz", + "integrity": "sha512-ldhMxz2r8fl/6QkXnBD3CR9/xg694oT6DZQ2s6c/RI28OjtSOpxnPrUCGOBJ46RCUxcWdx3p6kw/xnDHjKvaRA==", + "license": "MIT", + "dependencies": { + "pg-connection-string": "^2.12.0", + "pg-pool": "^3.13.0", + "pg-protocol": "^1.13.0", + "pg-types": "2.2.0", + "pgpass": "1.0.5" + }, + "engines": { + "node": ">= 16.0.0" + }, + "optionalDependencies": { + "pg-cloudflare": "^1.3.0" + }, + "peerDependencies": { + "pg-native": ">=3.0.1" + }, + "peerDependenciesMeta": { + "pg-native": { + "optional": true + } + } + }, + "node_modules/pg-cloudflare": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.3.0.tgz", + "integrity": "sha512-6lswVVSztmHiRtD6I8hw4qP/nDm1EJbKMRhf3HCYaqud7frGysPv7FYJ5noZQdhQtN2xJnimfMtvQq21pdbzyQ==", + "license": "MIT", + "optional": true + }, + "node_modules/pg-connection-string": { + "version": "2.12.0", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.12.0.tgz", + "integrity": "sha512-U7qg+bpswf3Cs5xLzRqbXbQl85ng0mfSV/J0nnA31MCLgvEaAo7CIhmeyrmJpOr7o+zm0rXK+hNnT5l9RHkCkQ==", + "license": "MIT" + }, + "node_modules/pg-int8": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", + "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==", + "license": "ISC", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/pg-pool": { + "version": "3.13.0", + "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.13.0.tgz", + "integrity": "sha512-gB+R+Xud1gLFuRD/QgOIgGOBE2KCQPaPwkzBBGC9oG69pHTkhQeIuejVIk3/cnDyX39av2AxomQiyPT13WKHQA==", + "license": "MIT", + "peerDependencies": { + "pg": ">=8.0" + } + }, + "node_modules/pg-protocol": { + "version": "1.13.0", + "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.13.0.tgz", + "integrity": "sha512-zzdvXfS6v89r6v7OcFCHfHlyG/wvry1ALxZo4LqgUoy7W9xhBDMaqOuMiF3qEV45VqsN6rdlcehHrfDtlCPc8w==", + "license": "MIT" + }, + "node_modules/pg-types": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz", + "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==", + "license": "MIT", + "dependencies": { + "pg-int8": "1.0.1", + "postgres-array": "~2.0.0", + "postgres-bytea": "~1.0.0", + "postgres-date": "~1.0.4", + "postgres-interval": "^1.1.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/pgpass": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz", + "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==", + "license": "MIT", + "dependencies": { + "split2": "^4.1.0" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.8", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", + "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postgres-array": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", + "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/postgres-bytea": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz", + "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-date": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz", + "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-interval": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz", + "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==", + "license": "MIT", + "dependencies": { + "xtend": "^4.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/qs": { + "version": "6.14.2", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz", + "integrity": "sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "2.5.3", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz", + "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/react": { + "version": "19.2.4", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", + "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.4", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", + "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.4" + } + }, + "node_modules/react-refresh": { + "version": "0.18.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz", + "integrity": "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-router": { + "version": "7.13.1", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.13.1.tgz", + "integrity": "sha512-td+xP4X2/6BJvZoX6xw++A2DdEi++YypA69bJUV5oVvqf6/9/9nNlD70YO1e9d3MyamJEBQFEzk6mbfDYbqrSA==", + "license": "MIT", + "dependencies": { + "cookie": "^1.0.1", + "set-cookie-parser": "^2.6.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, + "node_modules/react-router-dom": { + "version": "7.13.1", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.13.1.tgz", + "integrity": "sha512-UJnV3Rxc5TgUPJt2KJpo1Jpy0OKQr0AjgbZzBFjaPJcFOb2Y8jA5H3LT8HUJAiRLlWrEXWHbF1Z4SCZaQjWDHw==", + "license": "MIT", + "dependencies": { + "react-router": "7.13.1" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + } + }, + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, + "node_modules/rollup": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz", + "integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.59.0", + "@rollup/rollup-android-arm64": "4.59.0", + "@rollup/rollup-darwin-arm64": "4.59.0", + "@rollup/rollup-darwin-x64": "4.59.0", + "@rollup/rollup-freebsd-arm64": "4.59.0", + "@rollup/rollup-freebsd-x64": "4.59.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.59.0", + "@rollup/rollup-linux-arm-musleabihf": "4.59.0", + "@rollup/rollup-linux-arm64-gnu": "4.59.0", + "@rollup/rollup-linux-arm64-musl": "4.59.0", + "@rollup/rollup-linux-loong64-gnu": "4.59.0", + "@rollup/rollup-linux-loong64-musl": "4.59.0", + "@rollup/rollup-linux-ppc64-gnu": "4.59.0", + "@rollup/rollup-linux-ppc64-musl": "4.59.0", + "@rollup/rollup-linux-riscv64-gnu": "4.59.0", + "@rollup/rollup-linux-riscv64-musl": "4.59.0", + "@rollup/rollup-linux-s390x-gnu": "4.59.0", + "@rollup/rollup-linux-x64-gnu": "4.59.0", + "@rollup/rollup-linux-x64-musl": "4.59.0", + "@rollup/rollup-openbsd-x64": "4.59.0", + "@rollup/rollup-openharmony-arm64": "4.59.0", + "@rollup/rollup-win32-arm64-msvc": "4.59.0", + "@rollup/rollup-win32-ia32-msvc": "4.59.0", + "@rollup/rollup-win32-x64-gnu": "4.59.0", + "@rollup/rollup-win32-x64-msvc": "4.59.0", + "fsevents": "~2.3.2" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/send": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", + "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.1", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "~2.4.1", + "range-parser": "~1.2.1", + "statuses": "~2.0.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/send/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/send/node_modules/debug/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/serve-static": { + "version": "1.16.3", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz", + "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==", + "license": "MIT", + "dependencies": { + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "~0.19.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", + "license": "MIT" + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/tsx": { + "version": "4.21.0", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", + "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.27.0", + "get-tsconfig": "^4.7.5" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", + "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", + "dev": true, + "license": "MIT" + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/vite": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", + "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.27.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "license": "MIT", + "engines": { + "node": ">=0.4" + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..0b8a536 --- /dev/null +++ b/package.json @@ -0,0 +1,16 @@ +{ + "name": "postgres-admin-control-plane", + "private": true, + "version": "1.0.0", + "workspaces": [ + "backend", + "frontend" + ], + "scripts": { + "dev": "npm run dev -w backend", + "dev:frontend": "npm run dev -w frontend", + "dev:backend": "npm run dev -w backend", + "build": "npm run build -w backend && npm run build -w frontend", + "test": "npm run test -w backend && npm run test -w frontend" + } +}