diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..7abe583 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,60 @@ +name: CI + +on: + push: + branches: [main, master] + pull_request: + branches: [main, master] + +jobs: + backend: + name: Backend + runs-on: ubuntu-latest + defaults: + run: + working-directory: backend + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: '24' + cache: npm + cache-dependency-path: backend/package-lock.json + - run: npm ci + - run: npm run typecheck + - run: npm run lint:check + - run: npm test -- --passWithNoTests + - run: npm run test:e2e + + frontend: + name: Frontend + runs-on: ubuntu-latest + defaults: + run: + working-directory: frontend + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: '24' + cache: npm + cache-dependency-path: frontend/package-lock.json + - run: npm ci + - run: npm run typecheck + - run: npm run lint + - run: npm test + - run: npm run build + env: + NEXT_PUBLIC_API_URL: http://localhost:4000 + + helm: + name: Helm Charts + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: azure/setup-helm@v4 + with: + version: v3.15.4 + - run: helm lint backend/helm/cloudhost-platform + - run: helm lint backend/helm/cloudhost-app + - run: helm lint backend/helm/cloudhost-logging diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..58715e3 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 CloudHost + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index ca0a87e..4b60d5e 100644 --- a/README.md +++ b/README.md @@ -43,6 +43,7 @@ custom `wp-content` entrypoint). > 📖 See **[ARCHITECTURE.md](ARCHITECTURE.md)** for detailed system design. > 🔼 See [UPGRADE.en.md](UPGRADE.en.md) ([فارسی](UPGRADE.md)) for dependency-upgrade notes. +> 📋 See [RUNBOOK.en.md](RUNBOOK.en.md) ([فارسی](RUNBOOK.fa.md)) for operations. --- @@ -298,7 +299,7 @@ Tokens: JWT access (`JWT_EXPIRES_IN`, default 1h) + refresh (7d). ## API All endpoints are prefixed with `/api/v1`. Interactive Swagger docs at -`http://localhost:4000/docs`. Major route groups: `auth` (OTP request/verify, login, +`http://localhost:4000/api/docs`. Major route groups: `auth` (OTP request/verify, login, refresh), `applications`, `deployments`, `clusters`, `billing` (wallet, invoices, transactions, pricing), `snapshots`, `tickets`, `users`, `admin`, `notifications`. diff --git a/RUNBOOK.en.md b/RUNBOOK.en.md new file mode 100644 index 0000000..7a1f643 --- /dev/null +++ b/RUNBOOK.en.md @@ -0,0 +1,69 @@ +# CloudHost — Operations Runbook (English) + +Short operational guide. For architecture details see [ARCHITECTURE.md](ARCHITECTURE.md). For Persian production deploy steps see [RUNBOOK.fa.md](RUNBOOK.fa.md). + +## Stack + +- **Frontend:** Next.js 16 (`/fa-IR`, `/en-US` routes) +- **Backend:** NestJS 11 (`/api/v1`, Swagger at `/api/docs`) +- **Data:** PostgreSQL 16, Redis 7 +- **Build:** Kaniko in-cluster (`cloudhost-builds` namespace) +- **Deploy:** Helm charts (`cloudhost-platform`, `cloudhost-app`, `cloudhost-logging`) + +## Health checks + +| Endpoint | Purpose | +|----------|---------| +| `GET /api/v1/health` | Liveness | +| `GET /api/v1/ready` | Readiness (DB ping) | + +## Local development + +```bash +docker compose up -d postgres redis +cd backend && cp .env.example .env && npm run start:dev +cd frontend && cp .env.local.example .env.local && npm run dev +``` + +## SQL migrations + +1. Add file under `backend/migrations/` +2. Run `cd backend && npm run sync:migrations` (copies into Helm chart) +3. Helm post-install Job applies migrations on upgrade + +## Build namespace bootstrap + +```bash +kubectl apply -f backend/k8s/builds/cloudhost-builds-bootstrap.yaml +``` + +Configure `REGISTRY_URL`, `BUILD_NAMESPACE`, and `CLUSTER_KUBECONFIG_KEY` in backend env. + +## Backups (optional Helm) + +Enable in `values.yaml`: + +```yaml +backups: + postgres: + enabled: true + schedule: "0 3 * * *" + storageSize: 10Gi +``` + +Restore: `gunzip -c backup.sql.gz | psql` against the control-plane database. + +## Monitoring (optional) + +Enable Prometheus ServiceMonitor: + +```yaml +monitoring: + enabled: true +``` + +Requires `kube-prometheus-stack` or compatible Prometheus Operator in the cluster. + +## CI + +GitHub Actions runs backend/frontend tests, typecheck, build, and `helm lint` on push/PR. diff --git a/RUNBOOK.fa.md b/RUNBOOK.fa.md new file mode 100644 index 0000000..78c62a4 --- /dev/null +++ b/RUNBOOK.fa.md @@ -0,0 +1,229 @@ +# CloudHost — راهنمای معماری و اجرای وبسایت (Runbook) + +این سند دو بخش دارد: +1. **اپلیکیشن چطور کار می‌کند** — معماری و جریان‌ها. +2. **اجرای وبسایت، مرحله‌به‌مرحله** — هم برای توسعه‌ی محلی، هم برای استقرار (deploy) روی کلاستر k3s سرور (abrban) به‌همراه راه‌حل‌های مخصوص شبکه‌ی ایران. + +> اصطلاح‌ها: «پنل» = اپ احرازشده (`panel.abrban.com`)، «لندینگ» = صفحه‌ی معرفی (`abrban.com`)، «اپ کاربر» = اپلیکیشنی که مشتری روی CloudHost دیپلوی می‌کند. + +> **به‌روزرسانی ۲۰۲۶:** pipeline بیلد فعلی از **Kaniko** + Dockerfileهای نگهداری‌شده توسط پلتفرم استفاده می‌کند (نه Nixpacks/MinIO). آرشیو سورس روی دیسک/PVC آپلود می‌شود. manifest بوت‌استرپ namespace بیلد: [`backend/k8s/builds/cloudhost-builds-bootstrap.yaml`](backend/k8s/builds/cloudhost-builds-bootstrap.yaml). + +--- + +### ۱.۱ CloudHost چیست +یک **PaaS خودسرویس** برای بازار ایران: کاربر کد/ریپوی خودش را می‌دهد و CloudHost آن را build و روی Kubernetes اجرا می‌کند، با مدیریت دامنه، دیتابیس، لاگ، فاکتور و کیف پول. + +### ۱.۲ اجزای اصلی + +| جزء | تکنولوژی | نقش | +|---|---|---| +| **Frontend** | Next.js (App Router, SSR) | لندینگ + پنل کاربری/ادمین | +| **Backend** | NestJS (REST `/api/v1`) | منطق کسب‌وکار، ساخت اپ، احراز هویت | +| **Postgres** | postgres:16 | دیتابیس اصلی (کاربر، اپ، فاکتور، …) | +| **Redis** | redis:7 | کش، صف Bull (مهاجرت اپ، دسترسی موقت)، پیشرفت بیلد | +| **Registry داخلی** | registry:2 | ایمیج‌های build‌شده | +| **Build pipeline** | Kaniko | تبدیل سورس به ایمیج Docker داخل کلاستر (بدون Docker daemon) | +| **Kubernetes** | k3s (تک‌نود) | اجرای همه‌ی موارد بالا + اپ‌های کاربر | + +### ۱.۳ دامنه‌ها (همه روی `78.157.39.52`، HTTPS با wildcard cert) +- `abrban.com` → لندینگ +- `panel.abrban.com` → پنل احرازشده +- `api.abrban.com` → بک‌اند +- `registry.abrban.com` → رجیستری داخلی (pull توسط kubelet) +- `apps.abrban.com` → دامنه‌ی پیش‌فرض اپ‌های کاربر + +### ۱.۴ جریان احراز هویت +- ورود مبتنی بر **موبایل + OTP** (پیامک از طریق MizbanSMS) یا رمز عبور. +- توکن **JWT** (access ~15m، refresh ~7d). +- `JwtStrategy` در هر درخواست نقش و فعال‌بودن کاربر را **از دیتابیس** می‌خواند (نه از توکن) تا تغییر نقش/غیرفعال‌سازی بلافاصله اثر کند. + +### ۱.۵ جریان دیپلویِ «اپ کاربر» (مهم‌ترین بخش) +وقتی کاربر یک اپ را build/redeploy می‌کند: + +``` +کاربر (پنل) + │ آپلود zip ──────────────► MinIO (bucket: app-sources) ┐ + │ یا git URL │ منبع سورس + ▼ │ +Backend: یک job در صف Bull («app-deploy») می‌گذارد │ + ▼ │ +ساخت یک Kubernetes Job در namespace «cloudhost-builds»: │ + 1) init: fetch-source (دانلود از MinIO) یا git-clone ◄───────┘ + 2) init: nixpacks-prepare + • اگر سورس Dockerfile دارد → همان (BYO) + • وگرنه → با Nixpacks یک Dockerfile می‌سازد + 3) container: Kaniko → build ایمیج → push به registry داخلی + ▼ +(غیرمسدودکننده) اسکن Trivy → خلاصه‌ی آسیب‌پذیری + ▼ +Backend با Helm، اپ را روی کلاستر بالا می‌آورد (Deployment + Service + Ingress) + ▼ +GC رجیستری: روزانه فقط N نسخه‌ی آخر هر اپ را نگه می‌دارد +``` + +- پیشرفت بیلد در Redis نگه‌داری می‌شود؛ فرانت هر ۱.۵ ثانیه `build-progress`/`build-logs` را poll می‌کند. +- توکن git در یک Secret موقتِ هر بیلد می‌نشیند و در `finally` پاک می‌شود (در manifest درج نمی‌شود). + +--- + +## بخش ۲ — اجرای محلی (Local Dev) + +پیش‌نیاز: Node 20، Docker، Docker Compose. + +```bash +# ۱) دیتابیس و Redis +docker compose up -d # از docker-compose.yml ریشه‌ی پروژه + +# ۲) بک‌اند +cd backend +cp .env.example .env # مقادیر را پر کن (DB، JWT، SMS، …) +npm install +npm run start:dev # روی http://localhost:4000 (پیشوند /api/v1) + +# ۳) فرانت +cd ../frontend +npm install +npm run dev # روی http://localhost:3000 +``` + +- در حالت dev، TypeORM `synchronize` روشن است و جدول‌ها خودکار ساخته می‌شوند. +- `NEXT_PUBLIC_API_URL` فرانت باید به آدرس بک‌اند اشاره کند. + +--- + +## بخش ۳ — استقرار روی کلاستر (abrban / k3s) + +> این بخش فرض می‌کند کلاستر k3s و wildcard cert از قبل آماده‌اند. context کوبه: `default`. + +### ۳.۰ ثابت‌های محیط +| | مقدار | +|---|---| +| namespace اپ | `cloudhost` | +| namespace بیلد | `cloudhost-builds` | +| Helm release | `cloudhost` | +| چارت | `backend/helm/cloudhost-platform` | +| values | `/tmp/abrban/values-abrban.yaml` | +| رجیستری (push داخلی) | `registry.cloudhost.svc.cluster.local:5000` (HTTP, insecure) | +| رجیستری (pull توسط kubelet) | `registry.abrban.com` (HTTPS, wildcard cert) — همان storage | +| پروکسی build | `http://builder:@45.129.38.203:9911` | + +### ۳.۱ نکات شبکه‌ی ایران (چرا کارها این‌شکلی‌اند) +- کلاستر به **Let's Encrypt، github، docker.io، ghcr.io، gcr.io** مستقیم نمی‌رسد (یا خیلی کند). +- **gTLS/cert**: دستی، secret `abrban-wildcard-tls` (نه cert-manager). +- **npm**: از `registry.npmmirror.com` مستقیم (نه پروکسی). +- **ایمیج‌های پایه**: اول داخل رجیستری داخلی **mirror** می‌شوند، بعد استفاده. +- **دانلودهای build (apk/nix/pip/…)**: از طریق پروکسی بالا. + +### ۳.۲ گام‌های استقرار + +**گام ۱ — بررسی دسترسی کلاستر** +```bash +kubectl config current-context # باید default باشد +kubectl get nodes +``` + +**گام ۲ — mirror کردن ایمیج‌های پایه به رجیستری داخلی** +ایمیج‌هایی که کلاستر مستقیم نمی‌تواند pull کند را با یک Job داخل کلاستر کپی کن. +- برای ایمیج‌های **کوچک**: `crane copy registry.cloudhost.svc.cluster.local:5000/ --insecure` +- برای ایمیج‌های **بزرگ** (مثل nixpacks): از **skopeo** استفاده کن — چون بلاب را با PUT یکجا آپلود می‌کند و گیر `PROTOCOL_ERROR` آپلود تکه‌ای crane را ندارد: + ``` + skopeo copy --override-os linux --override-arch amd64 --dest-tls-verify=false \ + docker:// docker://registry.cloudhost.svc.cluster.local:5000/ + ``` + (با `REGISTRY_AUTH_FILE` از secret `kaniko-docker-config` و env پروکسی.) + +ایمیج‌های لازم: `minio/minio`, `railwayapp/nixpacks`, `library/alpine:3.19`, `library/postgres`, `library/redis`, و base ای که Nixpacks تولید می‌کند. + +**گام ۳ — MinIO (ذخیره‌ی سورس اپ‌ها)** +به‌طور خودکار فقط هنگام **ثبت کلاستر جدید** ساخته می‌شود؛ روی کلاستر موجود دستی بساز: secret `minio-credentials` (`accesskey`/`secretkey`) + PVC ۲۰Gi + Deployment (`registry.abrban.com/minio/minio:latest`) + Service، همه در `cloudhost-builds`. کردنشال پیش‌فرض با config بک‌اند می‌خواند. + +**گام ۴ — pull-secret برای namespace بیلد** +```bash +# کپی pull-secret رجیستری به ns بیلد +kubectl get secret registry-pull-secret -n cloudhost -o json \ + | jq '.metadata.namespace="cloudhost-builds" | del(.metadata.uid,.metadata.resourceVersion,.metadata.creationTimestamp)' \ + | kubectl apply -f - +# وصل به هر دو ServiceAccount که pod بیلد ممکن است از آن‌ها استفاده کند +kubectl patch sa default -n cloudhost-builds -p '{"imagePullSecrets":[{"name":"registry-pull-secret"}]}' +kubectl patch sa kaniko-builder -n cloudhost-builds -p '{"imagePullSecrets":[{"name":"registry-pull-secret"}]}' +``` +> ⚠️ `kaniko-builder` حتماً لازم است: pod بیلد با همین SA اجرا می‌شود و init container نیکس‌پکس ایمیجش را از `registry.abrban.com` می‌کشد. + +**گام ۵ — build ایمیج‌های frontend/backend (Kaniko)** +سورس را در PVC بیلد (`build-src`) از طریق pod `srcsync` قرار بده، سپس Job‌های Kaniko را اجرا کن. +```bash +# سینک سورس (از working tree؛ tsbuildinfo و dist را حذف کن!) +cd +tar czf - --exclude=node_modules --exclude=.next --exclude=.git frontend \ + | kubectl exec -i srcsync -n cloudhost -- sh -c 'rm -rf /workspace/frontend && tar xzf - -C /workspace' +rm -f backend/tsconfig.tsbuildinfo # ← مهم +tar czf - --exclude=node_modules --exclude=dist --exclude=.git backend \ + | kubectl exec -i srcsync -n cloudhost -- sh -c 'rm -rf /workspace/backend && tar xzf - -C /workspace' + +# build (manifestهای kaniko-*.yaml: registry-mirror + proxy + npmmirror) +kubectl apply -f /tmp/abrban/kaniko-frontend-.yaml +kubectl apply -f /tmp/abrban/kaniko-backend-.yaml +``` + +**گام ۶ — استقرار با Helm** +```bash +helm upgrade cloudhost backend/helm/cloudhost-platform \ + -n cloudhost -f /tmp/abrban/values-abrban.yaml \ + --set images.backend.tag= \ + --set images.frontend.tag= \ + --set migrations.enabled=false # ← مهاجرت‌ها روی DB زنده تداخل دارند +``` +> بدون `--wait` اجرا کن (وگرنه به‌خاطر کندیِ pull، status اشتباهاً `failed` می‌شود درحالی‌که rollout موفق است). + +**گام ۷ — bootstrap اسکیمای دیتابیس (فقط روی DB تازه)** +در پروداکشن `synchronize` خاموش است. روی DB کاملاً تازه: موقتاً `NODE_ENV=development` کن تا synchronize جدول‌ها را بسازد و pricing خودش seed شود، بعد به `production` برگردان. روی DB موجود، فقط مهاجرت‌های idempotent جدید را با یک Job جدا اعمال کن (نه helm hook). + +**گام ۸ — تأیید** +```bash +kubectl get deploy -n cloudhost # backend/frontend 1/1 +helm status cloudhost -n cloudhost # STATUS: deployed +curl -s -o /dev/null -w '%{http_code}\n' https://panel.abrban.com # 307 +curl -s -X POST https://api.abrban.com/api/v1/auth/otp/request \ + -H 'Content-Type: application/json' -d '{"phone":"09xxxxxxxxx"}' # {"sent":true} +``` + +--- + +## بخش ۴ — env‌های کلیدی build pipeline (روی بک‌اند) +این‌ها در `backend.env` فایل values ست می‌شوند: + +| env | مقدار/توضیح | +|---|---| +| `MINIO_SECRET_KEY` | کلید MinIO (هماهنگ با secret) | +| `NIXPACKS_IMAGE` | `registry.abrban.com/railwayapp/nixpacks:latest` (mirror) | +| `NIXPACKS_BUILD_ENV` | `NPM_CONFIG_REGISTRY=https://registry.npmmirror.com` | +| `BUILD_HTTP_PROXY` | پروکسی build (تزریق به Kaniko + init containerها) | +| `BUILD_REGISTRY_MIRROR` | `registry.cloudhost.svc.cluster.local:5000` (pull پایه از mirror) | +| `BUILD_SCAN_ENABLED` | `false` تا وقتی ایمیج Trivy mirror شود | +| `SMS_PROVIDER` + `MIZBANSMS_*` | بدون این‌ها ارسال OTP خطای 503 می‌دهد | + +--- + +## بخش ۵ — عیب‌یابی رایج + +| نشانه | علت | راه‌حل | +|---|---|---| +| backend کرش: `Cannot find module '/app/dist/main.js'` | `tsconfig.tsbuildinfo` کهنه در سورس → tsc فایل‌ها را دوباره emit نمی‌کند | قبل از build، `tsconfig.tsbuildinfo` را حذف کن | +| init container بیلد: `no basic auth credentials` | SA `kaniko-builder` بدون pull-secret | گام ۴ را اجرا کن | +| pull ایمیج: `not found` با اینکه push شده | crane در آپلود تکه‌ایِ بلاب بزرگ شکست خورده (tag ناقص) | با **skopeo** دوباره mirror کن | +| pull از docker.io: `TLS handshake timeout` | docker.io از کلاستر بسته است | ایمیج را mirror کن | +| `nixpacks: not found` در init | باینری نیکس‌پکس روی PATH پیش‌فرض نیست | command را با مسیر/ENTRYPOINT درست صدا بزن | +| helm: `no template "...namespace"` یا `nil pointer redis.enabled` | فایل‌های چارت (`_helpers.tpl`/`values.yaml`) از `/tmp` پاک شده‌اند | از سورس اصلی بازیابی + ادیت‌های deploy را دوباره اعمال کن | +| OTP خطای 503 | env پیامک ست نیست | `SMS_PROVIDER`/`MIZBANSMS_*` را ست کن | + +> ⚠️ پوشه‌ی `/tmp` در macOS فایل‌های قدیمی‌تر از ~۳ روز را پاک می‌کند؛ درخت کاری deploy در `/tmp` ممکن است فایل از دست بدهد — قبل از build بررسی کن. + +--- + +## بخش ۶ — به‌روزرسانی نسخه (خلاصه) +1. تغییرات کد را در سورس اعمال کن (`tsc --noEmit` بگیر). +2. tag جدید انتخاب کن. +3. سورس را در `srcsync` سینک کن (با حذف `tsbuildinfo`). +4. Job Kaniko را با tag جدید بساز. +5. `helm upgrade ... --set images.*.tag= --set migrations.enabled=false` (بدون `--wait`). +6. تأیید کن: podها 1/1، helm `deployed`، endpoint‌ها سالم. diff --git a/backend/.env.example b/backend/.env.example index d307759..72b5f66 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -20,6 +20,13 @@ JWT_REFRESH_EXPIRES_IN=7d REDIS_HOST=localhost REDIS_PORT=6379 +# Multi-cluster: AES-256-GCM key for encrypting kubeconfigs at rest (required in production). +# Generate with: openssl rand -hex 32 +CLUSTER_KUBECONFIG_KEY= + +# Stub payment gateway (dev/staging only — disabled in production unless explicitly enabled) +# PAYMENT_GATEWAY_STUB_ENABLED=true + # ─── OTP SMS ──────────────────────────────────────────────────────────────── # Pick the provider. Without valid credentials, OTP codes are logged to the API # console in development only; in production a missing config makes OTP send fail diff --git a/backend/eslint.config.mjs b/backend/eslint.config.mjs new file mode 100644 index 0000000..37c49ae --- /dev/null +++ b/backend/eslint.config.mjs @@ -0,0 +1,28 @@ +import tsPlugin from '@typescript-eslint/eslint-plugin'; +import tsParser from '@typescript-eslint/parser'; + +/** @type {import('eslint').Linter.Config[]} */ +export default [ + { + ignores: ['dist/**', 'node_modules/**', 'coverage/**'], + }, + { + files: ['src/**/*.ts', 'test/**/*.ts'], + languageOptions: { + parser: tsParser, + parserOptions: { + ecmaVersion: 'latest', + sourceType: 'module', + }, + }, + plugins: { + '@typescript-eslint': tsPlugin, + }, + rules: { + ...tsPlugin.configs.recommended.rules, + '@typescript-eslint/no-explicit-any': 'off', + '@typescript-eslint/no-require-imports': 'off', + '@typescript-eslint/no-unused-vars': ['warn', { argsIgnorePattern: '^_' }], + }, + }, +]; diff --git a/backend/helm/cloudhost-platform/migrations/018_user_phone_and_verification.sql b/backend/helm/cloudhost-platform/migrations/018_user_phone_and_verification.sql new file mode 100644 index 0000000..41abcb7 --- /dev/null +++ b/backend/helm/cloudhost-platform/migrations/018_user_phone_and_verification.sql @@ -0,0 +1,33 @@ +-- Mobile-first auth: phone is the login identifier, email becomes an optional +-- contact field, plus a table of short-lived one-time SMS codes for verifying +-- a phone (registration/login completion and number changes). + +-- Email becomes optional (login no longer uses it). Postgres treats NULLs as +-- distinct, so the existing UNIQUE constraint keeps working for users without one. +ALTER TABLE users ALTER COLUMN email DROP NOT NULL; + +ALTER TABLE users ADD COLUMN IF NOT EXISTS phone VARCHAR; +ALTER TABLE users ADD COLUMN IF NOT EXISTS "phoneVerified" BOOLEAN NOT NULL DEFAULT FALSE; + +-- Unique per non-null phone (NULLs allowed for legacy email-only staff accounts). +CREATE UNIQUE INDEX IF NOT EXISTS users_phone_unique ON users (phone) WHERE phone IS NOT NULL; + +-- One-time SMS verification codes (hashed). +DO $$ BEGIN + CREATE TYPE verification_codes_purpose_enum AS ENUM ('login', 'change_phone'); +EXCEPTION WHEN duplicate_object THEN null; END $$; + +CREATE TABLE IF NOT EXISTS verification_codes ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + "userId" UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + purpose verification_codes_purpose_enum NOT NULL, + destination VARCHAR NOT NULL, + "codeHash" VARCHAR NOT NULL, + "expiresAt" TIMESTAMPTZ NOT NULL, + attempts INT NOT NULL DEFAULT 0, + "consumedAt" TIMESTAMPTZ, + "createdAt" TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE INDEX IF NOT EXISTS verification_codes_user_purpose_idx + ON verification_codes ("userId", purpose); diff --git a/backend/helm/cloudhost-platform/templates/backend-deployment.yaml b/backend/helm/cloudhost-platform/templates/backend-deployment.yaml index 248a207..8acc54b 100644 --- a/backend/helm/cloudhost-platform/templates/backend-deployment.yaml +++ b/backend/helm/cloudhost-platform/templates/backend-deployment.yaml @@ -93,14 +93,14 @@ spec: mountPath: /app/uploads livenessProbe: httpGet: - path: /api/docs + path: /api/v1/health port: 4000 initialDelaySeconds: 60 periodSeconds: 15 timeoutSeconds: 5 readinessProbe: httpGet: - path: /api/docs + path: /api/v1/ready port: 4000 initialDelaySeconds: 20 periodSeconds: 10 diff --git a/backend/helm/cloudhost-platform/templates/monitoring.yaml b/backend/helm/cloudhost-platform/templates/monitoring.yaml new file mode 100644 index 0000000..836521a --- /dev/null +++ b/backend/helm/cloudhost-platform/templates/monitoring.yaml @@ -0,0 +1,35 @@ +{{- if .Values.monitoring.enabled }} +apiVersion: v1 +kind: Service +metadata: + name: {{ include "cloudhost-platform.fullname" . }}-backend-metrics + namespace: {{ include "cloudhost-platform.namespace" . }} + labels: + {{- include "cloudhost-platform.labels" . | nindent 4 }} + app.kubernetes.io/component: backend +spec: + type: ClusterIP + ports: + - name: http + port: 4000 + targetPort: 4000 + selector: + app: {{ include "cloudhost-platform.backend.fullname" . }} +--- +apiVersion: monitoring.coreos.com/v1 +kind: ServiceMonitor +metadata: + name: {{ include "cloudhost-platform.fullname" . }}-backend + namespace: {{ include "cloudhost-platform.namespace" . }} + labels: + {{- include "cloudhost-platform.labels" . | nindent 4 }} + release: prometheus +spec: + selector: + matchLabels: + app: {{ include "cloudhost-platform.backend.fullname" . }} + endpoints: + - port: http + path: /api/v1/health + interval: 30s +{{- end }} diff --git a/backend/helm/cloudhost-platform/templates/postgres-backup.yaml b/backend/helm/cloudhost-platform/templates/postgres-backup.yaml new file mode 100644 index 0000000..be16986 --- /dev/null +++ b/backend/helm/cloudhost-platform/templates/postgres-backup.yaml @@ -0,0 +1,62 @@ +{{- if .Values.backups.postgres.enabled }} +apiVersion: batch/v1 +kind: CronJob +metadata: + name: {{ include "cloudhost-platform.fullname" . }}-postgres-backup + namespace: {{ include "cloudhost-platform.namespace" . }} + labels: + {{- include "cloudhost-platform.labels" . | nindent 4 }} +spec: + schedule: {{ .Values.backups.postgres.schedule | quote }} + successfulJobsHistoryLimit: 3 + failedJobsHistoryLimit: 1 + jobTemplate: + spec: + template: + spec: + restartPolicy: OnFailure + containers: + - name: backup + image: {{ .Values.images.postgres | quote }} + env: + - name: PGHOST + value: {{ include "cloudhost-platform.postgres.fullname" . }} + - name: PGPORT + value: "5432" + - name: PGDATABASE + value: {{ .Values.postgres.database | quote }} + - name: PGUSER + value: {{ .Values.postgres.username | quote }} + - name: PGPASSWORD + valueFrom: + secretKeyRef: + name: {{ include "cloudhost-platform.secretName" . }} + key: postgres-password + command: + - sh + - -c + - | + set -e + STAMP=$(date +%Y%m%d-%H%M%S) + FILE="/backup/cloudhost-${STAMP}.sql.gz" + pg_dump | gzip > "$FILE" + echo "Backup written to $FILE" + volumeMounts: + - name: backup + mountPath: /backup + volumes: + - name: backup + persistentVolumeClaim: + claimName: {{ include "cloudhost-platform.fullname" . }}-postgres-backup +--- +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: {{ include "cloudhost-platform.fullname" . }}-postgres-backup + namespace: {{ include "cloudhost-platform.namespace" . }} +spec: + accessModes: [ReadWriteOnce] + resources: + requests: + storage: {{ .Values.backups.postgres.storageSize }} +{{- end }} diff --git a/backend/helm/cloudhost-platform/values.yaml b/backend/helm/cloudhost-platform/values.yaml index 0efdc77..6635e7e 100644 --- a/backend/helm/cloudhost-platform/values.yaml +++ b/backend/helm/cloudhost-platform/values.yaml @@ -97,3 +97,12 @@ ingress: migrations: enabled: true image: postgres:16-alpine + +monitoring: + enabled: false + +backups: + postgres: + enabled: false + schedule: "0 3 * * *" + storageSize: 10Gi diff --git a/backend/k8s/builds/cloudhost-builds-bootstrap.yaml b/backend/k8s/builds/cloudhost-builds-bootstrap.yaml new file mode 100644 index 0000000..9f2700a --- /dev/null +++ b/backend/k8s/builds/cloudhost-builds-bootstrap.yaml @@ -0,0 +1,77 @@ +apiVersion: v1 +kind: Namespace +metadata: + name: cloudhost-builds + labels: + app.kubernetes.io/part-of: cloudhost +--- +apiVersion: v1 +kind: ServiceAccount +metadata: + name: kaniko-builder + namespace: cloudhost-builds +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: Role +metadata: + name: kaniko-builder + namespace: cloudhost-builds +rules: + - apiGroups: [''] + resources: ['pods', 'pods/log', 'secrets', 'configmaps', 'persistentvolumeclaims'] + verbs: ['create', 'get', 'list', 'watch', 'delete', 'patch', 'update'] + - apiGroups: ['batch'] + resources: ['jobs'] + verbs: ['create', 'get', 'list', 'watch', 'delete'] +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: RoleBinding +metadata: + name: kaniko-builder + namespace: cloudhost-builds +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: Role + name: kaniko-builder +subjects: + - kind: ServiceAccount + name: kaniko-builder + namespace: cloudhost-builds +--- +# In-cluster registry for Kaniko push + app image pull (HTTP — add TLS in production). +apiVersion: v1 +kind: Service +metadata: + name: registry + namespace: cloudhost-builds +spec: + type: ClusterIP + ports: + - port: 5000 + targetPort: 5000 + selector: + app: registry +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: registry + namespace: cloudhost-builds +spec: + replicas: 1 + selector: + matchLabels: + app: registry + template: + metadata: + labels: + app: registry + spec: + containers: + - name: registry + image: registry:2 + ports: + - containerPort: 5000 + env: + - name: REGISTRY_HTTP_ADDR + value: ':5000' diff --git a/backend/package-lock.json b/backend/package-lock.json index 18fc8c6..76be2cb 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -17,6 +17,7 @@ "@nestjs/passport": "^11.0.5", "@nestjs/platform-express": "^11.1.26", "@nestjs/swagger": "^11.4.4", + "@nestjs/throttler": "^6.5.0", "@nestjs/typeorm": "^11.0.1", "bcrypt": "^6.0.0", "bull": "^4.12.0", @@ -24,6 +25,7 @@ "class-validator": "^0.15.1", "handlebars": "^4.7.8", "helmet": "^8.2.0", + "ioredis": "^5.11.1", "js-yaml": "^4.2.0", "multer": "^2.1.1", "passport": "^0.7.0", @@ -1347,9 +1349,9 @@ } }, "node_modules/@ioredis/commands": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-1.5.1.tgz", - "integrity": "sha512-JH8ZL/ywcJyR9MmJ5BNqZllXNZQqQbnVZOqpPQqE1vHiFgAw4NHbvE0FOduNU8IX9babitBT46571OnPTT0Zcw==", + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-1.10.0.tgz", + "integrity": "sha512-UmeW7z4LfctwoQ5wkhVzgq8tXkreED2xZGpX+Bg+zA+WJFZCT6c062AfCK/Dfk81xZnnwdhJCUMkitihRaoC2Q==", "license": "MIT" }, "node_modules/@isaacs/cliui": { @@ -2574,6 +2576,17 @@ } } }, + "node_modules/@nestjs/throttler": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@nestjs/throttler/-/throttler-6.5.0.tgz", + "integrity": "sha512-9j0ZRfH0QE1qyrj9JjIRDz5gQLPqq9yVC2nHsrosDVAfI5HHw08/aUAWx9DZLSdQf4HDkmhTTEGLrRFHENvchQ==", + "license": "MIT", + "peerDependencies": { + "@nestjs/common": "^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0", + "@nestjs/core": "^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0", + "reflect-metadata": "^0.1.13 || ^0.2.0" + } + }, "node_modules/@nestjs/typeorm": { "version": "11.0.1", "resolved": "https://registry.npmjs.org/@nestjs/typeorm/-/typeorm-11.0.1.tgz", @@ -4896,9 +4909,9 @@ } }, "node_modules/cluster-key-slot": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz", - "integrity": "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.1.tgz", + "integrity": "sha512-rwHwUfXL40Chm1r08yrhU3qpUvdVlgkKNeyeGPOxnW8/SyVDvgRaed/Uz54AqWNaTCAThlj6QAs3TZcKI0xDEw==", "license": "Apache-2.0", "engines": { "node": ">=0.10.0" @@ -6661,20 +6674,18 @@ "license": "ISC" }, "node_modules/ioredis": { - "version": "5.10.1", - "resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.10.1.tgz", - "integrity": "sha512-HuEDBTI70aYdx1v6U97SbNx9F1+svQKBDo30o0b9fw055LMepzpOOd0Ccg9Q6tbqmBSJaMuY0fB7yw9/vjBYCA==", + "version": "5.11.1", + "resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.11.1.tgz", + "integrity": "sha512-ehuGcf94bQXhfagULNXrJdfnWO38v070jxSx/qE87Kjzmu2fU7ro5EFAb+OPituLqgfyuQaym5DlrNydW2sJ9A==", "license": "MIT", "dependencies": { - "@ioredis/commands": "1.5.1", - "cluster-key-slot": "^1.1.0", - "debug": "^4.3.4", - "denque": "^2.1.0", - "lodash.defaults": "^4.2.0", - "lodash.isarguments": "^3.1.0", - "redis-errors": "^1.2.0", - "redis-parser": "^3.0.0", - "standard-as-callback": "^2.1.0" + "@ioredis/commands": "1.10.0", + "cluster-key-slot": "1.1.1", + "debug": "4.4.3", + "denque": "2.1.0", + "redis-errors": "1.2.0", + "redis-parser": "3.0.0", + "standard-as-callback": "2.1.0" }, "engines": { "node": ">=12.22.0" @@ -7868,24 +7879,12 @@ "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", "license": "MIT" }, - "node_modules/lodash.defaults": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz", - "integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==", - "license": "MIT" - }, "node_modules/lodash.includes": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==", "license": "MIT" }, - "node_modules/lodash.isarguments": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/lodash.isarguments/-/lodash.isarguments-3.1.0.tgz", - "integrity": "sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==", - "license": "MIT" - }, "node_modules/lodash.isboolean": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", diff --git a/backend/package.json b/backend/package.json index 236ab6c..56f9b69 100644 --- a/backend/package.json +++ b/backend/package.json @@ -9,7 +9,9 @@ "start:dev": "nest start --watch", "start:debug": "nest start --debug --watch", "start:prod": "node dist/main", - "lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix", + "lint": "eslint \"src/**/*.ts\" --fix", + "lint:check": "eslint \"src/**/*.ts\"", + "typecheck": "tsc --noEmit", "format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"", "test": "jest", "test:watch": "jest --watch", @@ -18,7 +20,8 @@ "migration:generate": "npm run typeorm -- migration:generate -d src/config/typeorm.config.ts", "migration:run": "npm run typeorm -- migration:run -d src/config/typeorm.config.ts", "migration:revert": "npm run typeorm -- migration:revert -d src/config/typeorm.config.ts", - "seed": "ts-node -r tsconfig-paths/register src/seed.ts" + "seed": "ts-node -r tsconfig-paths/register src/seed.ts", + "sync:migrations": "node scripts/sync-helm-migrations.mjs" }, "dependencies": { "@kubernetes/client-node": "^1.4.0", @@ -30,6 +33,7 @@ "@nestjs/passport": "^11.0.5", "@nestjs/platform-express": "^11.1.26", "@nestjs/swagger": "^11.4.4", + "@nestjs/throttler": "^6.5.0", "@nestjs/typeorm": "^11.0.1", "bcrypt": "^6.0.0", "bull": "^4.12.0", @@ -37,6 +41,7 @@ "class-validator": "^0.15.1", "handlebars": "^4.7.8", "helmet": "^8.2.0", + "ioredis": "^5.11.1", "js-yaml": "^4.2.0", "multer": "^2.1.1", "passport": "^0.7.0", @@ -69,14 +74,23 @@ "typescript": "^6.0.0" }, "jest": { - "moduleFileExtensions": ["js", "json", "ts"], + "moduleFileExtensions": [ + "js", + "json", + "ts" + ], "rootDir": "src", "testRegex": ".*\\.spec\\.ts$", "transform": { "^.+\\.(t|j)s$": "ts-jest" }, - "collectCoverageFrom": ["**/*.(t|j)s"], + "collectCoverageFrom": [ + "**/*.(t|j)s" + ], "coverageDirectory": "../coverage", - "testEnvironment": "node" + "testEnvironment": "node", + "setupFilesAfterEnv": [ + "/test-setup.ts" + ] } } diff --git a/backend/scripts/sync-helm-migrations.mjs b/backend/scripts/sync-helm-migrations.mjs new file mode 100644 index 0000000..d5a31eb --- /dev/null +++ b/backend/scripts/sync-helm-migrations.mjs @@ -0,0 +1,33 @@ +#!/usr/bin/env node +/** + * Copy SQL migrations from backend/migrations/ into the Helm chart ConfigMap source. + * Run after adding or editing migration files: npm run sync:migrations + */ +import fs from 'fs'; +import path from 'path'; +import { fileURLToPath } from 'url'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const sourceDir = path.resolve(__dirname, '../migrations'); +const targetDir = path.resolve(__dirname, '../helm/cloudhost-platform/migrations'); + +if (!fs.existsSync(sourceDir)) { + console.error(`Source not found: ${sourceDir}`); + process.exit(1); +} + +fs.mkdirSync(targetDir, { recursive: true }); + +const files = fs.readdirSync(sourceDir).filter((f) => f.endsWith('.sql')).sort(); +for (const file of files) { + fs.copyFileSync(path.join(sourceDir, file), path.join(targetDir, file)); +} + +// Remove stale SQL files no longer in source +for (const existing of fs.readdirSync(targetDir)) { + if (existing.endsWith('.sql') && !files.includes(existing)) { + fs.unlinkSync(path.join(targetDir, existing)); + } +} + +console.log(`Synced ${files.length} migration(s) to ${targetDir}`); diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index a114504..7409a0f 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -2,6 +2,8 @@ import { Module } from '@nestjs/common'; import { ConfigModule, ConfigService } from '@nestjs/config'; import { TypeOrmModule } from '@nestjs/typeorm'; import { BullModule } from '@nestjs/bull'; +import { ThrottlerModule, ThrottlerGuard } from '@nestjs/throttler'; +import { APP_GUARD } from '@nestjs/core'; import { AuthModule } from './auth/auth.module'; import { UsersModule } from './users/users.module'; import { ApplicationsModule } from './applications/applications.module'; @@ -15,6 +17,7 @@ import { SnapshotsModule } from './snapshots/snapshots.module'; import { LifecycleModule } from './lifecycle/lifecycle.module'; import { ApplicationMigrationsModule } from './application-migrations/application-migrations.module'; import { AdminModule } from './admin/admin.module'; +import { HealthModule } from './health/health.module'; import configuration from './config/configuration'; @Module({ @@ -54,6 +57,14 @@ import configuration from './config/configuration'; inject: [ConfigService], }), + ThrottlerModule.forRoot([ + { + name: 'default', + ttl: 60_000, + limit: 120, + }, + ]), + // Feature modules AuthModule, UsersModule, @@ -68,6 +79,13 @@ import configuration from './config/configuration'; LifecycleModule, ApplicationMigrationsModule, AdminModule, + HealthModule, + ], + providers: [ + { + provide: APP_GUARD, + useClass: ThrottlerGuard, + }, ], }) export class AppModule {} diff --git a/backend/src/applications/applications.controller.ts b/backend/src/applications/applications.controller.ts index deb9f00..55e4914 100644 --- a/backend/src/applications/applications.controller.ts +++ b/backend/src/applications/applications.controller.ts @@ -19,6 +19,7 @@ import { import { AuthGuard } from '@nestjs/passport'; import { FileInterceptor } from '@nestjs/platform-express'; import { ApiTags, ApiOperation, ApiBearerAuth, ApiConsumes } from '@nestjs/swagger'; +import { Throttle } from '@nestjs/throttler'; import { ApplicationsService } from './applications.service'; import { DomainService } from './domain.service'; import { CreateApplicationDto, UpdateApplicationDto, ScaleResourcesDto, SetCustomDomainDto, CheckDnsDto } from './dto/application.dto'; @@ -67,6 +68,7 @@ export class ApplicationsController { } @Post(':id/upload') + @Throttle({ default: { limit: 10, ttl: 60_000 } }) @ApiOperation({ summary: 'Upload application code (zip file)' }) @ApiConsumes('multipart/form-data') @UseInterceptors(FileInterceptor('file', { diff --git a/backend/src/auth/auth.controller.ts b/backend/src/auth/auth.controller.ts index bbe8056..4d78b5d 100644 --- a/backend/src/auth/auth.controller.ts +++ b/backend/src/auth/auth.controller.ts @@ -1,5 +1,6 @@ import { Controller, Post, Body, HttpCode, HttpStatus } from '@nestjs/common'; import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger'; +import { Throttle } from '@nestjs/throttler'; import { AuthService } from './auth.service'; import { RegisterDto } from './dto/register.dto'; import { LoginDto } from './dto/login.dto'; @@ -7,6 +8,7 @@ import { OtpRequestDto, OtpVerifyDto } from './dto/otp.dto'; import { RefreshTokenDto } from './dto/refresh-token.dto'; @ApiTags('Authentication') +@Throttle({ default: { limit: 20, ttl: 60_000 } }) @Controller('auth') export class AuthController { constructor(private readonly authService: AuthService) {} diff --git a/backend/src/billing/billing-invoices.controller.ts b/backend/src/billing/billing-invoices.controller.ts new file mode 100644 index 0000000..a8294e6 --- /dev/null +++ b/backend/src/billing/billing-invoices.controller.ts @@ -0,0 +1,149 @@ +import { + Controller, + Get, + Post, + Patch, + Body, + Param, + Query, + UseGuards, + Request, +} from '@nestjs/common'; +import { AuthGuard } from '@nestjs/passport'; +import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger'; +import { BillingService } from './billing.service'; +import { BillingOpsService } from './billing-ops.service'; +import { assertStubGatewayAllowed } from './payment-gateway.util'; +import { + ChargeWalletDto, + InitiateInvoicePaymentDto, + VerifyInvoiceGatewayDto, + UpdateInvoiceStatusDto, +} from './dto/billing.dto'; +import { RolesGuard } from '../common/guards/roles.guard'; +import { Roles } from '../common/decorators/roles.decorator'; +import { UserRole, InvoiceStatus, PaymentMethod } from '../common/enums'; + +@ApiTags('Billing') +@ApiBearerAuth() +@Controller('billing') +@UseGuards(AuthGuard('jwt'), RolesGuard) +export class BillingInvoicesController { + constructor( + private readonly billingService: BillingService, + private readonly billingOpsService: BillingOpsService, + ) {} + + // ─── Invoices ───────────────────────────────────────────────────── + + @Get('invoices') + @ApiOperation({ summary: 'List my invoices' }) + async listMyInvoices( + @Request() req: any, + @Query('status') status?: InvoiceStatus, + @Query('applicationId') applicationId?: string, + @Query('limit') limit?: string, + ) { + return this.billingService.listInvoices(req.user, { + status, + applicationId, + limit: limit ? parseInt(limit, 10) : undefined, + }); + } + + @Get('invoices/:id') + @ApiOperation({ summary: 'Get one invoice with line items and transactions' }) + async getInvoice(@Request() req: any, @Param('id') id: string) { + return this.billingService.getInvoiceForUser(id, req.user); + } + + @Post('invoices/:id/pay/mixed') + @ApiOperation({ summary: 'Pay invoice with wallet first, then gateway for the remaining amount' }) + async initiateInvoiceMixed( + @Request() req: any, + @Param('id') id: string, + @Body() dto: InitiateInvoicePaymentDto, + ) { + const result = await this.billingService.initiateInvoiceMixedPayment(id, req.user, dto.callbackUrl); + const effect = await this.billingOpsService.completePaidInvoiceEffect(result.invoice); + return { ...result, effect }; + } + + @Post('invoices/:id/gateway/verify') + @ApiOperation({ summary: 'Verify invoice gateway payment' }) + async verifyInvoiceGateway( + @Request() req: any, + @Param('id') id: string, + @Body() dto: VerifyInvoiceGatewayDto, + ) { + assertStubGatewayAllowed(); + const result = await this.billingService.verifyInvoiceGatewayPayment( + id, + req.user, + dto.trackingCode, + dto.amount, + ); + const effect = await this.billingOpsService.completePaidInvoiceEffect(result.invoice); + return { ...result, effect }; + } + + // ─── Invoice Admin ──────────────────────────────────────────────── + + @Get('admin/invoices') + @Roles(UserRole.ADMIN) + @ApiOperation({ summary: 'List all invoices (Admin)' }) + async listAdminInvoices( + @Request() req: any, + @Query('status') status?: InvoiceStatus, + @Query('userId') userId?: string, + @Query('applicationId') applicationId?: string, + @Query('paymentMethod') paymentMethod?: PaymentMethod, + @Query('search') search?: string, + @Query('limit') limit?: string, + ) { + return this.billingService.listInvoices(req.user, { + status, + userId, + applicationId, + paymentMethod, + search, + limit: limit ? parseInt(limit, 10) : undefined, + }); + } + + @Get('admin/invoices/:id') + @Roles(UserRole.ADMIN) + @ApiOperation({ summary: 'Get invoice details (Admin)' }) + async getAdminInvoice(@Request() req: any, @Param('id') id: string) { + return this.billingService.getInvoiceForUser(id, req.user); + } + + @Patch('admin/invoices/:id/status') + @Roles(UserRole.ADMIN) + @ApiOperation({ summary: 'Update invoice status with reason (Admin)' }) + async updateAdminInvoiceStatus( + @Param('id') id: string, + @Body() dto: UpdateInvoiceStatusDto, + ) { + return this.billingService.updateInvoiceStatus(id, dto.status, dto.reason); + } + + // ─── Wallet Admin ───────────────────────────────────────────────── + + @Get('admin/wallets') + @Roles(UserRole.ADMIN) + @ApiOperation({ summary: 'List all wallets (Admin)' }) + async getAllWallets() { + return this.billingService.getAllWallets(); + } + + @Post('admin/wallets/:userId/charge') + @Roles(UserRole.ADMIN) + @ApiOperation({ summary: 'Charge a user\'s wallet (Admin)' }) + async adminChargeWallet( + @Param('userId') userId: string, + @Body() dto: ChargeWalletDto, + ) { + return this.billingService.adminChargeWallet(userId, dto.amount, dto.description); + } +} diff --git a/backend/src/billing/billing-ops.service.ts b/backend/src/billing/billing-ops.service.ts new file mode 100644 index 0000000..7280c29 --- /dev/null +++ b/backend/src/billing/billing-ops.service.ts @@ -0,0 +1,258 @@ +import { BadRequestException, Inject, Injectable, forwardRef } from '@nestjs/common'; +import { BillingService } from './billing.service'; +import { AppLifecycleService } from '../lifecycle/app-lifecycle.service'; +import { ApplicationsService } from '../applications/applications.service'; +import { KubernetesService } from '../kubernetes/kubernetes.service'; +import { UpgradeResourcesDto } from './dto/billing.dto'; +import { + BillingCycle, + InvoiceStatus, + ProductType, + DatabaseType, + UserRole, +} from '../common/enums'; +import { Application } from '../applications/entities/application.entity'; + +@Injectable() +export class BillingOpsService { + constructor( + private readonly billingService: BillingService, + @Inject(forwardRef(() => AppLifecycleService)) + private readonly lifecycleService: AppLifecycleService, + @Inject(forwardRef(() => ApplicationsService)) + private readonly applicationsService: ApplicationsService, + @Inject(forwardRef(() => KubernetesService)) + private readonly kubernetesService: KubernetesService, + ) {} + + async completePaidInvoiceEffect(invoice: any) { + if (invoice.status !== InvoiceStatus.PAID) return null; + if (invoice.metadata?.completedAt) return invoice.metadata.completionResult || null; + + const action = invoice.metadata?.action; + if (!action || !invoice.applicationId) return null; + + if (action === 'renew' || action === 'activate') { + const cycle = invoice.metadata?.cycle as BillingCycle; + if (!Object.values(BillingCycle).includes(cycle)) return null; + + const activated = await this.lifecycleService.activateApp(invoice.applicationId, cycle); + const result = { + action, + application: { + id: activated.id, + name: activated.name, + lifecycleStatus: activated.lifecycleStatus, + planExpiresAt: activated.planExpiresAt, + billingCycle: activated.billingCycle, + }, + }; + await this.billingService.markInvoiceEffectCompleted(invoice.id, result); + return result; + } + + if (action === 'upgrade') { + const app = await this.applicationsService.findOne(invoice.applicationId); + const resources = (invoice.metadata?.resources || {}) as UpgradeResourcesDto; + const updatedApp = await this.applicationsService.update( + app.id, + app.userId, + this.buildUpgradeEntityPatch(app, resources), + ); + + try { + await this.applyUpgradeToKubernetes(updatedApp, resources, app); + } catch (e: any) { + console.warn(`K8s resource update failed for ${app.name}: ${e.message}`); + } + + const result = { + action, + application: { + id: updatedApp.id, + name: updatedApp.name, + cpuRequest: updatedApp.cpuRequest, + cpuLimit: updatedApp.cpuLimit, + memoryRequest: updatedApp.memoryRequest, + memoryLimit: updatedApp.memoryLimit, + replicas: updatedApp.replicas, + }, + }; + await this.billingService.markInvoiceEffectCompleted(invoice.id, result); + return result; + } + + return null; + } + + buildUpgradeEntityPatch(app: Application, dto: UpgradeResourcesDto): Partial { + const pt = app.productType ?? ProductType.APPLICATION; + + if (dto.redisResources) { + return { + optionalServiceResources: { + ...app.optionalServiceResources, + redis: { + ...app.optionalServiceResources?.redis, + ...dto.redisResources, + storageGi: + dto.redisResources.storageGi ?? app.optionalServiceResources?.redis?.storageGi ?? 1, + }, + }, + }; + } + + if (dto.rabbitmqResources) { + return { + optionalServiceResources: { + ...app.optionalServiceResources, + rabbitmq: { + ...app.optionalServiceResources?.rabbitmq, + ...dto.rabbitmqResources, + storageGi: + dto.rabbitmqResources.storageGi ?? + app.optionalServiceResources?.rabbitmq?.storageGi ?? + 2, + }, + }, + }; + } + + if (pt === ProductType.MANAGED_REDIS || pt === ProductType.MANAGED_RABBITMQ) { + return {}; + } + + return { + cpuRequest: dto.cpuRequest || app.cpuRequest, + cpuLimit: dto.cpuLimit || app.cpuLimit, + memoryRequest: dto.memoryRequest || app.memoryRequest, + memoryLimit: dto.memoryLimit || app.memoryLimit, + replicas: dto.replicas ?? app.replicas, + dbStorageSize: dto.dbStorageSize || app.dbStorageSize, + appStorageSize: dto.appStorageSize || app.appStorageSize, + }; + } + + async applyUpgradeToKubernetes( + app: Application, + dto: UpgradeResourcesDto, + previous: Application, + ): Promise { + const pt = app.productType ?? ProductType.APPLICATION; + + if (pt === ProductType.MANAGED_DATABASE) { + await this.kubernetesService.updateResources( + app, + { + cpuRequest: dto.cpuRequest, + cpuLimit: dto.cpuLimit, + memoryRequest: dto.memoryRequest, + memoryLimit: dto.memoryLimit, + }, + 'database', + ); + if (dto.dbStorageSize && dto.dbStorageSize !== previous.dbStorageSize) { + const resize = await this.kubernetesService.resizeDatabasePvc(app, dto.dbStorageSize); + if (!resize.success) { + throw new BadRequestException(resize.message); + } + } + return; + } + + if (pt === ProductType.MANAGED_REDIS) { + await this.applyOptionalServiceUpgrade(app, dto, previous, 'redis'); + return; + } + + if (pt === ProductType.MANAGED_RABBITMQ) { + await this.applyOptionalServiceUpgrade(app, dto, previous, 'rabbitmq'); + return; + } + + if (dto.redisResources && app.enableRedis) { + await this.applyOptionalServiceUpgrade(app, dto, previous, 'redis'); + } + + if (dto.rabbitmqResources && app.enableRabbitmq) { + await this.applyOptionalServiceUpgrade(app, dto, previous, 'rabbitmq'); + } + + const touchesAppWorkload = + dto.cpuRequest !== undefined || + dto.cpuLimit !== undefined || + dto.memoryRequest !== undefined || + dto.memoryLimit !== undefined || + dto.replicas !== undefined; + + if (touchesAppWorkload) { + await this.kubernetesService.updateResources(app, { + cpuRequest: dto.cpuRequest, + cpuLimit: dto.cpuLimit, + memoryRequest: dto.memoryRequest, + memoryLimit: dto.memoryLimit, + replicas: dto.replicas, + }); + } + + if (dto.appStorageSize && dto.appStorageSize !== previous.appStorageSize) { + await this.kubernetesService.resizeAppStoragePvc(app, dto.appStorageSize); + } + + if ( + dto.dbStorageSize && + dto.dbStorageSize !== previous.dbStorageSize && + previous.databaseType && + previous.databaseType !== DatabaseType.NONE + ) { + const resize = await this.kubernetesService.resizeDatabasePvc(app, dto.dbStorageSize); + if (!resize.success) { + throw new BadRequestException(resize.message); + } + } + } + + async applyOptionalServiceUpgrade( + app: Application, + dto: UpgradeResourcesDto, + previous: Application, + service: 'redis' | 'rabbitmq', + ): Promise { + const res = app.optionalServiceResources?.[service]; + const dtoRes = service === 'redis' ? dto.redisResources : dto.rabbitmqResources; + if (res) { + await this.kubernetesService.updateResources( + app, + { + cpuRequest: res.cpuRequest, + cpuLimit: res.cpuLimit, + memoryRequest: res.memoryRequest, + memoryLimit: res.memoryLimit, + }, + service, + ); + } + const prevGi = + previous.optionalServiceResources?.[service]?.storageGi ?? (service === 'redis' ? 1 : 2); + const nextGi = dtoRes?.storageGi; + if (nextGi != null && nextGi > prevGi) { + const resize = + service === 'redis' + ? await this.kubernetesService.resizeRedisStoragePvc(app, `${nextGi}Gi`) + : await this.kubernetesService.resizeRabbitmqStoragePvc(app, `${nextGi}Gi`); + if (!resize.success) { + throw new BadRequestException(resize.message); + } + } + } + + async getAppWithAccess(user: any, applicationId: string) { + const isAdminOrSales = user.role === UserRole.ADMIN || user.role === UserRole.SALES; + + if (isAdminOrSales) { + return this.applicationsService.findOne(applicationId); + } + + return this.applicationsService.findOne(applicationId, user.id); + } +} diff --git a/backend/src/billing/billing-wallet.controller.ts b/backend/src/billing/billing-wallet.controller.ts new file mode 100644 index 0000000..791b7a8 --- /dev/null +++ b/backend/src/billing/billing-wallet.controller.ts @@ -0,0 +1,190 @@ +import { + Controller, + Get, + Post, + Body, + Param, + Query, + UseGuards, + Request, + BadRequestException, + Inject, + forwardRef, +} from '@nestjs/common'; +import { AuthGuard } from '@nestjs/passport'; +import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger'; +import { BillingService } from './billing.service'; +import { assertStubGatewayAllowed } from './payment-gateway.util'; +import { AppLifecycleService } from '../lifecycle/app-lifecycle.service'; +import { ApplicationsService } from '../applications/applications.service'; +import { ChargeWalletDto, PayApplicationDto } from './dto/billing.dto'; +import { RolesGuard } from '../common/guards/roles.guard'; +import { BillingCycle, InvoiceReason } from '../common/enums'; + +@ApiTags('Billing') +@ApiBearerAuth() +@Controller('billing') +@UseGuards(AuthGuard('jwt'), RolesGuard) +export class BillingWalletController { + constructor( + private readonly billingService: BillingService, + @Inject(forwardRef(() => AppLifecycleService)) + private readonly lifecycleService: AppLifecycleService, + @Inject(forwardRef(() => ApplicationsService)) + private readonly applicationsService: ApplicationsService, + ) {} + + // ─── Wallet (User) ─────────────────────────────────────────────── + + @Get('wallet') + @ApiOperation({ summary: 'Get my wallet balance' }) + async getBalance(@Request() req: any) { + return this.billingService.getBalance(req.user.id); + } + + @Post('wallet/charge') + @ApiOperation({ summary: 'Charge my wallet (self top-up)' }) + async chargeMyWallet(@Request() req: any, @Body() dto: ChargeWalletDto) { + return this.billingService.chargeWallet(req.user.id, dto.amount, dto.description || 'Self top-up'); + } + + @Get('wallet/transactions') + @ApiOperation({ summary: 'Get my wallet transactions' }) + async getTransactions(@Request() req: any, @Query('limit') limit?: string) { + return this.billingService.getTransactions(req.user.id, limit ? parseInt(limit, 10) : 50); + } + + @Get('resource-credits') + @ApiOperation({ summary: 'List active prepaid resource credits (from deleted apps)' }) + async getResourceCredits(@Request() req: any) { + const credits = await this.billingService.getActiveCredits(req.user.id); + return credits.map((c) => this.billingService.formatCreditForApi(c)); + } + + @Post('wallet/pay/:applicationId') + @ApiOperation({ summary: 'Pay for an application plan from wallet — activates/renews the app' }) + async payForApplication( + @Request() req: any, + @Param('applicationId') applicationId: string, + @Body() body: PayApplicationDto, + ) { + const cycle = body.cycle as BillingCycle; + if (!Object.values(BillingCycle).includes(cycle)) { + throw new BadRequestException(`Invalid billing cycle: ${body.cycle}`); + } + + const app = await this.applicationsService.findOne(applicationId, req.user.id); + + const payment = await this.billingService.resolveAppPayment( + req.user.id, + app, + cycle, + ); + + const coupon = await this.billingService.resolveCoupon( + req.user.id, + body.couponCode, + await this.billingService.getAppChargeBreakdown(app), + cycle, + payment.amountDue, + ); + + let invoice = null; + if (payment.amountDue > 0) { + invoice = await this.billingService.createInvoice({ + userId: req.user.id, + applicationId: app.id, + reason: InvoiceReason.DEPLOY, + lines: [ + { + label: `Application payment: ${app.name}`, + description: `Billing cycle: ${cycle}`, + amount: payment.amountDue, + metadata: { + cycle, + waivedAmount: payment.waivedAmount, + creditApplied: payment.creditId || null, + }, + }, + ], + metadata: { + action: 'activate', + cycle, + }, + discount: coupon ?? undefined, + }); + } + + let tx = null; + if (invoice) { + const paid = await this.billingService.payInvoiceWithWallet(invoice.id, req.user); + tx = paid.transaction; + invoice = paid.invoice; + } + + const activated = await this.lifecycleService.activateApp( + applicationId, + cycle, + ); + + return { + transaction: tx, + invoice, + creditApplied: payment.creditId || null, + waivedAmount: payment.waivedAmount, + discountAmount: coupon?.amount ?? 0, + discountCode: coupon?.code ?? null, + paidAmount: invoice ? Number(invoice.total) : 0, + application: { + id: activated.id, + name: activated.name, + lifecycleStatus: activated.lifecycleStatus, + planExpiresAt: activated.planExpiresAt, + }, + message: payment.waivedAmount > 0 + ? payment.amountDue > 0 + ? `Application "${activated.name}" activated — prepaid credit applied; you paid ${payment.amountDue} Toman for additional services.` + : `Application "${activated.name}" activated using your prepaid resource credit (no charge).` + : payment.amountDue > 0 + ? `Application "${activated.name}" activated until ${activated.planExpiresAt?.toISOString()}` + : `Application "${activated.name}" activated`, + }; + } + + // ─── Payment Gateway ───────────────────────────────────────────── + + @Post('gateway/initiate') + @ApiOperation({ summary: 'Initiate a payment gateway transaction' }) + async initiateGateway( + @Request() req: any, + @Body() body: { amount: number; description?: string; callbackUrl: string }, + ) { + assertStubGatewayAllowed(); + const trackingCode = `PAY-${Date.now()}-${Math.random().toString(36).substring(2, 8).toUpperCase()}`; + return { + success: true, + trackingCode, + gatewayUrl: `${body.callbackUrl}?trackingCode=${trackingCode}&amount=${body.amount}&status=success`, + message: 'Redirect user to gatewayUrl to complete payment', + }; + } + + @Post('gateway/verify') + @ApiOperation({ summary: 'Verify a payment gateway transaction and charge wallet' }) + async verifyGateway( + @Request() req: any, + @Body() body: { trackingCode: string; amount: number }, + ) { + assertStubGatewayAllowed(); + await this.billingService.chargeWallet( + req.user.id, + body.amount, + `Payment gateway: ${body.trackingCode}`, + ); + return { + success: true, + message: 'Payment verified and wallet charged', + trackingCode: body.trackingCode, + }; + } +} diff --git a/backend/src/billing/billing.controller.ts b/backend/src/billing/billing.controller.ts index f5ad16a..d1b0cff 100644 --- a/backend/src/billing/billing.controller.ts +++ b/backend/src/billing/billing.controller.ts @@ -3,35 +3,27 @@ import { Get, Post, Patch, - Delete, Body, Param, - Query, UseGuards, Request, BadRequestException, Inject, forwardRef, - ForbiddenException, } from '@nestjs/common'; import { AuthGuard } from '@nestjs/passport'; import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger'; import { BillingService } from './billing.service'; +import { BillingOpsService } from './billing-ops.service'; import { AppLifecycleService } from '../lifecycle/app-lifecycle.service'; import { ApplicationsService } from '../applications/applications.service'; -import { KubernetesService } from '../kubernetes/kubernetes.service'; import { - ChargeWalletDto, CalculateCostDto, CalculateDeployCostDto, SetOptionalServicesPricingDto, RenewApplicationDto, UpgradeResourcesDto, CalculateUpgradeCostDto, - InitiateInvoicePaymentDto, - VerifyInvoiceGatewayDto, - UpdateInvoiceStatusDto, - PayApplicationDto, } from './dto/billing.dto'; import { UpdatePricingCatalogDto } from './dto/pricing-catalog.dto'; import { RolesGuard } from '../common/guards/roles.guard'; @@ -41,12 +33,7 @@ import { BillingCycle, AppLifecycleStatus, InvoiceReason, - InvoiceStatus, - PaymentMethod, - ProductType, - DatabaseType, } from '../common/enums'; -import { Application } from '../applications/entities/application.entity'; @ApiTags('Billing') @ApiBearerAuth() @@ -55,12 +42,11 @@ import { Application } from '../applications/entities/application.entity'; export class BillingController { constructor( private readonly billingService: BillingService, + private readonly billingOpsService: BillingOpsService, @Inject(forwardRef(() => AppLifecycleService)) private readonly lifecycleService: AppLifecycleService, @Inject(forwardRef(() => ApplicationsService)) private readonly applicationsService: ApplicationsService, - @Inject(forwardRef(() => KubernetesService)) - private readonly kubernetesService: KubernetesService, ) {} // ─── Pricing catalog (Admin) ────────────────────────────────────── @@ -152,274 +138,6 @@ export class BillingController { return this.billingService.setOptionalServicesPricing(dto); } - // ─── Wallet (User) ─────────────────────────────────────────────── - - @Get('wallet') - @ApiOperation({ summary: 'Get my wallet balance' }) - async getBalance(@Request() req: any) { - return this.billingService.getBalance(req.user.id); - } - - @Post('wallet/charge') - @ApiOperation({ summary: 'Charge my wallet (self top-up)' }) - async chargeMyWallet(@Request() req: any, @Body() dto: ChargeWalletDto) { - return this.billingService.chargeWallet(req.user.id, dto.amount, dto.description || 'Self top-up'); - } - - @Get('wallet/transactions') - @ApiOperation({ summary: 'Get my wallet transactions' }) - async getTransactions(@Request() req: any, @Query('limit') limit?: string) { - return this.billingService.getTransactions(req.user.id, limit ? parseInt(limit, 10) : 50); - } - - @Get('resource-credits') - @ApiOperation({ summary: 'List active prepaid resource credits (from deleted apps)' }) - async getResourceCredits(@Request() req: any) { - const credits = await this.billingService.getActiveCredits(req.user.id); - return credits.map((c) => this.billingService.formatCreditForApi(c)); - } - - // ─── Invoices ───────────────────────────────────────────────────── - - @Get('invoices') - @ApiOperation({ summary: 'List my invoices' }) - async listMyInvoices( - @Request() req: any, - @Query('status') status?: InvoiceStatus, - @Query('applicationId') applicationId?: string, - @Query('limit') limit?: string, - ) { - return this.billingService.listInvoices(req.user, { - status, - applicationId, - limit: limit ? parseInt(limit, 10) : undefined, - }); - } - - @Get('invoices/:id') - @ApiOperation({ summary: 'Get one invoice with line items and transactions' }) - async getInvoice(@Request() req: any, @Param('id') id: string) { - return this.billingService.getInvoiceForUser(id, req.user); - } - - @Post('invoices/:id/pay/mixed') - @ApiOperation({ summary: 'Pay invoice with wallet first, then gateway for the remaining amount' }) - async initiateInvoiceMixed( - @Request() req: any, - @Param('id') id: string, - @Body() dto: InitiateInvoicePaymentDto, - ) { - const result = await this.billingService.initiateInvoiceMixedPayment(id, req.user, dto.callbackUrl); - const effect = await this.completePaidInvoiceEffect(result.invoice); - return { ...result, effect }; - } - - @Post('invoices/:id/gateway/verify') - @ApiOperation({ summary: 'Verify invoice gateway payment' }) - async verifyInvoiceGateway( - @Request() req: any, - @Param('id') id: string, - @Body() dto: VerifyInvoiceGatewayDto, - ) { - const result = await this.billingService.verifyInvoiceGatewayPayment( - id, - req.user, - dto.trackingCode, - dto.amount, - ); - const effect = await this.completePaidInvoiceEffect(result.invoice); - return { ...result, effect }; - } - - @Post('wallet/pay/:applicationId') - @ApiOperation({ summary: 'Pay for an application plan from wallet — activates/renews the app' }) - async payForApplication( - @Request() req: any, - @Param('applicationId') applicationId: string, - @Body() body: PayApplicationDto, - ) { - const cycle = body.cycle as BillingCycle; - if (!Object.values(BillingCycle).includes(cycle)) { - throw new BadRequestException(`Invalid billing cycle: ${body.cycle}`); - } - - const app = await this.applicationsService.findOne(applicationId, req.user.id); - - const payment = await this.billingService.resolveAppPayment( - req.user.id, - app, - cycle, - ); - - const coupon = await this.billingService.resolveCoupon( - req.user.id, - body.couponCode, - await this.billingService.getAppChargeBreakdown(app), - cycle, - payment.amountDue, - ); - - let invoice = null; - if (payment.amountDue > 0) { - invoice = await this.billingService.createInvoice({ - userId: req.user.id, - applicationId: app.id, - reason: InvoiceReason.DEPLOY, - lines: [ - { - label: `Application payment: ${app.name}`, - description: `Billing cycle: ${cycle}`, - amount: payment.amountDue, - metadata: { - cycle, - waivedAmount: payment.waivedAmount, - creditApplied: payment.creditId || null, - }, - }, - ], - metadata: { - action: 'activate', - cycle, - }, - discount: coupon ?? undefined, - }); - } - - let tx = null; - if (invoice) { - const paid = await this.billingService.payInvoiceWithWallet(invoice.id, req.user); - tx = paid.transaction; - invoice = paid.invoice; - } - - const activated = await this.lifecycleService.activateApp( - applicationId, - cycle, - ); - - return { - transaction: tx, - invoice, - creditApplied: payment.creditId || null, - waivedAmount: payment.waivedAmount, - discountAmount: coupon?.amount ?? 0, - discountCode: coupon?.code ?? null, - paidAmount: invoice ? Number(invoice.total) : 0, - application: { - id: activated.id, - name: activated.name, - lifecycleStatus: activated.lifecycleStatus, - planExpiresAt: activated.planExpiresAt, - }, - message: payment.waivedAmount > 0 - ? payment.amountDue > 0 - ? `Application "${activated.name}" activated — prepaid credit applied; you paid ${payment.amountDue} Toman for additional services.` - : `Application "${activated.name}" activated using your prepaid resource credit (no charge).` - : payment.amountDue > 0 - ? `Application "${activated.name}" activated until ${activated.planExpiresAt?.toISOString()}` - : `Application "${activated.name}" activated`, - }; - } - - // ─── Payment Gateway ───────────────────────────────────────────── - - @Post('gateway/initiate') - @ApiOperation({ summary: 'Initiate a payment gateway transaction' }) - async initiateGateway( - @Request() req: any, - @Body() body: { amount: number; description?: string; callbackUrl: string }, - ) { - // In production, integrate with Zarinpal/IDPay/etc. - // For now, simulate a gateway redirect URL. - const trackingCode = `PAY-${Date.now()}-${Math.random().toString(36).substring(2, 8).toUpperCase()}`; - return { - success: true, - trackingCode, - gatewayUrl: `${body.callbackUrl}?trackingCode=${trackingCode}&amount=${body.amount}&status=success`, - message: 'Redirect user to gatewayUrl to complete payment', - }; - } - - @Post('gateway/verify') - @ApiOperation({ summary: 'Verify a payment gateway transaction and charge wallet' }) - async verifyGateway( - @Request() req: any, - @Body() body: { trackingCode: string; amount: number }, - ) { - // In production, verify with the gateway provider. - // For now, auto-approve and charge the wallet. - await this.billingService.chargeWallet( - req.user.id, - body.amount, - `Payment gateway: ${body.trackingCode}`, - ); - return { - success: true, - message: 'Payment verified and wallet charged', - trackingCode: body.trackingCode, - }; - } - - // ─── Invoice Admin ──────────────────────────────────────────────── - - @Get('admin/invoices') - @Roles(UserRole.ADMIN) - @ApiOperation({ summary: 'List all invoices (Admin)' }) - async listAdminInvoices( - @Request() req: any, - @Query('status') status?: InvoiceStatus, - @Query('userId') userId?: string, - @Query('applicationId') applicationId?: string, - @Query('paymentMethod') paymentMethod?: PaymentMethod, - @Query('search') search?: string, - @Query('limit') limit?: string, - ) { - return this.billingService.listInvoices(req.user, { - status, - userId, - applicationId, - paymentMethod, - search, - limit: limit ? parseInt(limit, 10) : undefined, - }); - } - - @Get('admin/invoices/:id') - @Roles(UserRole.ADMIN) - @ApiOperation({ summary: 'Get invoice details (Admin)' }) - async getAdminInvoice(@Request() req: any, @Param('id') id: string) { - return this.billingService.getInvoiceForUser(id, req.user); - } - - @Patch('admin/invoices/:id/status') - @Roles(UserRole.ADMIN) - @ApiOperation({ summary: 'Update invoice status with reason (Admin)' }) - async updateAdminInvoiceStatus( - @Param('id') id: string, - @Body() dto: UpdateInvoiceStatusDto, - ) { - return this.billingService.updateInvoiceStatus(id, dto.status, dto.reason); - } - - // ─── Wallet Admin ───────────────────────────────────────────────── - - @Get('admin/wallets') - @Roles(UserRole.ADMIN) - @ApiOperation({ summary: 'List all wallets (Admin)' }) - async getAllWallets() { - return this.billingService.getAllWallets(); - } - - @Post('admin/wallets/:userId/charge') - @Roles(UserRole.ADMIN) - @ApiOperation({ summary: 'Charge a user\'s wallet (Admin)' }) - async adminChargeWallet( - @Param('userId') userId: string, - @Body() dto: ChargeWalletDto, - ) { - return this.billingService.adminChargeWallet(userId, dto.amount, dto.description); - } - // ─── Application Renewal ────────────────────────────────────────── @Get('applications/:applicationId/renewal-cost') @@ -428,8 +146,7 @@ export class BillingController { @Request() req: any, @Param('applicationId') applicationId: string, ) { - // User can only view their own app, admin/sales can view any - const app = await this.getAppWithAccess(req.user, applicationId); + const app = await this.billingOpsService.getAppWithAccess(req.user, applicationId); const costs = await this.billingService.calculateRenewalCost(app); return { applicationId: app.id, @@ -448,7 +165,7 @@ export class BillingController { @Param('applicationId') applicationId: string, @Body() dto: RenewApplicationDto, ) { - const app = await this.getAppWithAccess(req.user, applicationId); + const app = await this.billingOpsService.getAppWithAccess(req.user, applicationId); const costs = await this.billingService.calculateRenewalCost(app); const amount = dto.cycle === BillingCycle.HOURLY ? costs.hourly : dto.cycle === BillingCycle.MONTHLY ? costs.monthly @@ -490,10 +207,8 @@ export class BillingController { @Param('applicationId') applicationId: string, @Body() dto: RenewApplicationDto, ) { - // User can only renew their own app, admin/sales can renew any - const app = await this.getAppWithAccess(req.user, applicationId); + const app = await this.billingOpsService.getAppWithAccess(req.user, applicationId); - // Calculate cost for the selected cycle const costs = await this.billingService.calculateRenewalCost(app); const amount = dto.cycle === BillingCycle.HOURLY ? costs.hourly : dto.cycle === BillingCycle.MONTHLY ? costs.monthly @@ -533,7 +248,6 @@ export class BillingController { const paid = await this.billingService.payInvoiceWithWallet(invoice.id, req.user); - // Activate the application const renewedApp = await this.lifecycleService.activateApp(app.id, dto.cycle); return { @@ -567,7 +281,6 @@ export class BillingController { } if (body.bypassPayment) { - // Direct activation without payment (for special cases, support, etc.) const renewedApp = await this.lifecycleService.activateApp(app.id, cycle); return { success: true, @@ -583,7 +296,6 @@ export class BillingController { }; } - // Normal renewal - deduct from app owner's wallet const costs = await this.billingService.calculateRenewalCost(app); const amount = cycle === BillingCycle.HOURLY ? costs.hourly : cycle === BillingCycle.MONTHLY ? costs.monthly @@ -640,7 +352,7 @@ export class BillingController { @Param('applicationId') applicationId: string, @Body() dto: CalculateUpgradeCostDto, ) { - const app = await this.getAppWithAccess(req.user, applicationId); + const app = await this.billingOpsService.getAppWithAccess(req.user, applicationId); const result = await this.billingService.calculateUpgradeCost(app, dto); return { @@ -675,7 +387,7 @@ export class BillingController { @Param('applicationId') applicationId: string, @Body() dto: UpgradeResourcesDto, ) { - const app = await this.getAppWithAccess(req.user, applicationId); + const app = await this.billingOpsService.getAppWithAccess(req.user, applicationId); if (app.lifecycleStatus !== AppLifecycleStatus.ACTIVE) { throw new BadRequestException( `Cannot upgrade resources for ${app.lifecycleStatus} application. Please renew first.`, @@ -727,20 +439,17 @@ export class BillingController { @Param('applicationId') applicationId: string, @Body() dto: UpgradeResourcesDto, ) { - const app = await this.getAppWithAccess(req.user, applicationId); + const app = await this.billingOpsService.getAppWithAccess(req.user, applicationId); - // Application must be active to upgrade if (app.lifecycleStatus !== AppLifecycleStatus.ACTIVE) { throw new BadRequestException( `Cannot upgrade resources for ${app.lifecycleStatus} application. Please renew first.` ); } - // Calculate upgrade cost const costResult = await this.billingService.calculateUpgradeCost(app, dto); let paidInvoice = null; - // If upgrading (positive difference), require payment if (costResult.proratedAmount > 0) { const walletUserId = req.user.role === UserRole.ADMIN || req.user.role === UserRole.SALES ? app.userId @@ -784,11 +493,11 @@ export class BillingController { const updatedApp = await this.applicationsService.update( app.id, app.userId, - this.buildUpgradeEntityPatch(app, dto), + this.billingOpsService.buildUpgradeEntityPatch(app, dto), ); try { - await this.applyUpgradeToKubernetes(updatedApp, dto, app); + await this.billingOpsService.applyUpgradeToKubernetes(updatedApp, dto, app); } catch (e: any) { console.warn(`K8s resource update failed for ${app.name}: ${e.message}`); } @@ -813,238 +522,4 @@ export class BillingController { : 'Resources updated (downgrade or no cost change).', }; } - - // ─── Helper Methods ─────────────────────────────────────────────── - - private async completePaidInvoiceEffect(invoice: any) { - if (invoice.status !== InvoiceStatus.PAID) return null; - if (invoice.metadata?.completedAt) return invoice.metadata.completionResult || null; - - const action = invoice.metadata?.action; - if (!action || !invoice.applicationId) return null; - - if (action === 'renew' || action === 'activate') { - const cycle = invoice.metadata?.cycle as BillingCycle; - if (!Object.values(BillingCycle).includes(cycle)) return null; - - const activated = await this.lifecycleService.activateApp(invoice.applicationId, cycle); - const result = { - action, - application: { - id: activated.id, - name: activated.name, - lifecycleStatus: activated.lifecycleStatus, - planExpiresAt: activated.planExpiresAt, - billingCycle: activated.billingCycle, - }, - }; - await this.billingService.markInvoiceEffectCompleted(invoice.id, result); - return result; - } - - if (action === 'upgrade') { - const app = await this.applicationsService.findOne(invoice.applicationId); - const resources = (invoice.metadata?.resources || {}) as UpgradeResourcesDto; - const updatedApp = await this.applicationsService.update( - app.id, - app.userId, - this.buildUpgradeEntityPatch(app, resources), - ); - - try { - await this.applyUpgradeToKubernetes(updatedApp, resources, app); - } catch (e: any) { - console.warn(`K8s resource update failed for ${app.name}: ${e.message}`); - } - - const result = { - action, - application: { - id: updatedApp.id, - name: updatedApp.name, - cpuRequest: updatedApp.cpuRequest, - cpuLimit: updatedApp.cpuLimit, - memoryRequest: updatedApp.memoryRequest, - memoryLimit: updatedApp.memoryLimit, - replicas: updatedApp.replicas, - }, - }; - await this.billingService.markInvoiceEffectCompleted(invoice.id, result); - return result; - } - - return null; - } - - private buildUpgradeEntityPatch(app: Application, dto: UpgradeResourcesDto): Partial { - const pt = app.productType ?? ProductType.APPLICATION; - - if (dto.redisResources) { - return { - optionalServiceResources: { - ...app.optionalServiceResources, - redis: { - ...app.optionalServiceResources?.redis, - ...dto.redisResources, - storageGi: - dto.redisResources.storageGi ?? app.optionalServiceResources?.redis?.storageGi ?? 1, - }, - }, - }; - } - - if (dto.rabbitmqResources) { - return { - optionalServiceResources: { - ...app.optionalServiceResources, - rabbitmq: { - ...app.optionalServiceResources?.rabbitmq, - ...dto.rabbitmqResources, - storageGi: - dto.rabbitmqResources.storageGi ?? - app.optionalServiceResources?.rabbitmq?.storageGi ?? - 2, - }, - }, - }; - } - - if (pt === ProductType.MANAGED_REDIS || pt === ProductType.MANAGED_RABBITMQ) { - return {}; - } - - return { - cpuRequest: dto.cpuRequest || app.cpuRequest, - cpuLimit: dto.cpuLimit || app.cpuLimit, - memoryRequest: dto.memoryRequest || app.memoryRequest, - memoryLimit: dto.memoryLimit || app.memoryLimit, - replicas: dto.replicas ?? app.replicas, - dbStorageSize: dto.dbStorageSize || app.dbStorageSize, - appStorageSize: dto.appStorageSize || app.appStorageSize, - }; - } - - private async applyUpgradeToKubernetes( - app: Application, - dto: UpgradeResourcesDto, - previous: Application, - ): Promise { - const pt = app.productType ?? ProductType.APPLICATION; - - if (pt === ProductType.MANAGED_DATABASE) { - await this.kubernetesService.updateResources( - app, - { - cpuRequest: dto.cpuRequest, - cpuLimit: dto.cpuLimit, - memoryRequest: dto.memoryRequest, - memoryLimit: dto.memoryLimit, - }, - 'database', - ); - if (dto.dbStorageSize && dto.dbStorageSize !== previous.dbStorageSize) { - const resize = await this.kubernetesService.resizeDatabasePvc(app, dto.dbStorageSize); - if (!resize.success) { - throw new BadRequestException(resize.message); - } - } - return; - } - - if (pt === ProductType.MANAGED_REDIS) { - await this.applyOptionalServiceUpgrade(app, dto, previous, 'redis'); - return; - } - - if (pt === ProductType.MANAGED_RABBITMQ) { - await this.applyOptionalServiceUpgrade(app, dto, previous, 'rabbitmq'); - return; - } - - if (dto.redisResources && app.enableRedis) { - await this.applyOptionalServiceUpgrade(app, dto, previous, 'redis'); - } - - if (dto.rabbitmqResources && app.enableRabbitmq) { - await this.applyOptionalServiceUpgrade(app, dto, previous, 'rabbitmq'); - } - - const touchesAppWorkload = - dto.cpuRequest !== undefined || - dto.cpuLimit !== undefined || - dto.memoryRequest !== undefined || - dto.memoryLimit !== undefined || - dto.replicas !== undefined; - - if (touchesAppWorkload) { - await this.kubernetesService.updateResources(app, { - cpuRequest: dto.cpuRequest, - cpuLimit: dto.cpuLimit, - memoryRequest: dto.memoryRequest, - memoryLimit: dto.memoryLimit, - replicas: dto.replicas, - }); - } - - if (dto.appStorageSize && dto.appStorageSize !== previous.appStorageSize) { - await this.kubernetesService.resizeAppStoragePvc(app, dto.appStorageSize); - } - - if ( - dto.dbStorageSize && - dto.dbStorageSize !== previous.dbStorageSize && - previous.databaseType && - previous.databaseType !== DatabaseType.NONE - ) { - const resize = await this.kubernetesService.resizeDatabasePvc(app, dto.dbStorageSize); - if (!resize.success) { - throw new BadRequestException(resize.message); - } - } - } - - private async applyOptionalServiceUpgrade( - app: Application, - dto: UpgradeResourcesDto, - previous: Application, - service: 'redis' | 'rabbitmq', - ): Promise { - const res = app.optionalServiceResources?.[service]; - const dtoRes = service === 'redis' ? dto.redisResources : dto.rabbitmqResources; - if (res) { - await this.kubernetesService.updateResources( - app, - { - cpuRequest: res.cpuRequest, - cpuLimit: res.cpuLimit, - memoryRequest: res.memoryRequest, - memoryLimit: res.memoryLimit, - }, - service, - ); - } - const prevGi = - previous.optionalServiceResources?.[service]?.storageGi ?? (service === 'redis' ? 1 : 2); - const nextGi = dtoRes?.storageGi; - if (nextGi != null && nextGi > prevGi) { - const resize = - service === 'redis' - ? await this.kubernetesService.resizeRedisStoragePvc(app, `${nextGi}Gi`) - : await this.kubernetesService.resizeRabbitmqStoragePvc(app, `${nextGi}Gi`); - if (!resize.success) { - throw new BadRequestException(resize.message); - } - } - } - - private async getAppWithAccess(user: any, applicationId: string) { - const isAdminOrSales = user.role === UserRole.ADMIN || user.role === UserRole.SALES; - - if (isAdminOrSales) { - return this.applicationsService.findOne(applicationId); - } - - // Regular user - must own the app - return this.applicationsService.findOne(applicationId, user.id); - } } diff --git a/backend/src/billing/billing.module.ts b/backend/src/billing/billing.module.ts index 2f6af7b..990ad58 100644 --- a/backend/src/billing/billing.module.ts +++ b/backend/src/billing/billing.module.ts @@ -1,7 +1,10 @@ import { Module, forwardRef } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; import { BillingService } from './billing.service'; +import { BillingOpsService } from './billing-ops.service'; import { BillingController } from './billing.controller'; +import { BillingWalletController } from './billing-wallet.controller'; +import { BillingInvoicesController } from './billing-invoices.controller'; import { PublicPricingController } from './public-pricing.controller'; import { DiscountController } from './discount.controller'; import { DiscountService } from './discount.service'; @@ -42,8 +45,14 @@ import { KubernetesModule } from '../kubernetes/kubernetes.module'; forwardRef(() => ApplicationsModule), forwardRef(() => KubernetesModule), ], - controllers: [BillingController, PublicPricingController, DiscountController], - providers: [BillingService, PricingCatalogService, DiscountService], - exports: [BillingService, PricingCatalogService, DiscountService], + controllers: [ + BillingController, + BillingWalletController, + BillingInvoicesController, + PublicPricingController, + DiscountController, + ], + providers: [BillingService, BillingOpsService, PricingCatalogService, DiscountService], + exports: [BillingService, BillingOpsService, PricingCatalogService, DiscountService], }) export class BillingModule {} diff --git a/backend/src/billing/payment-gateway.util.spec.ts b/backend/src/billing/payment-gateway.util.spec.ts new file mode 100644 index 0000000..3a2d51a --- /dev/null +++ b/backend/src/billing/payment-gateway.util.spec.ts @@ -0,0 +1,32 @@ +import { ForbiddenException } from '@nestjs/common'; +import { assertStubGatewayAllowed } from './payment-gateway.util'; + +describe('assertStubGatewayAllowed', () => { + const env = process.env; + + beforeEach(() => { + process.env = { ...env }; + }); + + afterAll(() => { + process.env = env; + }); + + it('allows in development', () => { + process.env.NODE_ENV = 'development'; + delete process.env.PAYMENT_GATEWAY_STUB_ENABLED; + expect(() => assertStubGatewayAllowed()).not.toThrow(); + }); + + it('blocks in production by default', () => { + process.env.NODE_ENV = 'production'; + delete process.env.PAYMENT_GATEWAY_STUB_ENABLED; + expect(() => assertStubGatewayAllowed()).toThrow(ForbiddenException); + }); + + it('allows in production when explicitly enabled for staging', () => { + process.env.NODE_ENV = 'production'; + process.env.PAYMENT_GATEWAY_STUB_ENABLED = 'true'; + expect(() => assertStubGatewayAllowed()).not.toThrow(); + }); +}); diff --git a/backend/src/billing/payment-gateway.util.ts b/backend/src/billing/payment-gateway.util.ts new file mode 100644 index 0000000..ca552dc --- /dev/null +++ b/backend/src/billing/payment-gateway.util.ts @@ -0,0 +1,14 @@ +import { ForbiddenException } from '@nestjs/common'; + +/** + * Stub gateway endpoints auto-approve payments without a real provider. + * Disabled in production unless PAYMENT_GATEWAY_STUB_ENABLED=true (staging only). + */ +export function assertStubGatewayAllowed(): void { + if ( + process.env.NODE_ENV === 'production' && + process.env.PAYMENT_GATEWAY_STUB_ENABLED !== 'true' + ) { + throw new ForbiddenException('Payment gateway is not configured'); + } +} diff --git a/backend/src/build/build-progress.store.ts b/backend/src/build/build-progress.store.ts new file mode 100644 index 0000000..5df193b --- /dev/null +++ b/backend/src/build/build-progress.store.ts @@ -0,0 +1,58 @@ +import { Injectable, OnModuleDestroy } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import Redis from 'ioredis'; +import type { BuildProgress } from './build.service'; + +const KEY_PREFIX = 'build:progress:'; +const TTL_SECONDS = 3600; + +@Injectable() +export class BuildProgressStore implements OnModuleDestroy { + private readonly redis: Redis; + + constructor(private readonly configService: ConfigService) { + this.redis = new Redis({ + host: this.configService.get('redis.host'), + port: this.configService.get('redis.port'), + lazyConnect: true, + maxRetriesPerRequest: 1, + }); + this.redis.connect().catch(() => { + // Redis may be unavailable in local unit tests — in-memory fallback remains in BuildService. + }); + } + + async get(deploymentId: string): Promise { + try { + const raw = await this.redis.get(`${KEY_PREFIX}${deploymentId}`); + return raw ? (JSON.parse(raw) as BuildProgress) : null; + } catch { + return null; + } + } + + async set(deploymentId: string, progress: BuildProgress): Promise { + try { + await this.redis.set( + `${KEY_PREFIX}${deploymentId}`, + JSON.stringify(progress), + 'EX', + TTL_SECONDS, + ); + } catch { + // Best-effort — local map still holds progress for this replica. + } + } + + async clear(deploymentId: string): Promise { + try { + await this.redis.del(`${KEY_PREFIX}${deploymentId}`); + } catch { + // ignore + } + } + + onModuleDestroy(): void { + this.redis.disconnect(); + } +} diff --git a/backend/src/build/build.module.ts b/backend/src/build/build.module.ts index caa562f..d028fe6 100644 --- a/backend/src/build/build.module.ts +++ b/backend/src/build/build.module.ts @@ -1,5 +1,6 @@ import { Module, forwardRef } from '@nestjs/common'; import { BuildService } from './build.service'; +import { BuildProgressStore } from './build-progress.store'; import { KubernetesModule } from '../kubernetes/kubernetes.module'; import { ClustersModule } from '../clusters/clusters.module'; @@ -8,7 +9,7 @@ import { ClustersModule } from '../clusters/clusters.module'; forwardRef(() => KubernetesModule), ClustersModule, ], - providers: [BuildService], + providers: [BuildService, BuildProgressStore], exports: [BuildService], }) export class BuildModule {} diff --git a/backend/src/build/build.service.spec.ts b/backend/src/build/build.service.spec.ts index 8cfe769..fe3aa51 100644 --- a/backend/src/build/build.service.spec.ts +++ b/backend/src/build/build.service.spec.ts @@ -1,469 +1,68 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { ConfigService } from '@nestjs/config'; +import { BuildService } from './build.service'; +import { Application } from '../applications/entities/application.entity'; import { AppRuntime } from '../common/enums'; +import { ClustersService } from '../clusters/clusters.service'; +import { RegistryService } from '../kubernetes/registry.service'; +import { BuildProgressStore } from './build-progress.store'; -/** - * Tests for build service — Dockerfile generation for all runtimes - */ +describe('BuildService', () => { + let service: BuildService; -// ───────────────────────────────────────────────────────────────────────────── -// Go Dockerfile tests -// ───────────────────────────────────────────────────────────────────────────── -describe('Go Dockerfile generation', () => { - function goDockerfile(app: { runtimeVersion?: string; port?: number }): string { - const goVersion = app.runtimeVersion || '1.22'; - const port = app.port || 8080; - return `FROM golang:${goVersion}-alpine AS builder -WORKDIR /app -RUN apk add --no-cache git -COPY go.mod go.sum* ./ -RUN go mod download || true -COPY . . -RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -ldflags="-w -s" -o main . + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + BuildService, + { + provide: ConfigService, + useValue: { + get: jest.fn((key: string) => { + const map: Record = { + 'build.namespace': 'cloudhost-builds', + 'build.serviceAccount': 'kaniko-builder', + 'registry.url': 'registry.local:5000', + }; + return map[key]; + }), + }, + }, + { provide: ClustersService, useValue: {} }, + { + provide: BuildProgressStore, + useValue: { get: jest.fn(), set: jest.fn(), clear: jest.fn() }, + }, + { provide: RegistryService, useValue: {} }, + ], + }).compile(); -FROM alpine:3.19 -WORKDIR /app -RUN apk --no-cache add ca-certificates tzdata -RUN addgroup -g 1001 -S appgroup && adduser -S appuser -u 1001 -G appgroup -COPY --from=builder /app/main . -RUN mkdir -p /app/data && chown -R appuser:appgroup /app -USER appuser -ENV PORT=${port} -EXPOSE ${port} -HEALTHCHECK --interval=30s --timeout=3s CMD wget --no-verbose --tries=1 --spider http://localhost:${port}/health || exit 1 -CMD ["./main"] -`; - } - - it('should use correct Go version', () => { - const df = goDockerfile({ runtimeVersion: '1.21' }); - expect(df).toContain('FROM golang:1.21-alpine'); + service = module.get(BuildService); }); - it('should default to Go 1.22', () => { - const df = goDockerfile({}); - expect(df).toContain('FROM golang:1.22-alpine'); - }); + describe('generateDockerfile', () => { + it('generates Go Dockerfile with requested runtime version', () => { + const app = { + runtime: AppRuntime.GO, + runtimeVersion: '1.22', + port: 8080, + } as Application; - it('should build static binary with CGO_ENABLED=0', () => { - const df = goDockerfile({}); - expect(df).toContain('CGO_ENABLED=0'); - }); + const dockerfile = (service as any).generateDockerfile(app) as string; - it('should use multi-stage build for smaller image', () => { - const df = goDockerfile({}); - expect(df).toContain('AS builder'); - expect(df).toContain('FROM alpine:3.19'); - }); + expect(dockerfile).toContain('FROM golang:1.22-alpine'); + expect(dockerfile).toContain('EXPOSE 8080'); + }); - it('should include health check', () => { - const df = goDockerfile({ port: 8080 }); - expect(df).toContain('HEALTHCHECK'); - expect(df).toContain('http://localhost:8080/health'); - }); + it('generates Node.js Dockerfile with default port', () => { + const app = { + runtime: AppRuntime.NODEJS, + runtimeVersion: '20', + } as Application; - it('should create data directory for persistent storage', () => { - const df = goDockerfile({}); - expect(df).toContain('mkdir -p /app/data'); - }); + const dockerfile = (service as any).generateDockerfile(app) as string; - it('should run as non-root user', () => { - const df = goDockerfile({}); - expect(df).toContain('USER appuser'); - }); -}); - -// ───────────────────────────────────────────────────────────────────────────── -// Python Dockerfile tests -// ───────────────────────────────────────────────────────────────────────────── -describe('Python Dockerfile generation', () => { - function pythonDockerfile(app: { runtimeVersion?: string; port?: number }): string { - const pythonVersion = app.runtimeVersion || '3.12'; - const port = app.port || 8000; - return `FROM python:${pythonVersion}-slim AS builder -WORKDIR /app -RUN apt-get update && apt-get install -y build-essential libpq-dev -COPY requirements.txt* ./ -RUN pip install --no-cache-dir --user -r requirements.txt 2>/dev/null || pip install --no-cache-dir --user flask gunicorn - -FROM python:${pythonVersion}-slim -WORKDIR /app -RUN groupadd -g 1001 appgroup && useradd -r -u 1001 -g appgroup appuser -COPY --from=builder /root/.local /home/appuser/.local -COPY . . -RUN mkdir -p /app/data && chown -R appuser:appgroup /app -USER appuser -ENV PATH=/home/appuser/.local/bin:$PATH -ENV PORT=${port} -EXPOSE ${port} -HEALTHCHECK --interval=30s --timeout=3s CMD curl -f http://localhost:${port}/health || exit 1 -CMD ["gunicorn", "-w", "4", "-b", "0.0.0.0:${port}", "app:app"] -`; - } - - it('should use correct Python version', () => { - const df = pythonDockerfile({ runtimeVersion: '3.11' }); - expect(df).toContain('FROM python:3.11-slim'); - }); - - it('should default to Python 3.12', () => { - const df = pythonDockerfile({}); - expect(df).toContain('FROM python:3.12-slim'); - }); - - it('should use multi-stage build', () => { - const df = pythonDockerfile({}); - expect(df).toContain('AS builder'); - }); - - it('should install from requirements.txt', () => { - const df = pythonDockerfile({}); - expect(df).toContain('requirements.txt'); - }); - - it('should include health check', () => { - const df = pythonDockerfile({ port: 8000 }); - expect(df).toContain('HEALTHCHECK'); - expect(df).toContain('http://localhost:8000/health'); - }); - - it('should run as non-root user', () => { - const df = pythonDockerfile({}); - expect(df).toContain('USER appuser'); - }); -}); - -// ───────────────────────────────────────────────────────────────────────────── -// Django Dockerfile tests -// ───────────────────────────────────────────────────────────────────────────── -describe('Django Dockerfile generation', () => { - function djangoDockerfile(app: { runtimeVersion?: string; port?: number }): string { - const pythonVersion = app.runtimeVersion || '3.12'; - const port = app.port || 8000; - return `FROM python:${pythonVersion}-slim AS builder -WORKDIR /app -RUN apt-get update && apt-get install -y build-essential libpq-dev -COPY requirements.txt* ./ -RUN pip install --no-cache-dir --user -r requirements.txt 2>/dev/null || pip install --no-cache-dir --user django gunicorn - -FROM python:${pythonVersion}-slim -WORKDIR /app -COPY --from=builder /root/.local /home/appuser/.local -COPY . . -RUN mkdir -p /app/staticfiles /app/media /app/data -USER appuser -ENV PORT=${port} -ENV DJANGO_SETTINGS_MODULE=config.settings -EXPOSE ${port} -HEALTHCHECK --interval=30s --timeout=3s CMD curl -f http://localhost:${port}/health/ || exit 1 -CMD ["sh", "-c", "python manage.py migrate --noinput && gunicorn config.wsgi:application --bind 0.0.0.0:${port}"] -`; - } - - it('should use correct Python version', () => { - const df = djangoDockerfile({ runtimeVersion: '3.10' }); - expect(df).toContain('FROM python:3.10-slim'); - }); - - it('should set DJANGO_SETTINGS_MODULE', () => { - const df = djangoDockerfile({}); - expect(df).toContain('DJANGO_SETTINGS_MODULE'); - }); - - it('should create staticfiles and media directories', () => { - const df = djangoDockerfile({}); - expect(df).toContain('/app/staticfiles'); - expect(df).toContain('/app/media'); - }); - - it('should run migrations on startup', () => { - const df = djangoDockerfile({}); - expect(df).toContain('migrate'); - }); - - it('should use gunicorn for production', () => { - const df = djangoDockerfile({}); - expect(df).toContain('gunicorn'); - }); -}); - -// ───────────────────────────────────────────────────────────────────────────── -// .NET Dockerfile tests -// ───────────────────────────────────────────────────────────────────────────── -describe('.NET Dockerfile generation', () => { - function dotnetDockerfile(app: { runtimeVersion?: string; port?: number }): string { - const dotnetVersion = app.runtimeVersion || '8.0'; - const port = app.port || 5000; - return `FROM mcr.microsoft.com/dotnet/sdk:${dotnetVersion} AS build -WORKDIR /src -COPY *.csproj ./ -RUN dotnet restore || true -COPY . . -RUN dotnet publish -c Release -o /app/publish - -FROM mcr.microsoft.com/dotnet/aspnet:${dotnetVersion} -WORKDIR /app -COPY --from=build /app/publish . -RUN mkdir -p /app/data -USER appuser -ENV ASPNETCORE_URLS=http://+:${port} -ENV ASPNETCORE_ENVIRONMENT=Production -EXPOSE ${port} -HEALTHCHECK --interval=30s --timeout=3s CMD curl -f http://localhost:${port}/health || exit 1 -CMD ["dotnet", "app.dll"] -`; - } - - it('should use correct .NET version', () => { - const df = dotnetDockerfile({ runtimeVersion: '7.0' }); - expect(df).toContain('dotnet/sdk:7.0'); - expect(df).toContain('dotnet/aspnet:7.0'); - }); - - it('should default to .NET 8.0', () => { - const df = dotnetDockerfile({}); - expect(df).toContain('dotnet/sdk:8.0'); - }); - - it('should use multi-stage build', () => { - const df = dotnetDockerfile({}); - expect(df).toContain('AS build'); - expect(df).toContain('dotnet/aspnet'); - }); - - it('should publish in Release mode', () => { - const df = dotnetDockerfile({}); - expect(df).toContain('-c Release'); - }); - - it('should set ASPNETCORE_ENVIRONMENT to Production', () => { - const df = dotnetDockerfile({}); - expect(df).toContain('ASPNETCORE_ENVIRONMENT=Production'); - }); - - it('should configure ASPNETCORE_URLS for correct port', () => { - const df = dotnetDockerfile({ port: 8080 }); - expect(df).toContain('ASPNETCORE_URLS=http://+:8080'); - }); -}); - -// ───────────────────────────────────────────────────────────────────────────── -// PHP Dockerfile tests -// ───────────────────────────────────────────────────────────────────────────── -describe('PHP Dockerfile generation', () => { - function phpDockerfile(app: { phpVersion?: string; port?: number }): string { - const phpVersion = app.phpVersion || '8.3'; - const port = app.port || 80; - return `FROM php:${phpVersion}-fpm-alpine -RUN apk add --no-cache nginx supervisor curl -RUN docker-php-ext-install pdo pdo_mysql opcache -WORKDIR /var/www/html -COPY . . -RUN mkdir -p /var/www/html/uploads /var/www/html/data -RUN chown -R www-data:www-data /var/www/html -EXPOSE ${port} -CMD ["/usr/bin/supervisord", "-c", "/etc/supervisord.conf"] -`; - } - - it('should use correct PHP version', () => { - const df = phpDockerfile({ phpVersion: '8.2' }); - expect(df).toContain('FROM php:8.2-fpm-alpine'); - }); - - it('should default to PHP 8.3', () => { - const df = phpDockerfile({}); - expect(df).toContain('FROM php:8.3-fpm-alpine'); - }); - - it('should use FPM with nginx via supervisord', () => { - const df = phpDockerfile({}); - expect(df).toContain('supervisord'); - expect(df).toContain('nginx'); - }); - - it('should install common PHP extensions', () => { - const df = phpDockerfile({}); - expect(df).toContain('pdo'); - expect(df).toContain('opcache'); - }); - - it('should create upload and data directories', () => { - const df = phpDockerfile({}); - expect(df).toContain('/var/www/html/uploads'); - expect(df).toContain('/var/www/html/data'); - }); -}); - -/** - * Tests for the WordPress build flow — specifically: - * 1. Helper pod PVC race condition (must wait for termination) - * 2. WordPress Dockerfile generation correctness - * 3. Entrypoint should use ENTRYPOINT not CMD to avoid double docker-entrypoint.sh execution - */ - -describe('WordPress Dockerfile generation', () => { - // Reproduce the wordpressDockerfile logic from build.service.ts - function wordpressDockerfile(app: { - runtimeVersion?: string; - phpVersion?: string; - codePath?: string; - port?: number; - }): string { - const wpVersion = app.runtimeVersion || '6.7'; - const phpVersion = app.phpVersion || '8.3'; - const hasUploadedCode = !!app.codePath; - - return `FROM wordpress:${wpVersion}-php${phpVersion}-apache -RUN docker-php-ext-install opcache -RUN a2enmod rewrite -RUN echo "upload_max_filesize = 64M\\npost_max_size = 64M\\nmax_execution_time = 300\\nmemory_limit = 256M" > /usr/local/etc/php/conf.d/uploads.ini -${hasUploadedCode ? `COPY . /tmp/user-content -RUN mkdir -p /usr/src/wordpress-user -ENTRYPOINT ["cloudhost-entrypoint.sh"] -CMD []` : `CMD ["apache2-foreground"]`} -EXPOSE 80 -`; - } - - it('should use ENTRYPOINT (not CMD) when user uploaded code', () => { - const df = wordpressDockerfile({ codePath: '/some/path/source.zip' }); - expect(df).toContain('ENTRYPOINT ["cloudhost-entrypoint.sh"]'); - expect(df).not.toContain('CMD ["cloudhost-entrypoint.sh"]'); - }); - - it('should use CMD apache2-foreground for fresh install (no code)', () => { - const df = wordpressDockerfile({}); - expect(df).toContain('CMD ["apache2-foreground"]'); - expect(df).not.toContain('ENTRYPOINT'); - }); - - it('should use correct WordPress and PHP versions', () => { - const df = wordpressDockerfile({ runtimeVersion: '6.4', phpVersion: '8.2' }); - expect(df).toContain('FROM wordpress:6.4-php8.2-apache'); - }); - - it('should default to WP 6.7 and PHP 8.3', () => { - const df = wordpressDockerfile({}); - expect(df).toContain('FROM wordpress:6.7-php8.3-apache'); - }); - - it('should COPY user content when codePath exists', () => { - const df = wordpressDockerfile({ codePath: '/tmp/source.zip' }); - expect(df).toContain('COPY . /tmp/user-content'); - }); - - it('should NOT copy user content for fresh install', () => { - const df = wordpressDockerfile({}); - expect(df).not.toContain('COPY . /tmp/user-content'); - }); -}); - -describe('Helper pod PVC race condition', () => { - it('should wait for pod deletion (not just fire-and-forget)', () => { - // Simulate the fix: after deleteNamespacedPod, poll readNamespacedPod until 404 - const deletionSteps = [ - { exists: true }, // pod still terminating - { exists: true }, // still terminating - { exists: false }, // gone (404) - ]; - - let pollCount = 0; - let fullyTerminated = false; - - for (const step of deletionSteps) { - pollCount++; - if (!step.exists) { - fullyTerminated = true; - break; - } - } - - expect(fullyTerminated).toBe(true); - expect(pollCount).toBe(3); - }); - - it('should time out if pod never terminates', () => { - const maxPolls = 30; // e.g. 60s / 2s interval - let pollCount = 0; - let timedOut = false; - - while (pollCount < maxPolls) { - pollCount++; - // Pod always exists (simulating stuck termination) - const exists = true; - if (!exists) break; - } - - if (pollCount >= maxPolls) { - timedOut = true; - } - - expect(timedOut).toBe(true); - }); -}); - -describe('WordPress entrypoint script', () => { - const entrypointScript = `#!/bin/bash -set -e - -# Merge user wp-content into PVC -if [ -d /usr/src/wordpress-user/wp-content ]; then - mkdir -p /var/www/html/wp-content - cp -a /usr/src/wordpress-user/wp-content/. /var/www/html/wp-content/ - chown -R www-data:www-data /var/www/html/wp-content -fi - -exec docker-entrypoint.sh apache2-foreground`; - - it('should call docker-entrypoint.sh exactly once (via exec)', () => { - const matches = entrypointScript.match(/docker-entrypoint\.sh/g); - expect(matches).toHaveLength(1); - }); - - it('should use exec to replace process', () => { - expect(entrypointScript).toContain('exec docker-entrypoint.sh apache2-foreground'); - }); - - it('should merge wp-content on every start when staged content exists', () => { - expect(entrypointScript).toContain('/usr/src/wordpress-user/wp-content'); - expect(entrypointScript).not.toContain('.user-content-merged'); - }); - - it('should not copy user wp-config.php (credentials come from env vars)', () => { - expect(entrypointScript).not.toContain('wp-config.php'); - }); - - it('should set proper ownership after merging wp-content', () => { - expect(entrypointScript).toContain('chown -R www-data:www-data /var/www/html/wp-content'); - }); -}); - -describe('WordPress zip structure handling', () => { - // The unzip init container handles single-subfolder flattening - it('should flatten single subfolder (public_html/) to root', () => { - // Simulate: zip contains only public_html/ - const extractedItems = ['public_html']; - const count = extractedItems.length; - const firstItem = extractedItems[0]; - - let flattenedToRoot = false; - if (count === 1 && firstItem === 'public_html') { - // cp -a /tmp/extract/public_html/. /workspace-out/source/ - flattenedToRoot = true; - } - - expect(flattenedToRoot).toBe(true); - }); - - it('should copy as-is when multiple items exist', () => { - // Simulate: zip contains multiple items at root - const extractedItems = ['wp-admin', 'wp-content', 'wp-includes', 'index.php']; - const count = extractedItems.length; - - let copiedAsIs = false; - if (count !== 1) { - copiedAsIs = true; - } - - expect(copiedAsIs).toBe(true); + expect(dockerfile).toContain('FROM node:20'); + expect(dockerfile).toContain('EXPOSE 3000'); + }); }); }); diff --git a/backend/src/build/build.service.ts b/backend/src/build/build.service.ts index 99555d7..1851e7b 100644 --- a/backend/src/build/build.service.ts +++ b/backend/src/build/build.service.ts @@ -10,6 +10,7 @@ import { Application } from '../applications/entities/application.entity'; import { AppRuntime } from '../common/enums'; import { ClustersService } from '../clusters/clusters.service'; import { RegistryService } from '../kubernetes/registry.service'; +import { BuildProgressStore } from './build-progress.store'; const execFileAsync = promisify(execFile); @@ -55,6 +56,7 @@ export class BuildService { private configService: ConfigService, private clustersService: ClustersService, private registryService: RegistryService, + private progressStore: BuildProgressStore, ) {} private beginBuildSession(deploymentId: string): void { @@ -277,17 +279,23 @@ export class BuildService { this.logger.log(`Cleaned up all build resources matching "${prefix}*" in ${buildNamespace}`); } - getProgress(deploymentId: string): BuildProgress | null { - return this.progressMap.get(deploymentId) ?? null; + async getProgress(deploymentId: string): Promise { + const local = this.progressMap.get(deploymentId); + if (local) return local; + const remote = await this.progressStore.get(deploymentId); + if (remote) this.progressMap.set(deploymentId, remote); + return remote; } setProgress(deploymentId: string | undefined, progress: BuildProgress): void { if (!deploymentId) return; this.progressMap.set(deploymentId, progress); + void this.progressStore.set(deploymentId, progress); } clearProgress(deploymentId: string): void { this.progressMap.delete(deploymentId); + void this.progressStore.clear(deploymentId); } /** diff --git a/backend/src/clusters/clusters.service.spec.ts b/backend/src/clusters/clusters.service.spec.ts index 58c47ab..8ea0fb7 100644 --- a/backend/src/clusters/clusters.service.spec.ts +++ b/backend/src/clusters/clusters.service.spec.ts @@ -1,105 +1,105 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { NotFoundException } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { ClustersService } from './clusters.service'; +import { Cluster } from './entities/cluster.entity'; +import { ClusterHealth } from './entities/cluster-health.entity'; +import { ClusterPool } from './entities/cluster-pool.entity'; +import { ClusterAllocationLog } from './entities/cluster-allocation-log.entity'; import { ClusterStatus } from '../common/enums'; +import { RegistryService } from '../kubernetes/registry.service'; +import { DataSource } from 'typeorm'; -/** - * Tests for ClustersService — getDefault and delete logic. - */ +describe('ClustersService', () => { + let service: ClustersService; -describe('ClustersService getDefault logic', () => { - // Simulate the fixed getDefault behavior - function getDefault(clusters: { id: string; isDefault: boolean; status: string }[]): { id: string } | null { - // Step 1: active + default - let result = clusters.find(c => c.isDefault && c.status === ClusterStatus.ACTIVE); - if (result) return { id: result.id }; + const clustersRepository = { + findOne: jest.fn(), + save: jest.fn().mockImplementation((x) => Promise.resolve(x)), + find: jest.fn(), + create: jest.fn(), + delete: jest.fn(), + count: jest.fn(), + }; - // Step 2: any active (fallback) - result = clusters.find(c => c.status === ClusterStatus.ACTIVE); - if (result) return { id: result.id }; + const healthRepository = { find: jest.fn(), save: jest.fn() }; + const poolRepository = { find: jest.fn(), findOne: jest.fn(), save: jest.fn() }; + const allocationLogsRepository = { save: jest.fn(), find: jest.fn() }; + const dataSource = { transaction: jest.fn() }; + const registryService = { ensureRegistryPullSecret: jest.fn() }; - return null; - } + beforeEach(async () => { + jest.clearAllMocks(); - it('should return active default cluster', () => { - const clusters = [ - { id: '1', isDefault: true, status: ClusterStatus.ACTIVE }, - { id: '2', isDefault: false, status: ClusterStatus.ACTIVE }, - ]; - expect(getDefault(clusters)?.id).toBe('1'); + const module: TestingModule = await Test.createTestingModule({ + providers: [ + ClustersService, + { provide: getRepositoryToken(Cluster), useValue: clustersRepository }, + { provide: getRepositoryToken(ClusterPool), useValue: poolRepository }, + { provide: getRepositoryToken(ClusterHealth), useValue: healthRepository }, + { provide: getRepositoryToken(ClusterAllocationLog), useValue: allocationLogsRepository }, + { provide: DataSource, useValue: dataSource }, + { provide: RegistryService, useValue: registryService }, + { + provide: ConfigService, + useValue: { + get: jest.fn((key: string) => { + if (key === 'CLUSTER_KUBECONFIG_KEY') return ''; + if (key === 'cluster.kubeconfigKey') return ''; + return undefined; + }), + }, + }, + ], + }).compile(); + + service = module.get(ClustersService); }); - it('should skip inactive default and return active cluster', () => { - const clusters = [ - { id: '1', isDefault: true, status: ClusterStatus.INACTIVE }, - { id: '2', isDefault: false, status: ClusterStatus.ACTIVE }, - ]; - expect(getDefault(clusters)?.id).toBe('2'); - }); + describe('getDefault', () => { + it('returns active default cluster', async () => { + const cluster = { + id: 'c-1', + name: 'primary', + isDefault: true, + status: ClusterStatus.ACTIVE, + kubeconfig: 'apiVersion: v1', + } as Cluster; - it('should return null when no active clusters exist', () => { - const clusters = [ - { id: '1', isDefault: true, status: ClusterStatus.INACTIVE }, - ]; - expect(getDefault(clusters)).toBeNull(); - }); + clustersRepository.findOne.mockResolvedValueOnce(cluster); - it('should handle both clusters being default (picks active one)', () => { - const clusters = [ - { id: 'inactive', isDefault: true, status: ClusterStatus.INACTIVE }, - { id: 'active', isDefault: true, status: ClusterStatus.ACTIVE }, - ]; - expect(getDefault(clusters)?.id).toBe('active'); - }); -}); - -describe('ClustersService delete logic', () => { - it('should reassign apps to replacement cluster on delete', () => { - // Simulate: cluster A (being deleted) has 3 apps, cluster B is active - const apps = [ - { id: 'app1', clusterId: 'A' }, - { id: 'app2', clusterId: 'A' }, - { id: 'app3', clusterId: 'B' }, - ]; - const deletedClusterId = 'A'; - const replacementId = 'B'; - - // Reassign - for (const app of apps) { - if (app.clusterId === deletedClusterId) { - app.clusterId = replacementId; - } - } - - expect(apps.filter(a => a.clusterId === 'A')).toHaveLength(0); - expect(apps.filter(a => a.clusterId === 'B')).toHaveLength(3); - }); - - it('should promote another cluster to default when default is deleted', () => { - const clusters = [ - { id: 'A', isDefault: true, status: ClusterStatus.ACTIVE }, - { id: 'B', isDefault: false, status: ClusterStatus.ACTIVE }, - ]; - - // Delete A - const deleted = clusters.splice(0, 1)[0]; - expect(deleted.isDefault).toBe(true); - - // Promote - const newDefault = clusters.find(c => c.status === ClusterStatus.ACTIVE); - if (newDefault) newDefault.isDefault = true; - - expect(clusters[0].isDefault).toBe(true); - expect(clusters[0].id).toBe('B'); - }); - - it('should nullify clusterId when no replacement cluster exists', () => { - const apps = [{ id: 'app1', clusterId: 'A' as string | null }]; - const hasReplacement = false; - - if (!hasReplacement) { - for (const app of apps) { - app.clusterId = null; - } - } - - expect(apps[0].clusterId).toBeNull(); + const result = await service.getDefault(); + + expect(result.id).toBe('c-1'); + expect(clustersRepository.findOne).toHaveBeenCalledWith({ + where: { isDefault: true, status: ClusterStatus.ACTIVE }, + }); + }); + + it('falls back to any active cluster when no default is set', async () => { + const fallback = { + id: 'c-2', + name: 'fallback', + isDefault: false, + status: ClusterStatus.ACTIVE, + kubeconfig: 'apiVersion: v1', + } as Cluster; + + clustersRepository.findOne + .mockResolvedValueOnce(null) + .mockResolvedValueOnce(fallback); + + const result = await service.getDefault(); + + expect(result.id).toBe('c-2'); + expect(clustersRepository.save).toHaveBeenCalled(); + }); + + it('throws when no active cluster exists', async () => { + clustersRepository.findOne.mockResolvedValue(null); + + await expect(service.getDefault()).rejects.toThrow(NotFoundException); + }); }); }); diff --git a/backend/src/config/configuration.ts b/backend/src/config/configuration.ts index 79e9207..06e4c51 100644 --- a/backend/src/config/configuration.ts +++ b/backend/src/config/configuration.ts @@ -84,6 +84,11 @@ export default () => ({ port: parseInt(process.env.REDIS_PORT || '6379', 10), }, + cluster: { + /** AES-256-GCM key for encrypting stored kubeconfigs. Required in production. */ + kubeconfigKey: process.env.CLUSTER_KUBECONFIG_KEY || '', + }, + // OTP SMS. Provider selectable via SMS_PROVIDER ('mizbansms' | 'kavenegar'). sms: { provider: (process.env.SMS_PROVIDER || 'mizbansms').trim().toLowerCase(), diff --git a/backend/src/config/validate-production-config.spec.ts b/backend/src/config/validate-production-config.spec.ts new file mode 100644 index 0000000..e374979 --- /dev/null +++ b/backend/src/config/validate-production-config.spec.ts @@ -0,0 +1,40 @@ +import { validateProductionConfig } from './validate-production-config'; + +describe('validateProductionConfig', () => { + const env = process.env; + + beforeEach(() => { + process.env = { ...env }; + }); + + afterAll(() => { + process.env = env; + }); + + it('does nothing in development', () => { + process.env.NODE_ENV = 'development'; + delete process.env.JWT_SECRET; + expect(() => validateProductionConfig()).not.toThrow(); + }); + + it('throws in production when secrets are missing or default', () => { + process.env.NODE_ENV = 'production'; + process.env.JWT_SECRET = 'default-jwt-secret'; + process.env.JWT_REFRESH_SECRET = 'default-refresh-secret'; + process.env.DB_PASSWORD = 'cloudhost_secret'; + + expect(() => validateProductionConfig()).toThrow(/Production configuration validation failed/); + expect(() => validateProductionConfig()).toThrow(/JWT_SECRET/); + expect(() => validateProductionConfig()).toThrow(/CLUSTER_KUBECONFIG_KEY/); + }); + + it('passes in production with strong secrets', () => { + process.env.NODE_ENV = 'production'; + process.env.JWT_SECRET = 'a-very-long-random-production-secret'; + process.env.JWT_REFRESH_SECRET = 'another-very-long-random-refresh-secret'; + process.env.DB_PASSWORD = 'strong-db-password-here'; + process.env.CLUSTER_KUBECONFIG_KEY = '0123456789abcdef0123456789abcdef'; + + expect(() => validateProductionConfig()).not.toThrow(); + }); +}); diff --git a/backend/src/config/validate-production-config.ts b/backend/src/config/validate-production-config.ts new file mode 100644 index 0000000..63bae96 --- /dev/null +++ b/backend/src/config/validate-production-config.ts @@ -0,0 +1,34 @@ +const DEFAULT_JWT_SECRET = 'default-jwt-secret'; +const DEFAULT_REFRESH_SECRET = 'default-refresh-secret'; +const DEFAULT_DB_PASSWORD = 'cloudhost_secret'; + +export function validateProductionConfig(): void { + if (process.env.NODE_ENV !== 'production') { + return; + } + + const errors: string[] = []; + + const jwtSecret = process.env.JWT_SECRET || DEFAULT_JWT_SECRET; + const refreshSecret = process.env.JWT_REFRESH_SECRET || DEFAULT_REFRESH_SECRET; + const dbPassword = process.env.DB_PASSWORD || DEFAULT_DB_PASSWORD; + + if (!process.env.JWT_SECRET || jwtSecret === DEFAULT_JWT_SECRET) { + errors.push('JWT_SECRET must be set to a strong random value in production'); + } + if (!process.env.JWT_REFRESH_SECRET || refreshSecret === DEFAULT_REFRESH_SECRET) { + errors.push('JWT_REFRESH_SECRET must be set to a strong random value in production'); + } + if (!process.env.DB_PASSWORD || dbPassword === DEFAULT_DB_PASSWORD) { + errors.push('DB_PASSWORD must be changed from the default in production'); + } + if (!process.env.CLUSTER_KUBECONFIG_KEY?.trim()) { + errors.push('CLUSTER_KUBECONFIG_KEY must be set in production to encrypt stored kubeconfigs'); + } + + if (errors.length > 0) { + throw new Error( + `Production configuration validation failed:\n${errors.map((e) => ` - ${e}`).join('\n')}`, + ); + } +} diff --git a/backend/src/deployments/deployments.controller.ts b/backend/src/deployments/deployments.controller.ts index 68e514c..1990c7c 100644 --- a/backend/src/deployments/deployments.controller.ts +++ b/backend/src/deployments/deployments.controller.ts @@ -10,6 +10,7 @@ import { AuthGuard } from '@nestjs/passport'; import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger'; import { DeploymentsService } from './deployments.service'; import { RolesGuard } from '../common/guards/roles.guard'; +import { UserRole } from '../common/enums'; @ApiTags('Deployments') @ApiBearerAuth() @@ -18,6 +19,12 @@ import { RolesGuard } from '../common/guards/roles.guard'; export class DeploymentsController { constructor(private readonly deploymentsService: DeploymentsService) {} + private ownershipUserId(req: { user: { id: string; role: string } }): string | undefined { + const isStaff = + req.user.role === UserRole.ADMIN || req.user.role === UserRole.TECHNICAL; + return isStaff ? undefined : req.user.id; + } + @Post('applications/:appId/deploy') @ApiOperation({ summary: 'Trigger a new deployment' }) async triggerDeployment(@Param('appId') appId: string, @Request() req: any) { @@ -26,14 +33,14 @@ export class DeploymentsController { @Get('applications/:appId') @ApiOperation({ summary: 'List deployments for an application' }) - async findByApplication(@Param('appId') appId: string) { - return this.deploymentsService.findByApplication(appId); + async findByApplication(@Param('appId') appId: string, @Request() req: any) { + return this.deploymentsService.findByApplication(appId, this.ownershipUserId(req)); } @Get(':id') @ApiOperation({ summary: 'Get deployment details' }) - async findOne(@Param('id') id: string) { - return this.deploymentsService.findOne(id); + async findOne(@Param('id') id: string, @Request() req: any) { + return this.deploymentsService.findOne(id, this.ownershipUserId(req)); } @Get('applications/:appId/logs') diff --git a/backend/src/deployments/deployments.service.spec.ts b/backend/src/deployments/deployments.service.spec.ts new file mode 100644 index 0000000..f66df3a --- /dev/null +++ b/backend/src/deployments/deployments.service.spec.ts @@ -0,0 +1,88 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { NotFoundException } from '@nestjs/common'; +import { DeploymentsService } from './deployments.service'; +import { Deployment } from './entities/deployment.entity'; +import { ApplicationsService } from '../applications/applications.service'; +import { KubernetesService } from '../kubernetes/kubernetes.service'; +import { BuildService } from '../build/build.service'; +import { ClustersService } from '../clusters/clusters.service'; + +describe('DeploymentsService authorization', () => { + let service: DeploymentsService; + + const deploymentsRepository = { + find: jest.fn(), + findOne: jest.fn(), + createQueryBuilder: jest.fn(), + }; + + const applicationsService = { + findOne: jest.fn(), + }; + + beforeEach(async () => { + jest.clearAllMocks(); + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + DeploymentsService, + { provide: getRepositoryToken(Deployment), useValue: deploymentsRepository }, + { provide: ApplicationsService, useValue: applicationsService }, + { provide: KubernetesService, useValue: {} }, + { provide: BuildService, useValue: {} }, + { provide: ClustersService, useValue: {} }, + ], + }).compile(); + + service = module.get(DeploymentsService); + }); + + describe('findByApplication', () => { + it('verifies application ownership before listing deployments', async () => { + const appId = 'app-1'; + const userId = 'user-1'; + const deployments = [{ id: 'd-1', applicationId: appId }] as Deployment[]; + + applicationsService.findOne.mockResolvedValue({ id: appId, userId }); + deploymentsRepository.find.mockResolvedValue(deployments); + + const result = await service.findByApplication(appId, userId); + + expect(applicationsService.findOne).toHaveBeenCalledWith(appId, userId); + expect(result).toEqual(deployments); + }); + + it('propagates NotFoundException when user does not own the app', async () => { + applicationsService.findOne.mockRejectedValue(new NotFoundException('Application not found')); + + await expect(service.findByApplication('app-1', 'other-user')).rejects.toThrow(NotFoundException); + expect(deploymentsRepository.find).not.toHaveBeenCalled(); + }); + }); + + describe('findOne', () => { + it('verifies application ownership before returning deployment', async () => { + const deployment = { + id: 'd-1', + applicationId: 'app-1', + application: { id: 'app-1' }, + } as Deployment; + + deploymentsRepository.findOne.mockResolvedValue(deployment); + applicationsService.findOne.mockResolvedValue({ id: 'app-1', userId: 'user-1' }); + + const result = await service.findOne('d-1', 'user-1'); + + expect(applicationsService.findOne).toHaveBeenCalledWith('app-1', 'user-1'); + expect(result).toBe(deployment); + }); + + it('throws when deployment does not exist', async () => { + deploymentsRepository.findOne.mockResolvedValue(null); + + await expect(service.findOne('missing', 'user-1')).rejects.toThrow(NotFoundException); + expect(applicationsService.findOne).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/backend/src/deployments/deployments.service.ts b/backend/src/deployments/deployments.service.ts index 8d6784c..7b2aac4 100644 --- a/backend/src/deployments/deployments.service.ts +++ b/backend/src/deployments/deployments.service.ts @@ -463,14 +463,15 @@ export class DeploymentsService { } } - async findByApplication(applicationId: string): Promise { + async findByApplication(applicationId: string, userId?: string): Promise { + await this.applicationsService.findOne(applicationId, userId); return this.deploymentsRepository.find({ where: { applicationId }, order: { createdAt: 'DESC' }, }); } - async findOne(id: string): Promise { + async findOne(id: string, userId?: string): Promise { const deployment = await this.deploymentsRepository.findOne({ where: { id }, relations: { application: true }, @@ -478,6 +479,7 @@ export class DeploymentsService { if (!deployment) { throw new NotFoundException('Deployment not found'); } + await this.applicationsService.findOne(deployment.applicationId, userId); return deployment; } @@ -537,7 +539,7 @@ export class DeploymentsService { if (!latest) return null; - const progress = this.buildService.getProgress(latest.id); + const progress = await this.buildService.getProgress(latest.id); if (progress) return progress; // No in-memory progress — infer from deployment status diff --git a/backend/src/health/health.controller.ts b/backend/src/health/health.controller.ts new file mode 100644 index 0000000..9129afc --- /dev/null +++ b/backend/src/health/health.controller.ts @@ -0,0 +1,25 @@ +import { Controller, Get } from '@nestjs/common'; +import { ApiTags, ApiOperation } from '@nestjs/swagger'; +import { InjectDataSource } from '@nestjs/typeorm'; +import { DataSource } from 'typeorm'; +import { SkipThrottle } from '@nestjs/throttler'; + +@ApiTags('Health') +@Controller() +@SkipThrottle() +export class HealthController { + constructor(@InjectDataSource() private readonly dataSource: DataSource) {} + + @Get('health') + @ApiOperation({ summary: 'Liveness probe' }) + health() { + return { status: 'ok', timestamp: new Date().toISOString() }; + } + + @Get('ready') + @ApiOperation({ summary: 'Readiness probe — checks database connectivity' }) + async ready() { + await this.dataSource.query('SELECT 1'); + return { status: 'ready', timestamp: new Date().toISOString() }; + } +} diff --git a/backend/src/health/health.module.ts b/backend/src/health/health.module.ts new file mode 100644 index 0000000..7476abe --- /dev/null +++ b/backend/src/health/health.module.ts @@ -0,0 +1,7 @@ +import { Module } from '@nestjs/common'; +import { HealthController } from './health.controller'; + +@Module({ + controllers: [HealthController], +}) +export class HealthModule {} diff --git a/backend/src/kubernetes/helm.service.spec.ts b/backend/src/kubernetes/helm.service.spec.ts index 90a242e..579bea4 100644 --- a/backend/src/kubernetes/helm.service.spec.ts +++ b/backend/src/kubernetes/helm.service.spec.ts @@ -26,10 +26,10 @@ describe('HelmService', () => { expect(service).toBeDefined(); }); - describe('chartPath', () => { + describe('resolveChartPath', () => { it('should resolve to helm/cloudhost-app relative to project root', () => { const expectedSuffix = path.join('helm', 'cloudhost-app'); - expect((service as any).chartPath).toContain(expectedSuffix); + expect((service as any).resolveChartPath('cloudhost-app')).toContain(expectedSuffix); }); }); diff --git a/backend/src/kubernetes/k8s-client-v1-migration.spec.ts b/backend/src/kubernetes/k8s-client-v1-migration.spec.ts index 606ca6b..854a501 100644 --- a/backend/src/kubernetes/k8s-client-v1-migration.spec.ts +++ b/backend/src/kubernetes/k8s-client-v1-migration.spec.ts @@ -9,6 +9,7 @@ jest.mock('@kubernetes/client-node', () => ({ import { RegistryService } from './registry.service'; import { KubernetesService } from './kubernetes.service'; +import { K8sLifecycleService } from './k8s-lifecycle.service'; /** * Regression tests for the @kubernetes/client-node 1.x migration. @@ -80,19 +81,25 @@ describe('KubernetesService — k8s v1 client shape', () => { let service: KubernetesService; const makeService = (clients: { coreApi?: any; appsApi?: any; networkingApi?: any; kc?: any }) => { + const k8sClientService = { + getK8sClient: jest.fn().mockResolvedValue({ + coreApi: clients.coreApi, + appsApi: clients.appsApi, + networkingApi: clients.networkingApi, + kc: clients.kc, + }), + getKubeconfig: jest.fn(), + }; + const k8sLifecycleService = new K8sLifecycleService(k8sClientService as any); const svc = new KubernetesService( configStub, {} as any, // clustersService {} as any, // helmService {} as any, // registryService + k8sClientService as any, + k8sLifecycleService, {} as any, // deploymentsRepository ); - jest.spyOn(svc as any, 'getK8sClient').mockResolvedValue({ - coreApi: clients.coreApi, - appsApi: clients.appsApi, - networkingApi: clients.networkingApi, - kc: clients.kc, - }); return svc; }; diff --git a/backend/src/kubernetes/k8s-client.service.ts b/backend/src/kubernetes/k8s-client.service.ts new file mode 100644 index 0000000..42b7eb1 --- /dev/null +++ b/backend/src/kubernetes/k8s-client.service.ts @@ -0,0 +1,38 @@ +import { Injectable } from '@nestjs/common'; +import * as k8s from '@kubernetes/client-node'; +import { ClustersService } from '../clusters/clusters.service'; +import { registerKubeconfigNoProxy } from '../common/kubernetes-proxy.util'; + +@Injectable() +export class K8sClientService { + constructor(private readonly clustersService: ClustersService) {} + + async getK8sClient(clusterId?: string): Promise<{ + coreApi: k8s.CoreV1Api; + appsApi: k8s.AppsV1Api; + networkingApi: k8s.NetworkingV1Api; + kc: k8s.KubeConfig; + }> { + const cluster = clusterId + ? await this.clustersService.findOne(clusterId) + : await this.clustersService.getDefault(); + + const kc = new k8s.KubeConfig(); + registerKubeconfigNoProxy(cluster.kubeconfig); + kc.loadFromString(cluster.kubeconfig); + + return { + coreApi: kc.makeApiClient(k8s.CoreV1Api), + appsApi: kc.makeApiClient(k8s.AppsV1Api), + networkingApi: kc.makeApiClient(k8s.NetworkingV1Api), + kc, + }; + } + + async getKubeconfig(clusterId?: string): Promise { + const cluster = clusterId + ? await this.clustersService.findOne(clusterId) + : await this.clustersService.getDefault(); + return cluster.kubeconfig; + } +} diff --git a/backend/src/kubernetes/k8s-lifecycle.service.ts b/backend/src/kubernetes/k8s-lifecycle.service.ts new file mode 100644 index 0000000..9f10e85 --- /dev/null +++ b/backend/src/kubernetes/k8s-lifecycle.service.ts @@ -0,0 +1,34 @@ +import { Injectable } from '@nestjs/common'; +import { Application } from '../applications/entities/application.entity'; +import { K8sClientService } from './k8s-client.service'; +import { primaryWorkloadLabel, userNamespace } from './k8s-workload.util'; + +/** Runtime logs and lightweight workload operations extracted from KubernetesService. */ +@Injectable() +export class K8sLifecycleService { + constructor(private readonly k8sClient: K8sClientService) {} + + async getPodLogs(app: Application): Promise { + const { coreApi } = await this.k8sClient.getK8sClient(app.clusterId); + const namespace = userNamespace(app.userId); + const podLabel = primaryWorkloadLabel(app); + + const pods = await coreApi.listNamespacedPod({ + namespace, + labelSelector: `app=${podLabel}`, + }); + + if (pods.items.length === 0) { + return 'No pods found for this application.'; + } + + const podName = pods.items[0].metadata?.name; + if (!podName) return 'Pod name not found.'; + + return coreApi.readNamespacedPodLog({ + name: podName, + namespace, + tailLines: 200, + }); + } +} diff --git a/backend/src/kubernetes/k8s-workload.util.ts b/backend/src/kubernetes/k8s-workload.util.ts new file mode 100644 index 0000000..3bd139d --- /dev/null +++ b/backend/src/kubernetes/k8s-workload.util.ts @@ -0,0 +1,42 @@ +import { Application } from '../applications/entities/application.entity'; +import { DatabaseType, isManagedProductType } from '../common/enums'; + +/** Kubernetes namespace for a user's applications. */ +export function userNamespace(userId: string): string { + return `user-${userId.split('-')[0]}`; +} + +/** Primary pod label selector target for an application workload. */ +export function primaryWorkloadLabel(app: Application): string { + if (isManagedProductType(app.productType)) { + if (app.databaseType && app.databaseType !== DatabaseType.NONE) { + return `${app.name}-db`; + } + if (app.enableRedis) return `${app.name}-redis`; + if (app.enableRabbitmq) return `${app.name}-rabbitmq`; + } + return app.name; +} + +export function getApplicationWorkloadDeployments( + app: Application, +): { name: string; runningReplicas: number }[] { + const managed = isManagedProductType(app.productType); + const workloads: { name: string; runningReplicas: number }[] = []; + + if (!managed) { + workloads.push({ name: app.name, runningReplicas: app.replicas || 1 }); + } + + if (app.databaseType && app.databaseType !== DatabaseType.NONE) { + workloads.push({ name: `${app.name}-db`, runningReplicas: 1 }); + } + if (app.enableRedis) { + workloads.push({ name: `${app.name}-redis`, runningReplicas: 1 }); + } + if (app.enableRabbitmq) { + workloads.push({ name: `${app.name}-rabbitmq`, runningReplicas: 1 }); + } + + return workloads; +} diff --git a/backend/src/kubernetes/kubernetes.module.ts b/backend/src/kubernetes/kubernetes.module.ts index 99bcd91..82f057d 100644 --- a/backend/src/kubernetes/kubernetes.module.ts +++ b/backend/src/kubernetes/kubernetes.module.ts @@ -3,6 +3,8 @@ import { TypeOrmModule } from '@nestjs/typeorm'; import { KubernetesService } from './kubernetes.service'; import { HelmService } from './helm.service'; import { RegistryService } from './registry.service'; +import { K8sClientService } from './k8s-client.service'; +import { K8sLifecycleService } from './k8s-lifecycle.service'; import { ElasticsearchService } from './elasticsearch.service'; import { ElasticsearchController } from './elasticsearch.controller'; import { LogsController } from './logs.controller'; @@ -13,7 +15,7 @@ import { Deployment } from '../deployments/entities/deployment.entity'; @Module({ imports: [forwardRef(() => ClustersModule), TypeOrmModule.forFeature([Application, Deployment])], controllers: [ElasticsearchController, LogsController], - providers: [KubernetesService, HelmService, RegistryService, ElasticsearchService], - exports: [KubernetesService, HelmService, RegistryService, ElasticsearchService], + providers: [KubernetesService, HelmService, RegistryService, ElasticsearchService, K8sClientService, K8sLifecycleService], + exports: [KubernetesService, HelmService, RegistryService, ElasticsearchService, K8sClientService, K8sLifecycleService], }) export class KubernetesModule {} diff --git a/backend/src/kubernetes/kubernetes.service.ts b/backend/src/kubernetes/kubernetes.service.ts index 7e9e471..24b1dd4 100644 --- a/backend/src/kubernetes/kubernetes.service.ts +++ b/backend/src/kubernetes/kubernetes.service.ts @@ -15,6 +15,8 @@ import { ensureAppUrlEnv } from '../applications/app-url.util'; import { AppRuntime, DatabaseType, CustomDomainStatus, ServiceAccessTarget, ProductType, isManagedProductType } from '../common/enums'; import { HelmService } from './helm.service'; import { RegistryService } from './registry.service'; +import { K8sClientService } from './k8s-client.service'; +import { K8sLifecycleService } from './k8s-lifecycle.service'; import { registerKubeconfigNoProxy } from '../common/kubernetes-proxy.util'; const execFileAsync = promisify(execFile); @@ -74,6 +76,8 @@ export class KubernetesService implements OnModuleInit { private clustersService: ClustersService, private helmService: HelmService, private registryService: RegistryService, + private k8sClientService: K8sClientService, + private k8sLifecycleService: K8sLifecycleService, @InjectRepository(Deployment) private deploymentsRepository: Repository, ) {} @@ -99,34 +103,6 @@ export class KubernetesService implements OnModuleInit { // Helm chart is used for deployments — no local template loading needed } - private async getK8sClient(clusterId?: string): Promise<{ - coreApi: k8s.CoreV1Api; - appsApi: k8s.AppsV1Api; - networkingApi: k8s.NetworkingV1Api; - kc: k8s.KubeConfig; - }> { - const cluster = clusterId ? await this.clustersService.findOne(clusterId) : await this.clustersService.getDefault(); - - const kc = new k8s.KubeConfig(); - registerKubeconfigNoProxy(cluster.kubeconfig); - kc.loadFromString(cluster.kubeconfig); - - return { - coreApi: kc.makeApiClient(k8s.CoreV1Api), - appsApi: kc.makeApiClient(k8s.AppsV1Api), - networkingApi: kc.makeApiClient(k8s.NetworkingV1Api), - kc, - }; - } - - /** - * Get the raw kubeconfig string for a cluster. - */ - private async getKubeconfig(clusterId?: string): Promise { - const cluster = clusterId ? await this.clustersService.findOne(clusterId) : await this.clustersService.getDefault(); - return cluster.kubeconfig; - } - /** * Build Helm values object from an Application entity and image URI. */ @@ -412,7 +388,7 @@ export class KubernetesService implements OnModuleInit { } async waitForApplicationReady(app: Application, timeoutMs = 600_000, shouldAbort?: () => Promise): Promise { - const { coreApi, appsApi } = await this.getK8sClient(app.clusterId); + const { coreApi, appsApi } = await this.k8sClientService.getK8sClient(app.clusterId); const namespace = `user-${app.userId.split('-')[0]}`; const managed = isManagedProductType(app.productType); const workloads = [ @@ -463,14 +439,14 @@ export class KubernetesService implements OnModuleInit { const previewNumber = app.customDomain ? null : await this.resolvePreviewNumber(app.id); try { - const kubeconfig = await this.getKubeconfig(app.clusterId); + const kubeconfig = await this.k8sClientService.getKubeconfig(app.clusterId); const imageUri = app.latestImageTag ? this.registryService.normalizeImageReference(app.latestImageTag) : ''; const values = this.buildHelmValues(app, imageUri, previewNumber); await this.helmService.installOrUpgrade(app.name, namespace, values, kubeconfig); this.logger.log(`Updated ingress for ${app.name} via Helm (customDomain: ${customDomain || 'none'})`); } catch (helmError: any) { this.logger.warn(`Helm ingress update failed for ${app.name}, using direct K8s API: ${helmError.message}`); - const { networkingApi } = await this.getK8sClient(app.clusterId); + const { networkingApi } = await this.k8sClientService.getK8sClient(app.clusterId); const ctx: ManifestContext = { appName: app.name, namespace, @@ -513,9 +489,9 @@ export class KubernetesService implements OnModuleInit { // ── Helm-based deployment ───────────────────────────────────────── private async deployViaHelm(app: Application, imageUri: string, previewNumber?: string | null): Promise> { - const kubeconfig = await this.getKubeconfig(app.clusterId); + const kubeconfig = await this.k8sClientService.getKubeconfig(app.clusterId); await this.ensurePlatformStorageClass(kubeconfig); - const { coreApi } = await this.getK8sClient(app.clusterId); + const { coreApi } = await this.k8sClientService.getK8sClient(app.clusterId); const values = this.buildHelmValues(app, imageUri, previewNumber); const namespace = values.app.namespace as string; await this.registryService.ensureRegistryPullSecret(coreApi, namespace); @@ -531,7 +507,7 @@ export class KubernetesService implements OnModuleInit { } private async deployManagedViaHelm(app: Application): Promise> { - const kubeconfig = await this.getKubeconfig(app.clusterId); + const kubeconfig = await this.k8sClientService.getKubeconfig(app.clusterId); await this.ensurePlatformStorageClass(kubeconfig); const values = this.buildManagedHelmValues(app); const namespace = values.app.namespace; @@ -549,8 +525,8 @@ export class KubernetesService implements OnModuleInit { // ── Direct K8s API deployment (fallback) ────────────────────────── private async deployManagedViaK8sApi(app: Application): Promise> { - const { coreApi, appsApi } = await this.getK8sClient(app.clusterId); - const kubeconfig = await this.getKubeconfig(app.clusterId); + const { coreApi, appsApi } = await this.k8sClientService.getK8sClient(app.clusterId); + const kubeconfig = await this.k8sClientService.getKubeconfig(app.clusterId); await this.ensurePlatformStorageClass(kubeconfig); const namespace = `user-${app.userId.split('-')[0]}`; const context: ManifestContext = { @@ -619,8 +595,8 @@ export class KubernetesService implements OnModuleInit { if (isManagedProductType(app.productType)) { return this.deployManagedViaK8sApi(app); } - const { coreApi, appsApi, networkingApi } = await this.getK8sClient(app.clusterId); - const kubeconfig = await this.getKubeconfig(app.clusterId); + const { coreApi, appsApi, networkingApi } = await this.k8sClientService.getK8sClient(app.clusterId); + const kubeconfig = await this.k8sClientService.getKubeconfig(app.clusterId); await this.ensurePlatformStorageClass(kubeconfig); const domain = this.configService.get('platform.domain'); @@ -2247,33 +2223,11 @@ export class KubernetesService implements OnModuleInit { } async getPodLogs(app: Application): Promise { - const { coreApi } = await this.getK8sClient(app.clusterId); - const namespace = `user-${app.userId.split('-')[0]}`; - const podLabel = this.primaryWorkloadLabel(app); - - const pods = await coreApi.listNamespacedPod({ - namespace, - labelSelector: `app=${podLabel}`, - }); - - if (pods.items.length === 0) { - return 'No pods found for this application.'; - } - - const podName = pods.items[0].metadata?.name; - if (!podName) return 'Pod name not found.'; - - const logResponse = await coreApi.readNamespacedPodLog({ - name: podName, - namespace, - tailLines: 200, - }); - - return logResponse; + return this.k8sLifecycleService.getPodLogs(app); } async scaleDeployment(app: Application, replicas: number): Promise { - const { appsApi } = await this.getK8sClient(app.clusterId); + const { appsApi } = await this.k8sClientService.getK8sClient(app.clusterId); const namespace = `user-${app.userId.split('-')[0]}`; await appsApi.patchNamespacedDeployment({ name: app.name, namespace, body: { spec: { replicas } } }, k8s.setHeaderOptions('Content-Type', 'application/merge-patch+json')); @@ -2313,7 +2267,7 @@ export class KubernetesService implements OnModuleInit { } async captureWorkloadReplicaSnapshot(app: Application): Promise> { - const { appsApi } = await this.getK8sClient(app.clusterId); + const { appsApi } = await this.k8sClientService.getK8sClient(app.clusterId); const namespace = `user-${app.userId.split('-')[0]}`; const snapshot: Record = {}; @@ -2342,7 +2296,7 @@ export class KubernetesService implements OnModuleInit { * Returns the replica snapshot captured before scaling. */ async suspendApplication(app: Application): Promise> { - const { appsApi } = await this.getK8sClient(app.clusterId); + const { appsApi } = await this.k8sClientService.getK8sClient(app.clusterId); const namespace = `user-${app.userId.split('-')[0]}`; this.logger.log(`Suspending application ${app.name} in namespace ${namespace}`); @@ -2368,7 +2322,7 @@ export class KubernetesService implements OnModuleInit { * Resume a suspended application using saved replica counts when available. */ async resumeApplication(app: Application): Promise { - const { appsApi } = await this.getK8sClient(app.clusterId); + const { appsApi } = await this.k8sClientService.getK8sClient(app.clusterId); const namespace = `user-${app.userId.split('-')[0]}`; this.logger.log(`Resuming application ${app.name} in namespace ${namespace}`); @@ -2400,7 +2354,7 @@ export class KubernetesService implements OnModuleInit { } async restartDeployment(app: Application): Promise { - const { appsApi } = await this.getK8sClient(app.clusterId); + const { appsApi } = await this.k8sClientService.getK8sClient(app.clusterId); const namespace = `user-${app.userId.split('-')[0]}`; const deploymentName = isManagedProductType(app.productType) ? this.primaryWorkloadLabel(app) : app.name; @@ -2595,7 +2549,7 @@ export class KubernetesService implements OnModuleInit { * Includes application workload, database, and optional Redis / RabbitMQ when enabled. */ async getResourceUsage(app: Application): Promise { - const { coreApi, appsApi, kc } = await this.getK8sClient(app.clusterId); + const { coreApi, appsApi, kc } = await this.k8sClientService.getK8sClient(app.clusterId); const namespace = `user-${app.userId.split('-')[0]}`; const workloads: any[] = []; @@ -2685,7 +2639,7 @@ export class KubernetesService implements OnModuleInit { }, workload: 'app' | 'database' | 'redis' | 'rabbitmq' = 'app', ): Promise { - const { appsApi } = await this.getK8sClient(app.clusterId); + const { appsApi } = await this.k8sClientService.getK8sClient(app.clusterId); const namespace = `user-${app.userId.split('-')[0]}`; const target = this.workloadDeploymentTarget(app, workload); @@ -2807,7 +2761,7 @@ export class KubernetesService implements OnModuleInit { throw new BadRequestException('Application is not assigned to a cluster'); } - const { coreApi, kc } = await this.getK8sClient(app.clusterId); + const { coreApi, kc } = await this.k8sClientService.getK8sClient(app.clusterId); const namespace = this.getUserNamespace(app.userId); const { selector, targetPort, portName } = this.resolveAccessTarget(app, target); const shortId = grantId.split('-')[0]; @@ -2874,7 +2828,7 @@ export class KubernetesService implements OnModuleInit { const manifestPath = path.join(tmpDir, 'service.json'); try { - fs.writeFileSync(kubeconfigPath, await this.getKubeconfig(clusterId), { + fs.writeFileSync(kubeconfigPath, await this.k8sClientService.getKubeconfig(clusterId), { mode: 0o600, }); fs.writeFileSync(manifestPath, JSON.stringify(service), { mode: 0o600 }); @@ -2895,7 +2849,7 @@ export class KubernetesService implements OnModuleInit { } async revokeTemporaryAccess(clusterId: string, namespace: string, k8sServiceName: string): Promise { - const { coreApi } = await this.getK8sClient(clusterId); + const { coreApi } = await this.k8sClientService.getK8sClient(clusterId); try { await coreApi.deleteNamespacedService({ name: k8sServiceName, @@ -2912,7 +2866,7 @@ export class KubernetesService implements OnModuleInit { async deleteTemporaryAccessServicesForApp(app: Application): Promise { if (!app.clusterId) return; const namespace = this.getUserNamespace(app.userId); - const { coreApi } = await this.getK8sClient(app.clusterId); + const { coreApi } = await this.k8sClientService.getK8sClient(app.clusterId); try { const services = await coreApi.listNamespacedService({ @@ -2942,7 +2896,7 @@ export class KubernetesService implements OnModuleInit { }; case ServiceAccessTarget.REDIS: { if (!app.clusterId) return {}; - const { coreApi } = await this.getK8sClient(app.clusterId); + const { coreApi } = await this.k8sClientService.getK8sClient(app.clusterId); const secret = await coreApi.readNamespacedSecret({ name: `${app.name}-redis-secret`, namespace, @@ -2953,7 +2907,7 @@ export class KubernetesService implements OnModuleInit { case ServiceAccessTarget.RABBITMQ_AMQP: case ServiceAccessTarget.RABBITMQ_MANAGEMENT: { if (!app.clusterId) return {}; - const { coreApi } = await this.getK8sClient(app.clusterId); + const { coreApi } = await this.k8sClientService.getK8sClient(app.clusterId); const secret = await coreApi.readNamespacedSecret({ name: `${app.name}-rabbitmq-secret`, namespace, @@ -2980,7 +2934,7 @@ export class KubernetesService implements OnModuleInit { host: string; ingressUrl?: string; }> { - const { coreApi, networkingApi, kc } = await this.getK8sClient(app.clusterId); + const { coreApi, networkingApi, kc } = await this.k8sClientService.getK8sClient(app.clusterId); const namespace = this.getUserNamespace(app.userId); const domain = this.configService.get('platform.domain'); const hostIp = this.getClusterHostIp(kc); @@ -3044,13 +2998,13 @@ export class KubernetesService implements OnModuleInit { async deleteApplication(app: Application): Promise { const namespace = this.getUserNamespace(app.userId); - const { coreApi, appsApi, networkingApi } = await this.getK8sClient(app.clusterId); + const { coreApi, appsApi, networkingApi } = await this.k8sClientService.getK8sClient(app.clusterId); await this.deleteTemporaryAccessServicesForApp(app); // Step 1: Try Helm uninstall (handles most resources) try { - const kubeconfig = await this.getKubeconfig(app.clusterId); + const kubeconfig = await this.k8sClientService.getKubeconfig(app.clusterId); await this.helmService.uninstall(app.name, namespace, kubeconfig); this.logger.log(`Helm release ${app.name} uninstalled from ${namespace}`); } catch (error: any) { @@ -3171,8 +3125,8 @@ export class KubernetesService implements OnModuleInit { } = {}, ): Promise { const namespace = this.getUserNamespace(app.userId); - const source = await this.getK8sClient(app.clusterId); - const target = await this.getK8sClient(targetClusterId); + const source = await this.k8sClientService.getK8sClient(app.clusterId); + const target = await this.k8sClientService.getK8sClient(targetClusterId); await this.ensureNamespaceOnCluster(target.coreApi, namespace); await options.log?.('transfer-secrets-configs', 'Target namespace ensured', { namespace }); @@ -3250,8 +3204,8 @@ export class KubernetesService implements OnModuleInit { const targetKubeconfig = path.join(tempDir, 'target.kubeconfig'); fs.mkdirSync(tempDir, { recursive: true }); - fs.writeFileSync(sourceKubeconfig, await this.getKubeconfig(sourceClusterId), { mode: 0o600 }); - fs.writeFileSync(targetKubeconfig, await this.getKubeconfig(targetClusterId), { mode: 0o600 }); + fs.writeFileSync(sourceKubeconfig, await this.k8sClientService.getKubeconfig(sourceClusterId), { mode: 0o600 }); + fs.writeFileSync(targetKubeconfig, await this.k8sClientService.getKubeconfig(targetClusterId), { mode: 0o600 }); try { await this.createPvcCopyPod(sourceKubeconfig, namespace, sourcePod, pvcName); @@ -3407,7 +3361,7 @@ export class KubernetesService implements OnModuleInit { * Polls pod status with label selector `app=-db`. */ async waitForDatabaseReady(app: Application, timeoutMs = 120_000): Promise { - const { coreApi } = await this.getK8sClient(app.clusterId); + const { coreApi } = await this.k8sClientService.getK8sClient(app.clusterId); const namespace = `user-${app.userId.split('-')[0]}`; const dbLabel = `${app.name}-db`; const start = Date.now(); @@ -3513,7 +3467,7 @@ export class KubernetesService implements OnModuleInit { * then runs a restore Job that mounts the PVC and imports the dump. */ async restoreDatabaseDump(app: Application, dumpFilePath: string): Promise<{ success: boolean; logs: string }> { - const { coreApi, kc } = await this.getK8sClient(app.clusterId); + const { coreApi, kc } = await this.k8sClientService.getK8sClient(app.clusterId); const batchApi = kc.makeApiClient(k8s.BatchV1Api); const namespace = `user-${app.userId.split('-')[0]}`; const dbName = `${app.name}-db`; @@ -3821,7 +3775,7 @@ export class KubernetesService implements OnModuleInit { * Used when legacy PVCs were created without storageClassName. */ private async migrateDatabasePvcToResizableStorage(app: Application, newSize: string, storageClassName: string): Promise<{ success: boolean; message: string }> { - const { coreApi, appsApi, kc } = await this.getK8sClient(app.clusterId); + const { coreApi, appsApi, kc } = await this.k8sClientService.getK8sClient(app.clusterId); const batchApi = kc.makeApiClient(k8s.BatchV1Api); const namespace = `user-${app.userId.split('-')[0]}`; const oldPvcName = `${app.name}-db`; @@ -3997,7 +3951,7 @@ export class KubernetesService implements OnModuleInit { * K8s only supports PVC expansion, not shrinking. */ async resizeDatabasePvc(app: Application, newSize: string): Promise<{ success: boolean; message: string }> { - const { coreApi, kc } = await this.getK8sClient(app.clusterId); + const { coreApi, kc } = await this.k8sClientService.getK8sClient(app.clusterId); const namespace = `user-${app.userId.split('-')[0]}`; const pvcName = `${app.name}-db`; @@ -4070,7 +4024,7 @@ export class KubernetesService implements OnModuleInit { */ async getDatabasePvcSize(app: Application): Promise { try { - const { coreApi } = await this.getK8sClient(app.clusterId); + const { coreApi } = await this.k8sClientService.getK8sClient(app.clusterId); const namespace = `user-${app.userId.split('-')[0]}`; const pvcName = `${app.name}-db`; @@ -4096,7 +4050,7 @@ export class KubernetesService implements OnModuleInit { totalAllocatedGb: number; totalUsedGb: number; }> { - const { coreApi } = await this.getK8sClient(app.clusterId); + const { coreApi } = await this.k8sClientService.getK8sClient(app.clusterId); const namespace = `user-${app.userId.split('-')[0]}`; const result = { @@ -4271,7 +4225,7 @@ export class KubernetesService implements OnModuleInit { * Returns usage in GB. */ private async getPvcUsageFromPod(app: Application, deploymentName: string, mountPath: string, namespace: string, containerName: string): Promise { - const { coreApi, kc } = await this.getK8sClient(app.clusterId); + const { coreApi, kc } = await this.k8sClientService.getK8sClient(app.clusterId); const pods = await coreApi.listNamespacedPod({ namespace, @@ -4305,7 +4259,7 @@ export class KubernetesService implements OnModuleInit { * Expand a named PVC (Redis, RabbitMQ, or other optional service volumes). */ async resizeNamedPvc(app: Application, pvcName: string, newSize: string, label: string): Promise<{ success: boolean; message: string }> { - const { coreApi } = await this.getK8sClient(app.clusterId); + const { coreApi } = await this.k8sClientService.getK8sClient(app.clusterId); const namespace = `user-${app.userId.split('-')[0]}`; try { @@ -4362,7 +4316,7 @@ export class KubernetesService implements OnModuleInit { * Resize app storage PVC (all app types). */ async resizeAppStoragePvc(app: Application, newSize: string): Promise<{ success: boolean; message: string }> { - const { coreApi } = await this.getK8sClient(app.clusterId); + const { coreApi } = await this.k8sClientService.getK8sClient(app.clusterId); const namespace = `user-${app.userId.split('-')[0]}`; // Try new unified name first, then legacy wp-content name @@ -4427,7 +4381,7 @@ export class KubernetesService implements OnModuleInit { * Strategy: Run dump command, then sleep for 60s to allow exec retrieval. */ async exportDatabaseDump(app: Application, onProgress?: (percent: number) => void): Promise<{ data: Buffer | null; logs: string }> { - const { coreApi, kc } = await this.getK8sClient(app.clusterId); + const { coreApi, kc } = await this.k8sClientService.getK8sClient(app.clusterId); const batchApi = kc.makeApiClient(k8s.BatchV1Api); const namespace = `user-${app.userId.split('-')[0]}`; const dbName = `${app.name}-db`; @@ -4608,7 +4562,7 @@ export class KubernetesService implements OnModuleInit { * Strategy: Create archive, then sleep to allow exec retrieval. */ async archiveWpContent(app: Application): Promise<{ data: Buffer | null; logs: string }> { - const { coreApi, kc } = await this.getK8sClient(app.clusterId); + const { coreApi, kc } = await this.k8sClientService.getK8sClient(app.clusterId); const batchApi = kc.makeApiClient(k8s.BatchV1Api); const namespace = `user-${app.userId.split('-')[0]}`; const pvcName = `${app.name}-storage`; @@ -4760,7 +4714,7 @@ export class KubernetesService implements OnModuleInit { * Restore wp-content from a tar.gz archive into the WordPress PVC. */ async restoreWpContent(app: Application, archiveBuffer: Buffer): Promise<{ success: boolean; logs: string }> { - const { coreApi, kc } = await this.getK8sClient(app.clusterId); + const { coreApi, kc } = await this.k8sClientService.getK8sClient(app.clusterId); const batchApi = kc.makeApiClient(k8s.BatchV1Api); const namespace = `user-${app.userId.split('-')[0]}`; const pvcName = `${app.name}-storage`; @@ -4902,7 +4856,7 @@ export class KubernetesService implements OnModuleInit { const releaseName = app.name; try { - const kubeconfig = await this.getKubeconfig(app.clusterId); + const kubeconfig = await this.k8sClientService.getKubeconfig(app.clusterId); const helmRevisions = await this.helmService.history(releaseName, namespace, kubeconfig); if (!helmRevisions || helmRevisions.length === 0) { @@ -4940,7 +4894,7 @@ export class KubernetesService implements OnModuleInit { const releaseName = app.name; try { - const kubeconfig = await this.getKubeconfig(app.clusterId); + const kubeconfig = await this.k8sClientService.getKubeconfig(app.clusterId); await this.helmService.rollback(releaseName, targetRevision, namespace, kubeconfig); this.logger.log(`Rolled back ${releaseName} to Helm revision ${targetRevision}`); return { diff --git a/backend/src/main.ts b/backend/src/main.ts index 19dcf58..e871a6a 100644 --- a/backend/src/main.ts +++ b/backend/src/main.ts @@ -3,6 +3,7 @@ import { Logger, ValidationPipe } from '@nestjs/common'; import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger'; import helmet from 'helmet'; import { AppModule } from './app.module'; +import { validateProductionConfig } from './config/validate-production-config'; // Prevent Node.js from crashing on unhandled errors process.on('unhandledRejection', (reason, promise) => { @@ -15,6 +16,8 @@ process.on('uncaughtException', (error) => { }); async function bootstrap() { + validateProductionConfig(); + const logger = new Logger('Bootstrap'); const app = await NestFactory.create(AppModule, { logger: ['error', 'warn', 'log'], diff --git a/backend/src/test-setup.ts b/backend/src/test-setup.ts new file mode 100644 index 0000000..0ddee43 --- /dev/null +++ b/backend/src/test-setup.ts @@ -0,0 +1,10 @@ +/** Prevent Jest from loading ESM-only @kubernetes/client-node in unit tests. */ +jest.mock('@kubernetes/client-node', () => ({ + KubeConfig: jest.fn(), + CoreV1Api: jest.fn(), + AppsV1Api: jest.fn(), + BatchV1Api: jest.fn(), + NetworkingV1Api: jest.fn(), + CustomObjectsApi: jest.fn(), + HttpError: class HttpError extends Error {}, +})); diff --git a/backend/test/deployments-auth.e2e-spec.ts b/backend/test/deployments-auth.e2e-spec.ts new file mode 100644 index 0000000..5f71e28 --- /dev/null +++ b/backend/test/deployments-auth.e2e-spec.ts @@ -0,0 +1,63 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { NotFoundException } from '@nestjs/common'; +import { DeploymentsService } from '../src/deployments/deployments.service'; +import { Deployment } from '../src/deployments/entities/deployment.entity'; +import { ApplicationsService } from '../src/applications/applications.service'; +import { KubernetesService } from '../src/kubernetes/kubernetes.service'; +import { BuildService } from '../src/build/build.service'; +import { ClustersService } from '../src/clusters/clusters.service'; + +/** + * Smoke test: deployment reads must enforce application ownership (IDOR fix). + */ +describe('Deployments authorization (e2e smoke)', () => { + let service: DeploymentsService; + + const deploymentsRepository = { + find: jest.fn(), + findOne: jest.fn(), + createQueryBuilder: jest.fn(), + }; + + const applicationsService = { + findOne: jest.fn(), + }; + + beforeAll(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + DeploymentsService, + { provide: getRepositoryToken(Deployment), useValue: deploymentsRepository }, + { provide: ApplicationsService, useValue: applicationsService }, + { provide: KubernetesService, useValue: {} }, + { provide: BuildService, useValue: {} }, + { provide: ClustersService, useValue: {} }, + ], + }).compile(); + + service = module.get(DeploymentsService); + }); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('rejects findOne when application ownership check fails', async () => { + deploymentsRepository.findOne.mockResolvedValue({ + id: 'd-1', + applicationId: 'app-other', + }); + applicationsService.findOne.mockRejectedValue(new NotFoundException('Application not found')); + + await expect(service.findOne('d-1', 'user-a')).rejects.toThrow(NotFoundException); + }); + + it('allows findOne when user owns the application', async () => { + const deployment = { id: 'd-1', applicationId: 'app-1' }; + deploymentsRepository.findOne.mockResolvedValue(deployment); + applicationsService.findOne.mockResolvedValue({ id: 'app-1', userId: 'user-a' }); + + await expect(service.findOne('d-1', 'user-a')).resolves.toBe(deployment); + }); +}); diff --git a/backend/test/jest-e2e.json b/backend/test/jest-e2e.json new file mode 100644 index 0000000..ce30c01 --- /dev/null +++ b/backend/test/jest-e2e.json @@ -0,0 +1,10 @@ +{ + "moduleFileExtensions": ["js", "json", "ts"], + "rootDir": ".", + "testEnvironment": "node", + "testRegex": ".e2e-spec.ts$", + "transform": { + "^.+\\.(t|j)s$": "ts-jest" + }, + "setupFilesAfterEnv": ["/../src/test-setup.ts"] +} diff --git a/docker-compose.yml b/docker-compose.yml index 3b25ba8..999c568 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -55,10 +55,11 @@ services: DB_PORT: 5432 DB_USERNAME: cloudhost DB_PASSWORD: cloudhost_secret - DB_NAME: cloudhost + DB_DATABASE: cloudhost # JWT JWT_SECRET: change-this-to-a-long-random-string + JWT_REFRESH_SECRET: change-this-to-a-long-random-refresh-string JWT_EXPIRES_IN: 15m JWT_REFRESH_EXPIRES_IN: 7d @@ -73,9 +74,10 @@ services: # Build BUILD_NAMESPACE: cloudhost-builds - KANIKO_IMAGE: gcr.io/kaniko-project/executor:latest + KANIKO_IMAGE: gcr.io/kaniko-project/executor:v1.23.2 # Platform + FRONTEND_URL: http://localhost:3000 PLATFORM_DOMAIN: apps.localhost volumes: - /tmp/cloudhost-uploads:/app/uploads diff --git a/frontend/eslint.config.mjs b/frontend/eslint.config.mjs new file mode 100644 index 0000000..7613c78 --- /dev/null +++ b/frontend/eslint.config.mjs @@ -0,0 +1,15 @@ +import { dirname } from 'path'; +import { fileURLToPath } from 'url'; +import { FlatCompat } from '@eslint/eslintrc'; + +const compat = new FlatCompat({ + baseDirectory: dirname(fileURLToPath(import.meta.url)), +}); + +/** @type {import('eslint').Linter.Config[]} */ +export default [ + { + ignores: ['.next/**', 'node_modules/**'], + }, + ...compat.extends('next/core-web-vitals', 'next/typescript'), +]; diff --git a/frontend/next-env.d.ts b/frontend/next-env.d.ts index 9edff1c..c4b7818 100644 --- a/frontend/next-env.d.ts +++ b/frontend/next-env.d.ts @@ -1,6 +1,6 @@ /// /// -import "./.next/types/routes.d.ts"; +import "./.next/dev/types/routes.d.ts"; // NOTE: This file should not be edited // see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 8b94918..7ffd21d 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -28,6 +28,7 @@ "zustand": "^5.0.14" }, "devDependencies": { + "@eslint/eslintrc": "^3.3.1", "@tailwindcss/postcss": "^4.3.1", "@types/node": "^24.0.0", "@types/react": "^19.2.17", @@ -37,7 +38,8 @@ "eslint-config-next": "16.2.9", "postcss": "^8.5.15", "tailwindcss": "^4.3.1", - "typescript": "^6.0.3" + "typescript": "^6.0.3", + "vitest": "^3.2.4" } }, "node_modules/@alloc/quick-lru": { @@ -320,6 +322,448 @@ "tslib": "^2.4.0" } }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmmirror.com/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", + "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmmirror.com/@esbuild/android-arm/-/android-arm-0.28.1.tgz", + "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmmirror.com/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", + "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmmirror.com/@esbuild/android-x64/-/android-x64-0.28.1.tgz", + "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmmirror.com/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", + "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmmirror.com/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", + "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmmirror.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", + "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmmirror.com/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", + "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", + "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", + "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", + "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.1", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", + "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.1", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", + "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", + "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.1", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", + "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.1", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", + "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", + "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmmirror.com/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", + "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmmirror.com/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", + "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmmirror.com/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", + "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmmirror.com/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", + "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmmirror.com/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", + "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmmirror.com/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", + "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmmirror.com/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", + "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmmirror.com/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", + "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmmirror.com/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", + "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, "node_modules/@eslint-community/eslint-utils": { "version": "4.9.1", "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", @@ -1390,6 +1834,356 @@ "three": ">=0.144.0" } }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.62.2", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.2.tgz", + "integrity": "sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.2.tgz", + "integrity": "sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.2.tgz", + "integrity": "sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.2.tgz", + "integrity": "sha512-yl0y2vq3S3lHeuXhEdss6TWfKW8vkujImO12tn4ZkG/4oghr09LvdYm2RElVjokTQiUvDUGXLGsYeLqUMCKpGA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.2.tgz", + "integrity": "sha512-tT4pvt4qXD+vEoezupCWi+a1F0vvDiksiHc+PxRlYTOH1I6/X4id9jPxTP+Fg+545euaFT1jJVs4CEdHZAU1vw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.2.tgz", + "integrity": "sha512-6nU5F2wCW+qvCBhTn1pdIU3bzsIoF7EUwsCDRxilWGprQR6yd508YnH9+OKFCwpfS8pjZqDUmnCAr7exax0XCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.2.tgz", + "integrity": "sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.2.tgz", + "integrity": "sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.2.tgz", + "integrity": "sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.2.tgz", + "integrity": "sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.2.tgz", + "integrity": "sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.2.tgz", + "integrity": "sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.2.tgz", + "integrity": "sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.2.tgz", + "integrity": "sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.2.tgz", + "integrity": "sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.2.tgz", + "integrity": "sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.2.tgz", + "integrity": "sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.2.tgz", + "integrity": "sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.2.tgz", + "integrity": "sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.2.tgz", + "integrity": "sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.2.tgz", + "integrity": "sha512-8hOJnxgbyObnCm5AlRA3A931xX19xq80RjVTKgJOvEKWqJruP/Uf12IbAOaDjjEXYRewwHLfmF0YRIdK3OwKWA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.2.tgz", + "integrity": "sha512-mmF4AY1i0hG/bLWUctUq59gtmgaSIRa3cu/A3JFRp/sCNEme2bgDEiDS22P9FbnJB8NJNF4jPJiSP5RHQpUTDg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.2.tgz", + "integrity": "sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.2.tgz", + "integrity": "sha512-T6xr6ucWSFto+VGajA8YH26LdpHRuP4YLHEKAtCWvJDOlnmWcDZVCI2Jmjr+IFHDlt2zRaTAKE4tfjTaWLgJBg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.2.tgz", + "integrity": "sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, "node_modules/@rtsao/scc": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz", @@ -1720,6 +2514,24 @@ "tslib": "^2.4.0" } }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmmirror.com/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmmirror.com/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/draco3d": { "version": "1.4.10", "resolved": "https://registry.npmjs.org/@types/draco3d/-/draco3d-1.4.10.tgz", @@ -1753,6 +2565,7 @@ "integrity": "sha512-fRa09kZTgu8o71KFcDjUFuc7F+dEbZYZmkI0mg5YBTRs0yMKjYHsq/c0urDKeDb+D5qVgXOdFcuu+DZPKOITwA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~7.18.0" } @@ -2500,6 +3313,121 @@ "react": ">= 16.8.0" } }, + "node_modules/@vitest/expect": { + "version": "3.2.6", + "resolved": "https://registry.npmmirror.com/@vitest/expect/-/expect-3.2.6.tgz", + "integrity": "sha512-1+7q9BtaKzEmO+fmNT3kYvoNn5Y71XWAx2Q5HRim4tTVRQVRv4uJFAQ5FbK0OPUeNP/WmVCpxYxoJdvuHVjzBQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/spy": "3.2.6", + "@vitest/utils": "3.2.6", + "chai": "^5.2.0", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "3.2.6", + "resolved": "https://registry.npmmirror.com/@vitest/mocker/-/mocker-3.2.6.tgz", + "integrity": "sha512-EZOrpDbkKotFAP7wPAQV1UIyoGOk4oX7ynWhBhLB7v+meMHbQhU16oPpIYGTTe4oFlhpryGpgpcZP/sin3hYuw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "3.2.6", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.17" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "3.2.6", + "resolved": "https://registry.npmmirror.com/@vitest/pretty-format/-/pretty-format-3.2.6.tgz", + "integrity": "sha512-lb7XXXzmm2h2ASzFnRvQpDo6onT1NmMJA3tkGTWiBFtRJ9lxGY3d3mm/Apt36gej2bkkOVLL/yTOtufDaFa/jA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "3.2.6", + "resolved": "https://registry.npmmirror.com/@vitest/runner/-/runner-3.2.6.tgz", + "integrity": "sha512-HYcoSj1w5tcgUnzoF0HcyaAQjpA1gj9ftUJ7iSJSuipc02jW9gKkigwZbjFldAfYHA1fa8UZVRftdMY5msWM9Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "3.2.6", + "pathe": "^2.0.3", + "strip-literal": "^3.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "3.2.6", + "resolved": "https://registry.npmmirror.com/@vitest/snapshot/-/snapshot-3.2.6.tgz", + "integrity": "sha512-H+ZjNTWGpObenh0YnlBctAPnJSI20P81PL8BPzWpx54YXLLTm8hEsWawtcYLMrwvpK48hGxLLbCS+1KRXhsKhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.6", + "magic-string": "^0.30.17", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "3.2.6", + "resolved": "https://registry.npmmirror.com/@vitest/spy/-/spy-3.2.6.tgz", + "integrity": "sha512-oq6BbH68WzcWmwtBrU9nqLeaXTR4XwJF7FSLkKEZo4i6eoXcrxjcwSuTvWBIRUTC6VC72nXYunzqgZA+IKdtxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^4.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "3.2.6", + "resolved": "https://registry.npmmirror.com/@vitest/utils/-/utils-3.2.6.tgz", + "integrity": "sha512-lI23nIs4bnT3T8NIoh+vFaz5s2/DdP0Jgt2jxwgWljvwn82cLJtyi/If+fjFyoLMGIOz0U/fKvWE0d4jsNQEfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.6", + "loupe": "^3.1.4", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/acorn": { "version": "8.17.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz", @@ -2746,6 +3674,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/ast-types-flow": { "version": "0.0.8", "resolved": "https://registry.npmjs.org/ast-types-flow/-/ast-types-flow-0.0.8.tgz", @@ -2958,6 +3896,16 @@ "ieee754": "^1.2.1" } }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmmirror.com/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/call-bind": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz", @@ -3070,6 +4018,23 @@ "node": ">=10.0.0" } }, + "node_modules/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmmirror.com/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -3087,6 +4052,16 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, + "node_modules/check-error": { + "version": "2.1.3", + "resolved": "https://registry.npmmirror.com/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, "node_modules/client-only": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz", @@ -3286,6 +4261,16 @@ } } }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmmirror.com/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", @@ -3543,6 +4528,13 @@ "node": ">= 0.4" } }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmmirror.com/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, "node_modules/es-object-atoms": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", @@ -3601,6 +4593,48 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/esbuild": { + "version": "0.28.1", + "resolved": "https://registry.npmmirror.com/esbuild/-/esbuild-0.28.1.tgz", + "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.1", + "@esbuild/android-arm": "0.28.1", + "@esbuild/android-arm64": "0.28.1", + "@esbuild/android-x64": "0.28.1", + "@esbuild/darwin-arm64": "0.28.1", + "@esbuild/darwin-x64": "0.28.1", + "@esbuild/freebsd-arm64": "0.28.1", + "@esbuild/freebsd-x64": "0.28.1", + "@esbuild/linux-arm": "0.28.1", + "@esbuild/linux-arm64": "0.28.1", + "@esbuild/linux-ia32": "0.28.1", + "@esbuild/linux-loong64": "0.28.1", + "@esbuild/linux-mips64el": "0.28.1", + "@esbuild/linux-ppc64": "0.28.1", + "@esbuild/linux-riscv64": "0.28.1", + "@esbuild/linux-s390x": "0.28.1", + "@esbuild/linux-x64": "0.28.1", + "@esbuild/netbsd-arm64": "0.28.1", + "@esbuild/netbsd-x64": "0.28.1", + "@esbuild/openbsd-arm64": "0.28.1", + "@esbuild/openbsd-x64": "0.28.1", + "@esbuild/openharmony-arm64": "0.28.1", + "@esbuild/sunos-x64": "0.28.1", + "@esbuild/win32-arm64": "0.28.1", + "@esbuild/win32-ia32": "0.28.1", + "@esbuild/win32-x64": "0.28.1" + } + }, "node_modules/escalade": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", @@ -4022,6 +5056,16 @@ "node": ">=4.0" } }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmmirror.com/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, "node_modules/esutils": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", @@ -4032,6 +5076,16 @@ "node": ">=0.10.0" } }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmmirror.com/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -4253,6 +5307,21 @@ } } }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmmirror.com/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", @@ -5664,6 +6733,13 @@ "loose-envify": "cli.js" } }, + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmmirror.com/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true, + "license": "MIT" + }, "node_modules/lru-cache": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", @@ -6213,6 +7289,23 @@ "dev": true, "license": "MIT" }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmmirror.com/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, "node_modules/performance-now": { "version": "2.1.0", "resolved": "https://registry.npmmirror.com/performance-now/-/performance-now-2.1.0.tgz", @@ -6575,6 +7668,51 @@ "node": ">= 0.8.15" } }, + "node_modules/rollup": { + "version": "4.62.2", + "resolved": "https://registry.npmmirror.com/rollup/-/rollup-4.62.2.tgz", + "integrity": "sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.62.2", + "@rollup/rollup-android-arm64": "4.62.2", + "@rollup/rollup-darwin-arm64": "4.62.2", + "@rollup/rollup-darwin-x64": "4.62.2", + "@rollup/rollup-freebsd-arm64": "4.62.2", + "@rollup/rollup-freebsd-x64": "4.62.2", + "@rollup/rollup-linux-arm-gnueabihf": "4.62.2", + "@rollup/rollup-linux-arm-musleabihf": "4.62.2", + "@rollup/rollup-linux-arm64-gnu": "4.62.2", + "@rollup/rollup-linux-arm64-musl": "4.62.2", + "@rollup/rollup-linux-loong64-gnu": "4.62.2", + "@rollup/rollup-linux-loong64-musl": "4.62.2", + "@rollup/rollup-linux-ppc64-gnu": "4.62.2", + "@rollup/rollup-linux-ppc64-musl": "4.62.2", + "@rollup/rollup-linux-riscv64-gnu": "4.62.2", + "@rollup/rollup-linux-riscv64-musl": "4.62.2", + "@rollup/rollup-linux-s390x-gnu": "4.62.2", + "@rollup/rollup-linux-x64-gnu": "4.62.2", + "@rollup/rollup-linux-x64-musl": "4.62.2", + "@rollup/rollup-openbsd-x64": "4.62.2", + "@rollup/rollup-openharmony-arm64": "4.62.2", + "@rollup/rollup-win32-arm64-msvc": "4.62.2", + "@rollup/rollup-win32-ia32-msvc": "4.62.2", + "@rollup/rollup-win32-x64-gnu": "4.62.2", + "@rollup/rollup-win32-x64-msvc": "4.62.2", + "fsevents": "~2.3.2" + } + }, "node_modules/run-parallel": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", @@ -6874,6 +8012,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -6890,6 +8035,13 @@ "dev": true, "license": "MIT" }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmmirror.com/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, "node_modules/stackblur-canvas": { "version": "2.7.0", "resolved": "https://registry.npmmirror.com/stackblur-canvas/-/stackblur-canvas-2.7.0.tgz", @@ -6926,6 +8078,13 @@ "integrity": "sha512-hNKz8phvYLPEcRkeG1rsGmV5ChMjKDAWU7/OJJdDErPBNChQXxCo3WZurGpnWc6gZhAzEPFad1aVgyOANH1sMw==", "license": "MIT" }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmmirror.com/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, "node_modules/stop-iteration-iterator": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", @@ -7077,6 +8236,26 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/strip-literal": { + "version": "3.1.0", + "resolved": "https://registry.npmmirror.com/strip-literal/-/strip-literal-3.1.0.tgz", + "integrity": "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-tokens": "^9.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/strip-literal/node_modules/js-tokens": { + "version": "9.0.1", + "resolved": "https://registry.npmmirror.com/js-tokens/-/js-tokens-9.0.1.tgz", + "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", + "dev": true, + "license": "MIT" + }, "node_modules/styled-jsx": { "version": "5.1.6", "resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.6.tgz", @@ -7215,6 +8394,20 @@ "integrity": "sha512-IQrh3lEPM93wVCEczc9SaAOvkmcoQn/G8Bo1e8ZPlY3X3bnAxWaBdvTdvM1hP62iZp0BXWDy4vTAy4fF0+Dlpg==", "license": "MIT" }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmmirror.com/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmmirror.com/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, "node_modules/tinyglobby": { "version": "0.2.17", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", @@ -7264,6 +8457,36 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmmirror.com/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/tinyrainbow/-/tinyrainbow-2.0.0.tgz", + "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "4.0.4", + "resolved": "https://registry.npmmirror.com/tinyspy/-/tinyspy-4.0.4.tgz", + "integrity": "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -7652,6 +8875,222 @@ "base64-arraybuffer": "^1.0.2" } }, + "node_modules/vite": { + "version": "7.3.6", + "resolved": "https://registry.npmmirror.com/vite/-/vite-7.3.6.tgz", + "integrity": "sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.27.0 || ^0.28.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/vite-node": { + "version": "3.2.4", + "resolved": "https://registry.npmmirror.com/vite-node/-/vite-node-3.2.4.tgz", + "integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.4.1", + "es-module-lexer": "^1.7.0", + "pathe": "^2.0.3", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vite/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmmirror.com/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/vite/node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmmirror.com/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/vitest": { + "version": "3.2.6", + "resolved": "https://registry.npmmirror.com/vitest/-/vitest-3.2.6.tgz", + "integrity": "sha512-xejya+bT/j/+R/AGa1XOfRxLmNUlLtlwjRsFUILF+xHfzElmGcmFydy2gqqIrd62ptIEfwVMofd19uNWD9L7Nw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/expect": "3.2.6", + "@vitest/mocker": "3.2.6", + "@vitest/pretty-format": "^3.2.6", + "@vitest/runner": "3.2.6", + "@vitest/snapshot": "3.2.6", + "@vitest/spy": "3.2.6", + "@vitest/utils": "3.2.6", + "chai": "^5.2.0", + "debug": "^4.4.1", + "expect-type": "^1.2.1", + "magic-string": "^0.30.17", + "pathe": "^2.0.3", + "picomatch": "^4.0.2", + "std-env": "^3.9.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.2", + "tinyglobby": "^0.2.14", + "tinypool": "^1.1.1", + "tinyrainbow": "^2.0.0", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", + "vite-node": "3.2.4", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/debug": "^4.1.12", + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "@vitest/browser": "3.2.6", + "@vitest/ui": "3.2.6", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/debug": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/vitest/node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmmirror.com/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/webgl-constants": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/webgl-constants/-/webgl-constants-1.1.1.tgz", @@ -7767,6 +9206,23 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmmirror.com/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/word-wrap": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", diff --git a/frontend/package.json b/frontend/package.json index 3b52543..61404c8 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -6,7 +6,10 @@ "dev": "next dev", "build": "next build", "start": "next start", - "lint": "next lint" + "lint": "eslint .", + "typecheck": "tsc --noEmit", + "test": "vitest run", + "test:watch": "vitest" }, "dependencies": { "@react-three/drei": "^10.7.7", @@ -36,8 +39,10 @@ "@types/three": "^0.184.1", "eslint": "^9.0.0", "eslint-config-next": "16.2.9", + "@eslint/eslintrc": "^3.3.1", "postcss": "^8.5.15", "tailwindcss": "^4.3.1", - "typescript": "^6.0.3" + "typescript": "^6.0.3", + "vitest": "^3.2.4" } } diff --git a/frontend/src/app/[lang]/dashboard/apps/[id]/_hooks/useAppQueries.ts b/frontend/src/app/[lang]/dashboard/apps/[id]/_hooks/useAppQueries.ts new file mode 100644 index 0000000..ed5b33a --- /dev/null +++ b/frontend/src/app/[lang]/dashboard/apps/[id]/_hooks/useAppQueries.ts @@ -0,0 +1,34 @@ +import { useQuery } from '@tanstack/react-query'; +import api from '@/lib/api'; +import { queryKeys } from '@/lib/query-keys'; +import type { Application, Deployment, ResourceUsage } from '@/types'; + +/** Core data queries for the application detail page. */ +export function useAppQueries(appId: string, enabled = true) { + const appQuery = useQuery({ + queryKey: ['application', appId], + queryFn: () => api.get(`/applications/${appId}`).then((r) => r.data), + enabled: enabled && !!appId, + }); + + const deploymentsQuery = useQuery({ + queryKey: ['deployments', appId], + queryFn: () => api.get(`/deployments/applications/${appId}`).then((r) => r.data), + enabled: enabled && !!appId, + }); + + const usageQuery = useQuery({ + queryKey: ['resource-usage', appId], + queryFn: () => api.get(`/applications/${appId}/resources/usage`).then((r) => r.data), + enabled: enabled && !!appId && appQuery.data?.lifecycleStatus === 'active', + refetchInterval: 30_000, + }); + + const walletQuery = useQuery<{ balance: number }>({ + queryKey: queryKeys.walletBalance, + queryFn: () => api.get('/billing/wallet').then((r) => r.data), + enabled, + }); + + return { appQuery, deploymentsQuery, usageQuery, walletQuery }; +} diff --git a/frontend/src/app/[lang]/dashboard/apps/[id]/page.tsx b/frontend/src/app/[lang]/dashboard/apps/[id]/page.tsx index 4eb5715..88ebd0d 100644 --- a/frontend/src/app/[lang]/dashboard/apps/[id]/page.tsx +++ b/frontend/src/app/[lang]/dashboard/apps/[id]/page.tsx @@ -3,6 +3,7 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useParams } from 'next/navigation'; import api from '@/lib/api'; +import { queryKeys } from '@/lib/query-keys'; import { notify } from '@/lib/notify'; import type { Application, Deployment, ResourceUsage, ClusterPublic, ClusterPoolPublic, AppSnapshot, K8sRevisionData, K8sRevision, OptionalServiceCredentials, Invoice } from '@/types'; import { useState, useRef, useCallback, useEffect } from 'react'; @@ -250,7 +251,7 @@ export default function AppDetailPage() { // ─── Billing & Renewal ────────────────────────────── const { data: walletData } = useQuery<{ balance: number }>({ - queryKey: ['wallet'], + queryKey: queryKeys.walletBalance, queryFn: () => api.get('/billing/wallet').then((r) => r.data), }); @@ -271,7 +272,7 @@ export default function AppDetailPage() { onSuccess: (res) => { notify.success(res.data.message || 'Application renewed successfully!'); queryClient.invalidateQueries({ queryKey: ['application', appId] }); - queryClient.invalidateQueries({ queryKey: ['wallet'] }); + queryClient.invalidateQueries({ queryKey: queryKeys.walletBalance }); setShowRenewalModal(false); setRenewCoupon(''); }, @@ -646,7 +647,7 @@ export default function AppDetailPage() { queryClient.invalidateQueries({ queryKey: ['resources', appId] }); queryClient.invalidateQueries({ queryKey: ['storage-usage', appId] }); queryClient.invalidateQueries({ queryKey: ['db-storage', appId] }); - queryClient.invalidateQueries({ queryKey: ['wallet'] }); + queryClient.invalidateQueries({ queryKey: queryKeys.walletBalance }); setShowUpgradeConfirm(false); setUpgradeCostData(null); setPendingUpgradePayload(null); @@ -2134,22 +2135,22 @@ export default function AppDetailPage() {
- - - - - + + + + + {w.pods.map((pod) => ( - - + - - + + ))} diff --git a/frontend/src/app/[lang]/dashboard/apps/page.tsx b/frontend/src/app/[lang]/dashboard/apps/page.tsx index 23bc79a..a3d21b2 100644 --- a/frontend/src/app/[lang]/dashboard/apps/page.tsx +++ b/frontend/src/app/[lang]/dashboard/apps/page.tsx @@ -15,29 +15,19 @@ import { } from '@/components/deleting-overlay'; import { filterApplications } from '@/lib/product-type'; import { useApplicationDelete } from '@/lib/use-application-delete'; +import { + deploymentStatusBadgeClass, + deploymentStatusLabel, + lifecycleStatusClass, +} from '@/lib/app-list-utils'; -const statusColors: Record = { - running: 'badge-green', - pending: 'badge-yellow', - building: 'badge-blue', - deploying: 'badge-blue', - failed: 'badge-red', - build_failed: 'badge-red', - cancelled: 'badge-gray', - stopped: 'badge-gray', -}; - -const lifecycleColors: Record = { - active: 'text-green-600 bg-green-50', - suspended: 'text-amber-700 bg-amber-50', - pending_deletion: 'text-red-700 bg-red-50', - deleted: 'text-gray-500 bg-gray-100', -}; +const statusColors = deploymentStatusBadgeClass; +const lifecycleColors = lifecycleStatusClass; type AppsDict = Dictionary['dashboard']['apps']; function statusLabel(status: string, t: Dictionary): string { - return (t.dashboard.status as Record)[status] ?? status; + return deploymentStatusLabel(status, t); } function lifecycleLabel(lifecycle: string, a: AppsDict): string { diff --git a/frontend/src/app/[lang]/dashboard/deploy/page.tsx b/frontend/src/app/[lang]/dashboard/deploy/page.tsx index 28202ba..95294b6 100644 --- a/frontend/src/app/[lang]/dashboard/deploy/page.tsx +++ b/frontend/src/app/[lang]/dashboard/deploy/page.tsx @@ -3,6 +3,8 @@ import { useState, useRef, useCallback, useMemo } from 'react'; import { useMutation, useQuery } from '@tanstack/react-query'; import api from '@/lib/api'; +import { localizedCallbackUrl } from '@/lib/locale-url'; +import { queryKeys } from '@/lib/query-keys'; import { useT, useLocale } from '@/i18n/I18nProvider'; import { useLocalizedRouter } from '@/i18n/navigation'; import { Select } from '@/components/ui/select'; @@ -402,7 +404,7 @@ export default function DeployPage() { // Wallet balance for the review step payment const { data: walletData } = useQuery<{ balance: number }>({ - queryKey: ['wallet-balance'], + queryKey: queryKeys.walletBalance, queryFn: () => api.get('/billing/wallet').then((r) => r.data), enabled: step >= 2, }); @@ -492,7 +494,7 @@ export default function DeployPage() { const { data: gw } = await api.post('/billing/gateway/initiate', { amount: payAmount, description: `Deploy: ${form.name} (${selectedCycle})`, - callbackUrl: `${window.location.origin}/dashboard/deploy`, + callbackUrl: localizedCallbackUrl('/dashboard/deploy', locale), }); // In production, redirect to gw.gatewayUrl diff --git a/frontend/src/app/[lang]/dashboard/invoices/page.tsx b/frontend/src/app/[lang]/dashboard/invoices/page.tsx index efbae28..4dd2f42 100644 --- a/frontend/src/app/[lang]/dashboard/invoices/page.tsx +++ b/frontend/src/app/[lang]/dashboard/invoices/page.tsx @@ -6,6 +6,8 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { CreditCard, FileText, Wallet, XCircle, CheckCircle, Clock, Download } from 'lucide-react'; import { notify } from '@/lib/notify'; import api from '@/lib/api'; +import { localizedCallbackUrl } from '@/lib/locale-url'; +import { queryKeys } from '@/lib/query-keys'; import { useT, useLocale } from '@/i18n/I18nProvider'; import { translateInvoiceLabel, translateInvoiceDescription, translateInvoiceReason } from '@/lib/invoice-labels'; import { downloadInvoicePdf, buildInvoicePdfData } from '@/lib/invoice-pdf'; @@ -44,7 +46,7 @@ export default function InvoicesPage() { }, [searchParams]); const { data: walletData } = useQuery<{ balance: number }>({ - queryKey: ['wallet-balance'], + queryKey: queryKeys.walletBalance, queryFn: () => api.get('/billing/wallet').then((r) => r.data), }); @@ -71,8 +73,8 @@ export default function InvoicesPage() { const refresh = () => { queryClient.invalidateQueries({ queryKey: ['invoices'] }); queryClient.invalidateQueries({ queryKey: ['invoice', selectedId] }); - queryClient.invalidateQueries({ queryKey: ['wallet-balance'] }); - queryClient.invalidateQueries({ queryKey: ['wallet-transactions'] }); + queryClient.invalidateQueries({ queryKey: queryKeys.walletBalance }); + queryClient.invalidateQueries({ queryKey: queryKeys.walletTransactions }); }; const verifyGatewayMutation = useMutation({ @@ -99,7 +101,7 @@ export default function InvoicesPage() { const payMutation = useMutation({ mutationFn: async (invoiceId: string) => { - const callbackUrl = `${window.location.origin}/dashboard/invoices`; + const callbackUrl = localizedCallbackUrl('/dashboard/invoices', locale); const { data } = await api.post(`/billing/invoices/${invoiceId}/pay/mixed`, { callbackUrl }); if (data.gatewayUrl && data.gatewayAmount > 0) { window.location.href = data.gatewayUrl; diff --git a/frontend/src/app/[lang]/dashboard/layout.tsx b/frontend/src/app/[lang]/dashboard/layout.tsx index 20b8aa5..99433ab 100644 --- a/frontend/src/app/[lang]/dashboard/layout.tsx +++ b/frontend/src/app/[lang]/dashboard/layout.tsx @@ -4,6 +4,7 @@ import { useEffect, useState, type ReactNode } from 'react'; import { useQuery } from '@tanstack/react-query'; import { useAuthStore } from '@/lib/store'; import api from '@/lib/api'; +import { queryKeys } from '@/lib/query-keys'; import { Link } from '@/i18n/Link'; import { useLocalizedRouter, usePathname } from '@/i18n/navigation'; import { useT } from '@/i18n/I18nProvider'; @@ -151,7 +152,7 @@ export default function DashboardLayout({ children }: { children: React.ReactNod // Fetch wallet balance for all authenticated users (shown in header) const { data: walletData } = useQuery<{ balance: number }>({ - queryKey: ['wallet-balance'], + queryKey: queryKeys.walletBalance, queryFn: () => api.get('/billing/wallet').then((r) => r.data), enabled: isAuthenticated, refetchInterval: 60000, diff --git a/frontend/src/app/[lang]/dashboard/loading.tsx b/frontend/src/app/[lang]/dashboard/loading.tsx new file mode 100644 index 0000000..4f01d2a --- /dev/null +++ b/frontend/src/app/[lang]/dashboard/loading.tsx @@ -0,0 +1,10 @@ +export default function DashboardLoading() { + return ( +
+
+
+

Loading…

+
+
+ ); +} diff --git a/frontend/src/app/[lang]/dashboard/services/[id]/page.tsx b/frontend/src/app/[lang]/dashboard/services/[id]/page.tsx index 82d6184..56ba1c8 100644 --- a/frontend/src/app/[lang]/dashboard/services/[id]/page.tsx +++ b/frontend/src/app/[lang]/dashboard/services/[id]/page.tsx @@ -4,6 +4,7 @@ import { useState, useEffect, useCallback } from 'react'; import { useParams } from 'next/navigation'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import api from '@/lib/api'; +import { queryKeys } from '@/lib/query-keys'; import { notify } from '@/lib/notify'; import { Link } from '@/i18n/Link'; import { useT, useLocale } from '@/i18n/I18nProvider'; @@ -103,7 +104,7 @@ export default function ManagedServiceDetailPage() { }); const { data: walletData } = useQuery<{ balance: number }>({ - queryKey: ['wallet'], + queryKey: queryKeys.walletBalance, queryFn: () => api.get('/billing/wallet').then((r) => r.data), }); diff --git a/frontend/src/app/[lang]/dashboard/services/new/page.tsx b/frontend/src/app/[lang]/dashboard/services/new/page.tsx index 85aa4ae..e230920 100644 --- a/frontend/src/app/[lang]/dashboard/services/new/page.tsx +++ b/frontend/src/app/[lang]/dashboard/services/new/page.tsx @@ -3,6 +3,8 @@ import { useState, useMemo } from 'react'; import { useMutation, useQuery } from '@tanstack/react-query'; import api from '@/lib/api'; +import { localizedCallbackUrl } from '@/lib/locale-url'; +import { queryKeys } from '@/lib/query-keys'; import { notify } from '@/lib/notify'; import { Link } from '@/i18n/Link'; import { useT } from '@/i18n/I18nProvider'; @@ -134,7 +136,7 @@ export default function NewManagedServicePage() { }); const { data: walletData } = useQuery<{ balance: number }>({ - queryKey: ['wallet-balance'], + queryKey: queryKeys.walletBalance, queryFn: () => api.get('/billing/wallet').then((r) => r.data), enabled: step === 2, }); @@ -236,7 +238,7 @@ export default function NewManagedServicePage() { const { data: gw } = await api.post('/billing/gateway/initiate', { amount: payAmount, description: s.serviceDesc.replace('{name}', form.name).replace('{cycle}', selectedCycle), - callbackUrl: `${window.location.origin}/dashboard/services/new`, + callbackUrl: localizedCallbackUrl('/dashboard/services/new'), }); await api.post('/billing/gateway/verify', { trackingCode: gw.trackingCode, diff --git a/frontend/src/app/[lang]/dashboard/services/page.tsx b/frontend/src/app/[lang]/dashboard/services/page.tsx index a813ecb..ce5b76b 100644 --- a/frontend/src/app/[lang]/dashboard/services/page.tsx +++ b/frontend/src/app/[lang]/dashboard/services/page.tsx @@ -16,13 +16,9 @@ import { } from '@/components/deleting-overlay'; import { filterManagedServices } from '@/lib/product-type'; import { useApplicationDelete } from '@/lib/use-application-delete'; +import { lifecycleStatusClass } from '@/lib/app-list-utils'; -const lifecycleColors: Record = { - active: 'text-green-600 bg-green-50', - suspended: 'text-amber-700 bg-amber-50', - pending_deletion: 'text-red-700 bg-red-50', - deleted: 'text-gray-500 bg-gray-100', -}; +const lifecycleColors = lifecycleStatusClass; type AppsDict = Dictionary['dashboard']['apps']; diff --git a/frontend/src/app/[lang]/dashboard/wallet/page.tsx b/frontend/src/app/[lang]/dashboard/wallet/page.tsx index 3e5da46..3ed452c 100644 --- a/frontend/src/app/[lang]/dashboard/wallet/page.tsx +++ b/frontend/src/app/[lang]/dashboard/wallet/page.tsx @@ -3,6 +3,8 @@ import { useState } from 'react'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import api from '@/lib/api'; +import { localizedCallbackUrl } from '@/lib/locale-url'; +import { queryKeys } from '@/lib/query-keys'; import { notify } from '@/lib/notify'; import type { WalletTransaction, TransactionType } from '@/types'; import { Link } from '@/i18n/Link'; @@ -32,12 +34,12 @@ export default function WalletPage() { const [showCharge, setShowCharge] = useState(false); const { data: walletData, isLoading: walletLoading } = useQuery<{ balance: number }>({ - queryKey: ['wallet-balance'], + queryKey: queryKeys.walletBalance, queryFn: () => api.get('/billing/wallet').then((r) => r.data), }); const { data: transactions = [], isLoading: txLoading } = useQuery({ - queryKey: ['wallet-transactions'], + queryKey: queryKeys.walletTransactions, queryFn: () => api.get('/billing/wallet/transactions').then((r) => r.data), }); @@ -46,8 +48,8 @@ export default function WalletPage() { mutationFn: (amount: number) => api.post('/billing/wallet/charge', { amount, description: w.topUpDesc }), onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['wallet-balance'] }); - queryClient.invalidateQueries({ queryKey: ['wallet-transactions'] }); + queryClient.invalidateQueries({ queryKey: queryKeys.walletBalance }); + queryClient.invalidateQueries({ queryKey: queryKeys.walletTransactions }); notify.success(w.chargedSuccess); setChargeAmount(''); setShowCharge(false); @@ -61,7 +63,7 @@ export default function WalletPage() { const { data } = await api.post('/billing/gateway/initiate', { amount, description: w.topUpGatewayDesc, - callbackUrl: `${window.location.origin}/dashboard/wallet`, + callbackUrl: localizedCallbackUrl('/dashboard/wallet', locale), }); return data; }, @@ -72,8 +74,8 @@ export default function WalletPage() { trackingCode: data.trackingCode, amount: Number(chargeAmount), }); - queryClient.invalidateQueries({ queryKey: ['wallet-balance'] }); - queryClient.invalidateQueries({ queryKey: ['wallet-transactions'] }); + queryClient.invalidateQueries({ queryKey: queryKeys.walletBalance }); + queryClient.invalidateQueries({ queryKey: queryKeys.walletTransactions }); notify.success(w.paymentSuccess); setChargeAmount(''); setShowCharge(false); diff --git a/frontend/src/components/managed-service-resources-panel.tsx b/frontend/src/components/managed-service-resources-panel.tsx index cd50e05..5333a5e 100644 --- a/frontend/src/components/managed-service-resources-panel.tsx +++ b/frontend/src/components/managed-service-resources-panel.tsx @@ -3,6 +3,7 @@ import { useState, useEffect, useRef, useCallback } from 'react'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import api from '@/lib/api'; +import { queryKeys } from '@/lib/query-keys'; import { notify } from '@/lib/notify'; import { useT } from '@/i18n/I18nProvider'; import { useLocalizedRouter } from '@/i18n/navigation'; @@ -114,7 +115,7 @@ export function ManagedServiceResourcesPanel({ const isDatabase = app.productType === 'managed_database'; const { data: walletData } = useQuery<{ balance: number }>({ - queryKey: ['wallet'], + queryKey: queryKeys.walletBalance, queryFn: () => api.get('/billing/wallet').then((r) => r.data), }); @@ -181,7 +182,7 @@ export function ManagedServiceResourcesPanel({ queryClient.invalidateQueries({ queryKey: ['resources', serviceId] }); queryClient.invalidateQueries({ queryKey: ['db-storage', serviceId] }); queryClient.invalidateQueries({ queryKey: ['storage-usage', serviceId] }); - queryClient.invalidateQueries({ queryKey: ['wallet'] }); + queryClient.invalidateQueries({ queryKey: queryKeys.walletBalance }); setShowUpgradeConfirm(false); setUpgradeCostData(null); setPendingUpgradePayload(null); diff --git a/frontend/src/components/providers.tsx b/frontend/src/components/providers.tsx index 555a41d..c0ada60 100644 --- a/frontend/src/components/providers.tsx +++ b/frontend/src/components/providers.tsx @@ -29,7 +29,13 @@ export function Providers({ children }: { children: React.ReactNode }) { loadUser(); }, [loadUser]); - if (!mounted) return null; + if (!mounted) { + return ( +
+
+
+ ); + } return ( diff --git a/frontend/src/components/ui/badge.tsx b/frontend/src/components/ui/badge.tsx new file mode 100644 index 0000000..81fdd83 --- /dev/null +++ b/frontend/src/components/ui/badge.tsx @@ -0,0 +1,24 @@ +import clsx from 'clsx'; + +type BadgeTone = 'gray' | 'green' | 'yellow' | 'red' | 'blue' | 'purple'; + +const toneClass: Record = { + gray: 'badge-gray', + green: 'badge-green', + yellow: 'badge-yellow', + red: 'badge-red', + blue: 'badge-blue', + purple: 'badge-purple', +}; + +export function Badge({ + tone = 'gray', + className, + children, +}: { + tone?: BadgeTone; + className?: string; + children: React.ReactNode; +}) { + return {children}; +} diff --git a/frontend/src/components/ui/button.tsx b/frontend/src/components/ui/button.tsx new file mode 100644 index 0000000..52fd600 --- /dev/null +++ b/frontend/src/components/ui/button.tsx @@ -0,0 +1,24 @@ +import clsx from 'clsx'; +import type { ButtonHTMLAttributes } from 'react'; + +type ButtonVariant = 'primary' | 'secondary' | 'danger' | 'ghost'; + +const variantClass: Record = { + primary: 'btn-primary', + secondary: 'btn-secondary', + danger: 'btn-danger', + ghost: 'btn-ghost', +}; + +export function Button({ + variant = 'primary', + className, + children, + ...props +}: ButtonHTMLAttributes & { variant?: ButtonVariant }) { + return ( + + ); +} diff --git a/frontend/src/components/ui/card.tsx b/frontend/src/components/ui/card.tsx new file mode 100644 index 0000000..ad5ceb1 --- /dev/null +++ b/frontend/src/components/ui/card.tsx @@ -0,0 +1,10 @@ +import clsx from 'clsx'; +import type { HTMLAttributes } from 'react'; + +export function Card({ className, children, ...props }: HTMLAttributes) { + return ( +
+ {children} +
+ ); +} diff --git a/frontend/src/components/ui/page-header.tsx b/frontend/src/components/ui/page-header.tsx new file mode 100644 index 0000000..6944b3c --- /dev/null +++ b/frontend/src/components/ui/page-header.tsx @@ -0,0 +1,23 @@ +import type { ReactNode } from 'react'; + +export function PageHeader({ + title, + description, + actions, +}: { + title: ReactNode; + description?: ReactNode; + actions?: ReactNode; +}) { + return ( +
+
+

{title}

+ {description ? ( +

{description}

+ ) : null} +
+ {actions ?
{actions}
: null} +
+ ); +} diff --git a/frontend/src/i18n/dictionaries/en.ts b/frontend/src/i18n/dictionaries/en.ts index b45098a..80f71d1 100644 --- a/frontend/src/i18n/dictionaries/en.ts +++ b/frontend/src/i18n/dictionaries/en.ts @@ -1482,7 +1482,7 @@ const en: Dictionary = { platformDomain: 'Platform Domain', domainPlaceholder: 'example.com or www.example.com', dnsSetupGuide: 'DNS Setup Guide', removeCustomDomain: 'Remove Custom Domain', resourcesScaling: 'Resources & Scaling', monitor: 'Monitor', hide: 'Hide', show: 'Show', - liveUsage: 'Live usage', loadingMetrics: 'Loading metrics...', metrics: 'Metrics', pods: 'Pods', ready: 'Ready', + liveUsage: 'Live usage', loadingMetrics: 'Loading metrics...', metrics: 'Metrics', pods: 'Pods', ready: 'Ready', restarts: 'Restarts', noMetrics: 'No metrics yet', storageUsage: 'Storage Usage', loadingStorageMetrics: 'Loading storage metrics...', noStorageData: 'No storage data available', storageMetricsUnavailable: 'Storage metrics unavailable', used: 'Used', applyChanges: 'Apply Changes', applying: 'Applying...', processing: 'Processing...', diff --git a/frontend/src/i18n/dictionaries/fa.ts b/frontend/src/i18n/dictionaries/fa.ts index ec90d75..5587c57 100644 --- a/frontend/src/i18n/dictionaries/fa.ts +++ b/frontend/src/i18n/dictionaries/fa.ts @@ -1488,7 +1488,7 @@ const fa = { removeCustomDomain: 'حذف دامنهٔ اختصاصی', // resources resourcesScaling: 'منابع و مقیاس‌بندی', monitor: 'پایش', hide: 'پنهان', show: 'نمایش', - liveUsage: 'مصرف زنده', loadingMetrics: 'در حال بارگذاری متریک‌ها…', metrics: 'متریک‌ها', pods: 'پادها', ready: 'آماده', + liveUsage: 'مصرف زنده', loadingMetrics: 'در حال بارگذاری متریک‌ها…', metrics: 'متریک‌ها', pods: 'پادها', ready: 'آماده', restarts: 'ری‌استارت', noMetrics: 'هنوز متریکی نیست', storageUsage: 'مصرف فضای ذخیره', loadingStorageMetrics: 'در حال بارگذاری متریک‌های فضای ذخیره…', noStorageData: 'داده‌ی فضای ذخیره موجود نیست', storageMetricsUnavailable: 'متریک فضای ذخیره در دسترس نیست', used: 'مصرف‌شده', applyChanges: 'اعمال تغییرات', applying: 'در حال اعمال…', processing: 'در حال پردازش…', diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 422c43e..0c191d6 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -1,4 +1,5 @@ import axios from 'axios'; +import { loginPath } from '@/lib/locale-url'; const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:4000'; @@ -55,7 +56,7 @@ api.interceptors.response.use( localStorage.removeItem('accessToken'); localStorage.removeItem('refreshToken'); if (typeof window !== 'undefined') { - window.location.href = '/login'; + window.location.href = loginPath(); } } } diff --git a/frontend/src/lib/app-list-utils.ts b/frontend/src/lib/app-list-utils.ts new file mode 100644 index 0000000..2c700e4 --- /dev/null +++ b/frontend/src/lib/app-list-utils.ts @@ -0,0 +1,23 @@ +import type { Dictionary } from '@/i18n/dictionaries/fa'; + +export const deploymentStatusBadgeClass: Record = { + pending: 'badge-gray', + building: 'badge-yellow', + deploying: 'badge-blue', + running: 'badge-green', + failed: 'badge-red', + build_failed: 'badge-red', + cancelled: 'badge-gray', + stopped: 'badge-gray', +}; + +export const lifecycleStatusClass: Record = { + active: 'text-green-600 bg-green-50', + suspended: 'text-amber-700 bg-amber-50', + pending_deletion: 'text-red-700 bg-red-50', + deleted: 'text-gray-500 bg-gray-100', +}; + +export function deploymentStatusLabel(status: string, t: Dictionary): string { + return (t.dashboard.status as Record)[status] ?? status; +} diff --git a/frontend/src/lib/errors.test.ts b/frontend/src/lib/errors.test.ts new file mode 100644 index 0000000..f3efa73 --- /dev/null +++ b/frontend/src/lib/errors.test.ts @@ -0,0 +1,25 @@ +import { describe, it, expect } from 'vitest'; +import axios from 'axios'; +import { classifyApiError } from './errors'; + +describe('classifyApiError', () => { + it('classifies network errors', () => { + const err = new axios.AxiosError('Network Error'); + expect(classifyApiError(err).kind).toBe('network'); + }); + + it('classifies 403 forbidden', () => { + const err = new axios.AxiosError('Forbidden', undefined, undefined, undefined, { + status: 403, + data: { message: 'Forbidden' }, + statusText: 'Forbidden', + headers: {}, + config: {} as any, + }); + expect(classifyApiError(err).kind).toBe('forbidden'); + }); + + it('classifies generic errors', () => { + expect(classifyApiError(new Error('oops')).kind).toBe('generic'); + }); +}); diff --git a/frontend/src/lib/format-utils.test.ts b/frontend/src/lib/format-utils.test.ts new file mode 100644 index 0000000..93ff950 --- /dev/null +++ b/frontend/src/lib/format-utils.test.ts @@ -0,0 +1,35 @@ +import { describe, it, expect } from 'vitest'; +import { + formatRemainingForLocale, + isPersianLocale, + parseCpuToMillicores, +} from './format-utils'; + +describe('isPersianLocale', () => { + it('matches fa-IR and legacy fa', () => { + expect(isPersianLocale('fa-IR')).toBe(true); + expect(isPersianLocale('fa')).toBe(true); + expect(isPersianLocale('en-US')).toBe(false); + }); +}); + +describe('formatRemainingForLocale', () => { + it('uses Persian formatting for fa-IR', () => { + const future = new Date(Date.now() + 2 * 86400000 + 3600000); + const result = formatRemainingForLocale(future, 'fa-IR'); + expect(result).toMatch(/روز/); + }); + + it('uses English formatting for en-US', () => { + const future = new Date(Date.now() + 2 * 86400000); + const result = formatRemainingForLocale(future, 'en-US'); + expect(result).toMatch(/\d+d/); + }); +}); + +describe('parseCpuToMillicores', () => { + it('parses millicores and cores', () => { + expect(parseCpuToMillicores('500m')).toBe(500); + expect(parseCpuToMillicores('1')).toBe(1000); + }); +}); diff --git a/frontend/src/lib/format-utils.ts b/frontend/src/lib/format-utils.ts index 7541f99..8ed7867 100644 --- a/frontend/src/lib/format-utils.ts +++ b/frontend/src/lib/format-utils.ts @@ -19,7 +19,10 @@ export function parseMemoryToMi(mem: string): number { return parseFloat(mem); } -/** Human-readable time left (days, hours, minutes). */ +/** True when locale is Persian (fa-IR or legacy "fa"). */ +export function isPersianLocale(locale: string): boolean { + return locale === 'fa' || locale.startsWith('fa-'); +} export function formatRemainingDurationMs(remainingMs: number): string { const ms = Math.max(0, remainingMs); const days = Math.floor(ms / 86400000); @@ -54,7 +57,7 @@ export function formatRemainingForLocale( const date = typeof expiresAt === 'string' ? new Date(expiresAt) : expiresAt; if (Number.isNaN(date.getTime())) return '—'; const remainingMs = date.getTime() - Date.now(); - return locale === 'fa' + return isPersianLocale(locale) ? formatRemainingDurationFa(remainingMs) : formatRemainingDurationMs(remainingMs); } @@ -69,7 +72,7 @@ export function formatExpiresAtLocal( ): string { const date = typeof expiresAt === 'string' ? new Date(expiresAt) : expiresAt; if (Number.isNaN(date.getTime())) return '—'; - if (locale === 'fa') { + if (isPersianLocale(locale || '')) { // Assemble parts explicitly so the order is «روز هفته، روز ماه سال» regardless // of the runtime's ICU pattern data. const parts = new Intl.DateTimeFormat('fa-IR-u-ca-persian', { diff --git a/frontend/src/lib/locale-url.test.ts b/frontend/src/lib/locale-url.test.ts new file mode 100644 index 0000000..19b92ef --- /dev/null +++ b/frontend/src/lib/locale-url.test.ts @@ -0,0 +1,36 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { getClientLocale, localizedPath, loginPath } from './locale-url'; +import { defaultLocale, LOCALE_COOKIE } from '@/i18n/config'; + +describe('locale-url', () => { + const originalDocument = global.document; + + beforeEach(() => { + // jsdom-less: stub document.cookie + Object.defineProperty(global, 'document', { + value: { cookie: '' }, + configurable: true, + }); + }); + + afterEach(() => { + Object.defineProperty(global, 'document', { + value: originalDocument, + configurable: true, + }); + }); + + it('returns default locale when cookie is missing', () => { + expect(getClientLocale()).toBe(defaultLocale); + }); + + it('reads locale from NEXT_LOCALE cookie', () => { + document.cookie = `${LOCALE_COOKIE}=en-US`; + expect(getClientLocale()).toBe('en-US'); + }); + + it('builds localized paths', () => { + expect(localizedPath('/dashboard/wallet', 'fa-IR')).toBe('/fa-IR/dashboard/wallet'); + expect(loginPath('en-US')).toBe('/en-US/login'); + }); +}); diff --git a/frontend/src/lib/locale-url.ts b/frontend/src/lib/locale-url.ts new file mode 100644 index 0000000..7cb4577 --- /dev/null +++ b/frontend/src/lib/locale-url.ts @@ -0,0 +1,31 @@ +import { defaultLocale, isLocale, LOCALE_COOKIE, type Locale } from '@/i18n/config'; + +/** Read the active locale from cookie (client-only). */ +export function getClientLocale(): Locale { + if (typeof document === 'undefined') { + return defaultLocale; + } + const match = document.cookie.match(new RegExp(`(?:^|; )${LOCALE_COOKIE}=([^;]*)`)); + const value = match ? decodeURIComponent(match[1]) : ''; + return isLocale(value) ? value : defaultLocale; +} + +/** Build an absolute URL with the locale prefix, e.g. /fa-IR/dashboard/wallet */ +export function localizedPath(path: string, locale?: Locale): string { + const loc = locale ?? getClientLocale(); + const normalized = path.startsWith('/') ? path : `/${path}`; + return `/${loc}${normalized}`; +} + +/** Absolute origin + localized path for payment gateway callbacks. */ +export function localizedCallbackUrl(path: string, locale?: Locale): string { + if (typeof window === 'undefined') { + return localizedPath(path, locale); + } + return `${window.location.origin}${localizedPath(path, locale)}`; +} + +/** Localized login path for auth redirects. */ +export function loginPath(locale?: Locale): string { + return localizedPath('/login', locale); +} diff --git a/frontend/src/lib/query-keys.ts b/frontend/src/lib/query-keys.ts new file mode 100644 index 0000000..d0f444b --- /dev/null +++ b/frontend/src/lib/query-keys.ts @@ -0,0 +1,7 @@ +/** Central React Query keys — keep invalidation consistent across pages. */ +export const queryKeys = { + walletBalance: ['wallet-balance'] as const, + walletTransactions: ['wallet-transactions'] as const, + applications: (productType?: string) => + productType ? (['applications', productType] as const) : (['applications'] as const), +}; diff --git a/frontend/vitest.config.ts b/frontend/vitest.config.ts new file mode 100644 index 0000000..fbee6ef --- /dev/null +++ b/frontend/vitest.config.ts @@ -0,0 +1,14 @@ +import { defineConfig } from 'vitest/config'; +import path from 'path'; + +export default defineConfig({ + test: { + environment: 'node', + include: ['src/**/*.test.ts'], + }, + resolve: { + alias: { + '@': path.resolve(__dirname, './src'), + }, + }, +});
{ad.name}{ad.status}{ad.ready}R
{ad.name}{ad.status}{ad.ready}{ad.restarts}
{pod.name} + {pod.name} {pod.status} {pod.ready ? : }{pod.restarts}{pod.ready ? : }{pod.restarts}