commit 800b0ba2c5782979ee7b98665d6356e889d31046
Author: Mortezakoohjani
Date: Tue Jul 21 21:43:33 2026 +0330
Deploy TorbatYar for torbatyar.ir with nginx multi-tenant routing.
Wire production domain, CORS for tenant subdomains, celery volume mounts, and nginx reverse proxy configs for apex, API, identity, auth, and wildcard tenants.
Co-authored-by: Cursor
diff --git a/.env.example b/.env.example
new file mode 100644
index 0000000..da79fa1
--- /dev/null
+++ b/.env.example
@@ -0,0 +1,104 @@
+# ============================================================
+# SuperApp SaaS Platform - Environment Configuration Example
+# این فایل را به .env کپی کنید و مقادیر واقعی را جایگزین نمایید.
+# ============================================================
+
+# ---- General ----
+ENVIRONMENT=development # development | staging | production
+DEBUG=true
+LOG_LEVEL=INFO # DEBUG | INFO | WARNING | ERROR
+SERVICE_NAME=core-service
+API_V1_PREFIX=/api/v1
+
+# ---- Platform Branding (White-label) ----
+# برند نباید در کد hardcode شود؛ از این مقادیر استفاده کنید.
+PLATFORM_NAME=Torbatyar
+PLATFORM_SUPPORT_EMAIL=support@example.com
+PLATFORM_PRIMARY_COLOR=#0284c7
+PLATFORM_SECONDARY_COLOR=#0f172a
+# دامنه پایه برای تخصیص خودکار زیردامنه tenant در onboarding (مثلاً .torbatyar.xyz)
+# دامنه پایه برای تخصیص خودکار زیردامنه tenant (مثلاً .torbatyar.ir)
+PLATFORM_BASE_DOMAIN=torbatyar.ir
+
+# ---- Core Database (core_platform_db) ----
+POSTGRES_HOST=postgres
+POSTGRES_PORT=5432
+POSTGRES_USER=superapp
+POSTGRES_PASSWORD=superapp_password
+POSTGRES_DB=core_platform_db
+# async driver برای FastAPI/SQLAlchemy
+DATABASE_URL=postgresql+asyncpg://superapp:superapp_password@postgres:5432/core_platform_db
+# sync driver برای Alembic
+DATABASE_URL_SYNC=postgresql+psycopg://superapp:superapp_password@postgres:5432/core_platform_db
+
+# ---- Redis ----
+REDIS_HOST=redis
+REDIS_PORT=6379
+REDIS_DB=0
+REDIS_URL=redis://redis:6379/0
+FEATURE_ACCESS_CACHE_TTL=300 # ثانیه
+TENANT_METADATA_CACHE_TTL=300
+
+# ---- Celery ----
+CELERY_BROKER_URL=redis://redis:6379/1
+CELERY_RESULT_BACKEND=redis://redis:6379/2
+
+# ---- Keycloak / SSO (فاز ۲ — فعال در Docker) ----
+# Production: auth.torbatyar.ir پشت nginx روی 192.168.10.156
+# Dev محلی: میتوانید hosts را به 127.0.0.1 اشاره دهید.
+KEYCLOAK_ENABLED=true
+KEYCLOAK_SERVER_URL=http://keycloak:8080
+KEYCLOAK_PUBLIC_URL=https://auth.torbatyar.ir
+KEYCLOAK_HOSTNAME=auth.torbatyar.ir
+KEYCLOAK_HOSTNAME_STRICT_HTTPS=false
+KEYCLOAK_REALM=superapp
+KEYCLOAK_CLIENT_ID=core-service
+KEYCLOAK_CLIENT_SECRET=change-me
+JWT_ALGORITHM=RS256
+JWT_AUDIENCE=account
+JWT_VERIFY_SIGNATURE=true
+# در production و Docker باید true باشد؛ در تستها false
+AUTH_REQUIRED=true
+
+# ---- Identity & Access Service ----
+IDENTITY_SERVICE_URL=http://identity-access-service:8001
+IDENTITY_DATABASE_URL=postgresql+asyncpg://superapp:superapp_password@postgres:5432/identity_access_db
+IDENTITY_DATABASE_URL_SYNC=postgresql+psycopg://superapp:superapp_password@postgres:5432/identity_access_db
+KEYCLOAK_FRONTEND_CLIENT_ID=superapp-frontend
+KEYCLOAK_IDENTITY_CLIENT_SECRET=change-me-identity
+IDENTITY_KEYCLOAK_CLIENT_ID=identity-access-service
+FRONTEND_CALLBACK_URL=https://torbatyar.ir/auth/callback
+CORS_ORIGINS=https://torbatyar.ir,https://www.torbatyar.ir,http://torbatyar.ir,http://www.torbatyar.ir,http://localhost:3000
+CORE_SERVICE_URL=http://core-service:8000
+
+# ---- OTP / Payamak SMS (Core Service) ----
+# ApiKey را در پارامتر password ارسال کنید (طبق مستندات ملیپیامک)
+PAYAMAK_USERNAME=9155105404
+PAYAMAK_FROM=9982004961
+PAYAMAK_FROM_NUMBER=9982004961
+# ApiKey را در پارامتر password ارسال کنید (مستندات ملیپیامک)
+PAYAMAK_API_KEY=967ceb37-eeef-429c-b6c5-b60a15306e4f
+PAYAMAK_APIKEY=967ceb37-eeef-429c-b6c5-b60a15306e4f
+PAYAMAK_PASSWORD=967ceb37-eeef-429c-b6c5-b60a15306e4f
+PAYAMAK_BODY_ID=245189
+# آدرس رسمی: https://api.payamak-panel.com/post/send.asmx/SendByBaseNumber
+JWT_SECRET=change-me-jwt-secret
+OTP_EXPIRE_SECONDS=120
+
+# شمارههای ادمین پلتفرم (OTP) — با کاما جدا شوند
+PLATFORM_ADMIN_MOBILES=09155105404
+
+# ---- Frontend (Next.js) ----
+FRONTEND_PORT=3000
+NEXT_PUBLIC_BACKEND_URL=https://api.torbatyar.ir
+NEXT_PUBLIC_API_BASE_URL=https://api.torbatyar.ir
+NEXT_PUBLIC_IDENTITY_API_URL=https://identity.torbatyar.ir
+NEXT_PUBLIC_KEYCLOAK_URL=https://auth.torbatyar.ir
+NEXT_PUBLIC_KEYCLOAK_REALM=superapp
+NEXT_PUBLIC_KEYCLOAK_CLIENT_ID=superapp-frontend
+
+INTERNAL_TOKEN_SECRET=change-me-internal-secret
+
+# ---- Keycloak Bootstrap ----
+KEYCLOAK_ADMIN=admin
+KEYCLOAK_ADMIN_PASSWORD=admin
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..c393362
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,48 @@
+# Python
+__pycache__/
+*.py[cod]
+*.egg-info/
+.venv/
+venv/
+.pytest_cache/
+.mypy_cache/
+.ruff_cache/
+*.egg
+
+# Environment
+.env
+*.env
+!.env.example
+
+# Databases / local
+*.sqlite3
+*.db
+test.db
+
+# Node / Frontend
+node_modules/
+.next/
+dist/
+frontend/.next/
+frontend/node_modules/
+
+# IDE / OS
+.idea/
+.vscode/
+.DS_Store
+Thumbs.db
+
+# Logs
+*.log
+
+# Deploy temp artifacts
+scripts/_deploy_bundle.tar.gz
+scripts/_tmp_*
+scripts/_check_*
+scripts/_fix_*
+scripts/_debug_*
+scripts/_vols.py
+scripts/_restart_*
+scripts/_reset_*
+scripts/_verify*
+scripts/_finalize_*
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..fc96730
--- /dev/null
+++ b/README.md
@@ -0,0 +1,101 @@
+# SuperApp SaaS Platform
+
+پلتفرم SaaS چندمستأجری (Multi-tenant)، ماژولار، API-first و microservice-ready.
+این مخزن شامل **فاز ۱ (Core Platform)** و **فاز ۲ (Identity & Access + SSO)** است.
+
+> برند، رنگها و تنظیمات هیچکدام در کد hardcode نشدهاند و همگی از `.env` / `config` / دیتابیس خوانده میشوند.
+
+## معماری در یک نگاه
+
+- **جداسازی اجباری Frontend/Backend:** دو اپلیکیشن کاملاً مستقل (`backend/` و `frontend/`).
+- **الگوی دیتابیس:** Database-per-service. هر سرویس فقط دیتابیس خودش را میشناسد.
+- **ارتباط بین سرویسها:** فقط از طریق REST API، Webhook، Async Event و الگوی Outbox/Inbox.
+- **چندمستأجری:** همه جداول بیزینسی ستون `tenant_id` دارند.
+
+جزئیات کامل در پوشه [`docs/`](./docs) موجود است:
+
+| فایل | توضیح |
+| --- | --- |
+| [`docs/architecture.md`](./docs/architecture.md) | معماری کلان و جداسازی Frontend/Backend |
+| [`docs/database_schema.md`](./docs/database_schema.md) | مدل دیتابیس Core |
+| [`docs/services_contracts.md`](./docs/services_contracts.md) | قراردادهای ارتباطی سرویسها |
+| [`docs/developer_guide.md`](./docs/developer_guide.md) | راهنمای توسعهدهنده |
+| [`docs/progress.md`](./docs/progress.md) | چکلیست پیشرفت فازها |
+| [`docs/last_step.md`](./docs/last_step.md) | آخرین وضعیت |
+
+## ساختار پروژه
+
+```
+superapp-platform/
+├── backend/
+│ ├── core-service/ # FastAPI Core Platform
+│ ├── shared-lib/ # کتابخانه مشترک backend
+│ └── services/ # placeholder سرویسهای آینده
+├── frontend/ # Next.js (کاملاً جدا)
+│ ├── app/
+│ ├── components/
+│ ├── lib/
+│ ├── hooks/
+│ ├── styles/
+│ └── public/
+├── docs/
+├── docker-compose.yml
+├── .env.example
+└── README.md
+```
+
+## راهاندازی سریع (Docker)
+
+```bash
+cp .env.example .env
+docker compose up -d --build
+```
+
+سایت: http://localhost:3000
+
+سرویسها پس از بالا آمدن (یک دستور: `docker compose up -d --build`):
+
+- **Frontend:** http://localhost:3000 (login: http://localhost:3000/login)
+- Core API: http://localhost:8000 (مستندات: http://localhost:8000/docs)
+- Identity API: http://localhost:8001 (SSO: http://localhost:8001/docs)
+- Keycloak: http://localhost:8080 (admin/admin — کاربر نمونه: platform.admin/admin123)
+- PostgreSQL: `localhost:5432`
+- Redis: `localhost:6379`
+
+> Frontend داخل Docker با hot-reload اجرا میشود؛ دیگر نیازی به `npm run dev` جداگانه نیست.
+
+## راهاندازی محلی
+
+### Backend
+```bash
+cd backend/core-service
+python -m venv .venv
+# Windows: .venv\Scripts\Activate.ps1
+pip install -r requirements.txt
+alembic upgrade head
+uvicorn app.main:app --reload
+```
+
+### Frontend
+```bash
+# ترجیحاً از Docker (همراه بقیه سرویسها):
+docker compose up -d --build
+
+# یا اجرای مستقیم برای دیباگ UI:
+cd frontend
+npm install
+npm run dev
+```
+Frontend: http://localhost:3000
+
+## اجرای تستها (Backend)
+
+```bash
+cd backend/core-service
+pytest -q
+```
+
+## فازهای بعدی
+
+سرویسهای آینده در [`backend/services/`](./backend/services) بهصورت placeholder آمادهاند.
+UI کامل در [`frontend/`](./frontend) توسعه مییابد.
diff --git a/TorbatYar.code-workspace b/TorbatYar.code-workspace
new file mode 100644
index 0000000..876a149
--- /dev/null
+++ b/TorbatYar.code-workspace
@@ -0,0 +1,8 @@
+{
+ "folders": [
+ {
+ "path": "."
+ }
+ ],
+ "settings": {}
+}
\ No newline at end of file
diff --git a/backend/README.md b/backend/README.md
new file mode 100644
index 0000000..64c93e5
--- /dev/null
+++ b/backend/README.md
@@ -0,0 +1,31 @@
+# Backend
+
+تمام کد منبع backend فقط در این پوشه قرار دارد.
+
+## ساختار
+```
+backend/
+├── core-service/ # سرویس Core Platform (FastAPI)
+├── shared-lib/ # کتابخانه مشترک بین سرویسهای backend
+└── services/ # placeholder سرویسهای آینده
+```
+
+## قوانین (اجباری)
+- **هیچ** کد React، Next.js، Tailwind، UI Component یا HTML در backend نیست.
+- frontend در پوشه جداگانه `frontend/` قرار دارد.
+- ارتباط با frontend فقط از طریق REST API.
+
+## راهاندازی
+```bash
+cd backend/core-service
+python -m venv .venv
+pip install -r requirements.txt
+alembic upgrade head
+uvicorn app.main:app --reload
+```
+
+## تستها
+```bash
+cd backend/core-service
+pytest -q
+```
diff --git a/backend/core-service/Dockerfile b/backend/core-service/Dockerfile
new file mode 100644
index 0000000..f5c181d
--- /dev/null
+++ b/backend/core-service/Dockerfile
@@ -0,0 +1,28 @@
+# ============================================================
+# Core Platform Service - Dockerfile
+# Build context: ریشه مخزن (برای دسترسی به backend/shared-lib)
+# ============================================================
+FROM python:3.11-slim AS base
+
+ENV PYTHONDONTWRITEBYTECODE=1 \
+ PYTHONUNBUFFERED=1 \
+ PIP_NO_CACHE_DIR=1
+
+WORKDIR /app
+
+RUN apt-get update \
+ && apt-get install -y --no-install-recommends build-essential libpq-dev \
+ && rm -rf /var/lib/apt/lists/*
+
+COPY backend/shared-lib/ /shared-lib/
+COPY backend/core-service/requirements.txt /app/requirements.txt
+
+RUN sed -i 's#-e ../shared-lib#-e /shared-lib#' /app/requirements.txt \
+ && pip install --upgrade pip \
+ && pip install -r requirements.txt
+
+COPY backend/core-service/ /app/
+
+EXPOSE 8000
+
+CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
diff --git a/backend/core-service/Dockerfile.dev b/backend/core-service/Dockerfile.dev
new file mode 100644
index 0000000..e36c60d
--- /dev/null
+++ b/backend/core-service/Dockerfile.dev
@@ -0,0 +1,22 @@
+# Dev image: فقط وابستگیها — کد با volume mount و uvicorn --reload
+FROM python:3.11-slim
+
+ENV PYTHONDONTWRITEBYTECODE=1 \
+ PYTHONUNBUFFERED=1 \
+ PIP_NO_CACHE_DIR=1 \
+ PYTHONPATH=/app
+
+WORKDIR /app
+
+RUN apt-get update \
+ && apt-get install -y --no-install-recommends build-essential libpq-dev \
+ && rm -rf /var/lib/apt/lists/*
+
+COPY backend/shared-lib/ /shared-lib/
+COPY backend/core-service/requirements.txt /app/requirements.txt
+
+RUN sed -i 's#-e ../shared-lib#-e /shared-lib#' /app/requirements.txt \
+ && pip install --upgrade pip \
+ && pip install -r requirements.txt
+
+EXPOSE 8000
diff --git a/backend/core-service/alembic.ini b/backend/core-service/alembic.ini
new file mode 100644
index 0000000..b3d9ee7
--- /dev/null
+++ b/backend/core-service/alembic.ini
@@ -0,0 +1,44 @@
+# پیکربندی Alembic برای Core Platform Service.
+# آدرس دیتابیس از env خوانده میشود (در alembic/env.py تنظیم شده)،
+# بنابراین sqlalchemy.url اینجا خالی است.
+[alembic]
+script_location = alembic
+prepend_sys_path = .
+version_path_separator = os
+sqlalchemy.url =
+
+[post_write_hooks]
+
+[loggers]
+keys = root,sqlalchemy,alembic
+
+[handlers]
+keys = console
+
+[formatters]
+keys = generic
+
+[logger_root]
+level = WARNING
+handlers = console
+qualname =
+
+[logger_sqlalchemy]
+level = WARNING
+handlers =
+qualname = sqlalchemy.engine
+
+[logger_alembic]
+level = INFO
+handlers =
+qualname = alembic
+
+[handler_console]
+class = StreamHandler
+args = (sys.stderr,)
+level = NOTSET
+formatter = generic
+
+[formatter_generic]
+format = %(levelname)-5.5s [%(name)s] %(message)s
+datefmt = %H:%M:%S
diff --git a/backend/core-service/alembic/README.md b/backend/core-service/alembic/README.md
new file mode 100644
index 0000000..ed740cc
--- /dev/null
+++ b/backend/core-service/alembic/README.md
@@ -0,0 +1,18 @@
+# Alembic Migrations
+
+مدیریت نسخههای اسکیمای دیتابیس `core_platform_db`.
+
+## دستورها
+
+```bash
+# ساخت migration جدید بر اساس تغییر مدلها
+alembic revision --autogenerate -m "توضیح تغییر"
+
+# اعمال migrationها
+alembic upgrade head
+
+# بازگشت یک مرحله
+alembic downgrade -1
+```
+
+> آدرس دیتابیس از `settings.database_url_sync` (متغیر محیطی `DATABASE_URL_SYNC`) خوانده میشود.
diff --git a/backend/core-service/alembic/env.py b/backend/core-service/alembic/env.py
new file mode 100644
index 0000000..5a14a9c
--- /dev/null
+++ b/backend/core-service/alembic/env.py
@@ -0,0 +1,59 @@
+"""محیط اجرای Alembic.
+
+از درایور sync (psycopg) برای migrationها استفاده میشود؛ آدرس آن از
+تنظیمات (settings.database_url_sync) خوانده میشود تا چیزی hardcode نشود.
+"""
+from __future__ import annotations
+
+from logging.config import fileConfig
+
+from alembic import context
+from sqlalchemy import engine_from_config, pool
+
+from app.core.config import settings
+from app.core.database import Base
+
+# ثبت همه مدلها روی Base.metadata برای autogenerate.
+import app.models # noqa: F401
+
+config = context.config
+config.set_main_option("sqlalchemy.url", settings.database_url_sync)
+
+if config.config_file_name is not None:
+ fileConfig(config.config_file_name)
+
+target_metadata = Base.metadata
+
+
+def run_migrations_offline() -> None:
+ context.configure(
+ url=settings.database_url_sync,
+ target_metadata=target_metadata,
+ literal_binds=True,
+ dialect_opts={"paramstyle": "named"},
+ compare_type=True,
+ )
+ with context.begin_transaction():
+ context.run_migrations()
+
+
+def run_migrations_online() -> None:
+ connectable = engine_from_config(
+ config.get_section(config.config_ini_section, {}),
+ prefix="sqlalchemy.",
+ poolclass=pool.NullPool,
+ )
+ with connectable.connect() as connection:
+ context.configure(
+ connection=connection,
+ target_metadata=target_metadata,
+ compare_type=True,
+ )
+ with context.begin_transaction():
+ context.run_migrations()
+
+
+if context.is_offline_mode():
+ run_migrations_offline()
+else:
+ run_migrations_online()
diff --git a/backend/core-service/alembic/script.py.mako b/backend/core-service/alembic/script.py.mako
new file mode 100644
index 0000000..aa5053c
--- /dev/null
+++ b/backend/core-service/alembic/script.py.mako
@@ -0,0 +1,26 @@
+"""${message}
+
+Revision ID: ${up_revision}
+Revises: ${down_revision | comma,n}
+Create Date: ${create_date}
+
+"""
+from typing import Sequence, Union
+
+from alembic import op
+import sqlalchemy as sa
+${imports if imports else ""}
+
+# revision identifiers, used by Alembic.
+revision: str = ${repr(up_revision)}
+down_revision: Union[str, None] = ${repr(down_revision)}
+branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
+depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
+
+
+def upgrade() -> None:
+ ${upgrades if upgrades else "pass"}
+
+
+def downgrade() -> None:
+ ${downgrades if downgrades else "pass"}
diff --git a/backend/core-service/alembic/versions/0001_initial.py b/backend/core-service/alembic/versions/0001_initial.py
new file mode 100644
index 0000000..e457062
--- /dev/null
+++ b/backend/core-service/alembic/versions/0001_initial.py
@@ -0,0 +1,35 @@
+"""initial core platform schema
+
+Revision ID: 0001_initial
+Revises:
+Create Date: 2026-07-06
+
+این migration اولیه، اسکیمای کامل Core Platform را از روی metadata مدلها
+میسازد تا با تعریف مدلها همیشه هماهنگ بماند. migrationهای بعدی بهصورت
+autogenerate و افزایشی ساخته خواهند شد.
+"""
+from __future__ import annotations
+
+from typing import Sequence, Union
+
+from alembic import op
+
+from app.core.database import Base
+
+# import مدلها تا همه جدولها روی metadata ثبت شوند.
+import app.models # noqa: F401
+
+revision: str = "0001_initial"
+down_revision: Union[str, None] = None
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+
+def upgrade() -> None:
+ bind = op.get_bind()
+ Base.metadata.create_all(bind=bind)
+
+
+def downgrade() -> None:
+ bind = op.get_bind()
+ Base.metadata.drop_all(bind=bind)
diff --git a/backend/core-service/alembic/versions/0002_users.py b/backend/core-service/alembic/versions/0002_users.py
new file mode 100644
index 0000000..237d3b3
--- /dev/null
+++ b/backend/core-service/alembic/versions/0002_users.py
@@ -0,0 +1,64 @@
+"""users table
+
+Revision ID: 0002_users
+Revises: 0001_initial
+Create Date: 2026-07-06
+"""
+from __future__ import annotations
+
+from typing import Sequence, Union
+
+import sqlalchemy as sa
+from alembic import op
+
+revision: str = "0002_users"
+down_revision: Union[str, None] = "0001_initial"
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+
+def upgrade() -> None:
+ op.create_table(
+ "users",
+ sa.Column("id", sa.UUID(), nullable=False),
+ sa.Column("mobile", sa.String(length=15), nullable=False),
+ sa.Column(
+ "role",
+ sa.Enum(
+ "user",
+ "pending_tenant_admin",
+ "tenant_admin",
+ "platform_admin",
+ name="user_role",
+ ),
+ nullable=False,
+ ),
+ sa.Column(
+ "status",
+ sa.Enum("active", "inactive", "suspended", name="user_status"),
+ nullable=False,
+ ),
+ sa.Column(
+ "created_at",
+ sa.DateTime(timezone=True),
+ server_default=sa.text("now()"),
+ nullable=False,
+ ),
+ sa.Column(
+ "updated_at",
+ sa.DateTime(timezone=True),
+ server_default=sa.text("now()"),
+ nullable=False,
+ ),
+ sa.PrimaryKeyConstraint("id"),
+ )
+ op.create_index("ix_users_mobile", "users", ["mobile"], unique=True)
+ op.create_index("ix_users_status", "users", ["status"])
+
+
+def downgrade() -> None:
+ op.drop_index("ix_users_status", table_name="users")
+ op.drop_index("ix_users_mobile", table_name="users")
+ op.drop_table("users")
+ op.execute("DROP TYPE IF EXISTS user_role")
+ op.execute("DROP TYPE IF EXISTS user_status")
diff --git a/backend/core-service/alembic/versions/0003_platform_admin.py b/backend/core-service/alembic/versions/0003_platform_admin.py
new file mode 100644
index 0000000..b76462f
--- /dev/null
+++ b/backend/core-service/alembic/versions/0003_platform_admin.py
@@ -0,0 +1,37 @@
+"""promote platform admin mobiles
+
+Revision ID: 0003_platform_admin
+Revises: 0002_users
+Create Date: 2026-07-07
+"""
+from __future__ import annotations
+
+from typing import Sequence, Union
+
+from alembic import op
+
+revision: str = "0003_platform_admin"
+down_revision: Union[str, None] = "0002_users"
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+
+def upgrade() -> None:
+ op.execute(
+ """
+ UPDATE users
+ SET role = 'platform_admin'
+ WHERE mobile IN ('09155105404', '9155105404')
+ """
+ )
+
+
+def downgrade() -> None:
+ op.execute(
+ """
+ UPDATE users
+ SET role = 'pending_tenant_admin'
+ WHERE mobile IN ('09155105404', '9155105404')
+ AND role = 'platform_admin'
+ """
+ )
diff --git a/backend/core-service/alembic/versions/0004_user_sso_link.py b/backend/core-service/alembic/versions/0004_user_sso_link.py
new file mode 100644
index 0000000..6335218
--- /dev/null
+++ b/backend/core-service/alembic/versions/0004_user_sso_link.py
@@ -0,0 +1,35 @@
+"""user SSO link and mobile verification fields
+
+Revision ID: 0004_user_sso_link
+Revises: 0003_platform_admin
+Create Date: 2026-07-07
+"""
+from __future__ import annotations
+
+from typing import Sequence, Union
+
+import sqlalchemy as sa
+from alembic import op
+
+revision: str = "0004_user_sso_link"
+down_revision: Union[str, None] = "0003_platform_admin"
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+
+def upgrade() -> None:
+ op.add_column("users", sa.Column("keycloak_sub", sa.String(length=100), nullable=True))
+ op.add_column("users", sa.Column("email", sa.String(length=255), nullable=True))
+ op.add_column(
+ "users",
+ sa.Column("mobile_verified", sa.Boolean(), nullable=False, server_default=sa.text("true")),
+ )
+ op.create_index("ix_users_keycloak_sub", "users", ["keycloak_sub"], unique=True)
+ op.alter_column("users", "mobile_verified", server_default=None)
+
+
+def downgrade() -> None:
+ op.drop_index("ix_users_keycloak_sub", table_name="users")
+ op.drop_column("users", "mobile_verified")
+ op.drop_column("users", "email")
+ op.drop_column("users", "keycloak_sub")
diff --git a/backend/core-service/alembic/versions/0005_tenant_onboarding.py b/backend/core-service/alembic/versions/0005_tenant_onboarding.py
new file mode 100644
index 0000000..73eeacc
--- /dev/null
+++ b/backend/core-service/alembic/versions/0005_tenant_onboarding.py
@@ -0,0 +1,183 @@
+"""tenant onboarding: profile fields, memberships, domain verification, default plans
+
+Revision ID: 0005_tenant_onboarding
+Revises: 0004_user_sso_link
+Create Date: 2026-07-07
+
+این migration زیرساخت فاز Onboarding/Activation را اضافه میکند:
+ - فیلدهای پروفایل/برندینگ و onboarding_completed روی tenants
+ - مقادیر جدید چرخه عمر tenant_status (draft/pending_activation/archived)
+ - ستونهای is_primary و verification_status روی domains
+ - جدول tenant_memberships (رابطه واقعی user <-> tenant)
+ - ستون current_tenant_id روی users (tenant انتخابی جاری کاربر)
+ - seed پلنهای پیشفرض FREE و STARTER
+"""
+from __future__ import annotations
+
+import uuid
+from typing import Sequence, Union
+
+import sqlalchemy as sa
+from alembic import op
+
+revision: str = "0005_tenant_onboarding"
+down_revision: Union[str, None] = "0004_user_sso_link"
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+
+def upgrade() -> None:
+ bind = op.get_bind()
+ is_postgres = bind.dialect.name == "postgresql"
+
+ # ---- ۱) مقادیر جدید چرخه عمر tenant_status ----
+ if is_postgres:
+ for value in ("DRAFT", "PENDING_ACTIVATION", "ARCHIVED"):
+ op.execute(f"ALTER TYPE tenant_status ADD VALUE IF NOT EXISTS '{value}'")
+
+ # ---- ۲) پروفایل/برندینگ روی tenants ----
+ op.add_column("tenants", sa.Column("business_type", sa.String(length=100), nullable=True))
+ op.add_column(
+ "tenants",
+ sa.Column("default_locale", sa.String(length=20), nullable=False, server_default="fa-IR"),
+ )
+ op.add_column(
+ "tenants",
+ sa.Column("timezone", sa.String(length=50), nullable=False, server_default="Asia/Tehran"),
+ )
+ op.add_column("tenants", sa.Column("primary_color", sa.String(length=20), nullable=True))
+ op.add_column("tenants", sa.Column("secondary_color", sa.String(length=20), nullable=True))
+ op.add_column("tenants", sa.Column("logo_url", sa.String(length=500), nullable=True))
+ op.add_column("tenants", sa.Column("favicon_url", sa.String(length=500), nullable=True))
+ op.add_column(
+ "tenants",
+ sa.Column(
+ "onboarding_completed", sa.Boolean(), nullable=False, server_default=sa.text("false")
+ ),
+ )
+ op.alter_column("tenants", "default_locale", server_default=None)
+ op.alter_column("tenants", "timezone", server_default=None)
+ op.alter_column("tenants", "onboarding_completed", server_default=None)
+
+ # ---- ۳) وضعیت تأیید دامنه ----
+ domain_verification_status = sa.Enum(
+ "pending", "verified", "failed", name="domain_verification_status"
+ )
+ domain_verification_status.create(bind, checkfirst=True)
+ op.add_column(
+ "domains", sa.Column("is_primary", sa.Boolean(), nullable=False, server_default=sa.text("false"))
+ )
+ op.add_column(
+ "domains",
+ sa.Column(
+ "verification_status",
+ domain_verification_status,
+ nullable=False,
+ server_default="pending",
+ ),
+ )
+ op.execute("UPDATE domains SET verification_status = 'verified' WHERE is_verified = true")
+ op.alter_column("domains", "is_primary", server_default=None)
+ op.alter_column("domains", "verification_status", server_default=None)
+
+ # ---- ۴) tenant انتخابی جاری کاربر ----
+ op.add_column("users", sa.Column("current_tenant_id", sa.UUID(), nullable=True))
+ op.create_foreign_key(
+ "fk_users_current_tenant_id",
+ "users",
+ "tenants",
+ ["current_tenant_id"],
+ ["id"],
+ ondelete="SET NULL",
+ )
+
+ # ---- ۵) جدول tenant_memberships ----
+ op.create_table(
+ "tenant_memberships",
+ sa.Column("id", sa.UUID(), nullable=False),
+ sa.Column("tenant_id", sa.UUID(), nullable=False),
+ sa.Column("user_id", sa.UUID(), nullable=False),
+ sa.Column(
+ "role",
+ sa.Enum(
+ "platform_admin",
+ "tenant_owner",
+ "tenant_admin",
+ "tenant_editor",
+ "tenant_viewer",
+ name="tenant_membership_role",
+ ),
+ nullable=False,
+ ),
+ sa.Column(
+ "status",
+ sa.Enum("active", "invited", "disabled", name="tenant_membership_status"),
+ nullable=False,
+ ),
+ sa.Column("is_owner", sa.Boolean(), nullable=False, server_default=sa.text("false")),
+ sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
+ sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
+ sa.ForeignKeyConstraint(["tenant_id"], ["tenants.id"], ondelete="CASCADE"),
+ sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"),
+ sa.PrimaryKeyConstraint("id"),
+ sa.UniqueConstraint("tenant_id", "user_id", name="uq_tenant_membership_tenant_user"),
+ )
+ op.create_index("ix_tenant_memberships_tenant_id", "tenant_memberships", ["tenant_id"])
+ op.create_index("ix_tenant_memberships_user_id", "tenant_memberships", ["user_id"])
+ op.create_index("ix_tenant_memberships_role", "tenant_memberships", ["role"])
+ op.create_index("ix_tenant_memberships_status", "tenant_memberships", ["status"])
+ op.alter_column("tenant_memberships", "is_owner", server_default=None)
+
+ # ---- ۶) seed پلنهای پیشفرض (idempotent — بر اساس code) ----
+ op.execute(
+ sa.text(
+ """
+ INSERT INTO plans (id, code, name, description, is_active)
+ VALUES (CAST(:id AS uuid), 'FREE', 'رایگان', 'پلن پیشفرض onboarding — بدون هزینه', true)
+ ON CONFLICT (code) DO NOTHING
+ """
+ ).bindparams(id=str(uuid.uuid4()))
+ )
+ op.execute(
+ sa.text(
+ """
+ INSERT INTO plans (id, code, name, description, is_active)
+ VALUES (CAST(:id AS uuid), 'STARTER', 'استارتر', 'پلن مقدماتی برای کسبوکارهای کوچک', true)
+ ON CONFLICT (code) DO NOTHING
+ """
+ ).bindparams(id=str(uuid.uuid4()))
+ )
+
+
+def downgrade() -> None:
+ bind = op.get_bind()
+
+ op.execute("DELETE FROM plans WHERE code IN ('FREE', 'STARTER')")
+
+ op.drop_index("ix_tenant_memberships_status", table_name="tenant_memberships")
+ op.drop_index("ix_tenant_memberships_role", table_name="tenant_memberships")
+ op.drop_index("ix_tenant_memberships_user_id", table_name="tenant_memberships")
+ op.drop_index("ix_tenant_memberships_tenant_id", table_name="tenant_memberships")
+ op.drop_table("tenant_memberships")
+ op.execute("DROP TYPE IF EXISTS tenant_membership_status")
+ op.execute("DROP TYPE IF EXISTS tenant_membership_role")
+
+ op.drop_constraint("fk_users_current_tenant_id", "users", type_="foreignkey")
+ op.drop_column("users", "current_tenant_id")
+
+ op.drop_column("domains", "verification_status")
+ op.drop_column("domains", "is_primary")
+ sa.Enum(name="domain_verification_status").drop(bind, checkfirst=True)
+
+ op.drop_column("tenants", "onboarding_completed")
+ op.drop_column("tenants", "favicon_url")
+ op.drop_column("tenants", "logo_url")
+ op.drop_column("tenants", "secondary_color")
+ op.drop_column("tenants", "primary_color")
+ op.drop_column("tenants", "timezone")
+ op.drop_column("tenants", "default_locale")
+ op.drop_column("tenants", "business_type")
+
+ # توجه: مقادیر جدید tenant_status (draft/pending_activation/archived) در
+ # PostgreSQL قابل حذف مستقیم از نوع enum نیستند؛ در صورت نیاز باید نوع را
+ # بازسازی کرد. در این downgrade نادیده گرفته میشود.
\ No newline at end of file
diff --git a/backend/core-service/app/__init__.py b/backend/core-service/app/__init__.py
new file mode 100644
index 0000000..a41c71b
--- /dev/null
+++ b/backend/core-service/app/__init__.py
@@ -0,0 +1,3 @@
+"""Core Platform Service - بسته اصلی برنامه."""
+
+__version__ = "0.1.0"
diff --git a/backend/core-service/app/api/__init__.py b/backend/core-service/app/api/__init__.py
new file mode 100644
index 0000000..917e81b
--- /dev/null
+++ b/backend/core-service/app/api/__init__.py
@@ -0,0 +1 @@
+"""لایه API."""
diff --git a/backend/core-service/app/api/deps.py b/backend/core-service/app/api/deps.py
new file mode 100644
index 0000000..e27cfde
--- /dev/null
+++ b/backend/core-service/app/api/deps.py
@@ -0,0 +1,106 @@
+"""Dependencyهای مشترک API."""
+from __future__ import annotations
+
+from dataclasses import dataclass
+from uuid import UUID
+
+from fastapi import Depends, Query, Request
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from app.core.database import get_db
+from app.core.security import get_current_user, get_optional_user
+from app.models.user import User
+from app.services.user_service import UserService
+from shared.exceptions import TenantNotResolvedError
+from shared.pagination import PaginationParams
+from shared.security import CurrentUser
+
+__all__ = [
+ "get_db",
+ "get_pagination",
+ "require_tenant",
+ "get_current_core_user",
+ "get_optional_core_user",
+ "TenantResolution",
+ "get_tenant_resolution",
+ "AsyncSession",
+]
+
+
+def get_pagination(
+ page: int = Query(default=1, ge=1),
+ page_size: int = Query(default=20, ge=1, le=100),
+) -> PaginationParams:
+ return PaginationParams(page=page, page_size=page_size)
+
+
+def require_tenant(request: Request) -> UUID:
+ """اطمینان از resolve شدن tenant برای endpointهای tenant-aware.
+
+ این dependency مقدار tenant_id را از request.state (که middleware پر
+ کرده) میخواند و در نبود آن خطای مناسب برمیگرداند.
+ """
+ tenant_id = getattr(request.state, "tenant_id", None)
+ if tenant_id is None:
+ raise TenantNotResolvedError(
+ "tenant قابل تشخیص نبود. یکی از هدرهای X-Tenant-ID یا "
+ "X-Tenant-Slug را ارسال کنید یا از دامنه معتبر استفاده نمایید."
+ )
+ return tenant_id
+
+
+async def get_current_core_user(
+ db: AsyncSession = Depends(get_db),
+ user: CurrentUser = Depends(get_current_user),
+) -> User:
+ """کاربر Core متناظر با هویت جاری (JWT محلی یا Keycloak SSO).
+
+ برای endpointهای onboarding/tenant-context استفاده میشود که نیاز به
+ رکورد واقعی users دارند (نه فقط claims توکن).
+ """
+ return await UserService(db).resolve_current(user)
+
+
+async def get_optional_core_user(
+ db: AsyncSession = Depends(get_db),
+ user: CurrentUser | None = Depends(get_optional_user),
+) -> User | None:
+ if user is None:
+ return None
+ try:
+ return await UserService(db).resolve_current(user)
+ except Exception: # noqa: BLE001 - عدم لینک بودن یعنی کاربر Core ندارد
+ return None
+
+
+@dataclass(frozen=True)
+class TenantResolution:
+ """نتیجه تشخیص tenant جاری request (هدر/دامنه/tenant انتخابی کاربر)."""
+
+ tenant_id: UUID | None
+ tenant_slug: str | None
+ source: str # "header" | "domain" | "user_default" | "none"
+
+
+async def get_tenant_resolution(
+ request: Request,
+ core_user: User | None = Depends(get_optional_core_user),
+) -> TenantResolution:
+ """تشخیص tenant جاری: اول هدر/دامنه (middleware)، سپس tenant انتخابی کاربر.
+
+ این dependency جایگزین middleware نمیشود و endpointهای platform-admin
+ فعلی را تحت تأثیر قرار نمیدهد (صرفاً یک ابزار کمکی جدید است).
+ """
+ tenant_id = getattr(request.state, "tenant_id", None)
+ tenant_slug = getattr(request.state, "tenant_slug", None)
+ if tenant_id is not None:
+ return TenantResolution(tenant_id=tenant_id, tenant_slug=tenant_slug, source="header")
+
+ if core_user is not None and core_user.current_tenant_id is not None:
+ return TenantResolution(
+ tenant_id=core_user.current_tenant_id,
+ tenant_slug=None,
+ source="user_default",
+ )
+
+ return TenantResolution(tenant_id=None, tenant_slug=None, source="none")
diff --git a/backend/core-service/app/api/v1/__init__.py b/backend/core-service/app/api/v1/__init__.py
new file mode 100644
index 0000000..706ea82
--- /dev/null
+++ b/backend/core-service/app/api/v1/__init__.py
@@ -0,0 +1,34 @@
+"""API نسخه ۱: تجمیع routerها."""
+from fastapi import APIRouter
+
+from app.api.v1 import (
+ auth,
+ domains,
+ features,
+ health,
+ me,
+ onboarding,
+ plans,
+ service_registry,
+ subscriptions,
+ tenant_context,
+ tenants,
+)
+from app.api.v1.admin import tenants as admin_tenants
+
+api_router = APIRouter()
+
+# health خارج از prefix نسخه هم در main اضافه میشود؛ اینجا برای کامل بودن.
+api_router.include_router(auth.router)
+api_router.include_router(admin_tenants.router)
+api_router.include_router(tenants.router)
+api_router.include_router(domains.router)
+api_router.include_router(plans.router)
+api_router.include_router(features.router)
+api_router.include_router(subscriptions.router)
+api_router.include_router(service_registry.router)
+api_router.include_router(me.router)
+api_router.include_router(onboarding.router)
+api_router.include_router(tenant_context.router)
+
+__all__ = ["api_router", "health"]
diff --git a/backend/core-service/app/api/v1/admin/__init__.py b/backend/core-service/app/api/v1/admin/__init__.py
new file mode 100644
index 0000000..513f75c
--- /dev/null
+++ b/backend/core-service/app/api/v1/admin/__init__.py
@@ -0,0 +1,4 @@
+"""Routerهای پنل ادمین."""
+from app.api.v1.admin import tenants
+
+__all__ = ["tenants"]
diff --git a/backend/core-service/app/api/v1/admin/tenants.py b/backend/core-service/app/api/v1/admin/tenants.py
new file mode 100644
index 0000000..dc0ff44
--- /dev/null
+++ b/backend/core-service/app/api/v1/admin/tenants.py
@@ -0,0 +1,63 @@
+"""APIهای مدیریت Tenant برای پنل ادمین."""
+from __future__ import annotations
+
+from uuid import UUID
+
+from fastapi import APIRouter, Depends, status
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from app.api.deps import get_db, get_pagination
+from app.core.security import require_authenticated
+from app.schemas.tenant import AdminTenantCreate, TenantRead, TenantUpdate
+from app.services.tenant_service import TenantService
+from shared.pagination import Page, PaginationParams
+from shared.security import CurrentUser
+
+router = APIRouter(prefix="/admin/tenants", tags=["admin-tenants"])
+
+
+@router.post("", response_model=TenantRead, status_code=status.HTTP_201_CREATED)
+async def create_tenant(
+ payload: AdminTenantCreate,
+ db: AsyncSession = Depends(get_db),
+ user: CurrentUser = Depends(require_authenticated),
+) -> TenantRead:
+ tenant = await TenantService(db).create_for_user(user, payload)
+ return TenantRead.model_validate(tenant)
+
+
+@router.get("", response_model=Page[TenantRead])
+async def list_tenants(
+ pagination: PaginationParams = Depends(get_pagination),
+ db: AsyncSession = Depends(get_db),
+ user: CurrentUser = Depends(require_authenticated),
+) -> Page[TenantRead]:
+ items, total = await TenantService(db).list_for_user(
+ user, offset=pagination.offset, limit=pagination.limit
+ )
+ return Page.create(
+ [TenantRead.model_validate(t) for t in items], total, pagination
+ )
+
+
+@router.get("/{tenant_id}", response_model=TenantRead)
+async def get_tenant(
+ tenant_id: UUID,
+ db: AsyncSession = Depends(get_db),
+ user: CurrentUser = Depends(require_authenticated),
+) -> TenantRead:
+ svc = TenantService(db)
+ tenant = await svc.get(tenant_id)
+ svc.ensure_access(tenant, user)
+ return TenantRead.model_validate(tenant)
+
+
+@router.patch("/{tenant_id}", response_model=TenantRead)
+async def update_tenant(
+ tenant_id: UUID,
+ payload: TenantUpdate,
+ db: AsyncSession = Depends(get_db),
+ user: CurrentUser = Depends(require_authenticated),
+) -> TenantRead:
+ tenant = await TenantService(db).update(tenant_id, payload, user=user)
+ return TenantRead.model_validate(tenant)
diff --git a/backend/core-service/app/api/v1/auth.py b/backend/core-service/app/api/v1/auth.py
new file mode 100644
index 0000000..11e0693
--- /dev/null
+++ b/backend/core-service/app/api/v1/auth.py
@@ -0,0 +1,32 @@
+"""APIهای احراز هویت OTP."""
+from __future__ import annotations
+
+from fastapi import APIRouter, Depends
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from app.api.deps import get_db
+from app.schemas.auth import (
+ OtpRequestPayload,
+ OtpRequestResponse,
+ OtpVerifyPayload,
+ TokenResponse,
+)
+from app.services.auth_service import AuthService
+
+router = APIRouter(prefix="/auth", tags=["auth"])
+
+
+@router.post("/otp/request", response_model=OtpRequestResponse)
+async def request_otp(
+ payload: OtpRequestPayload,
+ db: AsyncSession = Depends(get_db),
+) -> OtpRequestResponse:
+ return await AuthService(db).request_otp(payload.mobile, force_sms=payload.force_sms, context=payload.context)
+
+
+@router.post("/otp/verify", response_model=TokenResponse)
+async def verify_otp(
+ payload: OtpVerifyPayload,
+ db: AsyncSession = Depends(get_db),
+) -> TokenResponse:
+ return await AuthService(db).verify_otp(payload)
diff --git a/backend/core-service/app/api/v1/domains.py b/backend/core-service/app/api/v1/domains.py
new file mode 100644
index 0000000..cfd4f12
--- /dev/null
+++ b/backend/core-service/app/api/v1/domains.py
@@ -0,0 +1,59 @@
+"""APIهای مدیریت و resolve دامنه."""
+from __future__ import annotations
+
+from uuid import UUID
+
+from fastapi import APIRouter, Depends, status
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from app.api.deps import get_db
+from app.core.security import require_authenticated, require_tenant_admin
+from app.schemas.domain import (
+ DomainCreate,
+ DomainRead,
+ DomainResolveRequest,
+ DomainResolveResponse,
+)
+from app.services.domain_service import DomainService
+from app.services.tenant_service import TenantService
+
+router = APIRouter(tags=["domains"])
+
+
+@router.post(
+ "/tenants/{tenant_id}/domains",
+ response_model=DomainRead,
+ status_code=status.HTTP_201_CREATED,
+)
+async def create_domain(
+ tenant_id: UUID,
+ payload: DomainCreate,
+ db: AsyncSession = Depends(get_db),
+ _user=Depends(require_tenant_admin),
+) -> DomainRead:
+ domain = await DomainService(db).create(tenant_id, payload)
+ return DomainRead.model_validate(domain)
+
+
+@router.get("/tenants/{tenant_id}/domains", response_model=list[DomainRead])
+async def list_domains(
+ tenant_id: UUID,
+ db: AsyncSession = Depends(get_db),
+ _user=Depends(require_authenticated),
+) -> list[DomainRead]:
+ domains = await DomainService(db).list_by_tenant(tenant_id)
+ return [DomainRead.model_validate(d) for d in domains]
+
+
+@router.post("/domains/resolve", response_model=DomainResolveResponse)
+async def resolve_domain(
+ payload: DomainResolveRequest, db: AsyncSession = Depends(get_db)
+) -> DomainResolveResponse:
+ domain = await DomainService(db).resolve(payload.domain)
+ tenant = await TenantService(db).get(domain.tenant_id)
+ return DomainResolveResponse(
+ tenant_id=tenant.id,
+ tenant_slug=tenant.slug,
+ domain=domain.domain,
+ is_verified=domain.is_verified,
+ )
diff --git a/backend/core-service/app/api/v1/features.py b/backend/core-service/app/api/v1/features.py
new file mode 100644
index 0000000..ce58b58
--- /dev/null
+++ b/backend/core-service/app/api/v1/features.py
@@ -0,0 +1,37 @@
+"""APIهای مدیریت قابلیتها (Features)."""
+from __future__ import annotations
+
+from fastapi import APIRouter, Depends, status
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from app.api.deps import get_db, get_pagination
+from app.core.security import require_authenticated, require_platform_admin
+from app.schemas.plan import FeatureCreate, FeatureRead
+from app.services.plan_service import PlanService
+from shared.pagination import Page, PaginationParams
+
+router = APIRouter(prefix="/features", tags=["features"])
+
+
+@router.post("", response_model=FeatureRead, status_code=status.HTTP_201_CREATED)
+async def create_feature(
+ payload: FeatureCreate,
+ db: AsyncSession = Depends(get_db),
+ _user=Depends(require_platform_admin),
+) -> FeatureRead:
+ feature = await PlanService(db).create_feature(payload)
+ return FeatureRead.model_validate(feature)
+
+
+@router.get("", response_model=Page[FeatureRead])
+async def list_features(
+ pagination: PaginationParams = Depends(get_pagination),
+ db: AsyncSession = Depends(get_db),
+ _user=Depends(require_authenticated),
+) -> Page[FeatureRead]:
+ items, total = await PlanService(db).list_features(
+ offset=pagination.offset, limit=pagination.limit
+ )
+ return Page.create(
+ [FeatureRead.model_validate(f) for f in items], total, pagination
+ )
diff --git a/backend/core-service/app/api/v1/health.py b/backend/core-service/app/api/v1/health.py
new file mode 100644
index 0000000..3f42679
--- /dev/null
+++ b/backend/core-service/app/api/v1/health.py
@@ -0,0 +1,24 @@
+"""Endpoint سلامت سرویس."""
+from __future__ import annotations
+
+from fastapi import APIRouter
+
+from app import __version__
+from app.core.cache import cache
+from app.core.config import settings
+from app.schemas.common import HealthResponse
+
+router = APIRouter(tags=["health"])
+
+
+@router.get("/health", response_model=HealthResponse)
+async def health() -> HealthResponse:
+ """بررسی سلامت سرویس و وابستگیها (Redis)."""
+ redis_ok = await cache.ping()
+ return HealthResponse(
+ status="ok",
+ service=settings.service_name,
+ version=__version__,
+ environment=settings.environment,
+ dependencies={"redis": "ok" if redis_ok else "unavailable"},
+ )
diff --git a/backend/core-service/app/api/v1/me.py b/backend/core-service/app/api/v1/me.py
new file mode 100644
index 0000000..3546854
--- /dev/null
+++ b/backend/core-service/app/api/v1/me.py
@@ -0,0 +1,46 @@
+"""API `/me`: هویت جاری، عضویتها و نیاز به onboarding."""
+from __future__ import annotations
+
+from fastapi import APIRouter, Depends
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from app.api.deps import get_current_core_user, get_db
+from app.models.user import User
+from app.schemas.onboarding import MembershipSummary, MeResponse
+from app.services.tenant_context_service import TenantContextService
+
+router = APIRouter(prefix="/me", tags=["me"])
+
+
+@router.get("", response_model=MeResponse)
+async def get_me(
+ db: AsyncSession = Depends(get_db),
+ core_user: User = Depends(get_current_core_user),
+) -> MeResponse:
+ memberships = await TenantContextService(db).list_memberships_summary(core_user)
+
+ onboarding_required = True
+ if memberships:
+ current = next(
+ (m for m in memberships if m.tenant_id == core_user.current_tenant_id),
+ memberships[0],
+ )
+ onboarding_required = not current.onboarding_completed
+
+ return MeResponse(
+ user_id=core_user.id,
+ mobile=core_user.mobile,
+ email=core_user.email,
+ platform_role=core_user.role.value,
+ onboarding_required=onboarding_required,
+ current_tenant_id=core_user.current_tenant_id,
+ memberships=memberships,
+ )
+
+
+@router.get("/tenants", response_model=list[MembershipSummary])
+async def get_my_tenants(
+ db: AsyncSession = Depends(get_db),
+ core_user: User = Depends(get_current_core_user),
+) -> list[MembershipSummary]:
+ return await TenantContextService(db).list_memberships_summary(core_user)
diff --git a/backend/core-service/app/api/v1/onboarding.py b/backend/core-service/app/api/v1/onboarding.py
new file mode 100644
index 0000000..bf356db
--- /dev/null
+++ b/backend/core-service/app/api/v1/onboarding.py
@@ -0,0 +1,77 @@
+"""APIهای Onboarding: ایجاد و فعالسازی tenant توسط کاربر جاری."""
+from __future__ import annotations
+
+from uuid import UUID
+
+from fastapi import APIRouter, Depends, status
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from app.api.deps import get_current_core_user, get_db
+from app.core.security import get_current_user
+from app.models.user import User
+from app.schemas.onboarding import (
+ OnboardingBrandingUpdate,
+ OnboardingDomainUpdate,
+ OnboardingTenantCreate,
+ TenantContextRead,
+)
+from app.services.membership_service import MembershipService
+from app.services.onboarding_service import OnboardingService
+from app.services.tenant_context_service import TenantContextService
+from shared.security import CurrentUser
+
+router = APIRouter(prefix="/onboarding/tenant", tags=["onboarding"])
+
+
+@router.post("", response_model=TenantContextRead, status_code=status.HTTP_201_CREATED)
+async def create_onboarding_tenant(
+ payload: OnboardingTenantCreate,
+ db: AsyncSession = Depends(get_db),
+ core_user: User = Depends(get_current_core_user),
+) -> TenantContextRead:
+ tenant = await OnboardingService(db).create_tenant(core_user, payload)
+ return await TenantContextService(db).build(tenant, core_user)
+
+
+@router.patch("/{tenant_id}/branding", response_model=TenantContextRead)
+async def update_onboarding_branding(
+ tenant_id: UUID,
+ payload: OnboardingBrandingUpdate,
+ db: AsyncSession = Depends(get_db),
+ user: CurrentUser = Depends(get_current_user),
+ core_user: User = Depends(get_current_core_user),
+) -> TenantContextRead:
+ await MembershipService(db).ensure_role(
+ tenant_id=tenant_id, core_user_id=core_user.id, current_user=user
+ )
+ tenant = await OnboardingService(db).update_branding(tenant_id, payload)
+ return await TenantContextService(db).build(tenant, core_user)
+
+
+@router.patch("/{tenant_id}/domain", response_model=TenantContextRead)
+async def update_onboarding_domain(
+ tenant_id: UUID,
+ payload: OnboardingDomainUpdate,
+ db: AsyncSession = Depends(get_db),
+ user: CurrentUser = Depends(get_current_user),
+ core_user: User = Depends(get_current_core_user),
+) -> TenantContextRead:
+ await MembershipService(db).ensure_role(
+ tenant_id=tenant_id, core_user_id=core_user.id, current_user=user
+ )
+ tenant = await OnboardingService(db).update_domain(tenant_id, payload)
+ return await TenantContextService(db).build(tenant, core_user)
+
+
+@router.post("/{tenant_id}/complete", response_model=TenantContextRead)
+async def complete_onboarding(
+ tenant_id: UUID,
+ db: AsyncSession = Depends(get_db),
+ user: CurrentUser = Depends(get_current_user),
+ core_user: User = Depends(get_current_core_user),
+) -> TenantContextRead:
+ await MembershipService(db).ensure_role(
+ tenant_id=tenant_id, core_user_id=core_user.id, current_user=user
+ )
+ tenant = await OnboardingService(db).complete(tenant_id)
+ return await TenantContextService(db).build(tenant, core_user)
diff --git a/backend/core-service/app/api/v1/plans.py b/backend/core-service/app/api/v1/plans.py
new file mode 100644
index 0000000..560cc04
--- /dev/null
+++ b/backend/core-service/app/api/v1/plans.py
@@ -0,0 +1,57 @@
+"""APIهای مدیریت پلنها و اتصال قابلیت به پلن."""
+from __future__ import annotations
+
+from uuid import UUID
+
+from fastapi import APIRouter, Depends, status
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from app.api.deps import get_db, get_pagination
+from app.core.security import require_authenticated, require_platform_admin
+from app.schemas.plan import (
+ PlanCreate,
+ PlanFeatureCreate,
+ PlanFeatureRead,
+ PlanRead,
+)
+from app.services.plan_service import PlanService
+from shared.pagination import Page, PaginationParams
+
+router = APIRouter(prefix="/plans", tags=["plans"])
+
+
+@router.post("", response_model=PlanRead, status_code=status.HTTP_201_CREATED)
+async def create_plan(
+ payload: PlanCreate,
+ db: AsyncSession = Depends(get_db),
+ _user=Depends(require_platform_admin),
+) -> PlanRead:
+ plan = await PlanService(db).create_plan(payload)
+ return PlanRead.model_validate(plan)
+
+
+@router.get("", response_model=Page[PlanRead])
+async def list_plans(
+ pagination: PaginationParams = Depends(get_pagination),
+ db: AsyncSession = Depends(get_db),
+ _user=Depends(require_authenticated),
+) -> Page[PlanRead]:
+ items, total = await PlanService(db).list_plans(
+ offset=pagination.offset, limit=pagination.limit
+ )
+ return Page.create([PlanRead.model_validate(p) for p in items], total, pagination)
+
+
+@router.post(
+ "/{plan_id}/features",
+ response_model=PlanFeatureRead,
+ status_code=status.HTTP_201_CREATED,
+)
+async def attach_feature_to_plan(
+ plan_id: UUID,
+ payload: PlanFeatureCreate,
+ db: AsyncSession = Depends(get_db),
+ _user=Depends(require_platform_admin),
+) -> PlanFeatureRead:
+ plan_feature = await PlanService(db).attach_feature(plan_id, payload)
+ return PlanFeatureRead.model_validate(plan_feature)
diff --git a/backend/core-service/app/api/v1/service_registry.py b/backend/core-service/app/api/v1/service_registry.py
new file mode 100644
index 0000000..3346dca
--- /dev/null
+++ b/backend/core-service/app/api/v1/service_registry.py
@@ -0,0 +1,54 @@
+"""APIهای ServiceRegistry."""
+from __future__ import annotations
+
+from uuid import UUID
+
+from fastapi import APIRouter, Depends, status
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from app.api.deps import get_db, get_pagination
+from app.core.security import require_authenticated, require_platform_admin
+from app.schemas.service_registry import (
+ ServiceCreate,
+ ServiceRead,
+ ServiceStatusUpdate,
+)
+from app.services.service_registry_service import ServiceRegistryService
+from shared.pagination import Page, PaginationParams
+
+router = APIRouter(prefix="/services", tags=["service-registry"])
+
+
+@router.post("", response_model=ServiceRead, status_code=status.HTTP_201_CREATED)
+async def create_service(
+ payload: ServiceCreate,
+ db: AsyncSession = Depends(get_db),
+ _user=Depends(require_platform_admin),
+) -> ServiceRead:
+ service = await ServiceRegistryService(db).create(payload)
+ return ServiceRead.model_validate(service)
+
+
+@router.get("", response_model=Page[ServiceRead])
+async def list_services(
+ pagination: PaginationParams = Depends(get_pagination),
+ db: AsyncSession = Depends(get_db),
+ _user=Depends(require_authenticated),
+) -> Page[ServiceRead]:
+ items, total = await ServiceRegistryService(db).list(
+ offset=pagination.offset, limit=pagination.limit
+ )
+ return Page.create(
+ [ServiceRead.model_validate(s) for s in items], total, pagination
+ )
+
+
+@router.patch("/{service_id}/status", response_model=ServiceRead)
+async def update_service_status(
+ service_id: UUID,
+ payload: ServiceStatusUpdate,
+ db: AsyncSession = Depends(get_db),
+ _user=Depends(require_platform_admin),
+) -> ServiceRead:
+ service = await ServiceRegistryService(db).update_status(service_id, payload.status)
+ return ServiceRead.model_validate(service)
diff --git a/backend/core-service/app/api/v1/subscriptions.py b/backend/core-service/app/api/v1/subscriptions.py
new file mode 100644
index 0000000..4ed97cd
--- /dev/null
+++ b/backend/core-service/app/api/v1/subscriptions.py
@@ -0,0 +1,61 @@
+"""APIهای اشتراک و بررسی دسترسی قابلیت."""
+from __future__ import annotations
+
+from uuid import UUID
+
+from fastapi import APIRouter, Depends, status
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from app.api.deps import get_db
+from app.core.security import require_authenticated, require_tenant_admin
+from app.schemas.subscription import (
+ FeatureCheckRequest,
+ FeatureCheckResponse,
+ SubscriptionCreate,
+ SubscriptionRead,
+)
+from app.services.entitlement_service import EntitlementService
+from app.services.subscription_service import SubscriptionService
+
+router = APIRouter(prefix="/tenants/{tenant_id}", tags=["subscription"])
+
+
+@router.post(
+ "/subscription",
+ response_model=SubscriptionRead,
+ status_code=status.HTTP_201_CREATED,
+)
+async def create_subscription(
+ tenant_id: UUID,
+ payload: SubscriptionCreate,
+ db: AsyncSession = Depends(get_db),
+ _user=Depends(require_tenant_admin),
+) -> SubscriptionRead:
+ subscription = await SubscriptionService(db).create(tenant_id, payload)
+ return SubscriptionRead.model_validate(subscription)
+
+
+@router.get("/subscription", response_model=SubscriptionRead)
+async def get_subscription(
+ tenant_id: UUID,
+ db: AsyncSession = Depends(get_db),
+ _user=Depends(require_authenticated),
+) -> SubscriptionRead:
+ subscription = await SubscriptionService(db).get_current(tenant_id)
+ return SubscriptionRead.model_validate(subscription)
+
+
+@router.post("/features/check", response_model=FeatureCheckResponse)
+async def check_feature(
+ tenant_id: UUID,
+ payload: FeatureCheckRequest,
+ db: AsyncSession = Depends(get_db),
+ _user=Depends(require_authenticated),
+) -> FeatureCheckResponse:
+ result = await EntitlementService(db).evaluate(tenant_id, payload.feature_key)
+ return FeatureCheckResponse(
+ tenant_id=tenant_id,
+ feature_key=payload.feature_key,
+ has_access=result.has_access,
+ reason=result.reason,
+ )
diff --git a/backend/core-service/app/api/v1/tenant_context.py b/backend/core-service/app/api/v1/tenant_context.py
new file mode 100644
index 0000000..de01c06
--- /dev/null
+++ b/backend/core-service/app/api/v1/tenant_context.py
@@ -0,0 +1,46 @@
+"""APIهای Tenant Context جاری: `/tenant/current` و `/tenant/switch`."""
+from __future__ import annotations
+
+from fastapi import APIRouter, Depends
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from app.api.deps import get_current_core_user, get_db
+from app.models.enums import MembershipStatus
+from app.models.user import User
+from app.repositories.tenant import TenantRepository
+from app.schemas.onboarding import TenantContextRead, TenantSwitchRequest
+from app.services.membership_service import MembershipService
+from app.services.tenant_context_service import TenantContextService
+from shared.exceptions import ForbiddenError, NotFoundError
+
+router = APIRouter(prefix="/tenant", tags=["tenant-context"])
+
+
+@router.get("/current", response_model=TenantContextRead)
+async def get_current_tenant(
+ db: AsyncSession = Depends(get_db),
+ core_user: User = Depends(get_current_core_user),
+) -> TenantContextRead:
+ ctx_service = TenantContextService(db)
+ tenant = await ctx_service.resolve_current_tenant(core_user)
+ return await ctx_service.build(tenant, core_user)
+
+
+@router.post("/switch", response_model=TenantContextRead)
+async def switch_tenant(
+ payload: TenantSwitchRequest,
+ db: AsyncSession = Depends(get_db),
+ core_user: User = Depends(get_current_core_user),
+) -> TenantContextRead:
+ membership = await MembershipService(db).get(payload.tenant_id, core_user.id)
+ if membership is None or membership.status != MembershipStatus.ACTIVE:
+ raise ForbiddenError("عضو این tenant نیستید یا عضویت غیرفعال است")
+
+ core_user.current_tenant_id = payload.tenant_id
+ await db.commit()
+
+ tenant = await TenantRepository(db).get(payload.tenant_id)
+ if tenant is None:
+ raise NotFoundError(f"tenant یافت نشد: {payload.tenant_id}")
+ ctx_service = TenantContextService(db)
+ return await ctx_service.build(tenant, core_user)
diff --git a/backend/core-service/app/api/v1/tenants.py b/backend/core-service/app/api/v1/tenants.py
new file mode 100644
index 0000000..667b62d
--- /dev/null
+++ b/backend/core-service/app/api/v1/tenants.py
@@ -0,0 +1,80 @@
+"""APIهای مدیریت Tenant."""
+from __future__ import annotations
+
+from uuid import UUID
+
+from fastapi import APIRouter, Depends, status
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from app.api.deps import get_db, get_pagination
+from app.core.security import require_authenticated, require_platform_admin, require_tenant_admin
+from app.schemas.tenant import TenantCreate, TenantRead, TenantUpdate
+from app.services.tenant_service import TenantService
+from shared.pagination import Page, PaginationParams
+
+router = APIRouter(prefix="/tenants", tags=["tenants"])
+
+
+@router.post("", response_model=TenantRead, status_code=status.HTTP_201_CREATED)
+async def create_tenant(
+ payload: TenantCreate,
+ db: AsyncSession = Depends(get_db),
+ _user=Depends(require_platform_admin),
+) -> TenantRead:
+ tenant = await TenantService(db).create(payload)
+ return TenantRead.model_validate(tenant)
+
+
+@router.get("", response_model=Page[TenantRead])
+async def list_tenants(
+ pagination: PaginationParams = Depends(get_pagination),
+ db: AsyncSession = Depends(get_db),
+ _user=Depends(require_authenticated),
+) -> Page[TenantRead]:
+ items, total = await TenantService(db).list(
+ offset=pagination.offset, limit=pagination.limit
+ )
+ return Page.create(
+ [TenantRead.model_validate(t) for t in items], total, pagination
+ )
+
+
+@router.get("/{tenant_id}", response_model=TenantRead)
+async def get_tenant(
+ tenant_id: UUID,
+ db: AsyncSession = Depends(get_db),
+ _user=Depends(require_authenticated),
+) -> TenantRead:
+ tenant = await TenantService(db).get(tenant_id)
+ return TenantRead.model_validate(tenant)
+
+
+@router.patch("/{tenant_id}", response_model=TenantRead)
+async def update_tenant(
+ tenant_id: UUID,
+ payload: TenantUpdate,
+ db: AsyncSession = Depends(get_db),
+ _user=Depends(require_tenant_admin),
+) -> TenantRead:
+ tenant = await TenantService(db).update(tenant_id, payload)
+ return TenantRead.model_validate(tenant)
+
+
+@router.post("/{tenant_id}/suspend", response_model=TenantRead)
+async def suspend_tenant(
+ tenant_id: UUID,
+ db: AsyncSession = Depends(get_db),
+ _user=Depends(require_platform_admin),
+) -> TenantRead:
+ tenant = await TenantService(db).suspend(tenant_id)
+ return TenantRead.model_validate(tenant)
+
+
+@router.post("/{tenant_id}/activate", response_model=TenantRead)
+async def activate_tenant(
+ tenant_id: UUID,
+ db: AsyncSession = Depends(get_db),
+ _user=Depends(require_platform_admin),
+) -> TenantRead:
+ tenant = await TenantService(db).activate(tenant_id)
+ return TenantRead.model_validate(tenant)
diff --git a/backend/core-service/app/core/__init__.py b/backend/core-service/app/core/__init__.py
new file mode 100644
index 0000000..637d092
--- /dev/null
+++ b/backend/core-service/app/core/__init__.py
@@ -0,0 +1 @@
+"""زیرساخت هسته: config، database، cache، security، logging."""
diff --git a/backend/core-service/app/core/cache.py b/backend/core-service/app/core/cache.py
new file mode 100644
index 0000000..f8d77f9
--- /dev/null
+++ b/backend/core-service/app/core/cache.py
@@ -0,0 +1,129 @@
+"""لایه Cache مبتنی بر Redis (async).
+
+اگر Redis در دسترس نباشد (مثلاً در تستها)، عملیات cache بهصورت
+graceful نادیده گرفته میشوند تا برنامه از کار نیفتد.
+
+کلیدهای پیشنهادی:
+ tenant:{tenant_id}:metadata
+ tenant:{tenant_id}:features
+ feature_access:{tenant_id}:{feature_key}
+"""
+from __future__ import annotations
+
+from typing import Any
+
+import redis.asyncio as aioredis
+
+from app.core.config import settings
+from app.core.logging import get_logger
+
+logger = get_logger(__name__)
+
+
+class CacheClient:
+ """پوشش نازک روی redis.asyncio با تحمل خطا (fail-open)."""
+
+ def __init__(self, url: str) -> None:
+ self._url = url
+ self._client: aioredis.Redis | None = None
+ self._enabled = True
+
+ @property
+ def client(self) -> aioredis.Redis | None:
+ if not self._enabled:
+ return None
+ if self._client is None:
+ self._client = aioredis.from_url(
+ self._url, encoding="utf-8", decode_responses=True
+ )
+ return self._client
+
+ async def get(self, key: str) -> str | None:
+ client = self.client
+ if client is None:
+ return None
+ try:
+ return await client.get(key)
+ except Exception as exc: # pragma: no cover - وابسته به محیط
+ logger.warning("cache_get_failed", extra={"key": key, "error": str(exc)})
+ return None
+
+ async def set(self, key: str, value: str, ttl: int | None = None) -> None:
+ client = self.client
+ if client is None:
+ return
+ try:
+ await client.set(key, value, ex=ttl)
+ except Exception as exc: # pragma: no cover
+ logger.warning("cache_set_failed", extra={"key": key, "error": str(exc)})
+
+ async def ttl(self, key: str) -> int | None:
+ """ثانیه باقیمانده TTL — None اگر کلید نیست یا Redis غیرفعال."""
+ client = self.client
+ if client is None:
+ return None
+ try:
+ remaining = await client.ttl(key)
+ if remaining is None or remaining < 0:
+ return None
+ return int(remaining)
+ except Exception as exc: # pragma: no cover
+ logger.warning("cache_ttl_failed", extra={"key": key, "error": str(exc)})
+ return None
+
+ async def delete(self, *keys: str) -> None:
+ client = self.client
+ if client is None or not keys:
+ return
+ try:
+ await client.delete(*keys)
+ except Exception as exc: # pragma: no cover
+ logger.warning("cache_delete_failed", extra={"keys": keys, "error": str(exc)})
+
+ async def delete_pattern(self, pattern: str) -> None:
+ """حذف کلیدها بر اساس الگو (برای invalidate کردن گروهی)."""
+ client = self.client
+ if client is None:
+ return
+ try:
+ async for key in client.scan_iter(match=pattern):
+ await client.delete(key)
+ except Exception as exc: # pragma: no cover
+ logger.warning("cache_delete_pattern_failed", extra={"pattern": pattern, "error": str(exc)})
+
+ async def ping(self) -> bool:
+ client = self.client
+ if client is None:
+ return False
+ try:
+ return bool(await client.ping())
+ except Exception: # pragma: no cover
+ return False
+
+ async def close(self) -> None:
+ if self._client is not None:
+ try:
+ await self._client.aclose()
+ except Exception: # pragma: no cover
+ pass
+ self._client = None
+
+ def disable(self) -> None:
+ """غیرفعالسازی cache (مثلاً در تستها)."""
+ self._enabled = False
+
+
+# ---- کمککنندههای ساخت کلید (تا نام کلیدها یکسان بماند) ----
+def tenant_metadata_key(tenant_id: str) -> str:
+ return f"tenant:{tenant_id}:metadata"
+
+
+def tenant_features_key(tenant_id: str) -> str:
+ return f"tenant:{tenant_id}:features"
+
+
+def feature_access_key(tenant_id: str, feature_key: str) -> str:
+ return f"feature_access:{tenant_id}:{feature_key}"
+
+
+cache = CacheClient(settings.redis_url)
diff --git a/backend/core-service/app/core/config.py b/backend/core-service/app/core/config.py
new file mode 100644
index 0000000..6f2afd2
--- /dev/null
+++ b/backend/core-service/app/core/config.py
@@ -0,0 +1,154 @@
+"""تنظیمات مرکزی برنامه با Pydantic v2 Settings.
+
+همه مقادیر از متغیرهای محیطی (env) خوانده میشوند تا هیچ چیز در کد
+hardcode نشود (نام برند، رنگها، آدرسها و ...).
+"""
+from __future__ import annotations
+
+from functools import lru_cache
+from typing import Literal
+
+from pydantic import Field
+from pydantic_settings import BaseSettings, SettingsConfigDict
+
+
+class Settings(BaseSettings):
+ model_config = SettingsConfigDict(
+ env_file=".env",
+ env_file_encoding="utf-8",
+ case_sensitive=False,
+ extra="ignore",
+ )
+
+ # ---- General ----
+ environment: Literal["development", "staging", "production", "test"] = "development"
+ debug: bool = True
+ log_level: str = "INFO"
+ service_name: str = "core-service"
+ api_v1_prefix: str = "/api/v1"
+
+ # ---- Platform Branding (White-label) ----
+ platform_name: str = "SuperApp"
+ platform_support_email: str = "support@example.com"
+ platform_primary_color: str = "#0284c7"
+ platform_secondary_color: str = "#0f172a"
+ # دامنه پایه برای تخصیص زیردامنه خودکار در onboarding (مثلاً tenant.slug + "." + این مقدار)
+ platform_base_domain: str = Field(default="", validation_alias="PLATFORM_BASE_DOMAIN")
+
+ # ---- Database ----
+ # آدرس async برای برنامه و sync برای Alembic.
+ database_url: str = "postgresql+asyncpg://superapp:superapp_password@localhost:5432/core_platform_db"
+ database_url_sync: str = "postgresql+psycopg://superapp:superapp_password@localhost:5432/core_platform_db"
+ db_echo: bool = False
+ db_pool_size: int = 10
+ db_max_overflow: int = 20
+
+ # ---- Redis ----
+ redis_url: str = "redis://localhost:6379/0"
+ feature_access_cache_ttl: int = 300
+ tenant_metadata_cache_ttl: int = 300
+
+ # ---- Celery ----
+ celery_broker_url: str = "redis://localhost:6379/1"
+ celery_result_backend: str = "redis://localhost:6379/2"
+
+ # ---- Keycloak / SSO ----
+ keycloak_enabled: bool = False
+ keycloak_server_url: str = "http://localhost:8080"
+ keycloak_public_url: str = Field(default="", validation_alias="KEYCLOAK_PUBLIC_URL")
+ keycloak_realm: str = "superapp"
+ keycloak_client_id: str = "core-service"
+ keycloak_client_secret: str = "change-me"
+ jwt_algorithm: str = "RS256"
+ jwt_audience: str = "account"
+ jwt_verify_signature: bool = False
+ # اگر true باشد همه endpointهای محافظتشده نیازمند JWT معتبر هستند.
+ auth_required: bool = False
+ cors_origins: str = Field(
+ default="http://localhost:3000,http://127.0.0.1:3000",
+ validation_alias="CORS_ORIGINS",
+ )
+
+ # ---- Identity Service ----
+ identity_service_url: str = "http://localhost:8001"
+
+ # ---- Internal Service Tokens ----
+ internal_token_secret: str = "change-me-internal-secret"
+
+ # ---- OTP / Payamak SMS ----
+ payamak_username: str = "9155105404"
+ payamak_from: str = Field(default="9982004961", validation_alias="PAYAMAK_FROM")
+ payamak_api_key: str = Field(default="", validation_alias="PAYAMAK_API_KEY")
+ payamak_apikey: str = Field(default="", validation_alias="PAYAMAK_APIKEY")
+ payamak_password: str = Field(default="", validation_alias="PAYAMAK_PASSWORD")
+ payamak_body_id: int = Field(default=245189, validation_alias="PAYAMAK_BODY_ID")
+ otp_expire_seconds: int = 120
+
+ # ---- Platform bootstrap admins (OTP) ----
+ platform_admin_mobiles: str = Field(
+ default="",
+ validation_alias="PLATFORM_ADMIN_MOBILES",
+ )
+
+ @property
+ def platform_admin_mobile_set(self) -> set[str]:
+ from app.utils.phone import normalize_mobile
+
+ result: set[str] = set()
+ for raw in self.platform_admin_mobiles.split(","):
+ item = raw.strip()
+ if not item:
+ continue
+ try:
+ result.add(normalize_mobile(item))
+ except ValueError:
+ continue
+ return result
+
+ @property
+ def payamak_auth_secret(self) -> str:
+ """ApiKey در پارامتر password (طبق مستندات ملیپیامک)."""
+ return (self.payamak_api_key or self.payamak_apikey or self.payamak_password).strip()
+
+ @property
+ def payamak_verification_body_id(self) -> int:
+ """شناسه الگوی کد تأیید — همان PAYAMAK_BODY_ID در torbatkar-back."""
+ return self.payamak_body_id
+
+ # ---- Local JWT (OTP users) ----
+ jwt_secret: str = "change-me-jwt-secret"
+ jwt_access_token_expire_seconds: int = 3600
+ jwt_refresh_token_expire_seconds: int = 604800
+
+ @property
+ def is_production(self) -> bool:
+ return self.environment == "production"
+
+ @property
+ def keycloak_browser_url(self) -> str:
+ return self.keycloak_public_url or self.keycloak_server_url
+
+ @property
+ def keycloak_public_realm_url(self) -> str:
+ return f"{self.keycloak_browser_url}/realms/{self.keycloak_realm}"
+
+ @property
+ def cors_origin_list(self) -> list[str]:
+ return [origin.strip() for origin in self.cors_origins.split(",") if origin.strip()]
+
+ @property
+ def jwks_url(self) -> str:
+ """آدرس JWKS در Keycloak برای اعتبارسنجی امضای JWT."""
+ return (
+ f"{self.keycloak_server_url}/realms/{self.keycloak_realm}"
+ "/protocol/openid-connect/certs"
+ )
+
+
+@lru_cache
+def get_settings() -> Settings:
+ """نمونه cache شده تنظیمات (singleton)."""
+ return Settings()
+
+
+settings = get_settings()
diff --git a/backend/core-service/app/core/database.py b/backend/core-service/app/core/database.py
new file mode 100644
index 0000000..af072f9
--- /dev/null
+++ b/backend/core-service/app/core/database.py
@@ -0,0 +1,50 @@
+"""راهاندازی SQLAlchemy 2.x بهصورت async.
+
+- Base declarative مشترک همه مدلها
+- Engine و session factory
+- Dependency برای دریافت AsyncSession در FastAPI
+"""
+from __future__ import annotations
+
+from collections.abc import AsyncGenerator
+
+from sqlalchemy.ext.asyncio import (
+ AsyncSession,
+ async_sessionmaker,
+ create_async_engine,
+)
+from sqlalchemy.orm import DeclarativeBase
+
+from app.core.config import settings
+
+
+class Base(DeclarativeBase):
+ """پایه declarative مشترک برای همه مدلهای Core."""
+
+
+engine = create_async_engine(
+ settings.database_url,
+ echo=settings.db_echo,
+ pool_pre_ping=True,
+ future=True,
+)
+
+AsyncSessionLocal = async_sessionmaker(
+ bind=engine,
+ class_=AsyncSession,
+ expire_on_commit=False,
+ autoflush=False,
+)
+
+
+async def get_db() -> AsyncGenerator[AsyncSession, None]:
+ """Dependency: یک AsyncSession در طول عمر request فراهم میکند.
+
+ در صورت بروز خطا rollback و در پایان session بسته میشود.
+ """
+ async with AsyncSessionLocal() as session:
+ try:
+ yield session
+ except Exception:
+ await session.rollback()
+ raise
diff --git a/backend/core-service/app/core/logging.py b/backend/core-service/app/core/logging.py
new file mode 100644
index 0000000..cbc8ce6
--- /dev/null
+++ b/backend/core-service/app/core/logging.py
@@ -0,0 +1,61 @@
+"""پیکربندی لاگگیری ساختیافته (structured logging).
+
+از logging استاندارد پایتون با فرمت JSON-friendly استفاده میکنیم تا در
+production قابل جمعآوری توسط ابزارهای log aggregation باشد.
+"""
+from __future__ import annotations
+
+import logging
+import sys
+from typing import Any
+
+from app.core.config import settings
+
+_configured = False
+
+
+class _KeyValueFormatter(logging.Formatter):
+ """فرمت ساده key=value برای خوانایی و پردازش ماشینی."""
+
+ def format(self, record: logging.LogRecord) -> str:
+ base: dict[str, Any] = {
+ "level": record.levelname,
+ "logger": record.name,
+ "service": settings.service_name,
+ "msg": record.getMessage(),
+ }
+ if record.exc_info:
+ base["exc"] = self.formatException(record.exc_info)
+ # فیلدهای اضافی که با extra ارسال شدهاند
+ for key, value in record.__dict__.items():
+ if key in ("args", "msg", "levelname", "name", "exc_info", "exc_text",
+ "stack_info", "created", "msecs", "relativeCreated", "levelno",
+ "pathname", "filename", "module", "funcName", "lineno",
+ "processName", "process", "threadName", "thread", "taskName"):
+ continue
+ base.setdefault(key, value)
+ return " ".join(f"{k}={v!r}" for k, v in base.items())
+
+
+def configure_logging() -> None:
+ """پیکربندی سراسری لاگگیری (idempotent)."""
+ global _configured
+ if _configured:
+ return
+
+ handler = logging.StreamHandler(sys.stdout)
+ handler.setFormatter(_KeyValueFormatter())
+
+ root = logging.getLogger()
+ root.handlers.clear()
+ root.addHandler(handler)
+ root.setLevel(settings.log_level.upper())
+
+ # کاهش نویز لاگ کتابخانههای پرحرف
+ logging.getLogger("uvicorn.access").setLevel(logging.WARNING)
+ _configured = True
+
+
+def get_logger(name: str) -> logging.Logger:
+ configure_logging()
+ return logging.getLogger(name)
diff --git a/backend/core-service/app/core/security.py b/backend/core-service/app/core/security.py
new file mode 100644
index 0000000..0b7db84
--- /dev/null
+++ b/backend/core-service/app/core/security.py
@@ -0,0 +1,97 @@
+"""امنیت هسته: اعتبارسنجی JWT و dependencyهای احراز هویت/مجوز."""
+from __future__ import annotations
+
+from functools import lru_cache
+
+import jwt
+from fastapi import Depends
+from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
+
+from app.core.config import settings
+from shared.auth import JWTSettings, JWTValidator, PlatformRole, has_any_role
+from shared.exceptions import ForbiddenError, UnauthorizedError
+from shared.security import CurrentUser
+
+from app.services.token_service import TokenService
+
+bearer_scheme = HTTPBearer(auto_error=False)
+_token_service = TokenService()
+
+
+@lru_cache
+def get_jwt_validator() -> JWTValidator:
+ return JWTValidator(
+ JWTSettings(
+ keycloak_enabled=settings.keycloak_enabled,
+ keycloak_server_url=settings.keycloak_server_url,
+ keycloak_realm=settings.keycloak_realm,
+ jwt_algorithm=settings.jwt_algorithm,
+ jwt_audience=settings.jwt_audience,
+ jwt_verify_signature=settings.jwt_verify_signature,
+ jwt_issuer=settings.keycloak_public_realm_url,
+ )
+ )
+
+
+async def get_current_user(
+ credentials: HTTPAuthorizationCredentials | None = Depends(bearer_scheme),
+) -> CurrentUser:
+ if not settings.auth_required:
+ # در تستها یا حالت bootstrap بدون SSO
+ return CurrentUser(user_id="system", username="system", roles=["platform_admin"])
+ if credentials is None or not credentials.credentials:
+ raise UnauthorizedError("توکن احراز هویت ارائه نشده است")
+
+ token = credentials.credentials
+ try:
+ header = jwt.get_unverified_header(token)
+ except jwt.PyJWTError as exc:
+ raise UnauthorizedError(f"توکن نامعتبر است: {exc}") from exc
+
+ alg = header.get("alg", "")
+ if alg == "HS256":
+ return _token_service.validate_access_token(token)
+ return await get_jwt_validator().validate(token)
+
+
+async def get_optional_user(
+ credentials: HTTPAuthorizationCredentials | None = Depends(bearer_scheme),
+) -> CurrentUser | None:
+ if not settings.auth_required:
+ return CurrentUser(user_id="system", username="system", roles=["platform_admin"])
+ if credentials is None or not credentials.credentials:
+ return None
+ token = credentials.credentials
+ try:
+ header = jwt.get_unverified_header(token)
+ except jwt.PyJWTError:
+ return None
+ if header.get("alg") == "HS256":
+ try:
+ return _token_service.validate_access_token(token)
+ except UnauthorizedError:
+ return None
+ try:
+ return await get_jwt_validator().validate(token)
+ except UnauthorizedError:
+ return None
+
+
+def require_roles(*roles: PlatformRole | str):
+ """Dependency factory: کاربر باید حداقل یکی از نقشها را داشته باشد."""
+
+ async def _checker(user: CurrentUser = Depends(get_current_user)) -> CurrentUser:
+ if not settings.auth_required:
+ return user
+ if not has_any_role(user, *roles):
+ raise ForbiddenError("دسترسی کافی برای این عملیات ندارید")
+ return user
+
+ return _checker
+
+
+require_platform_admin = require_roles(PlatformRole.PLATFORM_ADMIN)
+require_tenant_admin = require_roles(
+ PlatformRole.PLATFORM_ADMIN, PlatformRole.TENANT_ADMIN
+)
+require_authenticated = get_current_user
diff --git a/backend/core-service/app/main.py b/backend/core-service/app/main.py
new file mode 100644
index 0000000..e25aa76
--- /dev/null
+++ b/backend/core-service/app/main.py
@@ -0,0 +1,84 @@
+"""نقطه ورود برنامه FastAPI برای Core Platform Service."""
+from __future__ import annotations
+
+from contextlib import asynccontextmanager
+
+from fastapi import FastAPI, Request
+from fastapi.middleware.cors import CORSMiddleware
+from fastapi.responses import JSONResponse
+
+from app import __version__
+from app.api.v1 import api_router, health
+from app.core.cache import cache
+from app.core.config import settings
+from app.core.logging import configure_logging, get_logger
+from app.middlewares.tenant import TenantResolutionMiddleware
+from shared.exceptions import AppError
+from shared.responses import ErrorDetail, ErrorResponse
+
+configure_logging()
+logger = get_logger(__name__)
+
+
+@asynccontextmanager
+async def lifespan(app: FastAPI):
+ logger.info("service_starting", extra={"service": settings.service_name})
+ yield
+ await cache.close()
+ logger.info("service_stopped")
+
+
+def create_app() -> FastAPI:
+ app = FastAPI(
+ title=f"{settings.platform_name} - Core Platform Service",
+ version=__version__,
+ description="سرویس هسته پلتفرم SaaS چندمستأجری (فاز ۱).",
+ lifespan=lifespan,
+ )
+
+ # CORS - در production باید origins محدود شود.
+ # allow_origin_regex برای سابدامین tenantها (.torbatyar.ir)
+ app.add_middleware(
+ CORSMiddleware,
+ allow_origins=settings.cors_origin_list,
+ allow_origin_regex=r"https?://([a-z0-9-]+\.)*torbatyar\.ir",
+ allow_credentials=False,
+ allow_methods=["*"],
+ allow_headers=["*"],
+ )
+
+ # Middleware تشخیص tenant.
+ app.add_middleware(
+ TenantResolutionMiddleware, base_domain=settings.platform_base_domain or None
+ )
+
+ # ثبت exception handlerها برای تبدیل خطاهای دامنه به پاسخ استاندارد.
+ _register_exception_handlers(app)
+
+ # routerها
+ app.include_router(health.router)
+ app.include_router(api_router, prefix=settings.api_v1_prefix)
+
+ return app
+
+
+def _register_exception_handlers(app: FastAPI) -> None:
+ @app.exception_handler(AppError)
+ async def app_error_handler(request: Request, exc: AppError) -> JSONResponse:
+ response = ErrorResponse(
+ error=ErrorDetail(
+ code=exc.error_code, message=exc.message, details=exc.details
+ )
+ )
+ return JSONResponse(status_code=exc.status_code, content=response.model_dump())
+
+ @app.exception_handler(Exception)
+ async def unhandled_handler(request: Request, exc: Exception) -> JSONResponse:
+ logger.error("unhandled_exception", extra={"error": str(exc)}, exc_info=exc)
+ response = ErrorResponse(
+ error=ErrorDetail(code="internal_error", message="خطای داخلی سرور")
+ )
+ return JSONResponse(status_code=500, content=response.model_dump())
+
+
+app = create_app()
diff --git a/backend/core-service/app/middlewares/__init__.py b/backend/core-service/app/middlewares/__init__.py
new file mode 100644
index 0000000..0dfed3b
--- /dev/null
+++ b/backend/core-service/app/middlewares/__init__.py
@@ -0,0 +1 @@
+"""Middlewareهای برنامه."""
diff --git a/backend/core-service/app/middlewares/tenant.py b/backend/core-service/app/middlewares/tenant.py
new file mode 100644
index 0000000..afc344a
--- /dev/null
+++ b/backend/core-service/app/middlewares/tenant.py
@@ -0,0 +1,108 @@
+"""Middleware تشخیص Tenant.
+
+ترتیب تشخیص tenant:
+ 1. هدر X-Tenant-ID
+ 2. هدر X-Tenant-Slug
+ 3. Subdomain (از روی Host)
+ 4. Custom domain (از روی Host)
+
+نتیجه در request.state قرار میگیرد:
+ request.state.tenant_id
+ request.state.tenant_slug
+
+توجه: این middleware اگر tenant پیدا نشد خطا نمیدهد؛ صرفاً state را خالی
+میگذارد. اعمال اجبار روی endpointهای tenant-aware از طریق dependency
+(require_tenant) انجام میشود.
+"""
+from __future__ import annotations
+
+from uuid import UUID
+
+from starlette.middleware.base import BaseHTTPMiddleware
+from starlette.requests import Request
+from starlette.responses import Response
+
+from app.core.cache import cache, tenant_metadata_key
+from app.core.database import AsyncSessionLocal
+from app.core.logging import get_logger
+from app.repositories.tenant import DomainRepository, TenantRepository
+from shared.tenant import HEADER_TENANT_ID, HEADER_TENANT_SLUG
+
+logger = get_logger(__name__)
+
+
+class TenantResolutionMiddleware(BaseHTTPMiddleware):
+ def __init__(self, app, *, base_domain: str | None = None) -> None:
+ super().__init__(app)
+ # base_domain برای تشخیص subdomain استفاده میشود (مثلاً example.com).
+ self.base_domain = base_domain
+
+ async def dispatch(self, request: Request, call_next) -> Response:
+ request.state.tenant_id = None
+ request.state.tenant_slug = None
+
+ try:
+ await self._resolve(request)
+ except Exception as exc: # pragma: no cover - نباید request را بشکند
+ logger.warning("tenant_resolution_error", extra={"error": str(exc)})
+
+ return await call_next(request)
+
+ async def _resolve(self, request: Request) -> None:
+ # ۱) هدر X-Tenant-ID
+ raw_id = request.headers.get(HEADER_TENANT_ID)
+ if raw_id:
+ try:
+ tenant_id = UUID(raw_id)
+ except ValueError:
+ tenant_id = None
+ if tenant_id is not None:
+ await self._set_from_tenant_id(request, tenant_id)
+ if request.state.tenant_id is not None:
+ return
+
+ # ۲) هدر X-Tenant-Slug
+ slug = request.headers.get(HEADER_TENANT_SLUG)
+ if slug:
+ await self._set_from_slug(request, slug.strip().lower())
+ if request.state.tenant_id is not None:
+ return
+
+ # ۳ و ۴) subdomain یا custom domain از روی Host
+ host = request.headers.get("host", "").split(":")[0].strip().lower()
+ if not host:
+ return
+
+ # اگر base_domain تعریف شده و host زیردامنه آن است → slug = بخش اول.
+ if self.base_domain and host.endswith("." + self.base_domain):
+ sub = host[: -(len(self.base_domain) + 1)]
+ if sub and sub != "www":
+ await self._set_from_slug(request, sub)
+ if request.state.tenant_id is not None:
+ return
+
+ # در غیر این صورت host را بهعنوان custom domain در نظر بگیر.
+ await self._set_from_domain(request, host)
+
+ async def _set_from_tenant_id(self, request: Request, tenant_id: UUID) -> None:
+ async with AsyncSessionLocal() as session:
+ tenant = await TenantRepository(session).get(tenant_id)
+ if tenant is not None:
+ request.state.tenant_id = tenant.id
+ request.state.tenant_slug = tenant.slug
+
+ async def _set_from_slug(self, request: Request, slug: str) -> None:
+ async with AsyncSessionLocal() as session:
+ tenant = await TenantRepository(session).get_by_slug(slug)
+ if tenant is not None:
+ request.state.tenant_id = tenant.id
+ request.state.tenant_slug = tenant.slug
+
+ async def _set_from_domain(self, request: Request, host: str) -> None:
+ async with AsyncSessionLocal() as session:
+ domain = await DomainRepository(session).get_by_domain(host)
+ if domain is not None:
+ tenant = await TenantRepository(session).get(domain.tenant_id)
+ if tenant is not None:
+ request.state.tenant_id = tenant.id
+ request.state.tenant_slug = tenant.slug
diff --git a/backend/core-service/app/models/__init__.py b/backend/core-service/app/models/__init__.py
new file mode 100644
index 0000000..0279c73
--- /dev/null
+++ b/backend/core-service/app/models/__init__.py
@@ -0,0 +1,38 @@
+"""مدلهای SQLAlchemy 2.x برای Core Platform.
+
+توجه: import همه مدلها در این ماژول لازم است تا Alembic و Base.metadata
+همه جدولها را بشناسند.
+"""
+from app.models.audit import AuditLog
+from app.models.domain import Domain
+from app.models.events import InboxEvent, OutboxEvent
+from app.models.membership import TenantMembership
+from app.models.plan import Feature, Plan, PlanFeature
+from app.models.registry import (
+ InternalServiceToken,
+ ModuleRegistry,
+ ServiceRegistry,
+ TenantModuleAccess,
+)
+from app.models.subscription import TenantFeatureAccess, TenantSubscription
+from app.models.tenant import Tenant
+from app.models.user import User
+
+__all__ = [
+ "Tenant",
+ "User",
+ "TenantMembership",
+ "Domain",
+ "Plan",
+ "Feature",
+ "PlanFeature",
+ "TenantSubscription",
+ "TenantFeatureAccess",
+ "ServiceRegistry",
+ "ModuleRegistry",
+ "TenantModuleAccess",
+ "InternalServiceToken",
+ "OutboxEvent",
+ "InboxEvent",
+ "AuditLog",
+]
diff --git a/backend/core-service/app/models/audit.py b/backend/core-service/app/models/audit.py
new file mode 100644
index 0000000..ee39545
--- /dev/null
+++ b/backend/core-service/app/models/audit.py
@@ -0,0 +1,35 @@
+"""مدل AuditLog: ثبت رخدادهای مهم برای پیگیری و امنیت."""
+from __future__ import annotations
+
+import uuid
+from datetime import datetime
+
+from sqlalchemy import DateTime, Index, String, func
+from sqlalchemy.orm import Mapped, mapped_column
+from sqlalchemy.types import JSON
+
+from app.core.database import Base
+from app.models.base import UUIDPrimaryKeyMixin
+from app.models.types import GUID
+
+
+class AuditLog(UUIDPrimaryKeyMixin, Base):
+ __tablename__ = "audit_logs"
+
+ tenant_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
+ actor_user_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
+ action: Mapped[str] = mapped_column(String(150), nullable=False)
+ resource_type: Mapped[str | None] = mapped_column(String(100), nullable=True)
+ resource_id: Mapped[str | None] = mapped_column(String(100), nullable=True)
+ # از نام ستون meta_data برای پرهیز از تداخل با کلمه رزروشده metadata استفاده شده.
+ meta_data: Mapped[dict | None] = mapped_column("metadata", JSON, nullable=True)
+ ip_address: Mapped[str | None] = mapped_column(String(64), nullable=True)
+ user_agent: Mapped[str | None] = mapped_column(String(500), nullable=True)
+ created_at: Mapped[datetime] = mapped_column(
+ DateTime(timezone=True), server_default=func.now(), nullable=False
+ )
+
+ __table_args__ = (
+ Index("ix_audit_logs_tenant_id", "tenant_id"),
+ Index("ix_audit_logs_created_at", "created_at"),
+ )
diff --git a/backend/core-service/app/models/base.py b/backend/core-service/app/models/base.py
new file mode 100644
index 0000000..0f5a7b1
--- /dev/null
+++ b/backend/core-service/app/models/base.py
@@ -0,0 +1,32 @@
+"""Mixinهای مشترک مدلها: شناسه UUID و زمانهای ایجاد/بهروزرسانی."""
+from __future__ import annotations
+
+import uuid
+from datetime import datetime
+
+from sqlalchemy import DateTime, func
+from sqlalchemy.orm import Mapped, mapped_column
+
+from app.models.types import GUID
+
+
+class UUIDPrimaryKeyMixin:
+ """کلید اصلی از نوع UUID با مقدار پیشفرض تولیدشده در سمت برنامه."""
+
+ id: Mapped[uuid.UUID] = mapped_column(
+ GUID(), primary_key=True, default=uuid.uuid4
+ )
+
+
+class TimestampMixin:
+ """ستونهای created_at و updated_at با مدیریت خودکار."""
+
+ created_at: Mapped[datetime] = mapped_column(
+ DateTime(timezone=True), server_default=func.now(), nullable=False
+ )
+ updated_at: Mapped[datetime] = mapped_column(
+ DateTime(timezone=True),
+ server_default=func.now(),
+ onupdate=func.now(),
+ nullable=False,
+ )
diff --git a/backend/core-service/app/models/domain.py b/backend/core-service/app/models/domain.py
new file mode 100644
index 0000000..11d1a9b
--- /dev/null
+++ b/backend/core-service/app/models/domain.py
@@ -0,0 +1,56 @@
+"""مدل Domain: دامنهها/زیردامنههای هر tenant."""
+from __future__ import annotations
+
+import uuid
+from datetime import datetime
+
+from sqlalchemy import Boolean, DateTime
+from sqlalchemy import Enum as SAEnum
+from sqlalchemy import ForeignKey, Index, String, func
+from sqlalchemy.orm import Mapped, mapped_column
+
+from app.core.database import Base
+from app.models.base import UUIDPrimaryKeyMixin
+from app.models.enums import DomainType, DomainVerificationStatus
+from app.models.types import GUID
+
+
+class Domain(UUIDPrimaryKeyMixin, Base):
+ __tablename__ = "domains"
+
+ tenant_id: Mapped[uuid.UUID] = mapped_column(
+ GUID(), ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False
+ )
+ # دامنه بهصورت یکتا در کل پلتفرم (مثلاً acme.example.com یا shop.acme.ir).
+ domain: Mapped[str] = mapped_column(String(255), nullable=False, unique=True)
+ domain_type: Mapped[DomainType] = mapped_column(
+ SAEnum(DomainType, name="domain_type"),
+ default=DomainType.SUBDOMAIN,
+ nullable=False,
+ )
+ is_verified: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
+ verified_at: Mapped[datetime | None] = mapped_column(
+ DateTime(timezone=True), nullable=True
+ )
+ # آیا این دامنه، دامنه اصلی/پیشفرض tenant است (مثلاً زیردامنه ساختهشده در onboarding).
+ is_primary: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
+ verification_status: Mapped[DomainVerificationStatus] = mapped_column(
+ SAEnum(
+ DomainVerificationStatus,
+ name="domain_verification_status",
+ values_callable=lambda enum_cls: [item.value for item in enum_cls],
+ ),
+ default=DomainVerificationStatus.PENDING,
+ nullable=False,
+ )
+ created_at: Mapped[datetime] = mapped_column(
+ DateTime(timezone=True), server_default=func.now(), nullable=False
+ )
+
+ __table_args__ = (
+ Index("ix_domains_domain", "domain", unique=True),
+ Index("ix_domains_tenant_id", "tenant_id"),
+ )
+
+ def __repr__(self) -> str: # pragma: no cover
+ return f""
diff --git a/backend/core-service/app/models/enums.py b/backend/core-service/app/models/enums.py
new file mode 100644
index 0000000..d9b975b
--- /dev/null
+++ b/backend/core-service/app/models/enums.py
@@ -0,0 +1,79 @@
+"""Enumهای دامنه مدلهای Core."""
+from __future__ import annotations
+
+import enum
+
+
+class TenantStatus(str, enum.Enum):
+ # چرخه عمر عملیاتی (فاز onboarding)
+ DRAFT = "draft"
+ PENDING_ACTIVATION = "pending_activation"
+ ACTIVE = "active"
+ SUSPENDED = "suspended"
+ ARCHIVED = "archived"
+ # مقادیر قدیمی (سازگاری با فازهای قبلی)
+ INACTIVE = "inactive"
+ DELETED = "deleted"
+
+
+class TenantMembershipRole(str, enum.Enum):
+ """نقش کاربر داخل یک tenant مشخص (متفاوت از نقش سراسری JWT)."""
+
+ PLATFORM_ADMIN = "platform_admin"
+ TENANT_OWNER = "tenant_owner"
+ TENANT_ADMIN = "tenant_admin"
+ TENANT_EDITOR = "tenant_editor"
+ TENANT_VIEWER = "tenant_viewer"
+
+
+class MembershipStatus(str, enum.Enum):
+ ACTIVE = "active"
+ INVITED = "invited"
+ DISABLED = "disabled"
+
+
+class DomainVerificationStatus(str, enum.Enum):
+ PENDING = "pending"
+ VERIFIED = "verified"
+ FAILED = "failed"
+
+
+class DomainType(str, enum.Enum):
+ SUBDOMAIN = "subdomain"
+ CUSTOM_DOMAIN = "custom_domain"
+
+
+class SubscriptionStatus(str, enum.Enum):
+ TRIALING = "trialing"
+ ACTIVE = "active"
+ PAST_DUE = "past_due"
+ CANCELED = "canceled"
+ EXPIRED = "expired"
+
+
+class LimitPeriod(str, enum.Enum):
+ """دوره سقف مصرف یک قابلیت."""
+
+ DAY = "day"
+ MONTH = "month"
+ YEAR = "year"
+ TOTAL = "total" # بدون بازنشانی دورهای
+
+
+class ServiceStatus(str, enum.Enum):
+ ACTIVE = "active"
+ INACTIVE = "inactive"
+ MAINTENANCE = "maintenance"
+
+
+class UserRole(str, enum.Enum):
+ USER = "user"
+ PENDING_TENANT_ADMIN = "pending_tenant_admin"
+ TENANT_ADMIN = "tenant_admin"
+ PLATFORM_ADMIN = "platform_admin"
+
+
+class UserStatus(str, enum.Enum):
+ ACTIVE = "active"
+ INACTIVE = "inactive"
+ SUSPENDED = "suspended"
diff --git a/backend/core-service/app/models/events.py b/backend/core-service/app/models/events.py
new file mode 100644
index 0000000..a405bac
--- /dev/null
+++ b/backend/core-service/app/models/events.py
@@ -0,0 +1,62 @@
+"""مدلهای Outbox و Inbox برای الگوی رویداد قابل اعتماد.
+
+OutboxEvent: رویدادهای خروجی که باید منتشر شوند.
+InboxEvent: رویدادهای ورودی برای idempotency و جلوگیری از پردازش تکراری.
+"""
+from __future__ import annotations
+
+import uuid
+from datetime import datetime
+
+from sqlalchemy import DateTime
+from sqlalchemy import Enum as SAEnum
+from sqlalchemy import Index, Integer, String, func
+from sqlalchemy.orm import Mapped, mapped_column
+from sqlalchemy.types import JSON
+
+from app.core.database import Base
+from app.models.base import UUIDPrimaryKeyMixin
+from app.models.types import GUID
+from shared.events import EventStatus
+
+
+class OutboxEvent(UUIDPrimaryKeyMixin, Base):
+ __tablename__ = "outbox_events"
+
+ event_type: Mapped[str] = mapped_column(String(150), nullable=False)
+ aggregate_type: Mapped[str] = mapped_column(String(100), nullable=False)
+ aggregate_id: Mapped[str | None] = mapped_column(String(100), nullable=True)
+ tenant_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
+ payload: Mapped[dict] = mapped_column(JSON, nullable=False, default=dict)
+ status: Mapped[EventStatus] = mapped_column(
+ SAEnum(EventStatus, name="event_status"),
+ default=EventStatus.PENDING,
+ nullable=False,
+ )
+ retry_count: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
+ created_at: Mapped[datetime] = mapped_column(
+ DateTime(timezone=True), server_default=func.now(), nullable=False
+ )
+ processed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
+
+ __table_args__ = (
+ Index("ix_outbox_events_status", "status"),
+ Index("ix_outbox_events_tenant_id", "tenant_id"),
+ )
+
+
+class InboxEvent(UUIDPrimaryKeyMixin, Base):
+ __tablename__ = "inbox_events"
+
+ # شناسه یکتای رویداد منبع برای جلوگیری از پردازش تکراری (idempotency).
+ event_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False, unique=True)
+ source_service: Mapped[str] = mapped_column(String(100), nullable=False)
+ event_type: Mapped[str] = mapped_column(String(150), nullable=False)
+ tenant_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
+ payload: Mapped[dict] = mapped_column(JSON, nullable=False, default=dict)
+ processed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
+
+ __table_args__ = (
+ Index("ix_inbox_events_event_id", "event_id", unique=True),
+ Index("ix_inbox_events_tenant_id", "tenant_id"),
+ )
diff --git a/backend/core-service/app/models/membership.py b/backend/core-service/app/models/membership.py
new file mode 100644
index 0000000..597c908
--- /dev/null
+++ b/backend/core-service/app/models/membership.py
@@ -0,0 +1,62 @@
+"""مدل TenantMembership: رابطه واقعی کاربر ↔ tenant (فاز Onboarding).
+
+هر کاربر میتواند در آینده عضو چند tenant باشد؛ نقش و وضعیت عضویت او در هر
+tenant مستقل از نقش سراسری (User.role / JWT roles) نگهداری میشود.
+"""
+from __future__ import annotations
+
+import uuid
+
+from sqlalchemy import Boolean
+from sqlalchemy import Enum as SAEnum
+from sqlalchemy import ForeignKey, Index, UniqueConstraint
+from sqlalchemy.orm import Mapped, mapped_column
+
+from app.core.database import Base
+from app.models.base import TimestampMixin, UUIDPrimaryKeyMixin
+from app.models.enums import MembershipStatus, TenantMembershipRole
+from app.models.types import GUID
+
+
+class TenantMembership(UUIDPrimaryKeyMixin, TimestampMixin, Base):
+ __tablename__ = "tenant_memberships"
+
+ tenant_id: Mapped[uuid.UUID] = mapped_column(
+ GUID(), ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False
+ )
+ user_id: Mapped[uuid.UUID] = mapped_column(
+ GUID(), ForeignKey("users.id", ondelete="CASCADE"), nullable=False
+ )
+ role: Mapped[TenantMembershipRole] = mapped_column(
+ SAEnum(
+ TenantMembershipRole,
+ name="tenant_membership_role",
+ values_callable=lambda enum_cls: [item.value for item in enum_cls],
+ ),
+ default=TenantMembershipRole.TENANT_VIEWER,
+ nullable=False,
+ )
+ status: Mapped[MembershipStatus] = mapped_column(
+ SAEnum(
+ MembershipStatus,
+ name="tenant_membership_status",
+ values_callable=lambda enum_cls: [item.value for item in enum_cls],
+ ),
+ default=MembershipStatus.ACTIVE,
+ nullable=False,
+ )
+ is_owner: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
+
+ __table_args__ = (
+ UniqueConstraint("tenant_id", "user_id", name="uq_tenant_membership_tenant_user"),
+ Index("ix_tenant_memberships_tenant_id", "tenant_id"),
+ Index("ix_tenant_memberships_user_id", "user_id"),
+ Index("ix_tenant_memberships_role", "role"),
+ Index("ix_tenant_memberships_status", "status"),
+ )
+
+ def __repr__(self) -> str: # pragma: no cover
+ return (
+ f""
+ )
diff --git a/backend/core-service/app/models/plan.py b/backend/core-service/app/models/plan.py
new file mode 100644
index 0000000..4887ab2
--- /dev/null
+++ b/backend/core-service/app/models/plan.py
@@ -0,0 +1,74 @@
+"""مدلهای Plan، Feature و PlanFeature.
+
+Plan: بسته اشتراک.
+Feature: یک قابلیت قابل کنترل (مثلاً accounting.invoice.create).
+PlanFeature: نگاشت قابلیتها به هر پلن به همراه سقف مصرف.
+"""
+from __future__ import annotations
+
+import uuid
+from datetime import datetime
+
+from sqlalchemy import Boolean, DateTime
+from sqlalchemy import Enum as SAEnum
+from sqlalchemy import ForeignKey, Index, Integer, String, Text, UniqueConstraint, func
+from sqlalchemy.orm import Mapped, mapped_column
+
+from app.core.database import Base
+from app.models.base import UUIDPrimaryKeyMixin
+from app.models.enums import LimitPeriod
+from app.models.types import GUID
+
+
+class Plan(UUIDPrimaryKeyMixin, Base):
+ __tablename__ = "plans"
+
+ code: Mapped[str] = mapped_column(String(100), nullable=False, unique=True)
+ name: Mapped[str] = mapped_column(String(255), nullable=False)
+ description: Mapped[str | None] = mapped_column(Text, nullable=True)
+ is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
+ created_at: Mapped[datetime] = mapped_column(
+ DateTime(timezone=True), server_default=func.now(), nullable=False
+ )
+
+ __table_args__ = (Index("ix_plans_code", "code", unique=True),)
+
+
+class Feature(UUIDPrimaryKeyMixin, Base):
+ __tablename__ = "features"
+
+ # کلید یکتای قابلیت، مثال: accounting.invoice.create
+ feature_key: Mapped[str] = mapped_column(String(150), nullable=False, unique=True)
+ name: Mapped[str] = mapped_column(String(255), nullable=False)
+ description: Mapped[str | None] = mapped_column(Text, nullable=True)
+ # سرویسی که این قابلیت به آن تعلق دارد، مثال: accounting، crm.
+ service_key: Mapped[str] = mapped_column(String(100), nullable=False)
+ is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
+
+ __table_args__ = (
+ Index("ix_features_feature_key", "feature_key", unique=True),
+ Index("ix_features_service_key", "service_key"),
+ )
+
+
+class PlanFeature(UUIDPrimaryKeyMixin, Base):
+ __tablename__ = "plan_features"
+
+ plan_id: Mapped[uuid.UUID] = mapped_column(
+ GUID(), ForeignKey("plans.id", ondelete="CASCADE"), nullable=False
+ )
+ feature_id: Mapped[uuid.UUID] = mapped_column(
+ GUID(), ForeignKey("features.id", ondelete="CASCADE"), nullable=False
+ )
+ # سقف مصرف؛ None یعنی نامحدود.
+ limit_value: Mapped[int | None] = mapped_column(Integer, nullable=True)
+ limit_period: Mapped[LimitPeriod | None] = mapped_column(
+ SAEnum(LimitPeriod, name="limit_period"), nullable=True
+ )
+ is_enabled: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
+
+ __table_args__ = (
+ UniqueConstraint("plan_id", "feature_id", name="uq_plan_feature"),
+ Index("ix_plan_features_plan_id", "plan_id"),
+ Index("ix_plan_features_feature_id", "feature_id"),
+ )
diff --git a/backend/core-service/app/models/registry.py b/backend/core-service/app/models/registry.py
new file mode 100644
index 0000000..8d64f18
--- /dev/null
+++ b/backend/core-service/app/models/registry.py
@@ -0,0 +1,92 @@
+"""مدلهای رجیستری سرویس/ماژول و توکنهای داخلی سرویس.
+
+- ServiceRegistry: ثبت سرویسهای داخلی آینده و آدرسهای آنها.
+- ModuleRegistry: ماژولهای قابل فعال/غیرفعالسازی برای tenantها.
+- TenantModuleAccess: وضعیت فعال بودن هر ماژول برای هر tenant.
+- InternalServiceToken: توکن امن ارتباط بین سرویسها (hash ذخیره میشود).
+"""
+from __future__ import annotations
+
+import uuid
+from datetime import datetime
+
+from sqlalchemy import Boolean, DateTime
+from sqlalchemy import Enum as SAEnum
+from sqlalchemy import ForeignKey, Index, String, Text, UniqueConstraint, func
+from sqlalchemy.orm import Mapped, mapped_column
+
+from app.core.database import Base
+from app.models.base import UUIDPrimaryKeyMixin
+from app.models.enums import ServiceStatus
+from app.models.types import GUID
+
+
+class ServiceRegistry(UUIDPrimaryKeyMixin, Base):
+ __tablename__ = "service_registry"
+
+ service_key: Mapped[str] = mapped_column(String(100), nullable=False, unique=True)
+ name: Mapped[str] = mapped_column(String(255), nullable=False)
+ base_url: Mapped[str | None] = mapped_column(String(500), nullable=True)
+ status: Mapped[ServiceStatus] = mapped_column(
+ SAEnum(ServiceStatus, name="service_status"),
+ default=ServiceStatus.ACTIVE,
+ nullable=False,
+ )
+ health_check_url: Mapped[str | None] = mapped_column(String(500), nullable=True)
+ created_at: Mapped[datetime] = mapped_column(
+ DateTime(timezone=True), server_default=func.now(), nullable=False
+ )
+
+ __table_args__ = (Index("ix_service_registry_service_key", "service_key", unique=True),)
+
+
+class ModuleRegistry(UUIDPrimaryKeyMixin, Base):
+ __tablename__ = "module_registry"
+
+ module_key: Mapped[str] = mapped_column(String(100), nullable=False, unique=True)
+ name: Mapped[str] = mapped_column(String(255), nullable=False)
+ description: Mapped[str | None] = mapped_column(Text, nullable=True)
+ # ماژول سیستمی همیشه فعال است و توسط tenant قابل غیرفعالسازی نیست.
+ is_system_module: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
+ is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
+
+ __table_args__ = (Index("ix_module_registry_module_key", "module_key", unique=True),)
+
+
+class TenantModuleAccess(UUIDPrimaryKeyMixin, Base):
+ __tablename__ = "tenant_module_access"
+
+ tenant_id: Mapped[uuid.UUID] = mapped_column(
+ GUID(), ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False
+ )
+ module_id: Mapped[uuid.UUID] = mapped_column(
+ GUID(), ForeignKey("module_registry.id", ondelete="CASCADE"), nullable=False
+ )
+ is_enabled: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
+ enabled_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
+ disabled_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
+
+ __table_args__ = (
+ UniqueConstraint("tenant_id", "module_id", name="uq_tenant_module_access"),
+ Index("ix_tenant_module_access_tenant_id", "tenant_id"),
+ )
+
+
+class InternalServiceToken(UUIDPrimaryKeyMixin, Base):
+ __tablename__ = "internal_service_tokens"
+
+ service_key: Mapped[str] = mapped_column(String(100), nullable=False)
+ # فقط hash توکن ذخیره میشود، نه خود توکن.
+ token_hash: Mapped[str] = mapped_column(String(255), nullable=False, unique=True)
+ # اسکوپها بهصورت رشتهی جداشده با کاما یا فضای مجاز.
+ scopes: Mapped[str | None] = mapped_column(Text, nullable=True)
+ expires_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
+ is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
+ created_at: Mapped[datetime] = mapped_column(
+ DateTime(timezone=True), server_default=func.now(), nullable=False
+ )
+
+ __table_args__ = (
+ Index("ix_internal_service_tokens_service_key", "service_key"),
+ Index("ix_internal_service_tokens_token_hash", "token_hash", unique=True),
+ )
diff --git a/backend/core-service/app/models/subscription.py b/backend/core-service/app/models/subscription.py
new file mode 100644
index 0000000..5956125
--- /dev/null
+++ b/backend/core-service/app/models/subscription.py
@@ -0,0 +1,66 @@
+"""مدلهای TenantSubscription و TenantFeatureAccess."""
+from __future__ import annotations
+
+import uuid
+from datetime import datetime
+
+from sqlalchemy import Boolean, DateTime
+from sqlalchemy import Enum as SAEnum
+from sqlalchemy import ForeignKey, Index, Integer, UniqueConstraint, func
+from sqlalchemy.orm import Mapped, mapped_column
+
+from app.core.database import Base
+from app.models.base import UUIDPrimaryKeyMixin
+from app.models.enums import SubscriptionStatus
+from app.models.types import GUID
+
+
+class TenantSubscription(UUIDPrimaryKeyMixin, Base):
+ __tablename__ = "tenant_subscriptions"
+
+ tenant_id: Mapped[uuid.UUID] = mapped_column(
+ GUID(), ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False
+ )
+ plan_id: Mapped[uuid.UUID] = mapped_column(
+ GUID(), ForeignKey("plans.id", ondelete="RESTRICT"), nullable=False
+ )
+ status: Mapped[SubscriptionStatus] = mapped_column(
+ SAEnum(SubscriptionStatus, name="subscription_status"),
+ default=SubscriptionStatus.ACTIVE,
+ nullable=False,
+ )
+ starts_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
+ ends_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
+ trial_ends_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
+ created_at: Mapped[datetime] = mapped_column(
+ DateTime(timezone=True), server_default=func.now(), nullable=False
+ )
+
+ __table_args__ = (
+ Index("ix_tenant_subscriptions_tenant_id", "tenant_id"),
+ Index("ix_tenant_subscriptions_status", "status"),
+ )
+
+
+class TenantFeatureAccess(UUIDPrimaryKeyMixin, Base):
+ """دسترسی سفارشی/override یک tenant به یک قابلیت (فراتر از پلن)."""
+
+ __tablename__ = "tenant_feature_access"
+
+ tenant_id: Mapped[uuid.UUID] = mapped_column(
+ GUID(), ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False
+ )
+ feature_id: Mapped[uuid.UUID] = mapped_column(
+ GUID(), ForeignKey("features.id", ondelete="CASCADE"), nullable=False
+ )
+ is_enabled: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
+ # سقف سفارشی؛ در صورت وجود، جایگزین سقف پلن میشود. None یعنی نامحدود.
+ custom_limit_value: Mapped[int | None] = mapped_column(Integer, nullable=True)
+ used_value: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
+ reset_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
+
+ __table_args__ = (
+ UniqueConstraint("tenant_id", "feature_id", name="uq_tenant_feature_access"),
+ Index("ix_tenant_feature_access_tenant_id", "tenant_id"),
+ Index("ix_tenant_feature_access_feature_id", "feature_id"),
+ )
diff --git a/backend/core-service/app/models/tenant.py b/backend/core-service/app/models/tenant.py
new file mode 100644
index 0000000..50f30a6
--- /dev/null
+++ b/backend/core-service/app/models/tenant.py
@@ -0,0 +1,49 @@
+"""مدل Tenant: مستأجر اصلی پلتفرم."""
+from __future__ import annotations
+
+import uuid
+
+from sqlalchemy import Boolean
+from sqlalchemy import Enum as SAEnum
+from sqlalchemy import Index, String
+from sqlalchemy.orm import Mapped, mapped_column
+
+from app.core.database import Base
+from app.models.base import TimestampMixin, UUIDPrimaryKeyMixin
+from app.models.enums import TenantStatus
+from app.models.types import GUID
+
+
+class Tenant(UUIDPrimaryKeyMixin, TimestampMixin, Base):
+ __tablename__ = "tenants"
+
+ name: Mapped[str] = mapped_column(String(255), nullable=False)
+ # slug یکتا برای تشخیص tenant (مثلاً در subdomain).
+ slug: Mapped[str] = mapped_column(String(100), nullable=False, unique=True)
+ status: Mapped[TenantStatus] = mapped_column(
+ SAEnum(TenantStatus, name="tenant_status"),
+ default=TenantStatus.ACTIVE,
+ nullable=False,
+ )
+ # شناسه کاربر مالک (از Identity Service آینده؛ اینجا فقط ذخیره میشود).
+ owner_user_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
+
+ # ---- پروفایل/تنظیمات tenant (فاز onboarding) ----
+ business_type: Mapped[str | None] = mapped_column(String(100), nullable=True)
+ default_locale: Mapped[str] = mapped_column(String(20), default="fa-IR", nullable=False)
+ timezone: Mapped[str] = mapped_column(String(50), default="Asia/Tehran", nullable=False)
+ primary_color: Mapped[str | None] = mapped_column(String(20), nullable=True)
+ secondary_color: Mapped[str | None] = mapped_column(String(20), nullable=True)
+ logo_url: Mapped[str | None] = mapped_column(String(500), nullable=True)
+ favicon_url: Mapped[str | None] = mapped_column(String(500), nullable=True)
+ onboarding_completed: Mapped[bool] = mapped_column(
+ Boolean, default=False, nullable=False
+ )
+
+ __table_args__ = (
+ Index("ix_tenants_slug", "slug", unique=True),
+ Index("ix_tenants_status", "status"),
+ )
+
+ def __repr__(self) -> str: # pragma: no cover
+ return f""
diff --git a/backend/core-service/app/models/types.py b/backend/core-service/app/models/types.py
new file mode 100644
index 0000000..5fb8b70
--- /dev/null
+++ b/backend/core-service/app/models/types.py
@@ -0,0 +1,41 @@
+"""انواع سفارشی قابل حمل دیتابیس.
+
+GUID: روی PostgreSQL از نوع UUID و روی SQLite (تستها) از CHAR(36)
+استفاده میکند تا مدلها هم در production و هم در تست کار کنند.
+"""
+from __future__ import annotations
+
+import uuid
+
+from sqlalchemy.dialects.postgresql import UUID as PG_UUID
+from sqlalchemy.types import CHAR, TypeDecorator
+
+
+class GUID(TypeDecorator):
+ """نوع شناسه سراسری منطبق با UUID.
+
+ در PostgreSQL بهصورت native UUID و در سایر دیالکتها بهصورت رشته
+ ۳۶ کاراکتری ذخیره میشود.
+ """
+
+ impl = CHAR
+ cache_ok = True
+
+ def load_dialect_impl(self, dialect):
+ if dialect.name == "postgresql":
+ return dialect.type_descriptor(PG_UUID(as_uuid=True))
+ return dialect.type_descriptor(CHAR(36))
+
+ def process_bind_param(self, value, dialect):
+ if value is None:
+ return value
+ if dialect.name == "postgresql":
+ return value if isinstance(value, uuid.UUID) else uuid.UUID(str(value))
+ return str(value)
+
+ def process_result_value(self, value, dialect):
+ if value is None:
+ return value
+ if isinstance(value, uuid.UUID):
+ return value
+ return uuid.UUID(str(value))
diff --git a/backend/core-service/app/models/user.py b/backend/core-service/app/models/user.py
new file mode 100644
index 0000000..7a9aa39
--- /dev/null
+++ b/backend/core-service/app/models/user.py
@@ -0,0 +1,53 @@
+"""مدل User: کاربران ثبتنامشده از طریق OTP."""
+from __future__ import annotations
+
+import uuid
+
+from sqlalchemy import Boolean, Enum as SAEnum
+from sqlalchemy import ForeignKey, Index, String
+from sqlalchemy.orm import Mapped, mapped_column
+
+from app.core.database import Base
+from app.models.base import TimestampMixin, UUIDPrimaryKeyMixin
+from app.models.enums import UserRole, UserStatus
+from app.models.types import GUID
+
+
+class User(UUIDPrimaryKeyMixin, TimestampMixin, Base):
+ __tablename__ = "users"
+
+ mobile: Mapped[str] = mapped_column(String(15), nullable=False, unique=True)
+ keycloak_sub: Mapped[str | None] = mapped_column(String(100), nullable=True, unique=True)
+ email: Mapped[str | None] = mapped_column(String(255), nullable=True)
+ mobile_verified: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
+ role: Mapped[UserRole] = mapped_column(
+ SAEnum(
+ UserRole,
+ name="user_role",
+ values_callable=lambda enum_cls: [item.value for item in enum_cls],
+ ),
+ default=UserRole.PENDING_TENANT_ADMIN,
+ nullable=False,
+ )
+ status: Mapped[UserStatus] = mapped_column(
+ SAEnum(
+ UserStatus,
+ name="user_status",
+ values_callable=lambda enum_cls: [item.value for item in enum_cls],
+ ),
+ default=UserStatus.ACTIVE,
+ nullable=False,
+ )
+ # tenant انتخابشده جاری کاربر (برای پشتیبانی چندtenant در آینده).
+ current_tenant_id: Mapped[uuid.UUID | None] = mapped_column(
+ GUID(), ForeignKey("tenants.id", ondelete="SET NULL"), nullable=True
+ )
+
+ __table_args__ = (
+ Index("ix_users_mobile", "mobile", unique=True),
+ Index("ix_users_keycloak_sub", "keycloak_sub", unique=True),
+ Index("ix_users_status", "status"),
+ )
+
+ def __repr__(self) -> str: # pragma: no cover
+ return f""
diff --git a/backend/core-service/app/repositories/__init__.py b/backend/core-service/app/repositories/__init__.py
new file mode 100644
index 0000000..e1cb295
--- /dev/null
+++ b/backend/core-service/app/repositories/__init__.py
@@ -0,0 +1 @@
+"""لایه Repository: دسترسی به داده جدا از منطق کسبوکار."""
diff --git a/backend/core-service/app/repositories/base.py b/backend/core-service/app/repositories/base.py
new file mode 100644
index 0000000..336281e
--- /dev/null
+++ b/backend/core-service/app/repositories/base.py
@@ -0,0 +1,46 @@
+"""Repository پایه با عملیات CRUD عمومی (async).
+
+هدف: جلوگیری از تکرار کد و متمرکز کردن الگوهای دسترسی به داده.
+"""
+from __future__ import annotations
+
+from typing import Generic, Sequence, TypeVar
+from uuid import UUID
+
+from sqlalchemy import func, select
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from app.core.database import Base
+
+ModelT = TypeVar("ModelT", bound=Base)
+
+
+class BaseRepository(Generic[ModelT]):
+ model: type[ModelT]
+
+ def __init__(self, session: AsyncSession) -> None:
+ self.session = session
+
+ async def get(self, entity_id: UUID) -> ModelT | None:
+ return await self.session.get(self.model, entity_id)
+
+ async def add(self, entity: ModelT) -> ModelT:
+ self.session.add(entity)
+ await self.session.flush()
+ return entity
+
+ async def delete(self, entity: ModelT) -> None:
+ await self.session.delete(entity)
+ await self.session.flush()
+
+ async def list(
+ self, *, offset: int = 0, limit: int = 20
+ ) -> Sequence[ModelT]:
+ stmt = select(self.model).offset(offset).limit(limit)
+ result = await self.session.execute(stmt)
+ return result.scalars().all()
+
+ async def count(self) -> int:
+ stmt = select(func.count()).select_from(self.model)
+ result = await self.session.execute(stmt)
+ return int(result.scalar_one())
diff --git a/backend/core-service/app/repositories/membership.py b/backend/core-service/app/repositories/membership.py
new file mode 100644
index 0000000..b474cb0
--- /dev/null
+++ b/backend/core-service/app/repositories/membership.py
@@ -0,0 +1,48 @@
+"""Repository مربوط به TenantMembership."""
+from __future__ import annotations
+
+from typing import Sequence
+from uuid import UUID
+
+from sqlalchemy import func, select
+
+from app.models.enums import MembershipStatus
+from app.models.membership import TenantMembership
+from app.repositories.base import BaseRepository
+
+
+class TenantMembershipRepository(BaseRepository[TenantMembership]):
+ model = TenantMembership
+
+ async def get_by_tenant_and_user(
+ self, tenant_id: UUID, user_id: UUID
+ ) -> TenantMembership | None:
+ stmt = select(TenantMembership).where(
+ TenantMembership.tenant_id == tenant_id,
+ TenantMembership.user_id == user_id,
+ )
+ result = await self.session.execute(stmt)
+ return result.scalar_one_or_none()
+
+ async def list_by_user(self, user_id: UUID) -> Sequence[TenantMembership]:
+ stmt = select(TenantMembership).where(TenantMembership.user_id == user_id)
+ result = await self.session.execute(stmt)
+ return result.scalars().all()
+
+ async def list_by_tenant(self, tenant_id: UUID) -> Sequence[TenantMembership]:
+ stmt = select(TenantMembership).where(TenantMembership.tenant_id == tenant_id)
+ result = await self.session.execute(stmt)
+ return result.scalars().all()
+
+ async def count_active_owners(self, tenant_id: UUID) -> int:
+ stmt = (
+ select(func.count())
+ .select_from(TenantMembership)
+ .where(
+ TenantMembership.tenant_id == tenant_id,
+ TenantMembership.is_owner.is_(True),
+ TenantMembership.status == MembershipStatus.ACTIVE,
+ )
+ )
+ result = await self.session.execute(stmt)
+ return int(result.scalar_one())
diff --git a/backend/core-service/app/repositories/plan.py b/backend/core-service/app/repositories/plan.py
new file mode 100644
index 0000000..2c92cf9
--- /dev/null
+++ b/backend/core-service/app/repositories/plan.py
@@ -0,0 +1,57 @@
+"""Repository مربوط به Plan، Feature و PlanFeature."""
+from __future__ import annotations
+
+from typing import Sequence
+from uuid import UUID
+
+from sqlalchemy import func, select
+
+from app.models.plan import Feature, Plan, PlanFeature
+from app.repositories.base import BaseRepository
+
+
+class PlanRepository(BaseRepository[Plan]):
+ model = Plan
+
+ async def get_by_code(self, code: str) -> Plan | None:
+ stmt = select(Plan).where(Plan.code == code)
+ result = await self.session.execute(stmt)
+ return result.scalar_one_or_none()
+
+ async def code_exists(self, code: str) -> bool:
+ stmt = select(func.count()).select_from(Plan).where(Plan.code == code)
+ result = await self.session.execute(stmt)
+ return int(result.scalar_one()) > 0
+
+
+class FeatureRepository(BaseRepository[Feature]):
+ model = Feature
+
+ async def get_by_key(self, feature_key: str) -> Feature | None:
+ stmt = select(Feature).where(Feature.feature_key == feature_key)
+ result = await self.session.execute(stmt)
+ return result.scalar_one_or_none()
+
+ async def key_exists(self, feature_key: str) -> bool:
+ stmt = select(func.count()).select_from(Feature).where(
+ Feature.feature_key == feature_key
+ )
+ result = await self.session.execute(stmt)
+ return int(result.scalar_one()) > 0
+
+
+class PlanFeatureRepository(BaseRepository[PlanFeature]):
+ model = PlanFeature
+
+ async def get(self, plan_id: UUID, feature_id: UUID) -> PlanFeature | None:
+ stmt = select(PlanFeature).where(
+ PlanFeature.plan_id == plan_id,
+ PlanFeature.feature_id == feature_id,
+ )
+ result = await self.session.execute(stmt)
+ return result.scalar_one_or_none()
+
+ async def list_by_plan(self, plan_id: UUID) -> Sequence[PlanFeature]:
+ stmt = select(PlanFeature).where(PlanFeature.plan_id == plan_id)
+ result = await self.session.execute(stmt)
+ return result.scalars().all()
diff --git a/backend/core-service/app/repositories/registry.py b/backend/core-service/app/repositories/registry.py
new file mode 100644
index 0000000..e421023
--- /dev/null
+++ b/backend/core-service/app/repositories/registry.py
@@ -0,0 +1,36 @@
+"""Repository مربوط به ServiceRegistry و رویدادها."""
+from __future__ import annotations
+
+from typing import Sequence
+
+from sqlalchemy import select
+
+from app.models.events import OutboxEvent
+from app.models.registry import ServiceRegistry
+from app.repositories.base import BaseRepository
+from shared.events import EventStatus
+
+
+class ServiceRegistryRepository(BaseRepository[ServiceRegistry]):
+ model = ServiceRegistry
+
+ async def get_by_key(self, service_key: str) -> ServiceRegistry | None:
+ stmt = select(ServiceRegistry).where(
+ ServiceRegistry.service_key == service_key
+ )
+ result = await self.session.execute(stmt)
+ return result.scalar_one_or_none()
+
+
+class OutboxRepository(BaseRepository[OutboxEvent]):
+ model = OutboxEvent
+
+ async def list_pending(self, limit: int = 100) -> Sequence[OutboxEvent]:
+ stmt = (
+ select(OutboxEvent)
+ .where(OutboxEvent.status == EventStatus.PENDING)
+ .order_by(OutboxEvent.created_at.asc())
+ .limit(limit)
+ )
+ result = await self.session.execute(stmt)
+ return result.scalars().all()
diff --git a/backend/core-service/app/repositories/subscription.py b/backend/core-service/app/repositories/subscription.py
new file mode 100644
index 0000000..2812a69
--- /dev/null
+++ b/backend/core-service/app/repositories/subscription.py
@@ -0,0 +1,39 @@
+"""Repository مربوط به اشتراک و دسترسی قابلیت tenant."""
+from __future__ import annotations
+
+from uuid import UUID
+
+from sqlalchemy import select
+from sqlalchemy.orm import joinedload
+
+from app.models.subscription import TenantFeatureAccess, TenantSubscription
+from app.repositories.base import BaseRepository
+
+
+class SubscriptionRepository(BaseRepository[TenantSubscription]):
+ model = TenantSubscription
+
+ async def get_active_by_tenant(self, tenant_id: UUID) -> TenantSubscription | None:
+ """آخرین اشتراک tenant را برمیگرداند (جدیدترین بر اساس created_at)."""
+ stmt = (
+ select(TenantSubscription)
+ .where(TenantSubscription.tenant_id == tenant_id)
+ .order_by(TenantSubscription.created_at.desc())
+ .limit(1)
+ )
+ result = await self.session.execute(stmt)
+ return result.scalar_one_or_none()
+
+
+class TenantFeatureAccessRepository(BaseRepository[TenantFeatureAccess]):
+ model = TenantFeatureAccess
+
+ async def get(
+ self, tenant_id: UUID, feature_id: UUID
+ ) -> TenantFeatureAccess | None:
+ stmt = select(TenantFeatureAccess).where(
+ TenantFeatureAccess.tenant_id == tenant_id,
+ TenantFeatureAccess.feature_id == feature_id,
+ )
+ result = await self.session.execute(stmt)
+ return result.scalar_one_or_none()
diff --git a/backend/core-service/app/repositories/tenant.py b/backend/core-service/app/repositories/tenant.py
new file mode 100644
index 0000000..6b29642
--- /dev/null
+++ b/backend/core-service/app/repositories/tenant.py
@@ -0,0 +1,65 @@
+"""Repository مربوط به Tenant و Domain."""
+from __future__ import annotations
+
+from typing import Sequence
+from uuid import UUID
+
+from sqlalchemy import func, select
+
+from app.models.domain import Domain
+from app.models.tenant import Tenant
+from app.repositories.base import BaseRepository
+
+
+class TenantRepository(BaseRepository[Tenant]):
+ model = Tenant
+
+ async def get_by_slug(self, slug: str) -> Tenant | None:
+ stmt = select(Tenant).where(Tenant.slug == slug)
+ result = await self.session.execute(stmt)
+ return result.scalar_one_or_none()
+
+ async def slug_exists(self, slug: str) -> bool:
+ stmt = select(func.count()).select_from(Tenant).where(Tenant.slug == slug)
+ result = await self.session.execute(stmt)
+ return int(result.scalar_one()) > 0
+
+ async def list_by_owner(
+ self, owner_user_id: UUID, *, offset: int = 0, limit: int = 20
+ ) -> Sequence[Tenant]:
+ stmt = (
+ select(Tenant)
+ .where(Tenant.owner_user_id == owner_user_id)
+ .offset(offset)
+ .limit(limit)
+ )
+ result = await self.session.execute(stmt)
+ return result.scalars().all()
+
+ async def count_by_owner(self, owner_user_id: UUID) -> int:
+ stmt = (
+ select(func.count())
+ .select_from(Tenant)
+ .where(Tenant.owner_user_id == owner_user_id)
+ )
+ result = await self.session.execute(stmt)
+ return int(result.scalar_one())
+
+
+class DomainRepository(BaseRepository[Domain]):
+ model = Domain
+
+ async def get_by_domain(self, domain: str) -> Domain | None:
+ stmt = select(Domain).where(Domain.domain == domain)
+ result = await self.session.execute(stmt)
+ return result.scalar_one_or_none()
+
+ async def list_by_tenant(self, tenant_id: UUID) -> Sequence[Domain]:
+ stmt = select(Domain).where(Domain.tenant_id == tenant_id)
+ result = await self.session.execute(stmt)
+ return result.scalars().all()
+
+ async def domain_exists(self, domain: str) -> bool:
+ stmt = select(func.count()).select_from(Domain).where(Domain.domain == domain)
+ result = await self.session.execute(stmt)
+ return int(result.scalar_one()) > 0
diff --git a/backend/core-service/app/repositories/user.py b/backend/core-service/app/repositories/user.py
new file mode 100644
index 0000000..ec04224
--- /dev/null
+++ b/backend/core-service/app/repositories/user.py
@@ -0,0 +1,21 @@
+"""Repository مربوط به User."""
+from __future__ import annotations
+
+from sqlalchemy import select
+
+from app.models.user import User
+from app.repositories.base import BaseRepository
+
+
+class UserRepository(BaseRepository[User]):
+ model = User
+
+ async def get_by_mobile(self, mobile: str) -> User | None:
+ stmt = select(User).where(User.mobile == mobile)
+ result = await self.session.execute(stmt)
+ return result.scalar_one_or_none()
+
+ async def get_by_keycloak_sub(self, keycloak_sub: str) -> User | None:
+ stmt = select(User).where(User.keycloak_sub == keycloak_sub)
+ result = await self.session.execute(stmt)
+ return result.scalar_one_or_none()
diff --git a/backend/core-service/app/schemas/__init__.py b/backend/core-service/app/schemas/__init__.py
new file mode 100644
index 0000000..84c4297
--- /dev/null
+++ b/backend/core-service/app/schemas/__init__.py
@@ -0,0 +1 @@
+"""اسکیماهای Pydantic v2 برای ورودی/خروجی API."""
diff --git a/backend/core-service/app/schemas/auth.py b/backend/core-service/app/schemas/auth.py
new file mode 100644
index 0000000..7364a37
--- /dev/null
+++ b/backend/core-service/app/schemas/auth.py
@@ -0,0 +1,57 @@
+"""اسکیماهای احراز هویت OTP."""
+from __future__ import annotations
+
+from typing import Literal
+from uuid import UUID
+
+from pydantic import BaseModel, Field, field_validator
+
+from app.utils.phone import normalize_mobile, normalize_otp_digits
+
+
+class OtpRequestPayload(BaseModel):
+ mobile: str = Field(..., min_length=10, max_length=15)
+ force_sms: bool = False
+ context: Literal["public", "admin"] = "public"
+
+ @field_validator("mobile")
+ @classmethod
+ def validate_mobile(cls, value: str) -> str:
+ return normalize_mobile(value)
+
+
+class OtpRequestResponse(BaseModel):
+ message: str
+ expires_in: int
+ skipped: bool = False
+
+
+class OtpVerifyPayload(BaseModel):
+ mobile: str = Field(..., min_length=10, max_length=15)
+ code: str = Field(..., min_length=4, max_length=8)
+ context: Literal["public", "admin"] = "public"
+ keycloak_sub: str | None = None
+ email: str | None = None
+
+ @field_validator("mobile")
+ @classmethod
+ def validate_mobile(cls, value: str) -> str:
+ return normalize_mobile(value)
+
+ @field_validator("code")
+ @classmethod
+ def validate_code(cls, value: str) -> str:
+ normalized = normalize_otp_digits(value)
+ if not normalized:
+ raise ValueError("کد تأیید نامعتبر است")
+ return normalized
+
+
+class TokenResponse(BaseModel):
+ access_token: str
+ refresh_token: str
+ token_type: str = "bearer"
+ expires_in: int
+ is_new_user: bool = False
+ user_id: UUID
+ role: str
diff --git a/backend/core-service/app/schemas/common.py b/backend/core-service/app/schemas/common.py
new file mode 100644
index 0000000..dc12ae0
--- /dev/null
+++ b/backend/core-service/app/schemas/common.py
@@ -0,0 +1,18 @@
+"""اسکیماهای پایه و مشترک."""
+from __future__ import annotations
+
+from pydantic import BaseModel, ConfigDict
+
+
+class ORMModel(BaseModel):
+ """پایه اسکیماهای خروجی که از مدل ORM ساخته میشوند."""
+
+ model_config = ConfigDict(from_attributes=True)
+
+
+class HealthResponse(BaseModel):
+ status: str = "ok"
+ service: str
+ version: str
+ environment: str
+ dependencies: dict[str, str] = {}
diff --git a/backend/core-service/app/schemas/domain.py b/backend/core-service/app/schemas/domain.py
new file mode 100644
index 0000000..cd5f18c
--- /dev/null
+++ b/backend/core-service/app/schemas/domain.py
@@ -0,0 +1,46 @@
+"""اسکیماهای Domain."""
+from __future__ import annotations
+
+from datetime import datetime
+from uuid import UUID
+
+from pydantic import BaseModel, Field, field_validator
+
+from app.models.enums import DomainType
+from app.schemas.common import ORMModel
+
+
+class DomainCreate(BaseModel):
+ domain: str = Field(..., min_length=1, max_length=255)
+ domain_type: DomainType = DomainType.SUBDOMAIN
+
+ @field_validator("domain")
+ @classmethod
+ def normalize_domain(cls, value: str) -> str:
+ return value.strip().lower()
+
+
+class DomainRead(ORMModel):
+ id: UUID
+ tenant_id: UUID
+ domain: str
+ domain_type: DomainType
+ is_verified: bool
+ verified_at: datetime | None
+ created_at: datetime
+
+
+class DomainResolveRequest(BaseModel):
+ domain: str = Field(..., min_length=1, max_length=255)
+
+ @field_validator("domain")
+ @classmethod
+ def normalize_domain(cls, value: str) -> str:
+ return value.strip().lower()
+
+
+class DomainResolveResponse(BaseModel):
+ tenant_id: UUID
+ tenant_slug: str
+ domain: str
+ is_verified: bool
diff --git a/backend/core-service/app/schemas/membership.py b/backend/core-service/app/schemas/membership.py
new file mode 100644
index 0000000..cf54299
--- /dev/null
+++ b/backend/core-service/app/schemas/membership.py
@@ -0,0 +1,19 @@
+"""اسکیماهای TenantMembership."""
+from __future__ import annotations
+
+from datetime import datetime
+from uuid import UUID
+
+from app.models.enums import MembershipStatus, TenantMembershipRole
+from app.schemas.common import ORMModel
+
+
+class MembershipRead(ORMModel):
+ id: UUID
+ tenant_id: UUID
+ user_id: UUID
+ role: TenantMembershipRole
+ status: MembershipStatus
+ is_owner: bool
+ created_at: datetime
+ updated_at: datetime
diff --git a/backend/core-service/app/schemas/onboarding.py b/backend/core-service/app/schemas/onboarding.py
new file mode 100644
index 0000000..007a39e
--- /dev/null
+++ b/backend/core-service/app/schemas/onboarding.py
@@ -0,0 +1,88 @@
+"""اسکیماهای Onboarding و Tenant Context."""
+from __future__ import annotations
+
+from uuid import UUID
+
+from pydantic import BaseModel, Field, field_validator
+
+from app.models.enums import MembershipStatus, TenantMembershipRole, TenantStatus
+from app.schemas.domain import DomainRead
+from app.schemas.tenant import SLUG_PATTERN, TenantRead
+
+
+class OnboardingTenantCreate(BaseModel):
+ """گام ۱ onboarding: ایجاد tenant توسط کاربر جاری."""
+
+ business_name: str = Field(..., min_length=1, max_length=255)
+ slug: str = Field(..., min_length=1, max_length=100)
+ business_type: str | None = Field(default=None, max_length=100)
+ default_locale: str = Field(default="fa-IR", max_length=20)
+ timezone: str = Field(default="Asia/Tehran", max_length=50)
+
+ @field_validator("slug")
+ @classmethod
+ def validate_slug(cls, value: str) -> str:
+ value = value.strip().lower()
+ if not SLUG_PATTERN.match(value):
+ raise ValueError(
+ "slug باید فقط شامل حروف کوچک انگلیسی، اعداد و خط تیره باشد"
+ )
+ return value
+
+
+class OnboardingBrandingUpdate(BaseModel):
+ """گام ۲ onboarding: برندینگ."""
+
+ primary_color: str | None = Field(default=None, max_length=20)
+ secondary_color: str | None = Field(default=None, max_length=20)
+ logo_url: str | None = Field(default=None, max_length=500)
+ favicon_url: str | None = Field(default=None, max_length=500)
+
+
+class OnboardingDomainUpdate(BaseModel):
+ """گام ۳ onboarding: دامنه اختصاصی (زیردامنه بهصورت خودکار از slug ساخته میشود)."""
+
+ custom_domain: str | None = Field(default=None, max_length=255)
+
+ @field_validator("custom_domain")
+ @classmethod
+ def normalize_domain(cls, value: str | None) -> str | None:
+ if value is None or not value.strip():
+ return None
+ return value.strip().lower()
+
+
+class MembershipSummary(BaseModel):
+ tenant_id: UUID
+ tenant_name: str
+ tenant_slug: str
+ tenant_status: TenantStatus
+ onboarding_completed: bool
+ role: TenantMembershipRole
+ status: MembershipStatus
+ is_owner: bool
+
+
+class MeResponse(BaseModel):
+ user_id: UUID
+ mobile: str
+ email: str | None
+ platform_role: str
+ onboarding_required: bool
+ current_tenant_id: UUID | None
+ memberships: list[MembershipSummary]
+
+
+class TenantContextRead(BaseModel):
+ tenant: TenantRead
+ role: TenantMembershipRole | None = None
+ is_owner: bool = False
+ plan_code: str | None = None
+ plan_name: str | None = None
+ subscription_status: str | None = None
+ domains: list[DomainRead] = Field(default_factory=list)
+ primary_domain: str | None = None
+
+
+class TenantSwitchRequest(BaseModel):
+ tenant_id: UUID
diff --git a/backend/core-service/app/schemas/plan.py b/backend/core-service/app/schemas/plan.py
new file mode 100644
index 0000000..84b2d59
--- /dev/null
+++ b/backend/core-service/app/schemas/plan.py
@@ -0,0 +1,62 @@
+"""اسکیماهای Plan، Feature و PlanFeature."""
+from __future__ import annotations
+
+from datetime import datetime
+from uuid import UUID
+
+from pydantic import BaseModel, Field
+
+from app.models.enums import LimitPeriod
+from app.schemas.common import ORMModel
+
+
+# ---- Plan ----
+class PlanCreate(BaseModel):
+ code: str = Field(..., min_length=1, max_length=100)
+ name: str = Field(..., min_length=1, max_length=255)
+ description: str | None = None
+ is_active: bool = True
+
+
+class PlanRead(ORMModel):
+ id: UUID
+ code: str
+ name: str
+ description: str | None
+ is_active: bool
+ created_at: datetime
+
+
+# ---- Feature ----
+class FeatureCreate(BaseModel):
+ feature_key: str = Field(..., min_length=1, max_length=150)
+ name: str = Field(..., min_length=1, max_length=255)
+ description: str | None = None
+ service_key: str = Field(..., min_length=1, max_length=100)
+ is_active: bool = True
+
+
+class FeatureRead(ORMModel):
+ id: UUID
+ feature_key: str
+ name: str
+ description: str | None
+ service_key: str
+ is_active: bool
+
+
+# ---- PlanFeature ----
+class PlanFeatureCreate(BaseModel):
+ feature_id: UUID
+ limit_value: int | None = Field(default=None, ge=0)
+ limit_period: LimitPeriod | None = None
+ is_enabled: bool = True
+
+
+class PlanFeatureRead(ORMModel):
+ id: UUID
+ plan_id: UUID
+ feature_id: UUID
+ limit_value: int | None
+ limit_period: LimitPeriod | None
+ is_enabled: bool
diff --git a/backend/core-service/app/schemas/service_registry.py b/backend/core-service/app/schemas/service_registry.py
new file mode 100644
index 0000000..34bc6d3
--- /dev/null
+++ b/backend/core-service/app/schemas/service_registry.py
@@ -0,0 +1,32 @@
+"""اسکیماهای ServiceRegistry."""
+from __future__ import annotations
+
+from datetime import datetime
+from uuid import UUID
+
+from pydantic import BaseModel, Field
+
+from app.models.enums import ServiceStatus
+from app.schemas.common import ORMModel
+
+
+class ServiceCreate(BaseModel):
+ service_key: str = Field(..., min_length=1, max_length=100)
+ name: str = Field(..., min_length=1, max_length=255)
+ base_url: str | None = Field(default=None, max_length=500)
+ health_check_url: str | None = Field(default=None, max_length=500)
+ status: ServiceStatus = ServiceStatus.ACTIVE
+
+
+class ServiceStatusUpdate(BaseModel):
+ status: ServiceStatus
+
+
+class ServiceRead(ORMModel):
+ id: UUID
+ service_key: str
+ name: str
+ base_url: str | None
+ status: ServiceStatus
+ health_check_url: str | None
+ created_at: datetime
diff --git a/backend/core-service/app/schemas/subscription.py b/backend/core-service/app/schemas/subscription.py
new file mode 100644
index 0000000..97e0999
--- /dev/null
+++ b/backend/core-service/app/schemas/subscription.py
@@ -0,0 +1,40 @@
+"""اسکیماهای اشتراک و بررسی دسترسی قابلیت."""
+from __future__ import annotations
+
+from datetime import datetime
+from uuid import UUID
+
+from pydantic import BaseModel
+
+from app.models.enums import SubscriptionStatus
+from app.schemas.common import ORMModel
+
+
+class SubscriptionCreate(BaseModel):
+ plan_id: UUID
+ status: SubscriptionStatus = SubscriptionStatus.ACTIVE
+ starts_at: datetime | None = None
+ ends_at: datetime | None = None
+ trial_ends_at: datetime | None = None
+
+
+class SubscriptionRead(ORMModel):
+ id: UUID
+ tenant_id: UUID
+ plan_id: UUID
+ status: SubscriptionStatus
+ starts_at: datetime | None
+ ends_at: datetime | None
+ trial_ends_at: datetime | None
+ created_at: datetime
+
+
+class FeatureCheckRequest(BaseModel):
+ feature_key: str
+
+
+class FeatureCheckResponse(BaseModel):
+ tenant_id: UUID
+ feature_key: str
+ has_access: bool
+ reason: str | None = None
diff --git a/backend/core-service/app/schemas/tenant.py b/backend/core-service/app/schemas/tenant.py
new file mode 100644
index 0000000..a27ebf8
--- /dev/null
+++ b/backend/core-service/app/schemas/tenant.py
@@ -0,0 +1,79 @@
+"""اسکیماهای Tenant."""
+from __future__ import annotations
+
+import re
+from datetime import datetime
+from uuid import UUID
+
+from pydantic import BaseModel, Field, field_validator
+
+from app.models.enums import TenantStatus
+from app.schemas.common import ORMModel
+
+SLUG_PATTERN = re.compile(r"^[a-z0-9]([a-z0-9-]{0,98}[a-z0-9])?$")
+
+
+class TenantCreate(BaseModel):
+ name: str = Field(..., min_length=1, max_length=255)
+ slug: str = Field(..., min_length=1, max_length=100)
+ owner_user_id: UUID | None = None
+ custom_domain: str | None = Field(default=None, max_length=255)
+
+ @field_validator("slug")
+ @classmethod
+ def validate_slug(cls, value: str) -> str:
+ value = value.strip().lower()
+ if not SLUG_PATTERN.match(value):
+ raise ValueError(
+ "slug باید فقط شامل حروف کوچک انگلیسی، اعداد و خط تیره باشد"
+ )
+ return value
+
+ @field_validator("custom_domain")
+ @classmethod
+ def normalize_domain(cls, value: str | None) -> str | None:
+ if value is None or not value.strip():
+ return None
+ return value.strip().lower()
+
+
+class AdminTenantCreate(BaseModel):
+ """ایجاد tenant توسط کاربر احراز هویتشده (مالک = کاربر جاری)."""
+
+ name: str = Field(..., min_length=1, max_length=255)
+ slug: str = Field(..., min_length=1, max_length=100)
+ custom_domain: str | None = Field(default=None, max_length=255)
+
+ @field_validator("slug")
+ @classmethod
+ def validate_slug(cls, value: str) -> str:
+ return TenantCreate.validate_slug(value)
+
+ @field_validator("custom_domain")
+ @classmethod
+ def normalize_domain(cls, value: str | None) -> str | None:
+ return TenantCreate.normalize_domain(value)
+
+
+class TenantUpdate(BaseModel):
+ name: str | None = Field(default=None, min_length=1, max_length=255)
+ status: TenantStatus | None = None
+ owner_user_id: UUID | None = None
+
+
+class TenantRead(ORMModel):
+ id: UUID
+ name: str
+ slug: str
+ status: TenantStatus
+ owner_user_id: UUID | None
+ business_type: str | None = None
+ default_locale: str = "fa-IR"
+ timezone: str = "Asia/Tehran"
+ primary_color: str | None = None
+ secondary_color: str | None = None
+ logo_url: str | None = None
+ favicon_url: str | None = None
+ onboarding_completed: bool = False
+ created_at: datetime
+ updated_at: datetime
diff --git a/backend/core-service/app/services/__init__.py b/backend/core-service/app/services/__init__.py
new file mode 100644
index 0000000..738cb31
--- /dev/null
+++ b/backend/core-service/app/services/__init__.py
@@ -0,0 +1 @@
+"""لایه Service: منطق کسبوکار Core Platform."""
diff --git a/backend/core-service/app/services/auth_service.py b/backend/core-service/app/services/auth_service.py
new file mode 100644
index 0000000..8fbc48c
--- /dev/null
+++ b/backend/core-service/app/services/auth_service.py
@@ -0,0 +1,107 @@
+"""سرویس احراز هویت: ثبتنام خودکار و صدور توکن."""
+from __future__ import annotations
+
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from app.core.config import settings
+from app.models.audit import AuditLog
+from app.models.enums import UserRole, UserStatus
+from app.models.user import User
+from app.repositories.user import UserRepository
+from app.schemas.auth import OtpRequestResponse, OtpVerifyPayload, TokenResponse
+from app.services.otp_service import OtpService
+from app.services.payamak_client import SmsDeliveryError
+from app.services.token_service import TokenService
+from shared.exceptions import AppError, UnauthorizedError
+
+
+class SmsSendError(AppError):
+ status_code = 502
+ error_code = "sms_delivery_failed"
+
+
+class AuthService:
+ def __init__(self, session: AsyncSession) -> None:
+ self.session = session
+ self.users = UserRepository(session)
+ self.otp = OtpService()
+ self.tokens = TokenService()
+
+ async def request_otp(self, mobile: str, *, force_sms: bool = False, context: str = "public") -> OtpRequestResponse:
+ try:
+ expires_in, skipped = await self.otp.request_otp(mobile, force=force_sms)
+ except SmsDeliveryError as exc:
+ raise SmsSendError(str(exc)) from exc
+ message = "کد قبلاً ارسال شده" if skipped else "کد تأیید ارسال شد"
+ return OtpRequestResponse(
+ message=message,
+ expires_in=expires_in,
+ skipped=skipped,
+ )
+
+ async def verify_otp(self, payload: OtpVerifyPayload) -> TokenResponse:
+ if not await self.otp.verify_otp(payload.mobile, payload.code):
+ raise UnauthorizedError("کد تأیید نامعتبر یا منقضی شده است")
+
+ user = await self.users.get_by_mobile(payload.mobile)
+ is_new_user = user is None
+ is_platform_admin = payload.mobile in settings.platform_admin_mobile_set
+
+ if is_new_user:
+ if is_platform_admin:
+ role = UserRole.PLATFORM_ADMIN
+ elif payload.context == "admin":
+ role = UserRole.PENDING_TENANT_ADMIN
+ else:
+ role = UserRole.USER
+ user = User(
+ mobile=payload.mobile,
+ role=role,
+ status=UserStatus.ACTIVE,
+ mobile_verified=True,
+ keycloak_sub=payload.keycloak_sub,
+ email=payload.email,
+ )
+ await self.users.add(user)
+ await self._log_registration(user)
+ await self.session.commit()
+ await self.session.refresh(user)
+ else:
+ if user.status != UserStatus.ACTIVE:
+ raise UnauthorizedError("حساب کاربری غیرفعال است")
+ if is_platform_admin and user.role != UserRole.PLATFORM_ADMIN:
+ user.role = UserRole.PLATFORM_ADMIN
+ user.mobile_verified = True
+ if payload.keycloak_sub and not user.keycloak_sub:
+ user.keycloak_sub = payload.keycloak_sub
+ if payload.email and not user.email:
+ user.email = payload.email
+ await self.session.commit()
+ await self.session.refresh(user)
+
+ access_token, expires_in = self.tokens.create_access_token(
+ user_id=user.id,
+ mobile=user.mobile,
+ role=user.role,
+ )
+ refresh_token = self.tokens.create_refresh_token(user.id)
+
+ return TokenResponse(
+ access_token=access_token,
+ refresh_token=refresh_token,
+ expires_in=expires_in,
+ is_new_user=is_new_user,
+ user_id=user.id,
+ role=user.role.value,
+ )
+
+ async def _log_registration(self, user: User) -> None:
+ log = AuditLog(
+ tenant_id=None,
+ actor_user_id=user.id,
+ action="user.registered",
+ resource_type="user",
+ resource_id=str(user.id),
+ meta_data={"mobile": user.mobile, "role": user.role.value},
+ )
+ self.session.add(log)
diff --git a/backend/core-service/app/services/domain_service.py b/backend/core-service/app/services/domain_service.py
new file mode 100644
index 0000000..bfe7176
--- /dev/null
+++ b/backend/core-service/app/services/domain_service.py
@@ -0,0 +1,58 @@
+"""سرویس مدیریت و resolve دامنهها."""
+from __future__ import annotations
+
+from typing import Sequence
+from uuid import UUID
+
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from app.models.domain import Domain
+from app.repositories.tenant import DomainRepository, TenantRepository
+from app.schemas.domain import DomainCreate
+from app.services.event_service import EventService
+from shared.events import CoreEventType
+from shared.exceptions import ConflictError, NotFoundError
+
+
+class DomainService:
+ def __init__(self, session: AsyncSession) -> None:
+ self.session = session
+ self.repo = DomainRepository(session)
+ self.tenant_repo = TenantRepository(session)
+ self.events = EventService(session)
+
+ async def create(self, tenant_id: UUID, data: DomainCreate) -> Domain:
+ tenant = await self.tenant_repo.get(tenant_id)
+ if tenant is None:
+ raise NotFoundError(f"tenant یافت نشد: {tenant_id}")
+ if await self.repo.domain_exists(data.domain):
+ raise ConflictError(
+ f"دامنه تکراری است: {data.domain}", error_code="domain_taken"
+ )
+
+ domain = Domain(
+ tenant_id=tenant_id,
+ domain=data.domain,
+ domain_type=data.domain_type,
+ )
+ await self.repo.add(domain)
+ await self.events.record(
+ event_type=CoreEventType.DOMAIN_CREATED.value,
+ aggregate_type="domain",
+ aggregate_id=str(domain.id),
+ tenant_id=tenant_id,
+ payload={"domain": domain.domain, "type": domain.domain_type.value},
+ )
+ await self.session.commit()
+ await self.session.refresh(domain)
+ return domain
+
+ async def list_by_tenant(self, tenant_id: UUID) -> Sequence[Domain]:
+ return await self.repo.list_by_tenant(tenant_id)
+
+ async def resolve(self, domain: str) -> Domain:
+ """یافتن دامنه و tenant متناظر آن."""
+ found = await self.repo.get_by_domain(domain.strip().lower())
+ if found is None:
+ raise NotFoundError(f"دامنه یافت نشد: {domain}")
+ return found
diff --git a/backend/core-service/app/services/entitlement_service.py b/backend/core-service/app/services/entitlement_service.py
new file mode 100644
index 0000000..cee6d1d
--- /dev/null
+++ b/backend/core-service/app/services/entitlement_service.py
@@ -0,0 +1,117 @@
+"""سرویس Entitlement: بررسی دسترسی tenant به یک قابلیت.
+
+قوانین بررسی (به ترتیب):
+1. ابتدا Redis cache بررسی میشود.
+2. اگر در cache نبود، از دیتابیس محاسبه میشود.
+3. نتیجه با TTL مشخص در Redis ذخیره میشود.
+4. اگر اشتراک tenant فعال نبود → دسترسی false.
+5. اگر قابلیت در پلن فعال نبود → false.
+6. اگر دسترسی سفارشی (custom access) وجود داشت، آن اولویت دارد.
+"""
+from __future__ import annotations
+
+from dataclasses import dataclass
+from uuid import UUID
+
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from app.core.cache import cache, feature_access_key
+from app.core.config import settings
+from app.models.enums import SubscriptionStatus, TenantStatus
+from app.repositories.plan import FeatureRepository, PlanFeatureRepository
+from app.repositories.subscription import (
+ SubscriptionRepository,
+ TenantFeatureAccessRepository,
+)
+from app.repositories.tenant import TenantRepository
+
+_ALLOWED_SUBSCRIPTION_STATUSES = {
+ SubscriptionStatus.ACTIVE,
+ SubscriptionStatus.TRIALING,
+}
+
+
+@dataclass
+class AccessResult:
+ has_access: bool
+ reason: str
+
+
+class EntitlementService:
+ def __init__(self, session: AsyncSession) -> None:
+ self.session = session
+ self.tenant_repo = TenantRepository(session)
+ self.feature_repo = FeatureRepository(session)
+ self.plan_feature_repo = PlanFeatureRepository(session)
+ self.subscription_repo = SubscriptionRepository(session)
+ self.feature_access_repo = TenantFeatureAccessRepository(session)
+
+ async def check_feature_access(
+ self, tenant_id: UUID, feature_key: str
+ ) -> bool:
+ """بررسی ساده دسترسی؛ فقط مقدار boolean برمیگرداند."""
+ result = await self.evaluate(tenant_id, feature_key)
+ return result.has_access
+
+ async def evaluate(self, tenant_id: UUID, feature_key: str) -> AccessResult:
+ """بررسی کامل دسترسی همراه با دلیل، با استفاده از cache."""
+ cache_key = feature_access_key(str(tenant_id), feature_key)
+
+ cached = await cache.get(cache_key)
+ if cached is not None:
+ return AccessResult(has_access=cached == "1", reason="cache")
+
+ result = await self._evaluate_from_db(tenant_id, feature_key)
+
+ await cache.set(
+ cache_key,
+ "1" if result.has_access else "0",
+ ttl=settings.feature_access_cache_ttl,
+ )
+ return result
+
+ async def _evaluate_from_db(
+ self, tenant_id: UUID, feature_key: str
+ ) -> AccessResult:
+ # ۱) tenant باید وجود داشته و فعال باشد.
+ tenant = await self.tenant_repo.get(tenant_id)
+ if tenant is None:
+ return AccessResult(False, "tenant_not_found")
+ if tenant.status != TenantStatus.ACTIVE:
+ return AccessResult(False, "tenant_inactive")
+
+ # ۲) قابلیت باید وجود داشته و فعال باشد.
+ feature = await self.feature_repo.get_by_key(feature_key)
+ if feature is None:
+ return AccessResult(False, "feature_not_found")
+ if not feature.is_active:
+ return AccessResult(False, "feature_inactive")
+
+ # ۳) بررسی override سفارشی tenant (بالاترین اولویت).
+ custom = await self.feature_access_repo.get(tenant_id, feature.id)
+ if custom is not None:
+ if not custom.is_enabled:
+ return AccessResult(False, "custom_disabled")
+ # اگر سقف سفارشی تعریف شده و مصرف از آن گذشته باشد → عدم دسترسی.
+ if (
+ custom.custom_limit_value is not None
+ and custom.used_value >= custom.custom_limit_value
+ ):
+ return AccessResult(False, "custom_limit_exceeded")
+ return AccessResult(True, "custom_enabled")
+
+ # ۴) اشتراک tenant باید فعال باشد.
+ subscription = await self.subscription_repo.get_active_by_tenant(tenant_id)
+ if subscription is None:
+ return AccessResult(False, "no_subscription")
+ if subscription.status not in _ALLOWED_SUBSCRIPTION_STATUSES:
+ return AccessResult(False, "subscription_inactive")
+
+ # ۵) قابلیت باید در پلن اشتراک فعال باشد.
+ plan_feature = await self.plan_feature_repo.get(
+ subscription.plan_id, feature.id
+ )
+ if plan_feature is None or not plan_feature.is_enabled:
+ return AccessResult(False, "feature_not_in_plan")
+
+ return AccessResult(True, "plan_enabled")
diff --git a/backend/core-service/app/services/event_service.py b/backend/core-service/app/services/event_service.py
new file mode 100644
index 0000000..132b3d4
--- /dev/null
+++ b/backend/core-service/app/services/event_service.py
@@ -0,0 +1,38 @@
+"""سرویس ثبت رویداد در Outbox.
+
+طبق الگوی Outbox، رویدادها ابتدا در همان تراکنش دیتابیس ذخیره میشوند و
+سپس توسط worker (Celery) منتشر میگردند. این کار atomicity را تضمین میکند.
+"""
+from __future__ import annotations
+
+from uuid import UUID
+
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from app.models.events import OutboxEvent
+
+
+class EventService:
+ def __init__(self, session: AsyncSession) -> None:
+ self.session = session
+
+ async def record(
+ self,
+ *,
+ event_type: str,
+ aggregate_type: str,
+ aggregate_id: str | None = None,
+ tenant_id: UUID | None = None,
+ payload: dict | None = None,
+ ) -> OutboxEvent:
+ """ثبت یک رویداد جدید در Outbox (بدون commit؛ در تراکنش جاری)."""
+ event = OutboxEvent(
+ event_type=event_type,
+ aggregate_type=aggregate_type,
+ aggregate_id=aggregate_id,
+ tenant_id=tenant_id,
+ payload=payload or {},
+ )
+ self.session.add(event)
+ await self.session.flush()
+ return event
diff --git a/backend/core-service/app/services/membership_service.py b/backend/core-service/app/services/membership_service.py
new file mode 100644
index 0000000..9d56354
--- /dev/null
+++ b/backend/core-service/app/services/membership_service.py
@@ -0,0 +1,83 @@
+"""سرویس مدیریت عضویت کاربر در tenant (TenantMembership)."""
+from __future__ import annotations
+
+from typing import Sequence
+from uuid import UUID
+
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from app.models.enums import MembershipStatus, TenantMembershipRole
+from app.models.membership import TenantMembership
+from app.repositories.membership import TenantMembershipRepository
+from shared.auth import PlatformRole, has_role
+from shared.exceptions import ForbiddenError
+from shared.security import CurrentUser
+
+# نقشهایی که اجازه مدیریت تنظیمات tenant (برندینگ/دامنه/تکمیل onboarding) را دارند.
+TENANT_MANAGER_ROLES = {TenantMembershipRole.TENANT_OWNER, TenantMembershipRole.TENANT_ADMIN}
+
+
+class MembershipService:
+ def __init__(self, session: AsyncSession) -> None:
+ self.session = session
+ self.repo = TenantMembershipRepository(session)
+
+ async def create_owner_membership(
+ self, tenant_id: UUID, user_id: UUID
+ ) -> TenantMembership:
+ existing = await self.repo.get_by_tenant_and_user(tenant_id, user_id)
+ if existing is not None:
+ existing.role = TenantMembershipRole.TENANT_OWNER
+ existing.is_owner = True
+ existing.status = MembershipStatus.ACTIVE
+ await self.session.commit()
+ await self.session.refresh(existing)
+ return existing
+
+ membership = TenantMembership(
+ tenant_id=tenant_id,
+ user_id=user_id,
+ role=TenantMembershipRole.TENANT_OWNER,
+ is_owner=True,
+ status=MembershipStatus.ACTIVE,
+ )
+ await self.repo.add(membership)
+ await self.session.commit()
+ await self.session.refresh(membership)
+ return membership
+
+ async def get(self, tenant_id: UUID, user_id: UUID) -> TenantMembership | None:
+ return await self.repo.get_by_tenant_and_user(tenant_id, user_id)
+
+ async def list_for_user(self, user_id: UUID) -> Sequence[TenantMembership]:
+ return await self.repo.list_by_user(user_id)
+
+ async def list_for_tenant(self, tenant_id: UUID) -> Sequence[TenantMembership]:
+ return await self.repo.list_by_tenant(tenant_id)
+
+ async def ensure_role(
+ self,
+ *,
+ tenant_id: UUID,
+ core_user_id: UUID,
+ current_user: CurrentUser,
+ allowed_roles: set[TenantMembershipRole] = frozenset(TENANT_MANAGER_ROLES),
+ ) -> TenantMembership | None:
+ """اطمینان از اینکه کاربر جاری نقش کافی روی این tenant دارد.
+
+ platform_admin همیشه دسترسی کامل دارد (bypass).
+ """
+ if has_role(current_user, PlatformRole.PLATFORM_ADMIN):
+ return None
+
+ membership = await self.get(tenant_id, core_user_id)
+ if (
+ membership is None
+ or membership.status != MembershipStatus.ACTIVE
+ or membership.role not in allowed_roles
+ ):
+ raise ForbiddenError("دسترسی کافی برای مدیریت این tenant ندارید")
+ return membership
+
+ async def count_active_owners(self, tenant_id: UUID) -> int:
+ return await self.repo.count_active_owners(tenant_id)
diff --git a/backend/core-service/app/services/onboarding_service.py b/backend/core-service/app/services/onboarding_service.py
new file mode 100644
index 0000000..2581bf9
--- /dev/null
+++ b/backend/core-service/app/services/onboarding_service.py
@@ -0,0 +1,175 @@
+"""سرویس Onboarding: ایجاد و فعالسازی tenant توسط کاربر جاری.
+
+جریان کامل:
+ ۱. POST /onboarding/tenant → ایجاد tenant (status=pending_activation) + عضویت owner + پلن پیشفرض + زیردامنه خودکار
+ ۲. PATCH /onboarding/tenant/{id}/branding → تنظیم رنگ/لوگو
+ ۳. PATCH /onboarding/tenant/{id}/domain → افزودن دامنه اختصاصی (اختیاری)
+ ۴. POST /onboarding/tenant/{id}/complete → اعتبارسنجی و فعالسازی نهایی (status=active)
+"""
+from __future__ import annotations
+
+from uuid import UUID
+
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from app.core.config import settings
+from app.models.domain import Domain
+from app.models.enums import DomainType, DomainVerificationStatus, SubscriptionStatus, TenantStatus
+from app.models.tenant import Tenant
+from app.models.user import User
+from app.repositories.tenant import DomainRepository, TenantRepository
+from app.schemas.onboarding import (
+ OnboardingBrandingUpdate,
+ OnboardingDomainUpdate,
+ OnboardingTenantCreate,
+)
+from app.schemas.subscription import SubscriptionCreate
+from app.schemas.tenant import TenantCreate
+from app.services.event_service import EventService
+from app.services.membership_service import MembershipService
+from app.services.plan_service import PlanService
+from app.services.subscription_service import SubscriptionService
+from app.services.tenant_service import TenantService
+from shared.events import CoreEventType
+from shared.exceptions import ConflictError, ValidationAppError
+
+
+class OnboardingService:
+ def __init__(self, session: AsyncSession) -> None:
+ self.session = session
+ self.tenant_service = TenantService(session)
+ self.tenant_repo = TenantRepository(session)
+ self.domain_repo = DomainRepository(session)
+ self.membership_service = MembershipService(session)
+ self.plan_service = PlanService(session)
+ self.subscription_service = SubscriptionService(session)
+ self.events = EventService(session)
+
+ async def create_tenant(self, core_user: User, data: OnboardingTenantCreate) -> Tenant:
+ tenant = await self.tenant_service.create(
+ TenantCreate(
+ name=data.business_name,
+ slug=data.slug,
+ owner_user_id=core_user.id,
+ )
+ )
+
+ tenant.status = TenantStatus.PENDING_ACTIVATION
+ tenant.business_type = data.business_type
+ tenant.default_locale = data.default_locale
+ tenant.timezone = data.timezone
+ await self.session.commit()
+ await self.session.refresh(tenant)
+
+ await self.membership_service.create_owner_membership(tenant.id, core_user.id)
+
+ plan = await self.plan_service.ensure_default_plan()
+ await self.subscription_service.create(
+ tenant.id,
+ SubscriptionCreate(plan_id=plan.id, status=SubscriptionStatus.ACTIVE),
+ )
+
+ await self._create_default_subdomain(tenant)
+
+ if core_user.current_tenant_id is None:
+ core_user.current_tenant_id = tenant.id
+ await self.session.commit()
+
+ await self.session.refresh(tenant)
+ return tenant
+
+ async def _create_default_subdomain(self, tenant: Tenant) -> None:
+ base_domain = (settings.platform_base_domain or "").strip().lower()
+ if not base_domain:
+ return
+ host = f"{tenant.slug}.{base_domain}"
+ if await self.domain_repo.domain_exists(host):
+ return
+ domain = Domain(
+ tenant_id=tenant.id,
+ domain=host,
+ domain_type=DomainType.SUBDOMAIN,
+ is_primary=True,
+ is_verified=True,
+ verification_status=DomainVerificationStatus.VERIFIED,
+ )
+ await self.domain_repo.add(domain)
+ await self.events.record(
+ event_type=CoreEventType.DOMAIN_CREATED.value,
+ aggregate_type="domain",
+ aggregate_id=str(domain.id),
+ tenant_id=tenant.id,
+ payload={"domain": host, "type": "subdomain", "auto_assigned": True},
+ )
+ await self.session.commit()
+
+ async def update_branding(
+ self, tenant_id: UUID, data: OnboardingBrandingUpdate
+ ) -> Tenant:
+ tenant = await self.tenant_service.get(tenant_id)
+ if data.primary_color is not None:
+ tenant.primary_color = data.primary_color
+ if data.secondary_color is not None:
+ tenant.secondary_color = data.secondary_color
+ if data.logo_url is not None:
+ tenant.logo_url = data.logo_url
+ if data.favicon_url is not None:
+ tenant.favicon_url = data.favicon_url
+ await self.session.commit()
+ await self.session.refresh(tenant)
+ return tenant
+
+ async def update_domain(self, tenant_id: UUID, data: OnboardingDomainUpdate) -> Tenant:
+ tenant = await self.tenant_service.get(tenant_id)
+ if data.custom_domain:
+ if await self.domain_repo.domain_exists(data.custom_domain):
+ raise ConflictError(
+ f"دامنه تکراری است: {data.custom_domain}", error_code="domain_taken"
+ )
+ domain = Domain(
+ tenant_id=tenant.id,
+ domain=data.custom_domain,
+ domain_type=DomainType.CUSTOM_DOMAIN,
+ is_primary=False,
+ is_verified=False,
+ verification_status=DomainVerificationStatus.PENDING,
+ )
+ await self.domain_repo.add(domain)
+ await self.events.record(
+ event_type=CoreEventType.DOMAIN_CREATED.value,
+ aggregate_type="domain",
+ aggregate_id=str(domain.id),
+ tenant_id=tenant.id,
+ payload={"domain": data.custom_domain, "type": "custom"},
+ )
+ await self.session.commit()
+ await self.session.refresh(tenant)
+ return tenant
+
+ async def complete(self, tenant_id: UUID) -> Tenant:
+ tenant = await self.tenant_service.get(tenant_id)
+
+ if not tenant.name or not tenant.slug:
+ raise ValidationAppError(
+ "اطلاعات کسبوکار (نام/slug) تکمیل نشده است", error_code="onboarding_incomplete"
+ )
+
+ owners = await self.membership_service.count_active_owners(tenant_id)
+ if owners < 1:
+ raise ValidationAppError(
+ "tenant باید حداقل یک مالک فعال داشته باشد", error_code="owner_required"
+ )
+
+ tenant.onboarding_completed = True
+ tenant.status = TenantStatus.ACTIVE
+ await self.events.record(
+ event_type=CoreEventType.TENANT_ACTIVATED.value,
+ aggregate_type="tenant",
+ aggregate_id=str(tenant.id),
+ tenant_id=tenant.id,
+ payload={"onboarding_completed": True},
+ )
+ await self.session.commit()
+ await self.session.refresh(tenant)
+ await self.tenant_service._invalidate_cache(tenant.id)
+ return tenant
diff --git a/backend/core-service/app/services/otp_service.py b/backend/core-service/app/services/otp_service.py
new file mode 100644
index 0000000..2f2c79a
--- /dev/null
+++ b/backend/core-service/app/services/otp_service.py
@@ -0,0 +1,77 @@
+"""سرویس OTP — همان زنجیره torbatkar-back/User/utils.send_phone_otp_shared."""
+from __future__ import annotations
+
+import random
+import secrets
+
+from app.core.cache import cache
+from app.core.config import settings
+from app.services.payamak_client import PayamakClient, SmsDeliveryError, _normalize_phone_for_sms
+from app.utils.phone import normalize_mobile, normalize_otp_digits
+
+_memory_otp: dict[str, str] = {}
+
+
+def _otp_key(mobile: str) -> str:
+ return f"otp:{mobile}"
+
+
+class OtpService:
+ def __init__(self) -> None:
+ self.sms = PayamakClient()
+
+ def _generate_code(self) -> str:
+ if settings.environment == "test":
+ return "1234"
+ return str(random.randint(1111, 9999))
+
+ async def _get_stored_code(self, mobile: str) -> str | None:
+ key = _otp_key(mobile)
+ stored = await cache.get(key)
+ if stored is None and cache.client is None:
+ stored = _memory_otp.get(mobile)
+ return stored
+
+ async def request_otp(self, mobile: str, *, force: bool = False) -> tuple[int, bool]:
+ """
+ Returns:
+ (expires_in, skipped) — skipped=True یعنی کد قبلی هنوز معتبر است (torbatkar).
+ """
+ normalized = normalize_mobile(mobile)
+ phone_for_sms = _normalize_phone_for_sms(normalized)
+ if not phone_for_sms:
+ raise SmsDeliveryError("شماره موبایل نامعتبر است")
+
+ key = _otp_key(normalized)
+ if not force:
+ stored = await self._get_stored_code(normalized)
+ if stored:
+ remaining = await cache.ttl(key)
+ expires_in = remaining if remaining else settings.otp_expire_seconds
+ return expires_in, True
+
+ code = self._generate_code()
+ ttl = settings.otp_expire_seconds
+
+ await cache.set(key, code, ttl=ttl)
+ if cache.client is None:
+ _memory_otp[normalized] = code
+
+ await self.sms.send_otp(phone_for_sms, code)
+ return ttl, False
+
+ async def verify_otp(self, mobile: str, code: str) -> bool:
+ normalized_code = normalize_otp_digits(code)
+ if not normalized_code:
+ return False
+
+ normalized = normalize_mobile(mobile)
+ key = _otp_key(normalized)
+ stored = await self._get_stored_code(normalized)
+
+ if stored is None or not secrets.compare_digest(stored, normalized_code):
+ return False
+
+ await cache.delete(key)
+ _memory_otp.pop(normalized, None)
+ return True
diff --git a/backend/core-service/app/services/payamak_client.py b/backend/core-service/app/services/payamak_client.py
new file mode 100644
index 0000000..fa6640b
--- /dev/null
+++ b/backend/core-service/app/services/payamak_client.py
@@ -0,0 +1,245 @@
+"""کلاینت Payamak — پورت مستقیم torbatkar-back/SMSPanel/utils.py (send_pattern_sms)."""
+from __future__ import annotations
+
+import asyncio
+import json
+import re
+from typing import Any
+
+import requests
+
+from app.core.config import settings
+from app.core.logging import get_logger
+
+logger = get_logger(__name__)
+
+# همان ثابتهای torbatkar-back
+PAYAMAK_TIMEOUT = (18, 45)
+PAYAMAK_PATTERN_URL = "https://api.payamak-panel.com/post/send.asmx/SendByBaseNumber"
+PAYAMAK_REST_PATTERN_URL = "https://rest.payamak-panel.com/api/SendSMS/BaseServiceNumber"
+
+
+class SmsDeliveryError(Exception):
+ def __init__(self, message: str) -> None:
+ self.message = message
+ super().__init__(message)
+
+
+def payamak_is_configured() -> bool:
+ return bool(settings.payamak_username and settings.payamak_auth_secret)
+
+
+def _normalize_phone_for_sms(phone: str | None) -> str | None:
+ """نرمالسازی شماره به فرمت 09xxxxxxxxx — همان torbatkar."""
+ if phone is None:
+ return None
+ digits = re.sub(r"\D", "", str(phone))
+ if digits.startswith("0098"):
+ digits = digits[4:]
+ elif digits.startswith("98") and len(digits) >= 12:
+ digits = digits[2:]
+ if len(digits) == 10:
+ digits = "0" + digits
+ if len(digits) == 11 and digits.startswith("0"):
+ return digits
+ return None
+
+
+def _parse_payamak_response_body(raw_text: str) -> dict[str, Any]:
+ if not raw_text or not str(raw_text).strip():
+ return {}
+ raw_text = str(raw_text).strip()
+ try:
+ data = json.loads(raw_text)
+ if isinstance(data, dict) and isinstance(data.get("d"), str):
+ try:
+ inner = json.loads(data["d"])
+ if isinstance(inner, dict):
+ return inner
+ except json.JSONDecodeError:
+ pass
+ return data if isinstance(data, dict) else {"value": data}
+ except json.JSONDecodeError:
+ pass
+ m = re.search(r"]*>([\s\S]*?)", raw_text, re.I)
+ if m:
+ inner = m.group(1).strip()
+ try:
+ parsed = json.loads(inner)
+ return parsed if isinstance(parsed, dict) else {"value": parsed}
+ except json.JSONDecodeError:
+ pass
+ m = re.search(r"]*>([^<]+)", raw_text, re.I)
+ if m:
+ value = m.group(1).strip()
+ try:
+ n = int(value)
+ return {"RetStatus": n} if n > 0 else {"RetStatus": n, "raw": value}
+ except ValueError:
+ return {"raw": value}
+ m = re.search(r'"RetStatus"\s*:\s*(-?\d+)', raw_text)
+ if m:
+ return {"RetStatus": int(m.group(1)), "_parse_fallback": True}
+ return {"raw": raw_text[:800]}
+
+
+def _payamak_pattern_logical_success(response_data: dict[str, Any]) -> tuple[bool, str | None]:
+ if not isinstance(response_data, dict):
+ return False, "پاسخ نامعتبر از پنل"
+ ss = (response_data.get("StrRetStatus") or response_data.get("strRetStatus") or "").strip()
+ if ss.lower() == "ok":
+ return True, None
+ rs = response_data.get("RetStatus")
+ try:
+ n = int(float(rs))
+ if n > 0:
+ return True, None
+ return False, ss or f"خطای پنل پیامک (کد: {n})"
+ except (TypeError, ValueError):
+ pass
+ raw = response_data.get("raw")
+ if raw:
+ return False, str(raw)[:300]
+ return False, "نتوانستیم نتیجه ارسال را تشخیص دهیم"
+
+
+def send_pattern_sms_sync(
+ to: str,
+ text: list[str] | str,
+ body_id: int,
+ *,
+ username: str | None = None,
+ password: str | None = None,
+) -> dict[str, Any]:
+ """ارسال پترن — کپی منطق torbatkar-back/SMSPanel/utils.send_pattern_sms."""
+ username = username or settings.payamak_username
+ password = password or settings.payamak_auth_secret
+ to = _normalize_phone_for_sms(to) or str(to)
+
+ if not all([username, password, to, text, body_id]):
+ return {"success": False, "error": "پارامترهای لازم ارسال نشده است"}
+
+ if not isinstance(text, list):
+ text = [text]
+
+ try:
+ bid = int(body_id)
+ except (TypeError, ValueError):
+ return {"success": False, "error": "شناسه الگو (bodyId) نامعتبر است"}
+
+ form_items = [
+ ("username", str(username)),
+ ("password", str(password)),
+ ("to", str(to)),
+ ("bodyId", str(bid)),
+ ]
+ for part in text:
+ form_items.append(("text", str(part)))
+
+ json_payload = {
+ "username": username,
+ "password": password,
+ "to": str(to),
+ "text": text,
+ "bodyId": bid,
+ }
+
+ text_param = (
+ json.dumps(text, ensure_ascii=False)
+ if isinstance(text, list) and len(text) > 1
+ else str(text[0] if isinstance(text, list) else text)
+ )
+
+ rest_form = {
+ "username": str(username),
+ "password": str(password),
+ "to": str(to),
+ "bodyId": str(bid),
+ "text": text_param,
+ }
+
+ attempts = [
+ ("rest_https", PAYAMAK_REST_PATTERN_URL, {"data": rest_form}),
+ ("asmx_https", PAYAMAK_PATTERN_URL, {"data": form_items}),
+ ]
+
+ response = None
+ response_data: dict[str, Any] = {}
+ success = False
+ err_logical: str | None = None
+ last_transport_error: str | None = None
+
+ for _label, url, req_kw in attempts:
+ try:
+ req_kw = {**req_kw, "timeout": PAYAMAK_TIMEOUT}
+ response = requests.post(url, **req_kw)
+ response_data = _parse_payamak_response_body(response.text)
+ ok_http = response.status_code == 200
+ ok_logical, err_logical = _payamak_pattern_logical_success(response_data)
+ success = ok_http and ok_logical
+ if success:
+ break
+ if not ok_logical and err_logical:
+ last_transport_error = err_logical
+ except requests.RequestException as exc:
+ last_transport_error = str(exc)
+ continue
+
+ if not success:
+ try:
+ response = requests.post(PAYAMAK_PATTERN_URL, json=json_payload, timeout=PAYAMAK_TIMEOUT)
+ response_data = _parse_payamak_response_body(response.text)
+ ok_http = response.status_code == 200
+ ok_logical, err_logical = _payamak_pattern_logical_success(response_data)
+ success = ok_http and ok_logical
+ except requests.RequestException as exc:
+ last_transport_error = str(exc)
+
+ err_msg = None if success else (err_logical or last_transport_error or "خطا در ارسال پیامک")
+
+ if success:
+ return {
+ "success": True,
+ "message_id": str(response_data.get("RetStatus", "")),
+ "api_response": response_data,
+ }
+ return {
+ "success": False,
+ "error": err_msg,
+ "api_response": response_data,
+ }
+
+
+class PayamakClient:
+ async def send_otp(self, mobile: str, code: str) -> None:
+ if not payamak_is_configured():
+ if settings.environment in ("development", "test"):
+ logger.info("payamak_otp_dev_mode", extra={"mobile": mobile, "code": code})
+ return
+ raise SmsDeliveryError("تنظیمات API پیامک در سرور پیکربندی نشده است")
+
+ phone_for_sms = _normalize_phone_for_sms(mobile)
+ if not phone_for_sms:
+ raise SmsDeliveryError("شماره موبایل نامعتبر است")
+
+ result = await asyncio.to_thread(
+ send_pattern_sms_sync,
+ phone_for_sms,
+ [str(code)],
+ settings.payamak_verification_body_id,
+ )
+
+ if not result.get("success"):
+ err = result.get("error") or "ارسال پیامک ناموفق بود"
+ if "name resolution" in str(err).lower() or "resolve" in str(err).lower():
+ err = "سرویس پیامک موقتاً در دسترس نیست. لطفاً چند دقیقه بعد دوباره تلاش کنید."
+ logger.error(
+ "payamak_otp_failed",
+ extra={"mobile": phone_for_sms, "error": err, "api": result.get("api_response")},
+ )
+ raise SmsDeliveryError(err)
+
+ logger.info(
+ "payamak_otp_sent",
+ extra={"mobile": phone_for_sms, "message_id": result.get("message_id")},
+ )
diff --git a/backend/core-service/app/services/plan_service.py b/backend/core-service/app/services/plan_service.py
new file mode 100644
index 0000000..ddf95a5
--- /dev/null
+++ b/backend/core-service/app/services/plan_service.py
@@ -0,0 +1,127 @@
+"""سرویس مدیریت پلنها، قابلیتها و اتصال آنها."""
+from __future__ import annotations
+
+from typing import Sequence
+from uuid import UUID
+
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from app.models.plan import Feature, Plan, PlanFeature
+from app.repositories.plan import (
+ FeatureRepository,
+ PlanFeatureRepository,
+ PlanRepository,
+)
+from app.schemas.plan import FeatureCreate, PlanCreate, PlanFeatureCreate
+from shared.exceptions import ConflictError, NotFoundError
+
+
+class PlanService:
+ def __init__(self, session: AsyncSession) -> None:
+ self.session = session
+ self.plan_repo = PlanRepository(session)
+ self.feature_repo = FeatureRepository(session)
+ self.plan_feature_repo = PlanFeatureRepository(session)
+
+ # ---- Plans ----
+ async def create_plan(self, data: PlanCreate) -> Plan:
+ if await self.plan_repo.code_exists(data.code):
+ raise ConflictError(f"کد پلن تکراری است: {data.code}", error_code="plan_code_taken")
+ plan = Plan(
+ code=data.code,
+ name=data.name,
+ description=data.description,
+ is_active=data.is_active,
+ )
+ await self.plan_repo.add(plan)
+ await self.session.commit()
+ await self.session.refresh(plan)
+ return plan
+
+ async def list_plans(self, *, offset: int, limit: int) -> tuple[Sequence[Plan], int]:
+ items = await self.plan_repo.list(offset=offset, limit=limit)
+ total = await self.plan_repo.count()
+ return items, total
+
+ async def get_plan(self, plan_id: UUID) -> Plan:
+ plan = await self.plan_repo.get(plan_id)
+ if plan is None:
+ raise NotFoundError(f"پلن یافت نشد: {plan_id}")
+ return plan
+
+ async def ensure_default_plan(
+ self, *, code: str = "FREE", name: str = "رایگان"
+ ) -> Plan:
+ """پلن پیشفرض را برمیگرداند؛ در صورت نبود، آن را میسازد (idempotent).
+
+ برای اطمینان از وجود پلن پیشفرض هنگام onboarding استفاده میشود؛ در
+ کنار seed مربوط به migration (که در محیط production زودتر اجرا میشود).
+ """
+ plan = await self.plan_repo.get_by_code(code)
+ if plan is not None:
+ return plan
+ plan = Plan(
+ code=code,
+ name=name,
+ description="پلن پیشفرض تخصیصیافته در زمان onboarding",
+ is_active=True,
+ )
+ await self.plan_repo.add(plan)
+ await self.session.commit()
+ await self.session.refresh(plan)
+ return plan
+
+ # ---- Features ----
+ async def create_feature(self, data: FeatureCreate) -> Feature:
+ if await self.feature_repo.key_exists(data.feature_key):
+ raise ConflictError(
+ f"feature_key تکراری است: {data.feature_key}",
+ error_code="feature_key_taken",
+ )
+ feature = Feature(
+ feature_key=data.feature_key,
+ name=data.name,
+ description=data.description,
+ service_key=data.service_key,
+ is_active=data.is_active,
+ )
+ await self.feature_repo.add(feature)
+ await self.session.commit()
+ await self.session.refresh(feature)
+ return feature
+
+ async def list_features(self, *, offset: int, limit: int) -> tuple[Sequence[Feature], int]:
+ items = await self.feature_repo.list(offset=offset, limit=limit)
+ total = await self.feature_repo.count()
+ return items, total
+
+ # ---- Plan <-> Feature ----
+ async def attach_feature(self, plan_id: UUID, data: PlanFeatureCreate) -> PlanFeature:
+ plan = await self.plan_repo.get(plan_id)
+ if plan is None:
+ raise NotFoundError(f"پلن یافت نشد: {plan_id}")
+ feature = await self.feature_repo.get(data.feature_id)
+ if feature is None:
+ raise NotFoundError(f"قابلیت یافت نشد: {data.feature_id}")
+
+ existing = await self.plan_feature_repo.get(plan_id, data.feature_id)
+ if existing is not None:
+ # بهروزرسانی تنظیمات موجود بهجای ایجاد رکورد تکراری.
+ existing.limit_value = data.limit_value
+ existing.limit_period = data.limit_period
+ existing.is_enabled = data.is_enabled
+ await self.session.commit()
+ await self.session.refresh(existing)
+ return existing
+
+ plan_feature = PlanFeature(
+ plan_id=plan_id,
+ feature_id=data.feature_id,
+ limit_value=data.limit_value,
+ limit_period=data.limit_period,
+ is_enabled=data.is_enabled,
+ )
+ await self.plan_feature_repo.add(plan_feature)
+ await self.session.commit()
+ await self.session.refresh(plan_feature)
+ return plan_feature
diff --git a/backend/core-service/app/services/service_registry_service.py b/backend/core-service/app/services/service_registry_service.py
new file mode 100644
index 0000000..9f3a0a0
--- /dev/null
+++ b/backend/core-service/app/services/service_registry_service.py
@@ -0,0 +1,51 @@
+"""سرویس مدیریت ServiceRegistry (ثبت سرویسهای داخلی آینده)."""
+from __future__ import annotations
+
+from typing import Sequence
+from uuid import UUID
+
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from app.models.enums import ServiceStatus
+from app.models.registry import ServiceRegistry
+from app.repositories.registry import ServiceRegistryRepository
+from app.schemas.service_registry import ServiceCreate
+from shared.exceptions import ConflictError, NotFoundError
+
+
+class ServiceRegistryService:
+ def __init__(self, session: AsyncSession) -> None:
+ self.session = session
+ self.repo = ServiceRegistryRepository(session)
+
+ async def create(self, data: ServiceCreate) -> ServiceRegistry:
+ if await self.repo.get_by_key(data.service_key) is not None:
+ raise ConflictError(
+ f"service_key تکراری است: {data.service_key}",
+ error_code="service_key_taken",
+ )
+ service = ServiceRegistry(
+ service_key=data.service_key,
+ name=data.name,
+ base_url=data.base_url,
+ health_check_url=data.health_check_url,
+ status=data.status,
+ )
+ await self.repo.add(service)
+ await self.session.commit()
+ await self.session.refresh(service)
+ return service
+
+ async def list(self, *, offset: int, limit: int) -> tuple[Sequence[ServiceRegistry], int]:
+ items = await self.repo.list(offset=offset, limit=limit)
+ total = await self.repo.count()
+ return items, total
+
+ async def update_status(self, service_id: UUID, status: ServiceStatus) -> ServiceRegistry:
+ service = await self.repo.get(service_id)
+ if service is None:
+ raise NotFoundError(f"سرویس یافت نشد: {service_id}")
+ service.status = status
+ await self.session.commit()
+ await self.session.refresh(service)
+ return service
diff --git a/backend/core-service/app/services/subscription_service.py b/backend/core-service/app/services/subscription_service.py
new file mode 100644
index 0000000..671f1e2
--- /dev/null
+++ b/backend/core-service/app/services/subscription_service.py
@@ -0,0 +1,62 @@
+"""سرویس مدیریت اشتراک tenant."""
+from __future__ import annotations
+
+from uuid import UUID
+
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from app.core.cache import cache
+from app.models.subscription import TenantSubscription
+from app.repositories.plan import PlanRepository
+from app.repositories.subscription import SubscriptionRepository
+from app.repositories.tenant import TenantRepository
+from app.schemas.subscription import SubscriptionCreate
+from app.services.event_service import EventService
+from shared.events import CoreEventType
+from shared.exceptions import NotFoundError
+
+
+class SubscriptionService:
+ def __init__(self, session: AsyncSession) -> None:
+ self.session = session
+ self.repo = SubscriptionRepository(session)
+ self.tenant_repo = TenantRepository(session)
+ self.plan_repo = PlanRepository(session)
+ self.events = EventService(session)
+
+ async def create(self, tenant_id: UUID, data: SubscriptionCreate) -> TenantSubscription:
+ tenant = await self.tenant_repo.get(tenant_id)
+ if tenant is None:
+ raise NotFoundError(f"tenant یافت نشد: {tenant_id}")
+ plan = await self.plan_repo.get(data.plan_id)
+ if plan is None:
+ raise NotFoundError(f"پلن یافت نشد: {data.plan_id}")
+
+ subscription = TenantSubscription(
+ tenant_id=tenant_id,
+ plan_id=data.plan_id,
+ status=data.status,
+ starts_at=data.starts_at,
+ ends_at=data.ends_at,
+ trial_ends_at=data.trial_ends_at,
+ )
+ await self.repo.add(subscription)
+ await self.events.record(
+ event_type=CoreEventType.SUBSCRIPTION_CREATED.value,
+ aggregate_type="subscription",
+ aggregate_id=str(subscription.id),
+ tenant_id=tenant_id,
+ payload={"plan_id": str(data.plan_id), "status": data.status.value},
+ )
+ await self.session.commit()
+ await self.session.refresh(subscription)
+ # با تغییر اشتراک، cache دسترسی قابلیتها باید باطل شود.
+ await cache.delete_pattern(f"feature_access:{tenant_id}:*")
+ await cache.delete(f"tenant:{tenant_id}:features")
+ return subscription
+
+ async def get_current(self, tenant_id: UUID) -> TenantSubscription:
+ subscription = await self.repo.get_active_by_tenant(tenant_id)
+ if subscription is None:
+ raise NotFoundError(f"اشتراکی برای tenant یافت نشد: {tenant_id}")
+ return subscription
diff --git a/backend/core-service/app/services/tenant_context_service.py b/backend/core-service/app/services/tenant_context_service.py
new file mode 100644
index 0000000..584dfa2
--- /dev/null
+++ b/backend/core-service/app/services/tenant_context_service.py
@@ -0,0 +1,89 @@
+"""ساخت TenantContext کامل (tenant + پلن + دامنهها + نقش کاربر) برای frontend."""
+from __future__ import annotations
+
+from uuid import UUID
+
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from app.models.tenant import Tenant
+from app.models.user import User
+from app.repositories.membership import TenantMembershipRepository
+from app.repositories.plan import PlanRepository
+from app.repositories.subscription import SubscriptionRepository
+from app.repositories.tenant import DomainRepository, TenantRepository
+from app.schemas.domain import DomainRead
+from app.schemas.onboarding import MembershipSummary, TenantContextRead
+from app.schemas.tenant import TenantRead
+from shared.exceptions import NotFoundError
+
+
+class TenantContextService:
+ def __init__(self, session: AsyncSession) -> None:
+ self.session = session
+ self.tenant_repo = TenantRepository(session)
+ self.domain_repo = DomainRepository(session)
+ self.membership_repo = TenantMembershipRepository(session)
+ self.subscription_repo = SubscriptionRepository(session)
+ self.plan_repo = PlanRepository(session)
+
+ async def build(self, tenant: Tenant, core_user: User | None) -> TenantContextRead:
+ membership = None
+ if core_user is not None:
+ membership = await self.membership_repo.get_by_tenant_and_user(
+ tenant.id, core_user.id
+ )
+
+ domains = await self.domain_repo.list_by_tenant(tenant.id)
+ primary = next((d for d in domains if d.is_primary), None)
+ if primary is None and domains:
+ primary = domains[0]
+
+ subscription = await self.subscription_repo.get_active_by_tenant(tenant.id)
+ plan = await self.plan_repo.get(subscription.plan_id) if subscription else None
+
+ return TenantContextRead(
+ tenant=TenantRead.model_validate(tenant),
+ role=membership.role if membership else None,
+ is_owner=bool(membership.is_owner) if membership else False,
+ plan_code=plan.code if plan else None,
+ plan_name=plan.name if plan else None,
+ subscription_status=subscription.status.value if subscription else None,
+ domains=[DomainRead.model_validate(d) for d in domains],
+ primary_domain=primary.domain if primary else None,
+ )
+
+ async def resolve_current_tenant(self, core_user: User) -> Tenant:
+ """tenant جاری کاربر را برمیگرداند (بر اساس current_tenant_id یا اولین عضویت)."""
+ if core_user.current_tenant_id is not None:
+ tenant = await self.tenant_repo.get(core_user.current_tenant_id)
+ if tenant is not None:
+ return tenant
+
+ memberships = await self.membership_repo.list_by_user(core_user.id)
+ if memberships:
+ tenant = await self.tenant_repo.get(memberships[0].tenant_id)
+ if tenant is not None:
+ return tenant
+
+ raise NotFoundError("هیچ tenantی برای کاربر جاری یافت نشد")
+
+ async def list_memberships_summary(self, core_user: User) -> list[MembershipSummary]:
+ memberships = await self.membership_repo.list_by_user(core_user.id)
+ result: list[MembershipSummary] = []
+ for m in memberships:
+ tenant = await self.tenant_repo.get(m.tenant_id)
+ if tenant is None:
+ continue
+ result.append(
+ MembershipSummary(
+ tenant_id=tenant.id,
+ tenant_name=tenant.name,
+ tenant_slug=tenant.slug,
+ tenant_status=tenant.status,
+ onboarding_completed=tenant.onboarding_completed,
+ role=m.role,
+ status=m.status,
+ is_owner=m.is_owner,
+ )
+ )
+ return result
diff --git a/backend/core-service/app/services/tenant_service.py b/backend/core-service/app/services/tenant_service.py
new file mode 100644
index 0000000..40d9418
--- /dev/null
+++ b/backend/core-service/app/services/tenant_service.py
@@ -0,0 +1,156 @@
+"""سرویس مدیریت Tenant."""
+from __future__ import annotations
+
+from typing import Sequence
+from uuid import UUID
+
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from app.core.cache import cache, tenant_metadata_key
+from app.models.enums import DomainType, TenantStatus
+from app.models.tenant import Tenant
+from app.repositories.tenant import DomainRepository, TenantRepository
+from app.schemas.tenant import AdminTenantCreate, TenantCreate, TenantUpdate
+from app.services.event_service import EventService
+from shared.auth import PlatformRole, has_role
+from shared.events import CoreEventType
+from shared.exceptions import ConflictError, ForbiddenError, NotFoundError
+from shared.security import CurrentUser
+
+
+class TenantService:
+ def __init__(self, session: AsyncSession) -> None:
+ self.session = session
+ self.repo = TenantRepository(session)
+ self.domain_repo = DomainRepository(session)
+ self.events = EventService(session)
+
+ async def create(self, data: TenantCreate) -> Tenant:
+ # slug باید در کل پلتفرم یکتا باشد.
+ if await self.repo.slug_exists(data.slug):
+ raise ConflictError(f"slug تکراری است: {data.slug}", error_code="slug_taken")
+
+ tenant = Tenant(
+ name=data.name,
+ slug=data.slug,
+ owner_user_id=data.owner_user_id,
+ status=TenantStatus.ACTIVE,
+ )
+ await self.repo.add(tenant)
+ if data.custom_domain:
+ await self._add_custom_domain(tenant.id, data.custom_domain)
+ await self.events.record(
+ event_type=CoreEventType.TENANT_CREATED.value,
+ aggregate_type="tenant",
+ aggregate_id=str(tenant.id),
+ tenant_id=tenant.id,
+ payload={"slug": tenant.slug, "name": tenant.name},
+ )
+ await self.session.commit()
+ await self.session.refresh(tenant)
+ return tenant
+
+ async def create_for_user(self, user: CurrentUser, data: AdminTenantCreate) -> Tenant:
+ owner_id = UUID(user.user_id)
+ return await self.create(
+ TenantCreate(
+ name=data.name,
+ slug=data.slug,
+ owner_user_id=owner_id,
+ custom_domain=data.custom_domain,
+ )
+ )
+
+ async def _add_custom_domain(self, tenant_id: UUID, domain: str) -> None:
+ if await self.domain_repo.domain_exists(domain):
+ raise ConflictError(
+ f"دامنه تکراری است: {domain}", error_code="domain_taken"
+ )
+ from app.models.domain import Domain
+
+ await self.domain_repo.add(
+ Domain(
+ tenant_id=tenant_id,
+ domain=domain,
+ domain_type=DomainType.CUSTOM_DOMAIN,
+ )
+ )
+
+ async def get(self, tenant_id: UUID) -> Tenant:
+ tenant = await self.repo.get(tenant_id)
+ if tenant is None:
+ raise NotFoundError(f"tenant یافت نشد: {tenant_id}")
+ return tenant
+
+ async def get_by_slug(self, slug: str) -> Tenant | None:
+ return await self.repo.get_by_slug(slug)
+
+ async def list(self, *, offset: int, limit: int) -> tuple[Sequence[Tenant], int]:
+ items = await self.repo.list(offset=offset, limit=limit)
+ total = await self.repo.count()
+ return items, total
+
+ async def list_for_user(
+ self, user: CurrentUser, *, offset: int, limit: int
+ ) -> tuple[Sequence[Tenant], int]:
+ if has_role(user, PlatformRole.PLATFORM_ADMIN):
+ return await self.list(offset=offset, limit=limit)
+ owner_id = UUID(user.user_id)
+ items = await self.repo.list_by_owner(owner_id, offset=offset, limit=limit)
+ total = await self.repo.count_by_owner(owner_id)
+ return items, total
+
+ def ensure_access(self, tenant: Tenant, user: CurrentUser) -> None:
+ if has_role(user, PlatformRole.PLATFORM_ADMIN):
+ return
+ if str(tenant.owner_user_id) != user.user_id:
+ raise ForbiddenError("دسترسی به این tenant ندارید")
+
+ async def update(
+ self, tenant_id: UUID, data: TenantUpdate, user: CurrentUser | None = None
+ ) -> Tenant:
+ tenant = await self.get(tenant_id)
+ if user is not None:
+ self.ensure_access(tenant, user)
+ if data.name is not None:
+ tenant.name = data.name
+ if data.status is not None:
+ tenant.status = data.status
+ if data.owner_user_id is not None:
+ tenant.owner_user_id = data.owner_user_id
+ await self.session.commit()
+ await self.session.refresh(tenant)
+ await self._invalidate_cache(tenant_id)
+ return tenant
+
+ async def suspend(self, tenant_id: UUID) -> Tenant:
+ tenant = await self.get(tenant_id)
+ tenant.status = TenantStatus.SUSPENDED
+ await self.events.record(
+ event_type=CoreEventType.TENANT_SUSPENDED.value,
+ aggregate_type="tenant",
+ aggregate_id=str(tenant.id),
+ tenant_id=tenant.id,
+ )
+ await self.session.commit()
+ await self.session.refresh(tenant)
+ await self._invalidate_cache(tenant_id)
+ return tenant
+
+ async def activate(self, tenant_id: UUID) -> Tenant:
+ tenant = await self.get(tenant_id)
+ tenant.status = TenantStatus.ACTIVE
+ await self.events.record(
+ event_type=CoreEventType.TENANT_ACTIVATED.value,
+ aggregate_type="tenant",
+ aggregate_id=str(tenant.id),
+ tenant_id=tenant.id,
+ )
+ await self.session.commit()
+ await self.session.refresh(tenant)
+ await self._invalidate_cache(tenant_id)
+ return tenant
+
+ async def _invalidate_cache(self, tenant_id: UUID) -> None:
+ await cache.delete(tenant_metadata_key(str(tenant_id)))
+ await cache.delete_pattern(f"feature_access:{tenant_id}:*")
diff --git a/backend/core-service/app/services/token_service.py b/backend/core-service/app/services/token_service.py
new file mode 100644
index 0000000..638626b
--- /dev/null
+++ b/backend/core-service/app/services/token_service.py
@@ -0,0 +1,83 @@
+"""تولید و اعتبارسنجی JWT محلی برای کاربران OTP."""
+from __future__ import annotations
+
+from datetime import datetime, timedelta, timezone
+from uuid import UUID
+
+import jwt
+
+from app.core.config import settings
+from app.models.enums import UserRole
+from shared.exceptions import UnauthorizedError
+from shared.security import CurrentUser
+
+OTP_ISSUER = "core-service-otp"
+
+
+class TokenService:
+ def create_access_token(
+ self,
+ *,
+ user_id: UUID,
+ mobile: str,
+ role: UserRole,
+ ) -> tuple[str, int]:
+ expires_in = settings.jwt_access_token_expire_seconds
+ now = datetime.now(timezone.utc)
+ payload = {
+ "sub": str(user_id),
+ "preferred_username": mobile,
+ "realm_access": {"roles": [role.value]},
+ "iss": OTP_ISSUER,
+ "aud": settings.jwt_audience,
+ "iat": int(now.timestamp()),
+ "exp": int((now + timedelta(seconds=expires_in)).timestamp()),
+ }
+ token = jwt.encode(payload, settings.jwt_secret, algorithm="HS256")
+ return token, expires_in
+
+ def validate_access_token(self, token: str) -> CurrentUser:
+ try:
+ claims = jwt.decode(
+ token,
+ settings.jwt_secret,
+ algorithms=["HS256"],
+ audience=settings.jwt_audience,
+ issuer=OTP_ISSUER,
+ )
+ except jwt.PyJWTError as exc:
+ raise UnauthorizedError(f"توکن نامعتبر است: {exc}") from exc
+
+ realm_access = claims.get("realm_access") or {}
+ roles = list(realm_access.get("roles", [])) if isinstance(realm_access, dict) else []
+ return CurrentUser(
+ user_id=str(claims.get("sub", "")),
+ username=claims.get("preferred_username"),
+ roles=roles,
+ )
+
+ def create_refresh_token(self, user_id: UUID) -> str:
+ expires_in = settings.jwt_refresh_token_expire_seconds
+ now = datetime.now(timezone.utc)
+ payload = {
+ "sub": str(user_id),
+ "type": "refresh",
+ "iss": OTP_ISSUER,
+ "iat": int(now.timestamp()),
+ "exp": int((now + timedelta(seconds=expires_in)).timestamp()),
+ }
+ return jwt.encode(payload, settings.jwt_secret, algorithm="HS256")
+
+ def validate_refresh_token(self, token: str) -> UUID:
+ try:
+ claims = jwt.decode(
+ token,
+ settings.jwt_secret,
+ algorithms=["HS256"],
+ issuer=OTP_ISSUER,
+ )
+ except jwt.PyJWTError as exc:
+ raise UnauthorizedError(f"توکن refresh نامعتبر است: {exc}") from exc
+ if claims.get("type") != "refresh":
+ raise UnauthorizedError("توکن refresh نامعتبر است")
+ return UUID(str(claims["sub"]))
diff --git a/backend/core-service/app/services/user_service.py b/backend/core-service/app/services/user_service.py
new file mode 100644
index 0000000..1a40533
--- /dev/null
+++ b/backend/core-service/app/services/user_service.py
@@ -0,0 +1,49 @@
+"""سرویس کمکی برای resolve کردن کاربر Core از روی CurrentUser (JWT).
+
+توکن ممکن است از دو منبع باشد:
+ ۱. JWT محلی OTP (HS256) — sub برابر با users.id است.
+ ۲. JWT مرکزی Keycloak (RS256) — sub برابر با users.keycloak_sub است.
+"""
+from __future__ import annotations
+
+from uuid import UUID
+
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from app.models.user import User
+from app.repositories.user import UserRepository
+from shared.exceptions import ForbiddenError
+from shared.security import CurrentUser
+
+
+class UserService:
+ def __init__(self, session: AsyncSession) -> None:
+ self.session = session
+ self.repo = UserRepository(session)
+
+ async def resolve_current(self, current_user: CurrentUser) -> User:
+ """کاربر Core متناظر با هویت جاری را پیدا میکند.
+
+ اگر کاربر SSO هنوز به یک رکورد Core لینک نشده باشد (مثلاً موبایل
+ تأیید نشده)، خطای قابل مدیریت در frontend برمیگرداند تا کاربر به
+ جریان تکمیل موبایل هدایت شود.
+ """
+ candidate_id: UUID | None = None
+ try:
+ candidate_id = UUID(str(current_user.user_id))
+ except (ValueError, TypeError, AttributeError):
+ candidate_id = None
+
+ if candidate_id is not None:
+ user = await self.repo.get(candidate_id)
+ if user is not None:
+ return user
+
+ user = await self.repo.get_by_keycloak_sub(str(current_user.user_id))
+ if user is not None:
+ return user
+
+ raise ForbiddenError(
+ "پروفایل کاربری هسته یافت نشد. ابتدا شماره موبایل خود را تأیید کنید.",
+ error_code="core_profile_not_linked",
+ )
diff --git a/backend/core-service/app/tests/__init__.py b/backend/core-service/app/tests/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/backend/core-service/app/tests/conftest.py b/backend/core-service/app/tests/conftest.py
new file mode 100644
index 0000000..4e6c3dc
--- /dev/null
+++ b/backend/core-service/app/tests/conftest.py
@@ -0,0 +1,54 @@
+"""پیکربندی مشترک تستها (pytest).
+
+تستها روی SQLite (aiosqlite) اجرا میشوند تا نیازی به PostgreSQL/Redis
+واقعی نباشد. متغیرهای محیطی پیش از import ماژولهای برنامه تنظیم میشوند تا
+engine و session با SQLite ساخته شوند.
+"""
+from __future__ import annotations
+
+import os
+
+# --- تنظیم محیط تست پیش از هر import از برنامه ---
+os.environ["ENVIRONMENT"] = "test"
+os.environ["AUTH_REQUIRED"] = "false"
+os.environ["DATABASE_URL"] = "sqlite+aiosqlite:///./test_core.db"
+os.environ["DATABASE_URL_SYNC"] = "sqlite:///./test_core.db"
+os.environ["KEYCLOAK_ENABLED"] = "false"
+os.environ["JWT_VERIFY_SIGNATURE"] = "false"
+os.environ["JWT_SECRET"] = "test-jwt-secret"
+
+import pytest_asyncio # noqa: E402
+from httpx import ASGITransport, AsyncClient # noqa: E402
+
+import app.models # noqa: E402,F401 (ثبت مدلها روی metadata)
+from app.core.cache import cache # noqa: E402
+from app.core.database import AsyncSessionLocal, Base, engine # noqa: E402
+from app.main import app # noqa: E402
+
+# در تستها Redis در دسترس نیست؛ cache را غیرفعال میکنیم (fail-open).
+cache.disable()
+
+
+@pytest_asyncio.fixture
+async def db_setup():
+ """ساخت و پاکسازی اسکیمای دیتابیس برای هر تست (ایزوله)."""
+ async with engine.begin() as conn:
+ await conn.run_sync(Base.metadata.create_all)
+ yield
+ async with engine.begin() as conn:
+ await conn.run_sync(Base.metadata.drop_all)
+
+
+@pytest_asyncio.fixture
+async def client(db_setup):
+ """کلاینت HTTP async برای فراخوانی API درونفرآیندی."""
+ transport = ASGITransport(app=app)
+ async with AsyncClient(transport=transport, base_url="http://testserver") as ac:
+ yield ac
+
+
+@pytest_asyncio.fixture
+async def session(db_setup):
+ """یک AsyncSession برای تستهای سطح سرویس/repository."""
+ async with AsyncSessionLocal() as s:
+ yield s
diff --git a/backend/core-service/app/tests/test_domains.py b/backend/core-service/app/tests/test_domains.py
new file mode 100644
index 0000000..8fb3856
--- /dev/null
+++ b/backend/core-service/app/tests/test_domains.py
@@ -0,0 +1,41 @@
+"""تستهای مدیریت و resolve دامنه."""
+from __future__ import annotations
+
+
+async def _create_tenant(client, slug="domainco"):
+ resp = await client.post("/api/v1/tenants", json={"name": "Domain Co", "slug": slug})
+ return resp.json()["id"]
+
+
+async def test_create_domain(client):
+ tenant_id = await _create_tenant(client)
+ resp = await client.post(
+ f"/api/v1/tenants/{tenant_id}/domains",
+ json={"domain": "shop.example.com", "domain_type": "custom_domain"},
+ )
+ assert resp.status_code == 201
+ data = resp.json()
+ assert data["domain"] == "shop.example.com"
+ assert data["tenant_id"] == tenant_id
+
+
+async def test_resolve_domain(client):
+ tenant_id = await _create_tenant(client, slug="resolveco")
+ await client.post(
+ f"/api/v1/tenants/{tenant_id}/domains",
+ json={"domain": "resolve.example.com"},
+ )
+ resp = await client.post(
+ "/api/v1/domains/resolve", json={"domain": "resolve.example.com"}
+ )
+ assert resp.status_code == 200
+ data = resp.json()
+ assert data["tenant_id"] == tenant_id
+ assert data["tenant_slug"] == "resolveco"
+
+
+async def test_resolve_unknown_domain_returns_404(client):
+ resp = await client.post(
+ "/api/v1/domains/resolve", json={"domain": "nope.example.com"}
+ )
+ assert resp.status_code == 404
diff --git a/backend/core-service/app/tests/test_features.py b/backend/core-service/app/tests/test_features.py
new file mode 100644
index 0000000..1766be0
--- /dev/null
+++ b/backend/core-service/app/tests/test_features.py
@@ -0,0 +1,107 @@
+"""تستهای قابلیتها و بررسی دسترسی (Entitlement)."""
+from __future__ import annotations
+
+
+async def _setup_tenant_with_plan(client, *, slug, feature_key, attach=True, active=True):
+ """ساخت tenant، پلن، قابلیت، اتصال و اشتراک برای سناریوی تست."""
+ tenant_id = (
+ await client.post("/api/v1/tenants", json={"name": "T", "slug": slug})
+ ).json()["id"]
+
+ plan_id = (
+ await client.post(
+ "/api/v1/plans", json={"code": f"plan-{slug}", "name": "Plan"}
+ )
+ ).json()["id"]
+
+ feature_id = (
+ await client.post(
+ "/api/v1/features",
+ json={
+ "feature_key": feature_key,
+ "name": "Feature",
+ "service_key": feature_key.split(".")[0],
+ },
+ )
+ ).json()["id"]
+
+ if attach:
+ await client.post(
+ f"/api/v1/plans/{plan_id}/features",
+ json={"feature_id": feature_id, "is_enabled": True},
+ )
+
+ await client.post(
+ f"/api/v1/tenants/{tenant_id}/subscription",
+ json={"plan_id": plan_id, "status": "active" if active else "expired"},
+ )
+ return tenant_id
+
+
+async def test_create_feature(client):
+ resp = await client.post(
+ "/api/v1/features",
+ json={
+ "feature_key": "accounting.invoice.create",
+ "name": "ساخت فاکتور",
+ "service_key": "accounting",
+ },
+ )
+ assert resp.status_code == 201
+ assert resp.json()["feature_key"] == "accounting.invoice.create"
+
+
+async def test_feature_key_unique(client):
+ payload = {
+ "feature_key": "crm.lead.create",
+ "name": "Lead",
+ "service_key": "crm",
+ }
+ first = await client.post("/api/v1/features", json=payload)
+ assert first.status_code == 201
+ second = await client.post("/api/v1/features", json=payload)
+ assert second.status_code == 409
+
+
+async def test_check_feature_access_granted(client):
+ tenant_id = await _setup_tenant_with_plan(
+ client, slug="grant", feature_key="ecommerce.product.create"
+ )
+ resp = await client.post(
+ f"/api/v1/tenants/{tenant_id}/features/check",
+ json={"feature_key": "ecommerce.product.create"},
+ )
+ assert resp.status_code == 200
+ body = resp.json()
+ assert body["has_access"] is True
+ assert body["reason"] == "plan_enabled"
+
+
+async def test_check_feature_access_denied_when_not_in_plan(client):
+ tenant_id = await _setup_tenant_with_plan(
+ client,
+ slug="denyplan",
+ feature_key="live_chat.widget.enable",
+ attach=False,
+ )
+ resp = await client.post(
+ f"/api/v1/tenants/{tenant_id}/features/check",
+ json={"feature_key": "live_chat.widget.enable"},
+ )
+ assert resp.status_code == 200
+ assert resp.json()["has_access"] is False
+
+
+async def test_check_feature_access_denied_when_subscription_inactive(client):
+ tenant_id = await _setup_tenant_with_plan(
+ client,
+ slug="expired",
+ feature_key="ai_assistant.chat.reply",
+ active=False,
+ )
+ resp = await client.post(
+ f"/api/v1/tenants/{tenant_id}/features/check",
+ json={"feature_key": "ai_assistant.chat.reply"},
+ )
+ assert resp.status_code == 200
+ assert resp.json()["has_access"] is False
diff --git a/backend/core-service/app/tests/test_health.py b/backend/core-service/app/tests/test_health.py
new file mode 100644
index 0000000..db248ed
--- /dev/null
+++ b/backend/core-service/app/tests/test_health.py
@@ -0,0 +1,11 @@
+"""تست endpoint سلامت."""
+from __future__ import annotations
+
+
+async def test_health_ok(client):
+ resp = await client.get("/health")
+ assert resp.status_code == 200
+ body = resp.json()
+ assert body["status"] == "ok"
+ assert body["service"]
+ assert "redis" in body["dependencies"]
diff --git a/backend/core-service/app/tests/test_middleware.py b/backend/core-service/app/tests/test_middleware.py
new file mode 100644
index 0000000..0dd5027
--- /dev/null
+++ b/backend/core-service/app/tests/test_middleware.py
@@ -0,0 +1,55 @@
+"""تست Middleware تشخیص tenant."""
+from __future__ import annotations
+
+from fastapi import FastAPI, Request
+from httpx import ASGITransport, AsyncClient
+
+from app.middlewares.tenant import TenantResolutionMiddleware
+from app.models.tenant import Tenant
+
+
+def _build_probe_app() -> FastAPI:
+ """اپ کوچک برای بازتاب tenant تشخیصدادهشده از request.state."""
+ probe = FastAPI()
+ probe.add_middleware(TenantResolutionMiddleware)
+
+ @probe.get("/whoami")
+ async def whoami(request: Request):
+ tid = getattr(request.state, "tenant_id", None)
+ slug = getattr(request.state, "tenant_slug", None)
+ return {"tenant_id": str(tid) if tid else None, "tenant_slug": slug}
+
+ return probe
+
+
+async def test_resolve_by_tenant_slug_header(session):
+ tenant = Tenant(name="Mid Co", slug="midco")
+ session.add(tenant)
+ await session.commit()
+
+ transport = ASGITransport(app=_build_probe_app())
+ async with AsyncClient(transport=transport, base_url="http://testserver") as ac:
+ resp = await ac.get("/whoami", headers={"X-Tenant-Slug": "midco"})
+ assert resp.status_code == 200
+ assert resp.json()["tenant_slug"] == "midco"
+
+
+async def test_resolve_by_tenant_id_header(session):
+ tenant = Tenant(name="Mid Co2", slug="midco2")
+ session.add(tenant)
+ await session.commit()
+ tid = str(tenant.id)
+
+ transport = ASGITransport(app=_build_probe_app())
+ async with AsyncClient(transport=transport, base_url="http://testserver") as ac:
+ resp = await ac.get("/whoami", headers={"X-Tenant-ID": tid})
+ assert resp.status_code == 200
+ assert resp.json()["tenant_id"] == tid
+
+
+async def test_unresolved_tenant_is_none(session):
+ transport = ASGITransport(app=_build_probe_app())
+ async with AsyncClient(transport=transport, base_url="http://testserver") as ac:
+ resp = await ac.get("/whoami", headers={"X-Tenant-Slug": "unknown-xyz"})
+ assert resp.status_code == 200
+ assert resp.json()["tenant_id"] is None
diff --git a/backend/core-service/app/tests/test_onboarding.py b/backend/core-service/app/tests/test_onboarding.py
new file mode 100644
index 0000000..665921a
--- /dev/null
+++ b/backend/core-service/app/tests/test_onboarding.py
@@ -0,0 +1,216 @@
+"""تستهای فاز ۳: onboarding و فعالسازی workspace (tenant)."""
+from __future__ import annotations
+
+import pytest
+
+
+@pytest.fixture(autouse=True)
+def enable_auth(monkeypatch):
+ monkeypatch.setenv("AUTH_REQUIRED", "true")
+ monkeypatch.setenv("JWT_SECRET", "test-jwt-secret")
+ monkeypatch.setenv("PLATFORM_BASE_DOMAIN", "localhost")
+ from app.core.config import get_settings, settings
+
+ get_settings.cache_clear()
+ fresh = get_settings()
+ monkeypatch.setattr(settings, "auth_required", True)
+ monkeypatch.setattr(settings, "jwt_secret", fresh.jwt_secret)
+ monkeypatch.setattr(settings, "platform_base_domain", fresh.platform_base_domain)
+
+
+async def _login(client, mobile: str) -> dict:
+ await client.post("/api/v1/auth/otp/request", json={"mobile": mobile})
+ resp = await client.post(
+ "/api/v1/auth/otp/verify", json={"mobile": mobile, "code": "1234"}
+ )
+ assert resp.status_code == 200
+ data = resp.json()
+ return {"Authorization": f"Bearer {data['access_token']}"}, data["user_id"]
+
+
+async def test_me_requires_onboarding_for_new_user(client):
+ headers, user_id = await _login(client, "09121110001")
+
+ resp = await client.get("/api/v1/me", headers=headers)
+ assert resp.status_code == 200
+ data = resp.json()
+ assert data["user_id"] == user_id
+ assert data["onboarding_required"] is True
+ assert data["current_tenant_id"] is None
+ assert data["memberships"] == []
+
+
+async def test_full_onboarding_flow(client):
+ headers, user_id = await _login(client, "09121110002")
+
+ create = await client.post(
+ "/api/v1/onboarding/tenant",
+ json={
+ "business_name": "کافه تربت",
+ "slug": "cafe-torbat-2",
+ "business_type": "cafe",
+ "default_locale": "fa-IR",
+ "timezone": "Asia/Tehran",
+ },
+ headers=headers,
+ )
+ assert create.status_code == 201, create.text
+ body = create.json()
+ tenant_id = body["tenant"]["id"]
+ assert body["tenant"]["status"] == "pending_activation"
+ assert body["tenant"]["onboarding_completed"] is False
+ assert body["role"] == "tenant_owner"
+ assert body["is_owner"] is True
+ assert body["plan_code"] == "FREE"
+ assert body["primary_domain"] == "cafe-torbat-2.localhost"
+ assert body["domains"][0]["domain_type"] == "subdomain"
+ assert body["domains"][0]["is_verified"] is True
+
+ # اگر دوباره تلاش کنیم tenant دیگری بسازیم روی همین slug، باید خطای تکراری بگیریم.
+ dup = await client.post(
+ "/api/v1/onboarding/tenant",
+ json={"business_name": "X", "slug": "cafe-torbat-2"},
+ headers=headers,
+ )
+ assert dup.status_code == 409
+ assert dup.json()["error"]["code"] == "slug_taken"
+
+ branding = await client.patch(
+ f"/api/v1/onboarding/tenant/{tenant_id}/branding",
+ json={"primary_color": "#112233", "secondary_color": "#445566"},
+ headers=headers,
+ )
+ assert branding.status_code == 200
+ assert branding.json()["tenant"]["primary_color"] == "#112233"
+
+ domain = await client.patch(
+ f"/api/v1/onboarding/tenant/{tenant_id}/domain",
+ json={"custom_domain": "cafe2.example.com"},
+ headers=headers,
+ )
+ assert domain.status_code == 200
+ hosts = [d["domain"] for d in domain.json()["domains"]]
+ assert "cafe2.example.com" in hosts
+
+ complete = await client.post(
+ f"/api/v1/onboarding/tenant/{tenant_id}/complete", headers=headers
+ )
+ assert complete.status_code == 200
+ completed_body = complete.json()
+ assert completed_body["tenant"]["status"] == "active"
+ assert completed_body["tenant"]["onboarding_completed"] is True
+
+ me = await client.get("/api/v1/me", headers=headers)
+ me_data = me.json()
+ assert me_data["onboarding_required"] is False
+ assert me_data["current_tenant_id"] == tenant_id
+ assert len(me_data["memberships"]) == 1
+ assert me_data["memberships"][0]["role"] == "tenant_owner"
+
+ current = await client.get("/api/v1/tenant/current", headers=headers)
+ assert current.status_code == 200
+ assert current.json()["tenant"]["id"] == tenant_id
+
+ my_tenants = await client.get("/api/v1/me/tenants", headers=headers)
+ assert my_tenants.status_code == 200
+ slugs = [t["tenant_slug"] for t in my_tenants.json()]
+ assert "cafe-torbat-2" in slugs
+
+
+async def test_onboarding_tenant_requires_auth(client):
+ resp = await client.post(
+ "/api/v1/onboarding/tenant",
+ json={"business_name": "X", "slug": "no-auth-tenant"},
+ )
+ assert resp.status_code == 401
+
+
+async def test_branding_forbidden_for_non_member(client):
+ headers_owner, _ = await _login(client, "09121110003")
+ create = await client.post(
+ "/api/v1/onboarding/tenant",
+ json={"business_name": "Owner Co", "slug": "owner-co"},
+ headers=headers_owner,
+ )
+ tenant_id = create.json()["tenant"]["id"]
+
+ headers_other, _ = await _login(client, "09121110004")
+ resp = await client.patch(
+ f"/api/v1/onboarding/tenant/{tenant_id}/branding",
+ json={"primary_color": "#000000"},
+ headers=headers_other,
+ )
+ assert resp.status_code == 403
+
+
+async def test_complete_fails_without_required_fields(client):
+ headers, _ = await _login(client, "09121110005")
+ create = await client.post(
+ "/api/v1/onboarding/tenant",
+ json={"business_name": "بدون برند", "slug": "no-brand-co"},
+ headers=headers,
+ )
+ tenant_id = create.json()["tenant"]["id"]
+
+ # بدون تنظیم برندینگ کامل هم میتوان تکمیل کرد (فیلدهای برندینگ اختیاریاند)،
+ # اما تکمیل روی tenant دیگری که عضو آن نیستیم باید 403/404 بدهد.
+ other_headers, _ = await _login(client, "09121110006")
+ resp = await client.post(
+ f"/api/v1/onboarding/tenant/{tenant_id}/complete", headers=other_headers
+ )
+ assert resp.status_code == 403
+
+ ok = await client.post(
+ f"/api/v1/onboarding/tenant/{tenant_id}/complete", headers=headers
+ )
+ assert ok.status_code == 200
+ assert ok.json()["tenant"]["status"] == "active"
+
+
+async def test_tenant_switch_between_multiple_tenants(client):
+ headers, user_id = await _login(client, "09121110007")
+
+ first = await client.post(
+ "/api/v1/onboarding/tenant",
+ json={"business_name": "First Co", "slug": "first-co"},
+ headers=headers,
+ )
+ first_id = first.json()["tenant"]["id"]
+
+ second = await client.post(
+ "/api/v1/onboarding/tenant",
+ json={"business_name": "Second Co", "slug": "second-co"},
+ headers=headers,
+ )
+ second_id = second.json()["tenant"]["id"]
+
+ # اولین tenant ساختهشده بهعنوان current انتخاب میشود (چون current_tenant_id قبلاً خالی بود)؛
+ # ساخت tenant دوم آن را تغییر نمیدهد.
+ me = await client.get("/api/v1/me", headers=headers)
+ assert me.json()["current_tenant_id"] == first_id
+ assert len(me.json()["memberships"]) == 2
+
+ switch = await client.post(
+ "/api/v1/tenant/switch", json={"tenant_id": second_id}, headers=headers
+ )
+ assert switch.status_code == 200
+ assert switch.json()["tenant"]["id"] == second_id
+
+ current = await client.get("/api/v1/tenant/current", headers=headers)
+ assert current.json()["tenant"]["id"] == second_id
+
+
+async def test_tenant_switch_forbidden_for_non_member(client):
+ headers_owner, _ = await _login(client, "09121110008")
+ create = await client.post(
+ "/api/v1/onboarding/tenant",
+ json={"business_name": "Solo Co", "slug": "solo-co"},
+ headers=headers_owner,
+ )
+ tenant_id = create.json()["tenant"]["id"]
+
+ headers_other, _ = await _login(client, "09121110009")
+ resp = await client.post(
+ "/api/v1/tenant/switch", json={"tenant_id": tenant_id}, headers=headers_other
+ )
+ assert resp.status_code == 403
diff --git a/backend/core-service/app/tests/test_otp_auth.py b/backend/core-service/app/tests/test_otp_auth.py
new file mode 100644
index 0000000..4c9f9e0
--- /dev/null
+++ b/backend/core-service/app/tests/test_otp_auth.py
@@ -0,0 +1,128 @@
+"""تستهای OTP و ثبتنام خودکار."""
+from __future__ import annotations
+
+import pytest
+
+
+@pytest.fixture(autouse=True)
+def enable_auth(monkeypatch):
+ monkeypatch.setenv("AUTH_REQUIRED", "true")
+ monkeypatch.setenv("JWT_SECRET", "test-jwt-secret")
+ from app.core.config import get_settings, settings
+
+ get_settings.cache_clear()
+ fresh = get_settings()
+ monkeypatch.setattr(settings, "auth_required", True)
+ monkeypatch.setattr(settings, "jwt_secret", fresh.jwt_secret)
+
+
+async def test_otp_request_and_verify_new_user(client):
+ mobile = "09123456789"
+ req = await client.post(
+ "/api/v1/auth/otp/request",
+ json={"mobile": mobile, "context": "admin"},
+ )
+ assert req.status_code == 200
+ assert req.json()["expires_in"] == 120
+
+ verify = await client.post(
+ "/api/v1/auth/otp/verify",
+ json={"mobile": mobile, "code": "1234", "context": "admin"},
+ )
+ assert verify.status_code == 200
+ data = verify.json()
+ assert data["is_new_user"] is True
+ assert data["role"] == "pending_tenant_admin"
+ assert "access_token" in data
+ assert "refresh_token" in data
+
+
+async def test_otp_verify_existing_user(client):
+ mobile = "09111111111"
+ await client.post("/api/v1/auth/otp/request", json={"mobile": mobile})
+ first = await client.post(
+ "/api/v1/auth/otp/verify",
+ json={"mobile": mobile, "code": "1234"},
+ )
+ assert first.json()["is_new_user"] is True
+
+ await client.post("/api/v1/auth/otp/request", json={"mobile": mobile})
+ second = await client.post(
+ "/api/v1/auth/otp/verify",
+ json={"mobile": mobile, "code": "1234"},
+ )
+ assert second.status_code == 200
+ assert second.json()["is_new_user"] is False
+
+
+async def test_otp_verify_invalid_code(client):
+ mobile = "09222222222"
+ await client.post("/api/v1/auth/otp/request", json={"mobile": mobile})
+ resp = await client.post(
+ "/api/v1/auth/otp/verify",
+ json={"mobile": mobile, "code": "000000"},
+ )
+ assert resp.status_code == 401
+
+
+async def test_otp_request_skips_unexpired_code(client):
+ mobile = "09444444444"
+ first = await client.post("/api/v1/auth/otp/request", json={"mobile": mobile})
+ assert first.status_code == 200
+ assert first.json()["skipped"] is False
+
+ second = await client.post("/api/v1/auth/otp/request", json={"mobile": mobile})
+ assert second.status_code == 200
+ body = second.json()
+ assert body["skipped"] is True
+ assert body["message"] == "کد قبلاً ارسال شده"
+
+ forced = await client.post(
+ "/api/v1/auth/otp/request",
+ json={"mobile": mobile, "force_sms": True},
+ )
+ assert forced.status_code == 200
+ assert forced.json()["skipped"] is False
+
+
+async def test_platform_admin_mobile_gets_admin_role(client, monkeypatch):
+ from app.core.config import get_settings, settings
+
+ monkeypatch.setenv("PLATFORM_ADMIN_MOBILES", "09155105404")
+ get_settings.cache_clear()
+ fresh = get_settings()
+ monkeypatch.setattr(settings, "platform_admin_mobiles", fresh.platform_admin_mobiles)
+
+ mobile = "09155105404"
+ await client.post("/api/v1/auth/otp/request", json={"mobile": mobile})
+ verify = await client.post(
+ "/api/v1/auth/otp/verify",
+ json={"mobile": mobile, "code": "1234"},
+ )
+ assert verify.status_code == 200
+ assert verify.json()["role"] == "platform_admin"
+
+
+async def test_admin_tenant_with_otp_token(client):
+ mobile = "09333333333"
+ await client.post("/api/v1/auth/otp/request", json={"mobile": mobile})
+ auth = await client.post(
+ "/api/v1/auth/otp/verify",
+ json={"mobile": mobile, "code": "1234"},
+ )
+ token = auth.json()["access_token"]
+ headers = {"Authorization": f"Bearer {token}"}
+
+ created = await client.post(
+ "/api/v1/admin/tenants",
+ json={"name": "My Shop", "slug": "my-shop"},
+ headers=headers,
+ )
+ assert created.status_code == 201
+ body = created.json()
+ assert body["slug"] == "my-shop"
+ assert body["owner_user_id"] == auth.json()["user_id"]
+
+ listed = await client.get("/api/v1/admin/tenants", headers=headers)
+ assert listed.status_code == 200
+ assert listed.json()["meta"]["total_items"] == 1
diff --git a/backend/core-service/app/tests/test_outbox.py b/backend/core-service/app/tests/test_outbox.py
new file mode 100644
index 0000000..fca16f9
--- /dev/null
+++ b/backend/core-service/app/tests/test_outbox.py
@@ -0,0 +1,35 @@
+"""تست پردازش رویدادهای Outbox."""
+from __future__ import annotations
+
+from sqlalchemy import select
+
+from app.models.events import OutboxEvent
+from app.services.tenant_service import TenantService
+from app.schemas.tenant import TenantCreate
+from app.workers.tasks import process_pending_outbox
+from shared.events import EventStatus
+
+
+async def test_outbox_event_recorded_on_tenant_create(session):
+ await TenantService(session).create(
+ TenantCreate(name="Evt Co", slug="evtco")
+ )
+ result = await session.execute(select(OutboxEvent))
+ events = result.scalars().all()
+ assert len(events) == 1
+ assert events[0].event_type == "tenant.created"
+ assert events[0].status == EventStatus.PENDING
+
+
+async def test_process_pending_outbox(session):
+ await TenantService(session).create(
+ TenantCreate(name="Proc Co", slug="procco")
+ )
+ summary = await process_pending_outbox(session)
+ assert summary["processed"] == 1
+ assert summary["failed"] == 0
+
+ result = await session.execute(select(OutboxEvent))
+ events = result.scalars().all()
+ assert all(e.status == EventStatus.PROCESSED for e in events)
+ assert all(e.processed_at is not None for e in events)
diff --git a/backend/core-service/app/tests/test_payamak_client.py b/backend/core-service/app/tests/test_payamak_client.py
new file mode 100644
index 0000000..500be1e
--- /dev/null
+++ b/backend/core-service/app/tests/test_payamak_client.py
@@ -0,0 +1,35 @@
+"""تست پارس و نرمالسازی Payamak — هممنطق torbatkar-back/SMSPanel/utils."""
+from __future__ import annotations
+
+from app.services.payamak_client import (
+ _normalize_phone_for_sms,
+ _parse_payamak_response_body,
+ _payamak_pattern_logical_success,
+)
+
+
+def test_normalize_phone_eleven_digits():
+ assert _normalize_phone_for_sms("09123456789") == "09123456789"
+ assert _normalize_phone_for_sms("9123456789") == "09123456789"
+ assert _normalize_phone_for_sms("+989123456789") == "09123456789"
+ assert _normalize_phone_for_sms("00989123456789") == "09123456789"
+ assert _normalize_phone_for_sms("invalid") is None
+
+
+def test_parse_asmx_success():
+ xml = """
+
+ 98765432101234567
+ """
+ data = _parse_payamak_response_body(xml)
+ ok, err = _payamak_pattern_logical_success(data)
+ assert ok is True
+ assert err is None
+
+
+def test_parse_asmx_error():
+ xml = "-4"
+ data = _parse_payamak_response_body(xml)
+ ok, err = _payamak_pattern_logical_success(data)
+ assert ok is False
+ assert err is not None
diff --git a/backend/core-service/app/tests/test_tenants.py b/backend/core-service/app/tests/test_tenants.py
new file mode 100644
index 0000000..91d8444
--- /dev/null
+++ b/backend/core-service/app/tests/test_tenants.py
@@ -0,0 +1,57 @@
+"""تستهای مدیریت Tenant."""
+from __future__ import annotations
+
+
+async def _create_tenant(client, slug="acme", name="Acme Inc"):
+ return await client.post("/api/v1/tenants", json={"name": name, "slug": slug})
+
+
+async def test_create_tenant(client):
+ resp = await _create_tenant(client)
+ assert resp.status_code == 201
+ data = resp.json()
+ assert data["slug"] == "acme"
+ assert data["status"] == "active"
+ assert "id" in data
+
+
+async def test_create_tenant_normalizes_slug(client):
+ resp = await _create_tenant(client, slug="Acme-Co")
+ assert resp.status_code == 201
+ assert resp.json()["slug"] == "acme-co"
+
+
+async def test_slug_must_be_unique(client):
+ first = await _create_tenant(client, slug="dup")
+ assert first.status_code == 201
+ second = await _create_tenant(client, slug="dup")
+ assert second.status_code == 409
+ assert second.json()["error"]["code"] == "slug_taken"
+
+
+async def test_get_and_list_tenants(client):
+ created = await _create_tenant(client, slug="listme")
+ tenant_id = created.json()["id"]
+
+ got = await client.get(f"/api/v1/tenants/{tenant_id}")
+ assert got.status_code == 200
+ assert got.json()["id"] == tenant_id
+
+ listed = await client.get("/api/v1/tenants")
+ assert listed.status_code == 200
+ body = listed.json()
+ assert body["meta"]["total_items"] >= 1
+ assert len(body["items"]) >= 1
+
+
+async def test_suspend_and_activate(client):
+ created = await _create_tenant(client, slug="susp")
+ tenant_id = created.json()["id"]
+
+ suspended = await client.post(f"/api/v1/tenants/{tenant_id}/suspend")
+ assert suspended.status_code == 200
+ assert suspended.json()["status"] == "suspended"
+
+ activated = await client.post(f"/api/v1/tenants/{tenant_id}/activate")
+ assert activated.status_code == 200
+ assert activated.json()["status"] == "active"
diff --git a/backend/core-service/app/utils/phone.py b/backend/core-service/app/utils/phone.py
new file mode 100644
index 0000000..6fcad5c
--- /dev/null
+++ b/backend/core-service/app/utils/phone.py
@@ -0,0 +1,12 @@
+"""نرمالسازی شماره موبایل و کد OTP (هممنطق torbatkar-back)."""
+from __future__ import annotations
+
+from shared.phone import normalize_mobile as _normalize_mobile
+from shared.phone import normalize_otp_digits
+
+
+def normalize_mobile(raw: str) -> str:
+ return _normalize_mobile(raw)
+
+
+__all__ = ["normalize_mobile", "normalize_otp_digits"]
diff --git a/backend/core-service/app/workers/__init__.py b/backend/core-service/app/workers/__init__.py
new file mode 100644
index 0000000..9e21e4f
--- /dev/null
+++ b/backend/core-service/app/workers/__init__.py
@@ -0,0 +1 @@
+"""Workerهای پسزمینه (Celery)."""
diff --git a/backend/core-service/app/workers/celery_app.py b/backend/core-service/app/workers/celery_app.py
new file mode 100644
index 0000000..23ce90e
--- /dev/null
+++ b/backend/core-service/app/workers/celery_app.py
@@ -0,0 +1,45 @@
+"""پیکربندی Celery برای پردازشهای async.
+
+صفهای اولیه:
+ default - وظایف عمومی
+ core_events - پردازش رویدادهای هسته (Outbox)
+ notifications - اعلانها (فازهای بعدی)
+ webhooks - ارسال webhook (فازهای بعدی)
+"""
+from __future__ import annotations
+
+from celery import Celery
+from kombu import Queue
+
+from app.core.config import settings
+
+celery_app = Celery(
+ "core_service",
+ broker=settings.celery_broker_url,
+ backend=settings.celery_result_backend,
+ include=["app.workers.tasks"],
+)
+
+celery_app.conf.update(
+ task_serializer="json",
+ result_serializer="json",
+ accept_content=["json"],
+ timezone="UTC",
+ enable_utc=True,
+ task_track_started=True,
+ task_default_queue="default",
+ task_queues=(
+ Queue("default"),
+ Queue("core_events"),
+ Queue("notifications"),
+ Queue("webhooks"),
+ ),
+ # زمانبندی دورهای برای پردازش Outbox.
+ beat_schedule={
+ "process-outbox-events": {
+ "task": "app.workers.tasks.process_outbox_events",
+ "schedule": 30.0, # هر ۳۰ ثانیه
+ "options": {"queue": "core_events"},
+ },
+ },
+)
diff --git a/backend/core-service/app/workers/tasks.py b/backend/core-service/app/workers/tasks.py
new file mode 100644
index 0000000..8b6892d
--- /dev/null
+++ b/backend/core-service/app/workers/tasks.py
@@ -0,0 +1,72 @@
+"""وظایف Celery.
+
+در فاز ۱ فقط یک وظیفه نمونه پیاده شده است: process_outbox_events که
+رویدادهای pending را میخواند و وضعیت آنها را به processed/failed تغییر
+میدهد. انتشار واقعی رویدادها (به message bus یا webhook) در فازهای بعد.
+"""
+from __future__ import annotations
+
+import asyncio
+from datetime import datetime, timezone
+
+from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
+
+from app.core.config import settings
+from app.core.logging import get_logger
+from app.repositories.registry import OutboxRepository
+from app.workers.celery_app import celery_app
+from shared.events import EventStatus
+
+logger = get_logger(__name__)
+
+
+async def process_pending_outbox(session: AsyncSession, *, batch_size: int = 100) -> dict:
+ """منطق اصلی پردازش Outbox روی یک session مشخص.
+
+ این تابع مستقل از Celery است تا هم توسط task و هم در تست قابل استفاده باشد.
+ برمیگرداند: تعداد پردازششده و ناموفق.
+ """
+ repo = OutboxRepository(session)
+ pending = await repo.list_pending(limit=batch_size)
+
+ processed = 0
+ failed = 0
+ now = datetime.now(timezone.utc)
+
+ for event in pending:
+ try:
+ # در فاز ۱ صرفاً بهعنوان پردازششده علامت میخورد.
+ # منطق انتشار واقعی رویداد در فازهای بعدی اینجا اضافه میشود.
+ event.status = EventStatus.PROCESSED
+ event.processed_at = now
+ processed += 1
+ except Exception as exc: # pragma: no cover - محافظ
+ event.status = EventStatus.FAILED
+ event.retry_count += 1
+ failed += 1
+ logger.warning(
+ "outbox_event_failed",
+ extra={"event_id": str(event.id), "error": str(exc)},
+ )
+
+ await session.commit()
+ return {"processed": processed, "failed": failed}
+
+
+async def _run_process_outbox() -> dict:
+ """ساخت engine/session مستقل برای اجرای task در حلقه رویداد جدید."""
+ engine = create_async_engine(settings.database_url, pool_pre_ping=True)
+ session_factory = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
+ try:
+ async with session_factory() as session:
+ return await process_pending_outbox(session)
+ finally:
+ await engine.dispose()
+
+
+@celery_app.task(name="app.workers.tasks.process_outbox_events")
+def process_outbox_events() -> dict:
+ """Task دورهای پردازش رویدادهای Outbox."""
+ result = asyncio.run(_run_process_outbox())
+ logger.info("outbox_processed", extra=result)
+ return result
diff --git a/backend/core-service/pytest.ini b/backend/core-service/pytest.ini
new file mode 100644
index 0000000..82ac114
--- /dev/null
+++ b/backend/core-service/pytest.ini
@@ -0,0 +1,5 @@
+[pytest]
+asyncio_mode = auto
+testpaths = app/tests
+python_files = test_*.py
+addopts = -q
diff --git a/backend/core-service/requirements.txt b/backend/core-service/requirements.txt
new file mode 100644
index 0000000..b0c423b
--- /dev/null
+++ b/backend/core-service/requirements.txt
@@ -0,0 +1,32 @@
+# ---- Core Framework ----
+fastapi==0.111.0
+uvicorn[standard]==0.30.1
+pydantic==2.7.4
+pydantic-settings==2.3.4
+
+# ---- Database ----
+sqlalchemy==2.0.31
+alembic==1.13.2
+asyncpg==0.29.0 # درایور async برای PostgreSQL
+psycopg[binary]==3.2.1 # درایور sync برای Alembic
+
+# ---- Cache / Redis ----
+redis==5.0.7
+
+# ---- Celery ----
+celery==5.4.0
+
+# ---- HTTP client (ارتباط بین سرویسها) ----
+httpx==0.27.0
+requests==2.32.3
+
+# ---- Auth / JWT ----
+pyjwt[crypto]==2.8.0
+
+# ---- کتابخانه مشترک پلتفرم ----
+-e ../shared-lib
+
+# ---- Testing ----
+pytest==8.2.2
+pytest-asyncio==0.23.7
+aiosqlite==0.20.0 # دیتابیس درونحافظهای برای تستها
diff --git a/backend/services/README.md b/backend/services/README.md
new file mode 100644
index 0000000..1c651a9
--- /dev/null
+++ b/backend/services/README.md
@@ -0,0 +1,13 @@
+# services/ — سرویسهای آینده (Placeholder)
+
+این پوشه شامل placeholder سرویسهای آینده پلتفرم است. در **فاز ۱** هیچکدام
+پیادهسازی نشدهاند و فقط README مسئولیتها موجود است.
+
+هر سرویس در فازهای بعدی طبق اصول معماری زیر ساخته میشود:
+
+- **Database-per-service:** هر سرویس دیتابیس مستقل خود را دارد.
+- همه جداول بیزینسی ستون `tenant_id` دارند.
+- ارتباط بین سرویسها فقط از طریق REST API، Webhook، Async Event و الگوی Outbox/Inbox.
+- ارتباط مستقیم بین دیتابیس سرویسها **ممنوع** است.
+
+جزئیات قراردادها در [`../../docs/services_contracts.md`](../../docs/services_contracts.md).
diff --git a/backend/services/accounting/README.md b/backend/services/accounting/README.md
new file mode 100644
index 0000000..4a132ef
--- /dev/null
+++ b/backend/services/accounting/README.md
@@ -0,0 +1,19 @@
+# Accounting Service (Placeholder)
+
+> وضعیت: طراحیشده در معماری، پیادهسازی در فازهای بعدی.
+
+## مسئولیتها
+- حسابداری ۴ سطحی
+- اسناد دوبل (Double-entry)
+- مرکز هزینه
+- پروژه
+- فاکتور
+- پرداخت
+- مالیات
+- سامانه مؤدیان
+- گزارشهای مالی
+
+## اصول
+- دیتابیس مستقل (`accounting_db`) با `tenant_id` در جداول بیزینسی.
+- ارتباط با سایر سرویسها فقط از طریق API/Event.
+- بررسی دسترسی قابلیتها از Core (`accounting.*`).
diff --git a/backend/services/ai_assistant/README.md b/backend/services/ai_assistant/README.md
new file mode 100644
index 0000000..ab306c5
--- /dev/null
+++ b/backend/services/ai_assistant/README.md
@@ -0,0 +1,15 @@
+# AI Assistant Service (Placeholder)
+
+> وضعیت: طراحیشده در معماری، پیادهسازی در فازهای بعدی.
+
+## مسئولیتها
+- پاسخگویی هوشمند چت
+- پشتیبانی فروشگاه
+- کمک به CRM
+- اتصال به knowledge base
+- routing به اپراتور انسانی
+
+## اصول
+- دیتابیس مستقل (`ai_assistant_db`) با `tenant_id` در جداول بیزینسی.
+- ارتباط با سایر سرویسها فقط از طریق API/Event.
+- بررسی دسترسی قابلیتها از Core (`ai_assistant.*`).
diff --git a/backend/services/crm/README.md b/backend/services/crm/README.md
new file mode 100644
index 0000000..cb95803
--- /dev/null
+++ b/backend/services/crm/README.md
@@ -0,0 +1,18 @@
+# CRM Service (Placeholder)
+
+> وضعیت: طراحیشده در معماری، پیادهسازی در فازهای بعدی.
+
+## مسئولیتها
+- مشتریان
+- سرنخها (Leads)
+- فرصتها (Opportunities)
+- pipeline
+- stageهای قابل تنظیم
+- task
+- activity
+- automation
+
+## اصول
+- دیتابیس مستقل (`crm_db`) با `tenant_id` در جداول بیزینسی.
+- ارتباط با سایر سرویسها فقط از طریق API/Event.
+- بررسی دسترسی قابلیتها از Core (`crm.*`).
diff --git a/backend/services/ecommerce/README.md b/backend/services/ecommerce/README.md
new file mode 100644
index 0000000..256caf7
--- /dev/null
+++ b/backend/services/ecommerce/README.md
@@ -0,0 +1,19 @@
+# Ecommerce / Online Store Builder Service (Placeholder)
+
+> وضعیت: طراحیشده در معماری، پیادهسازی در فازهای بعدی.
+
+## مسئولیتها
+- فروشگاهساز
+- محصولات
+- دستهبندی
+- موجودی
+- سفارش
+- پرداخت
+- ارسال
+- تخفیف
+- سبد خرید
+
+## اصول
+- دیتابیس مستقل (`ecommerce_db`) با `tenant_id` در جداول بیزینسی.
+- ارتباط با سایر سرویسها فقط از طریق API/Event.
+- بررسی دسترسی قابلیتها از Core (`ecommerce.*`).
diff --git a/backend/services/file_storage/README.md b/backend/services/file_storage/README.md
new file mode 100644
index 0000000..81ebf9d
--- /dev/null
+++ b/backend/services/file_storage/README.md
@@ -0,0 +1,14 @@
+# File Storage Service (Placeholder)
+
+> وضعیت: طراحیشده در معماری، پیادهسازی در فازهای بعدی.
+
+## مسئولیتها
+- آپلود فایل
+- مدیریت asset
+- دسترسی tenant-aware
+- اتصال به S3-compatible storage
+
+## اصول
+- دیتابیس مستقل (`file_storage_db`) با `tenant_id` در جداول بیزینسی.
+- ارتباط با سایر سرویسها فقط از طریق API/Event.
+- بررسی دسترسی قابلیتها از Core (`file_storage.*`).
diff --git a/backend/services/identity-access/Dockerfile b/backend/services/identity-access/Dockerfile
new file mode 100644
index 0000000..36e7838
--- /dev/null
+++ b/backend/services/identity-access/Dockerfile
@@ -0,0 +1,16 @@
+FROM python:3.11-slim
+
+ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1 PIP_NO_CACHE_DIR=1 PYTHONPATH=/app
+WORKDIR /app
+
+RUN apt-get update && apt-get install -y --no-install-recommends build-essential libpq-dev \
+ && rm -rf /var/lib/apt/lists/*
+
+COPY backend/shared-lib/ /shared-lib/
+COPY backend/services/identity-access/requirements.txt /app/requirements.txt
+RUN sed -i 's#-e ../../shared-lib#-e /shared-lib#' /app/requirements.txt \
+ && pip install --upgrade pip && pip install -r requirements.txt
+
+COPY backend/services/identity-access/ /app/
+EXPOSE 8001
+CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8001"]
diff --git a/backend/services/identity-access/Dockerfile.dev b/backend/services/identity-access/Dockerfile.dev
new file mode 100644
index 0000000..ded0ca5
--- /dev/null
+++ b/backend/services/identity-access/Dockerfile.dev
@@ -0,0 +1,22 @@
+# Dev image: فقط وابستگیها — کد با volume mount و uvicorn --reload
+FROM python:3.11-slim
+
+ENV PYTHONDONTWRITEBYTECODE=1 \
+ PYTHONUNBUFFERED=1 \
+ PIP_NO_CACHE_DIR=1 \
+ PYTHONPATH=/app
+
+WORKDIR /app
+
+RUN apt-get update \
+ && apt-get install -y --no-install-recommends build-essential libpq-dev \
+ && rm -rf /var/lib/apt/lists/*
+
+COPY backend/shared-lib/ /shared-lib/
+COPY backend/services/identity-access/requirements.txt /app/requirements.txt
+
+RUN sed -i 's#-e ../../shared-lib#-e /shared-lib#' /app/requirements.txt \
+ && pip install --upgrade pip \
+ && pip install -r requirements.txt
+
+EXPOSE 8001
diff --git a/backend/services/identity-access/README.md b/backend/services/identity-access/README.md
new file mode 100644
index 0000000..ad6cf0e
--- /dev/null
+++ b/backend/services/identity-access/README.md
@@ -0,0 +1,42 @@
+# Identity & Access Service
+
+سرویس احراز هویت و دسترسی (فاز ۲) — SSO مرکزی با Keycloak.
+
+## مسئولیتها
+- مدیریت پروفایل کاربر و عضویت tenant
+- ارائه پیکربندی OIDC به frontend
+- تبدیل authorization code به token (BFF)
+- همگامسازی کاربران با Keycloak Admin API
+
+## دیتابیس
+`identity_access_db` (مستقل از Core — database-per-service)
+
+## APIهای اصلی
+| متد | مسیر | توضیح |
+| --- | --- | --- |
+| GET | `/health` | سلامت سرویس |
+| GET | `/api/v1/auth/config` | پیکربندی OIDC (عمومی) |
+| POST | `/api/v1/auth/token` | تبدیل code به token |
+| GET | `/api/v1/auth/me` | کاربر جاری |
+| POST | `/api/v1/users` | ثبت کاربر (admin) |
+| POST | `/api/v1/tenants/{id}/members` | افزودن عضو tenant |
+
+## اجرا
+```bash
+cd backend/services/identity-access
+pip install -r requirements.txt
+alembic upgrade head
+uvicorn app.main:app --reload --port 8001
+```
+
+## تست
+```bash
+pytest -q
+```
+
+## SSO Flow
+1. Frontend از `/api/v1/auth/config` آدرس Keycloak را میگیرد.
+2. کاربر به Keycloak redirect میشود.
+3. پس از login، callback با `code` به frontend برمیگردد.
+4. Frontend کد را به `/api/v1/auth/token` میفرستد و token دریافت میکند.
+5. همه سرویسها (Core، CRM، ...) همان JWT را validate میکنند.
diff --git a/backend/services/identity-access/alembic.ini b/backend/services/identity-access/alembic.ini
new file mode 100644
index 0000000..d96553e
--- /dev/null
+++ b/backend/services/identity-access/alembic.ini
@@ -0,0 +1,29 @@
+[alembic]
+script_location = alembic
+prepend_sys_path = .
+version_path_separator = os
+sqlalchemy.url =
+[loggers]
+keys = root,sqlalchemy,alembic
+[handlers]
+keys = console
+[formatters]
+keys = generic
+[logger_root]
+level = WARNING
+handlers = console
+[logger_sqlalchemy]
+level = WARNING
+handlers =
+qualname = sqlalchemy.engine
+[logger_alembic]
+level = INFO
+handlers =
+qualname = alembic
+[handler_console]
+class = StreamHandler
+args = (sys.stderr,)
+level = NOTSET
+formatter = generic
+[formatter_generic]
+format = %(levelname)-5.5s [%(name)s] %(message)s
diff --git a/backend/services/identity-access/alembic/env.py b/backend/services/identity-access/alembic/env.py
new file mode 100644
index 0000000..a652497
--- /dev/null
+++ b/backend/services/identity-access/alembic/env.py
@@ -0,0 +1,34 @@
+from logging.config import fileConfig
+
+from alembic import context
+from sqlalchemy import engine_from_config, pool
+
+from app.core.config import settings
+from app.core.database import Base
+import app.models # noqa
+
+config = context.config
+config.set_main_option("sqlalchemy.url", settings.database_url_sync)
+if config.config_file_name:
+ fileConfig(config.config_file_name)
+target_metadata = Base.metadata
+
+
+def run_migrations_offline():
+ context.configure(url=settings.database_url_sync, target_metadata=target_metadata, literal_binds=True)
+ with context.begin_transaction():
+ context.run_migrations()
+
+
+def run_migrations_online():
+ connectable = engine_from_config(config.get_section(config.config_ini_section), prefix="sqlalchemy.", poolclass=pool.NullPool)
+ with connectable.connect() as connection:
+ context.configure(connection=connection, target_metadata=target_metadata)
+ with context.begin_transaction():
+ context.run_migrations()
+
+
+if context.is_offline_mode():
+ run_migrations_offline()
+else:
+ run_migrations_online()
diff --git a/backend/services/identity-access/alembic/versions/0001_initial.py b/backend/services/identity-access/alembic/versions/0001_initial.py
new file mode 100644
index 0000000..8a175f0
--- /dev/null
+++ b/backend/services/identity-access/alembic/versions/0001_initial.py
@@ -0,0 +1,17 @@
+"""initial identity schema"""
+from alembic import op
+from app.core.database import Base
+import app.models # noqa
+
+revision = "0001_initial"
+down_revision = None
+
+
+def upgrade():
+ bind = op.get_bind()
+ Base.metadata.create_all(bind=bind)
+
+
+def downgrade():
+ bind = op.get_bind()
+ Base.metadata.drop_all(bind=bind)
diff --git a/backend/services/identity-access/alembic/versions/0002_user_mobile.py b/backend/services/identity-access/alembic/versions/0002_user_mobile.py
new file mode 100644
index 0000000..77b9ca6
--- /dev/null
+++ b/backend/services/identity-access/alembic/versions/0002_user_mobile.py
@@ -0,0 +1,40 @@
+"""user mobile fields for unified SSO
+
+Revision ID: 0002_user_mobile
+Revises: 0001_initial
+Create Date: 2026-07-07
+"""
+from __future__ import annotations
+
+from typing import Sequence, Union
+
+import sqlalchemy as sa
+from alembic import op
+
+revision: str = "0002_user_mobile"
+down_revision: Union[str, None] = "0001_initial"
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+
+def upgrade() -> None:
+ op.add_column("user_profiles", sa.Column("mobile", sa.String(length=15), nullable=True))
+ op.add_column(
+ "user_profiles",
+ sa.Column("mobile_verified", sa.Boolean(), nullable=False, server_default=sa.text("false")),
+ )
+ op.add_column(
+ "user_profiles",
+ sa.Column("mobile_verified_at", sa.DateTime(timezone=True), nullable=True),
+ )
+ op.add_column("user_profiles", sa.Column("core_user_id", sa.UUID(), nullable=True))
+ op.create_index("ix_user_profiles_mobile", "user_profiles", ["mobile"], unique=True)
+ op.alter_column("user_profiles", "mobile_verified", server_default=None)
+
+
+def downgrade() -> None:
+ op.drop_index("ix_user_profiles_mobile", table_name="user_profiles")
+ op.drop_column("user_profiles", "core_user_id")
+ op.drop_column("user_profiles", "mobile_verified_at")
+ op.drop_column("user_profiles", "mobile_verified")
+ op.drop_column("user_profiles", "mobile")
diff --git a/backend/services/identity-access/alembic/versions/0003_fix_enum_values.py b/backend/services/identity-access/alembic/versions/0003_fix_enum_values.py
new file mode 100644
index 0000000..41499a3
--- /dev/null
+++ b/backend/services/identity-access/alembic/versions/0003_fix_enum_values.py
@@ -0,0 +1,32 @@
+"""align PostgreSQL enum labels with Python enum values (lowercase)
+
+Revision ID: 0003_fix_enum_values
+Revises: 0002_user_mobile
+Create Date: 2026-07-07
+"""
+from __future__ import annotations
+
+from typing import Sequence, Union
+
+from alembic import op
+
+revision: str = "0003_fix_enum_values"
+down_revision: Union[str, None] = "0002_user_mobile"
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+
+def upgrade() -> None:
+ op.execute("ALTER TYPE user_status RENAME VALUE 'ACTIVE' TO 'active'")
+ op.execute("ALTER TYPE user_status RENAME VALUE 'INACTIVE' TO 'inactive'")
+ op.execute("ALTER TYPE user_status RENAME VALUE 'SUSPENDED' TO 'suspended'")
+ op.execute("ALTER TYPE membership_role RENAME VALUE 'TENANT_ADMIN' TO 'tenant_admin'")
+ op.execute("ALTER TYPE membership_role RENAME VALUE 'TENANT_MEMBER' TO 'tenant_member'")
+
+
+def downgrade() -> None:
+ op.execute("ALTER TYPE membership_role RENAME VALUE 'tenant_member' TO 'TENANT_MEMBER'")
+ op.execute("ALTER TYPE membership_role RENAME VALUE 'tenant_admin' TO 'TENANT_ADMIN'")
+ op.execute("ALTER TYPE user_status RENAME VALUE 'suspended' TO 'SUSPENDED'")
+ op.execute("ALTER TYPE user_status RENAME VALUE 'inactive' TO 'INACTIVE'")
+ op.execute("ALTER TYPE user_status RENAME VALUE 'active' TO 'ACTIVE'")
diff --git a/backend/services/identity-access/app/__init__.py b/backend/services/identity-access/app/__init__.py
new file mode 100644
index 0000000..d368261
--- /dev/null
+++ b/backend/services/identity-access/app/__init__.py
@@ -0,0 +1,3 @@
+"""Identity & Access Service — فاز ۲."""
+
+__version__ = "0.1.0"
diff --git a/backend/services/identity-access/app/api/v1/__init__.py b/backend/services/identity-access/app/api/v1/__init__.py
new file mode 100644
index 0000000..883cb26
--- /dev/null
+++ b/backend/services/identity-access/app/api/v1/__init__.py
@@ -0,0 +1,9 @@
+from fastapi import APIRouter
+
+from app.api.v1 import auth, health, users
+
+api_router = APIRouter()
+api_router.include_router(users.router)
+api_router.include_router(auth.router)
+
+__all__ = ["api_router", "health"]
diff --git a/backend/services/identity-access/app/api/v1/auth.py b/backend/services/identity-access/app/api/v1/auth.py
new file mode 100644
index 0000000..09ffa3a
--- /dev/null
+++ b/backend/services/identity-access/app/api/v1/auth.py
@@ -0,0 +1,194 @@
+from fastapi import APIRouter, Depends
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from app.core.database import get_db
+from app.core.security import require_authenticated, require_platform_admin
+from app.schemas.auth import (
+ AuthConfigResponse,
+ AuthSessionResponse,
+ HandoffRedeemRequest,
+ LoginUrlResponse,
+ MeResponse,
+ MobileAuthStartResponse,
+ MobileOtpRequest,
+ MobileOtpVerify,
+ MobileRegisterVerify,
+ OtpRequestResponse,
+ PasswordChangeRequest,
+ PasswordChangeResponse,
+ ProfileUpdateRequest,
+ RegisterCompleteResponse,
+ RegisterRequest,
+ TokenExchangeRequest,
+ TokenResponse,
+ VerifyMobileResponse,
+)
+from app.services.auth_service import AuthService
+from app.services.handoff_store import handoff_store
+from app.services.mobile_auth_service import MobileAuthService
+from app.services.registration_service import RegistrationService
+from shared.exceptions import NotFoundError, UnauthorizedError
+from shared.security import CurrentUser
+
+router = APIRouter(prefix="/auth", tags=["auth"])
+
+
+@router.get("/config", response_model=AuthConfigResponse)
+async def auth_config(db: AsyncSession = Depends(get_db)) -> AuthConfigResponse:
+ """پیکربندی OIDC برای frontend — endpoint عمومی."""
+ return AuthService(db).get_oidc_config()
+
+
+@router.get("/login-url", response_model=LoginUrlResponse)
+async def login_url(db: AsyncSession = Depends(get_db)) -> LoginUrlResponse:
+ """آدرس ورود Keycloak با state و PKCE سمت سرور — endpoint عمومی."""
+ return AuthService(db).build_login_url()
+
+
+@router.post("/token", response_model=TokenResponse)
+async def exchange_token(
+ payload: TokenExchangeRequest, db: AsyncSession = Depends(get_db)
+) -> TokenResponse:
+ """تبدیل authorization code به access token (BFF)."""
+ return await AuthService(db).exchange_token(payload)
+
+
+@router.get("/me", response_model=MeResponse)
+async def get_me(
+ db: AsyncSession = Depends(get_db),
+ user: CurrentUser = Depends(require_authenticated),
+) -> MeResponse:
+ return await AuthService(db).get_me(user)
+
+
+@router.patch("/me", response_model=MeResponse)
+async def update_me(
+ payload: ProfileUpdateRequest,
+ db: AsyncSession = Depends(get_db),
+ user: CurrentUser = Depends(require_authenticated),
+) -> MeResponse:
+ """بهروزرسانی پروفایل کاربر جاری (نام نمایشی و ایمیل)."""
+ return await AuthService(db).update_profile(user, payload)
+
+
+@router.post("/password", response_model=PasswordChangeResponse)
+async def change_password(
+ payload: PasswordChangeRequest,
+ db: AsyncSession = Depends(get_db),
+ user: CurrentUser = Depends(require_authenticated),
+) -> PasswordChangeResponse:
+ """تغییر یا تنظیم رمز عبور کاربر جاری."""
+ return await AuthService(db).change_password(user, payload)
+
+
+@router.post("/register", response_model=OtpRequestResponse, status_code=201)
+async def register_user(
+ payload: RegisterRequest,
+ db: AsyncSession = Depends(get_db),
+) -> OtpRequestResponse:
+ """ثبتنام با ایمیل/نام کاربری/رمز + ارسال OTP موبایل."""
+ return await RegistrationService(db).register_with_credentials(payload)
+
+
+@router.post("/register/verify-mobile", response_model=AuthSessionResponse)
+async def verify_registration_mobile(
+ payload: MobileOtpVerify,
+ db: AsyncSession = Depends(get_db),
+) -> AuthSessionResponse:
+ """تکمیل ثبتنام پس از تأیید OTP (پس از register)."""
+ return await RegistrationService(db).complete_registration_verify(payload)
+
+
+@router.post("/session/redeem", response_model=TokenResponse)
+async def redeem_session_handoff(payload: HandoffRedeemRequest) -> TokenResponse:
+ """تبدیل کد handoff (از Keycloak theme) به توکن SSO — یکبارمصرف."""
+ raw = handoff_store.pop(payload.handoff_code)
+ if raw is None:
+ raise UnauthorizedError("کد ورود منقضی یا نامعتبر است")
+ return TokenResponse(
+ access_token=raw["access_token"],
+ refresh_token=raw.get("refresh_token"),
+ expires_in=raw.get("expires_in", 3600),
+ token_type=raw.get("token_type", "Bearer"),
+ )
+
+
+@router.post("/mobile/start", response_model=MobileAuthStartResponse)
+async def mobile_auth_start(
+ payload: MobileOtpRequest,
+ db: AsyncSession = Depends(get_db),
+) -> MobileAuthStartResponse:
+ """ورود/ثبتنام یکپارچه — بکاند تشخیص میدهد کاربر عضو است یا نه."""
+ return await MobileAuthService(db).start(payload)
+
+
+@router.post("/mobile/complete", response_model=AuthSessionResponse)
+async def mobile_auth_complete(
+ payload: MobileRegisterVerify,
+ db: AsyncSession = Depends(get_db),
+) -> AuthSessionResponse:
+ """تأیید OTP و ورود یا ثبتنام + صدور توکن SSO."""
+ return await MobileAuthService(db).complete(payload)
+
+
+@router.post("/login/mobile", response_model=OtpRequestResponse)
+async def login_mobile_request_otp(
+ payload: MobileOtpRequest,
+ db: AsyncSession = Depends(get_db),
+) -> OtpRequestResponse:
+ """درخواست OTP برای ورود کاربر موجود."""
+ start = await MobileAuthService(db).start(payload)
+ if start.intent != "login":
+ raise NotFoundError("حسابی با این شماره یافت نشد. ابتدا ثبتنام کنید")
+ return OtpRequestResponse(
+ message=start.message,
+ expires_in=start.expires_in,
+ skipped=start.skipped,
+ )
+
+
+@router.post("/login/mobile/verify", response_model=AuthSessionResponse)
+async def login_mobile_verify(
+ payload: MobileOtpVerify,
+ db: AsyncSession = Depends(get_db),
+) -> AuthSessionResponse:
+ """ورود با OTP برای کاربر موجود."""
+ return await MobileAuthService(db).complete_login(payload)
+
+
+@router.post("/register/mobile", response_model=OtpRequestResponse)
+async def register_mobile_request_otp(
+ payload: MobileOtpRequest,
+ db: AsyncSession = Depends(get_db),
+) -> OtpRequestResponse:
+ """ثبتنام فقط با موبایل — مرحله ارسال OTP."""
+ return await RegistrationService(db).request_mobile_register_otp(payload)
+
+
+@router.post("/register/mobile/verify", response_model=AuthSessionResponse, status_code=201)
+async def register_mobile_verify(
+ payload: MobileRegisterVerify,
+ db: AsyncSession = Depends(get_db),
+) -> AuthSessionResponse:
+ """ثبتنام فقط با موبایل — تأیید OTP و ساخت حساب."""
+ return await RegistrationService(db).complete_mobile_register(payload)
+
+
+@router.post("/mobile/request", response_model=OtpRequestResponse)
+async def request_mobile_verification(
+ payload: MobileOtpRequest,
+ db: AsyncSession = Depends(get_db),
+ user: CurrentUser = Depends(require_authenticated),
+) -> OtpRequestResponse:
+ """ارسال OTP برای تأیید موبایل کاربر SSO (پس از لاگین)."""
+ return await RegistrationService(db).request_verify_mobile(user, payload.mobile)
+
+
+@router.post("/mobile/verify", response_model=VerifyMobileResponse)
+async def verify_mobile(
+ payload: MobileOtpVerify,
+ db: AsyncSession = Depends(get_db),
+ user: CurrentUser = Depends(require_authenticated),
+) -> VerifyMobileResponse:
+ """تأیید موبایل کاربر SSO."""
+ return await RegistrationService(db).verify_mobile_for_user(user, payload)
diff --git a/backend/services/identity-access/app/api/v1/health.py b/backend/services/identity-access/app/api/v1/health.py
new file mode 100644
index 0000000..cbbddca
--- /dev/null
+++ b/backend/services/identity-access/app/api/v1/health.py
@@ -0,0 +1,14 @@
+from fastapi import APIRouter
+
+from app import __version__
+from app.core.config import settings
+from app.schemas.common import HealthResponse
+
+router = APIRouter(tags=["health"])
+
+
+@router.get("/health", response_model=HealthResponse)
+async def health() -> HealthResponse:
+ return HealthResponse(
+ status="ok", service=settings.service_name, version=__version__
+ )
diff --git a/backend/services/identity-access/app/api/v1/users.py b/backend/services/identity-access/app/api/v1/users.py
new file mode 100644
index 0000000..ac49ff3
--- /dev/null
+++ b/backend/services/identity-access/app/api/v1/users.py
@@ -0,0 +1,70 @@
+from uuid import UUID
+
+from fastapi import APIRouter, Depends, Header, Query, status
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from app.core.database import get_db
+from app.core.security import require_platform_admin
+from app.schemas.user import MembershipCreate, MembershipRead, UserCreate, UserRead
+from app.services.user_service import UserService
+
+router = APIRouter(tags=["users"])
+
+
+@router.get("/users", response_model=list[UserRead])
+async def list_users(
+ offset: int = Query(default=0, ge=0),
+ limit: int = Query(default=50, ge=1, le=100),
+ db: AsyncSession = Depends(get_db),
+ _user=Depends(require_platform_admin),
+) -> list[UserRead]:
+ users = await UserService(db).list_users(offset=offset, limit=limit)
+ return [UserRead.model_validate(user) for user in users]
+
+
+@router.post("/users", response_model=UserRead, status_code=status.HTTP_201_CREATED)
+async def register_user(
+ payload: UserCreate,
+ db: AsyncSession = Depends(get_db),
+ _user=Depends(require_platform_admin),
+) -> UserRead:
+ user = await UserService(db).register(payload)
+ return UserRead.model_validate(user)
+
+
+@router.get("/users/{user_id}", response_model=UserRead)
+async def get_user(
+ user_id: UUID,
+ db: AsyncSession = Depends(get_db),
+ _user=Depends(require_platform_admin),
+) -> UserRead:
+ user = await UserService(db).get(user_id)
+ return UserRead.model_validate(user)
+
+
+@router.post(
+ "/tenants/{tenant_id}/members",
+ response_model=MembershipRead,
+ status_code=status.HTTP_201_CREATED,
+)
+async def add_tenant_member(
+ tenant_id: UUID,
+ payload: MembershipCreate,
+ db: AsyncSession = Depends(get_db),
+ _user=Depends(require_platform_admin),
+ authorization: str | None = Header(default=None),
+) -> MembershipRead:
+ membership = await UserService(db).add_member(
+ tenant_id, payload, authorization=authorization
+ )
+ return MembershipRead.model_validate(membership)
+
+
+@router.get("/tenants/{tenant_id}/members", response_model=list[MembershipRead])
+async def list_tenant_members(
+ tenant_id: UUID,
+ db: AsyncSession = Depends(get_db),
+ _user=Depends(require_platform_admin),
+) -> list[MembershipRead]:
+ rows = await UserService(db).list_members(tenant_id)
+ return [MembershipRead.model_validate(m) for m in rows]
diff --git a/backend/services/identity-access/app/core/config.py b/backend/services/identity-access/app/core/config.py
new file mode 100644
index 0000000..e3994cf
--- /dev/null
+++ b/backend/services/identity-access/app/core/config.py
@@ -0,0 +1,158 @@
+"""تنظیمات Identity & Access Service."""
+from __future__ import annotations
+
+from functools import lru_cache
+from typing import Literal
+
+from pydantic import Field
+from pydantic_settings import BaseSettings, SettingsConfigDict
+
+
+class Settings(BaseSettings):
+ model_config = SettingsConfigDict(
+ env_file=".env",
+ env_file_encoding="utf-8",
+ case_sensitive=False,
+ extra="ignore",
+ )
+
+ environment: Literal["development", "staging", "production", "test"] = "development"
+ debug: bool = True
+ log_level: str = "INFO"
+ # نام سرویس با alias اختصاصی تا SERVICE_NAME مشترک (core-service) در .env
+ # مقدار این سرویس را بازنویسی نکند.
+ service_name: str = Field(
+ default="identity-access-service", validation_alias="IDENTITY_SERVICE_NAME"
+ )
+ api_v1_prefix: str = "/api/v1"
+
+ database_url: str = Field(
+ default="postgresql+asyncpg://superapp:superapp_password@localhost:5432/identity_access_db",
+ validation_alias="DATABASE_URL",
+ )
+ database_url_sync: str = Field(
+ default="postgresql+psycopg://superapp:superapp_password@localhost:5432/identity_access_db",
+ validation_alias="DATABASE_URL_SYNC",
+ )
+
+ # Core Platform (برای اعتبارسنجی tenant هنگام عضویت)
+ core_service_url: str = Field(default="http://localhost:8000", validation_alias="CORE_SERVICE_URL")
+
+ # Keycloak / SSO
+ keycloak_enabled: bool = True
+ # آدرس داخلی (Docker network) برای Admin API و token exchange
+ keycloak_server_url: str = "http://localhost:8080"
+ # آدرس عمومی برای redirect مرورگر (معمولاً localhost:8080 در dev)
+ keycloak_public_url: str = Field(
+ default="", validation_alias="KEYCLOAK_PUBLIC_URL"
+ )
+ keycloak_realm: str = "superapp"
+ keycloak_admin_user: str = "admin"
+ keycloak_admin_password: str = "admin"
+ keycloak_client_id: str = Field(
+ default="identity-access-service",
+ validation_alias="IDENTITY_KEYCLOAK_CLIENT_ID",
+ )
+ keycloak_client_secret: str = Field(
+ default="change-me-identity",
+ validation_alias="KEYCLOAK_IDENTITY_CLIENT_SECRET",
+ )
+ keycloak_frontend_client_id: str = Field(
+ default="superapp-frontend", validation_alias="KEYCLOAK_FRONTEND_CLIENT_ID"
+ )
+
+ jwt_algorithm: str = "RS256"
+ jwt_audience: str = "account"
+ jwt_verify_signature: bool = True
+ auth_required: bool = True
+
+ # آدرس callback فرانتاند پس از لاگین Keycloak
+ frontend_callback_url: str = Field(
+ default="http://localhost:3000/auth/callback", validation_alias="FRONTEND_CALLBACK_URL"
+ )
+ platform_admin_mobiles: str = Field(
+ default="", validation_alias="PLATFORM_ADMIN_MOBILES"
+ )
+ cors_origins: str = Field(
+ default="http://torbatyar.xyz:3000,http://localhost:3000,http://127.0.0.1:3000",
+ validation_alias="CORS_ORIGINS",
+ )
+
+ @property
+ def platform_admin_mobile_set(self) -> set[str]:
+ from shared.phone import normalize_mobile
+
+ result: set[str] = set()
+ for raw in self.platform_admin_mobiles.split(","):
+ raw = raw.strip()
+ if not raw:
+ continue
+ try:
+ result.add(normalize_mobile(raw))
+ except ValueError:
+ continue
+ return result
+
+ @property
+ def cors_origin_list(self) -> list[str]:
+ origins = [origin.strip() for origin in self.cors_origins.split(",") if origin.strip()]
+ kc = self.keycloak_public_url.rstrip("/") if self.keycloak_public_url else ""
+ if kc and kc not in origins:
+ origins.append(kc)
+ return origins
+
+ @property
+ def keycloak_admin_url(self) -> str:
+ return f"{self.keycloak_server_url}/admin/realms/{self.keycloak_realm}"
+
+ @property
+ def keycloak_browser_url(self) -> str:
+ return self.keycloak_public_url or self.keycloak_server_url
+
+ @property
+ def keycloak_realm_url(self) -> str:
+ return f"{self.keycloak_server_url}/realms/{self.keycloak_realm}"
+
+ @property
+ def keycloak_public_realm_url(self) -> str:
+ return f"{self.keycloak_browser_url}/realms/{self.keycloak_realm}"
+
+ @property
+ def oidc_auth_url(self) -> str:
+ return f"{self.keycloak_realm_url}/protocol/openid-connect/auth"
+
+ @property
+ def oidc_public_auth_url(self) -> str:
+ return f"{self.keycloak_public_realm_url}/protocol/openid-connect/auth"
+
+ @property
+ def oidc_token_url(self) -> str:
+ return f"{self.keycloak_realm_url}/protocol/openid-connect/token"
+
+ @property
+ def oidc_public_token_url(self) -> str:
+ return f"{self.keycloak_public_realm_url}/protocol/openid-connect/token"
+
+ @property
+ def oidc_public_logout_url(self) -> str:
+ return f"{self.keycloak_public_realm_url}/protocol/openid-connect/logout"
+
+ @property
+ def oidc_logout_url(self) -> str:
+ return f"{self.keycloak_realm_url}/protocol/openid-connect/logout"
+
+ @property
+ def oidc_public_userinfo_url(self) -> str:
+ return f"{self.keycloak_public_realm_url}/protocol/openid-connect/userinfo"
+
+ @property
+ def oidc_userinfo_url(self) -> str:
+ return f"{self.keycloak_realm_url}/protocol/openid-connect/userinfo"
+
+
+@lru_cache
+def get_settings() -> Settings:
+ return Settings()
+
+
+settings = get_settings()
diff --git a/backend/services/identity-access/app/core/database.py b/backend/services/identity-access/app/core/database.py
new file mode 100644
index 0000000..b21f5a4
--- /dev/null
+++ b/backend/services/identity-access/app/core/database.py
@@ -0,0 +1,23 @@
+from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
+from sqlalchemy.orm import DeclarativeBase
+
+from app.core.config import settings
+
+
+class Base(DeclarativeBase):
+ pass
+
+
+engine = create_async_engine(settings.database_url, pool_pre_ping=True, future=True)
+AsyncSessionLocal = async_sessionmaker(
+ bind=engine, class_=AsyncSession, expire_on_commit=False, autoflush=False
+)
+
+
+async def get_db():
+ async with AsyncSessionLocal() as session:
+ try:
+ yield session
+ except Exception:
+ await session.rollback()
+ raise
diff --git a/backend/services/identity-access/app/core/logging.py b/backend/services/identity-access/app/core/logging.py
new file mode 100644
index 0000000..f7980b0
--- /dev/null
+++ b/backend/services/identity-access/app/core/logging.py
@@ -0,0 +1,24 @@
+import logging
+import sys
+
+from app.core.config import settings
+
+_configured = False
+
+
+def configure_logging() -> None:
+ global _configured
+ if _configured:
+ return
+ handler = logging.StreamHandler(sys.stdout)
+ handler.setFormatter(logging.Formatter("%(levelname)s [%(name)s] %(message)s"))
+ root = logging.getLogger()
+ root.handlers.clear()
+ root.addHandler(handler)
+ root.setLevel(settings.log_level.upper())
+ _configured = True
+
+
+def get_logger(name: str) -> logging.Logger:
+ configure_logging()
+ return logging.getLogger(name)
diff --git a/backend/services/identity-access/app/core/security.py b/backend/services/identity-access/app/core/security.py
new file mode 100644
index 0000000..73fd5c5
--- /dev/null
+++ b/backend/services/identity-access/app/core/security.py
@@ -0,0 +1,54 @@
+from functools import lru_cache
+
+from fastapi import Depends
+from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
+
+from app.core.config import settings
+from shared.auth import JWTSettings, JWTValidator, PlatformRole, has_any_role
+from shared.exceptions import ForbiddenError, UnauthorizedError
+from shared.security import CurrentUser
+
+bearer_scheme = HTTPBearer(auto_error=False)
+
+
+@lru_cache
+def get_jwt_validator() -> JWTValidator:
+ return JWTValidator(
+ JWTSettings(
+ keycloak_enabled=settings.keycloak_enabled,
+ keycloak_server_url=settings.keycloak_server_url,
+ keycloak_realm=settings.keycloak_realm,
+ jwt_algorithm=settings.jwt_algorithm,
+ jwt_audience=settings.jwt_audience,
+ jwt_verify_signature=settings.jwt_verify_signature,
+ jwt_issuer=settings.keycloak_public_realm_url,
+ )
+ )
+
+
+async def get_current_user(
+ credentials: HTTPAuthorizationCredentials | None = Depends(bearer_scheme),
+) -> CurrentUser:
+ if not settings.auth_required:
+ return CurrentUser(user_id="system", username="system", roles=["platform_admin"])
+ if credentials is None or not credentials.credentials:
+ raise UnauthorizedError("توکن احراز هویت ارائه نشده است")
+ return await get_jwt_validator().validate(credentials.credentials)
+
+
+def require_roles(*roles: PlatformRole | str):
+ async def _checker(user: CurrentUser = Depends(get_current_user)) -> CurrentUser:
+ if not settings.auth_required:
+ return user
+ if not has_any_role(user, *roles):
+ raise ForbiddenError("دسترسی کافی برای این عملیات ندارید")
+ return user
+
+ return _checker
+
+
+require_platform_admin = require_roles(PlatformRole.PLATFORM_ADMIN)
+require_tenant_admin = require_roles(
+ PlatformRole.PLATFORM_ADMIN, PlatformRole.TENANT_ADMIN
+)
+require_authenticated = get_current_user
diff --git a/backend/services/identity-access/app/main.py b/backend/services/identity-access/app/main.py
new file mode 100644
index 0000000..850f839
--- /dev/null
+++ b/backend/services/identity-access/app/main.py
@@ -0,0 +1,87 @@
+from contextlib import asynccontextmanager
+
+from fastapi import FastAPI, Request
+from fastapi.middleware.cors import CORSMiddleware
+from fastapi.responses import JSONResponse
+
+from app import __version__
+from app.api.v1 import api_router, health
+from app.core.config import settings
+from app.core.logging import configure_logging, get_logger
+from shared.exceptions import AppError
+from shared.responses import ErrorDetail, ErrorResponse
+
+configure_logging()
+logger = get_logger(__name__)
+
+
+@asynccontextmanager
+async def lifespan(app: FastAPI):
+ logger.info("service_starting", extra={"service": settings.service_name})
+ yield
+
+
+def create_app() -> FastAPI:
+ app = FastAPI(
+ title="Identity & Access Service",
+ version=__version__,
+ description="سرویس احراز هویت و دسترسی (فاز ۲) — SSO با Keycloak",
+ lifespan=lifespan,
+ )
+ app.add_middleware(
+ CORSMiddleware,
+ allow_origins=settings.cors_origin_list,
+ allow_origin_regex=r"https?://([a-z0-9-]+\.)*torbatyar\.ir",
+ allow_credentials=False,
+ allow_methods=["*"],
+ allow_headers=["*"],
+ )
+
+ def _cors_headers(request: Request) -> dict[str, str]:
+ """هدرهای CORS برای پاسخهای خطا.
+
+ هندلر خطای عمومی (۵۰۰) بیرون از CORSMiddleware اجرا میشود، بنابراین
+ هدر CORS را دستی اضافه میکنیم تا خطاهای سرور در مرورگر بهاشتباه
+ «خطای CORS» نمایش داده نشوند و پیام واقعی به کاربر برسد.
+ """
+ import re
+
+ origin = request.headers.get("origin")
+ if not origin:
+ return {}
+ if origin in settings.cors_origin_list or re.fullmatch(
+ r"https?://([a-z0-9-]+\.)*torbatyar\.ir", origin
+ ):
+ return {
+ "Access-Control-Allow-Origin": origin,
+ "Vary": "Origin",
+ }
+ return {}
+
+ @app.exception_handler(AppError)
+ async def app_error_handler(request: Request, exc: AppError) -> JSONResponse:
+ return JSONResponse(
+ status_code=exc.status_code,
+ content=ErrorResponse(
+ error=ErrorDetail(code=exc.error_code, message=exc.message, details=exc.details)
+ ).model_dump(),
+ headers=_cors_headers(request),
+ )
+
+ @app.exception_handler(Exception)
+ async def unhandled_handler(request: Request, exc: Exception) -> JSONResponse:
+ logger.error("unhandled_exception", extra={"error": str(exc)}, exc_info=exc)
+ return JSONResponse(
+ status_code=500,
+ content=ErrorResponse(
+ error=ErrorDetail(code="internal_error", message="خطای داخلی سرور")
+ ).model_dump(),
+ headers=_cors_headers(request),
+ )
+
+ app.include_router(health.router)
+ app.include_router(api_router, prefix=settings.api_v1_prefix)
+ return app
+
+
+app = create_app()
diff --git a/backend/services/identity-access/app/models/__init__.py b/backend/services/identity-access/app/models/__init__.py
new file mode 100644
index 0000000..7f5f776
--- /dev/null
+++ b/backend/services/identity-access/app/models/__init__.py
@@ -0,0 +1,4 @@
+from app.models.membership import TenantMembership
+from app.models.user import UserProfile
+
+__all__ = ["UserProfile", "TenantMembership"]
diff --git a/backend/services/identity-access/app/models/membership.py b/backend/services/identity-access/app/models/membership.py
new file mode 100644
index 0000000..92fa0ac
--- /dev/null
+++ b/backend/services/identity-access/app/models/membership.py
@@ -0,0 +1,38 @@
+import uuid
+from datetime import datetime
+
+from sqlalchemy import Boolean, DateTime, Enum as SAEnum, ForeignKey, Index, UniqueConstraint, func
+from sqlalchemy.orm import Mapped, mapped_column
+
+from app.core.database import Base
+from app.models.types import GUID, MembershipRole
+
+
+class TenantMembership(Base):
+ """عضویت کاربر در یک tenant — tenant_id فقط UUID مرجع Core است (بدون FK بین DB)."""
+
+ __tablename__ = "tenant_memberships"
+
+ id: Mapped[uuid.UUID] = mapped_column(GUID(), primary_key=True, default=uuid.uuid4)
+ tenant_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
+ user_id: Mapped[uuid.UUID] = mapped_column(
+ GUID(), ForeignKey("user_profiles.id", ondelete="CASCADE"), nullable=False
+ )
+ role: Mapped[MembershipRole] = mapped_column(
+ SAEnum(
+ MembershipRole,
+ name="membership_role",
+ values_callable=lambda enum_cls: [item.value for item in enum_cls],
+ ),
+ default=MembershipRole.TENANT_MEMBER,
+ )
+ is_active: Mapped[bool] = mapped_column(Boolean, default=True)
+ joined_at: Mapped[datetime] = mapped_column(
+ DateTime(timezone=True), server_default=func.now()
+ )
+
+ __table_args__ = (
+ UniqueConstraint("tenant_id", "user_id", name="uq_tenant_membership"),
+ Index("ix_tenant_memberships_tenant_id", "tenant_id"),
+ Index("ix_tenant_memberships_user_id", "user_id"),
+ )
diff --git a/backend/services/identity-access/app/models/types.py b/backend/services/identity-access/app/models/types.py
new file mode 100644
index 0000000..d433464
--- /dev/null
+++ b/backend/services/identity-access/app/models/types.py
@@ -0,0 +1,40 @@
+import enum
+import uuid
+
+from sqlalchemy.dialects.postgresql import UUID as PG_UUID
+from sqlalchemy.types import CHAR, TypeDecorator
+
+
+class GUID(TypeDecorator):
+ impl = CHAR
+ cache_ok = True
+
+ def load_dialect_impl(self, dialect):
+ if dialect.name == "postgresql":
+ return dialect.type_descriptor(PG_UUID(as_uuid=True))
+ return dialect.type_descriptor(CHAR(36))
+
+ def process_bind_param(self, value, dialect):
+ if value is None:
+ return value
+ if dialect.name == "postgresql":
+ return value if isinstance(value, uuid.UUID) else uuid.UUID(str(value))
+ return str(value)
+
+ def process_result_value(self, value, dialect):
+ if value is None:
+ return value
+ if isinstance(value, uuid.UUID):
+ return value
+ return uuid.UUID(str(value))
+
+
+class UserStatus(str, enum.Enum):
+ ACTIVE = "active"
+ INACTIVE = "inactive"
+ SUSPENDED = "suspended"
+
+
+class MembershipRole(str, enum.Enum):
+ TENANT_ADMIN = "tenant_admin"
+ TENANT_MEMBER = "tenant_member"
diff --git a/backend/services/identity-access/app/models/user.py b/backend/services/identity-access/app/models/user.py
new file mode 100644
index 0000000..f3b7674
--- /dev/null
+++ b/backend/services/identity-access/app/models/user.py
@@ -0,0 +1,46 @@
+import uuid
+from datetime import datetime
+
+from sqlalchemy import Boolean, DateTime, Enum as SAEnum, Index, String, func
+from sqlalchemy.orm import Mapped, mapped_column
+
+from app.core.database import Base
+from app.models.types import GUID, UserStatus
+
+
+class UserProfile(Base):
+ """پروفایل کاربر — شناسه Keycloak در keycloak_sub ذخیره میشود."""
+
+ __tablename__ = "user_profiles"
+
+ id: Mapped[uuid.UUID] = mapped_column(GUID(), primary_key=True, default=uuid.uuid4)
+ keycloak_sub: Mapped[str] = mapped_column(String(100), unique=True, nullable=False)
+ email: Mapped[str] = mapped_column(String(255), nullable=False)
+ username: Mapped[str | None] = mapped_column(String(150), nullable=True)
+ display_name: Mapped[str | None] = mapped_column(String(255), nullable=True)
+ mobile: Mapped[str | None] = mapped_column(String(15), nullable=True)
+ mobile_verified: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
+ mobile_verified_at: Mapped[datetime | None] = mapped_column(
+ DateTime(timezone=True), nullable=True
+ )
+ core_user_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
+ status: Mapped[UserStatus] = mapped_column(
+ SAEnum(
+ UserStatus,
+ name="user_status",
+ values_callable=lambda enum_cls: [item.value for item in enum_cls],
+ ),
+ default=UserStatus.ACTIVE,
+ )
+ created_at: Mapped[datetime] = mapped_column(
+ DateTime(timezone=True), server_default=func.now()
+ )
+ updated_at: Mapped[datetime] = mapped_column(
+ DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
+ )
+
+ __table_args__ = (
+ Index("ix_user_profiles_keycloak_sub", "keycloak_sub", unique=True),
+ Index("ix_user_profiles_email", "email"),
+ Index("ix_user_profiles_mobile", "mobile", unique=True),
+ )
diff --git a/backend/services/identity-access/app/repositories/user.py b/backend/services/identity-access/app/repositories/user.py
new file mode 100644
index 0000000..fd26972
--- /dev/null
+++ b/backend/services/identity-access/app/repositories/user.py
@@ -0,0 +1,72 @@
+from uuid import UUID
+
+from sqlalchemy import select
+
+from app.models.membership import TenantMembership
+from app.models.user import UserProfile
+
+
+class UserRepository:
+ def __init__(self, session) -> None:
+ self.session = session
+
+ async def get(self, user_id: UUID) -> UserProfile | None:
+ return await self.session.get(UserProfile, user_id)
+
+ async def get_by_sub(self, keycloak_sub: str) -> UserProfile | None:
+ stmt = select(UserProfile).where(UserProfile.keycloak_sub == keycloak_sub)
+ result = await self.session.execute(stmt)
+ return result.scalar_one_or_none()
+
+ async def get_by_mobile(self, mobile: str) -> UserProfile | None:
+ stmt = select(UserProfile).where(UserProfile.mobile == mobile)
+ result = await self.session.execute(stmt)
+ return result.scalar_one_or_none()
+
+ async def get_by_username(self, username: str) -> UserProfile | None:
+ stmt = select(UserProfile).where(UserProfile.username == username)
+ result = await self.session.execute(stmt)
+ return result.scalar_one_or_none()
+
+ async def add(self, user: UserProfile) -> UserProfile:
+ self.session.add(user)
+ await self.session.flush()
+ return user
+
+ async def list(self, offset: int = 0, limit: int = 20) -> list[UserProfile]:
+ stmt = (
+ select(UserProfile)
+ .order_by(UserProfile.created_at.desc())
+ .offset(offset)
+ .limit(limit)
+ )
+ result = await self.session.execute(stmt)
+ return list(result.scalars().all())
+
+
+class MembershipRepository:
+ def __init__(self, session) -> None:
+ self.session = session
+
+ async def get(self, tenant_id: UUID, user_id: UUID) -> TenantMembership | None:
+ stmt = select(TenantMembership).where(
+ TenantMembership.tenant_id == tenant_id,
+ TenantMembership.user_id == user_id,
+ )
+ result = await self.session.execute(stmt)
+ return result.scalar_one_or_none()
+
+ async def list_by_tenant(self, tenant_id: UUID) -> list[TenantMembership]:
+ stmt = select(TenantMembership).where(TenantMembership.tenant_id == tenant_id)
+ result = await self.session.execute(stmt)
+ return list(result.scalars().all())
+
+ async def list_by_user(self, user_id: UUID) -> list[TenantMembership]:
+ stmt = select(TenantMembership).where(TenantMembership.user_id == user_id)
+ result = await self.session.execute(stmt)
+ return list(result.scalars().all())
+
+ async def add(self, membership: TenantMembership) -> TenantMembership:
+ self.session.add(membership)
+ await self.session.flush()
+ return membership
diff --git a/backend/services/identity-access/app/schemas/auth.py b/backend/services/identity-access/app/schemas/auth.py
new file mode 100644
index 0000000..5b071be
--- /dev/null
+++ b/backend/services/identity-access/app/schemas/auth.py
@@ -0,0 +1,168 @@
+from datetime import datetime
+from typing import Literal
+
+from pydantic import BaseModel, EmailStr, Field, field_validator, model_validator
+
+from shared.phone import normalize_mobile, normalize_otp_digits
+
+
+class AuthConfigResponse(BaseModel):
+ """پیکربندی OIDC برای frontend — بدون hardcode در کلاینت."""
+
+ issuer: str
+ client_id: str
+ authorization_endpoint: str
+ token_endpoint: str
+ logout_endpoint: str
+ userinfo_endpoint: str
+ callback_url: str
+
+
+class LoginUrlResponse(BaseModel):
+ """آدرس ورود Keycloak به همراه state (PKCE سمت سرور)."""
+
+ authorization_url: str
+ state: str
+
+
+class TokenExchangeRequest(BaseModel):
+ code: str = Field(..., min_length=1)
+ redirect_uri: str = Field(..., min_length=1)
+ state: str | None = Field(default=None)
+ code_verifier: str | None = Field(default=None, min_length=43, max_length=128)
+
+
+class TokenResponse(BaseModel):
+ access_token: str
+ refresh_token: str | None = None
+ expires_in: int
+ token_type: str = "Bearer"
+
+
+class MeResponse(BaseModel):
+ user_id: str
+ keycloak_sub: str
+ email: str | None
+ username: str | None
+ display_name: str | None
+ mobile: str | None
+ mobile_verified: bool
+ requires_mobile_verification: bool
+ roles: list[str]
+ tenant_memberships: list[dict]
+
+
+class ProfileUpdateRequest(BaseModel):
+ """بهروزرسانی پروفایل کاربر جاری (نام نمایشی و ایمیل)."""
+
+ display_name: str | None = Field(default=None, max_length=255)
+ email: EmailStr | None = None
+
+
+class PasswordChangeRequest(BaseModel):
+ """تغییر یا تنظیم رمز عبور کاربر جاری."""
+
+ current_password: str | None = Field(default=None, max_length=128)
+ new_password: str = Field(..., min_length=8, max_length=128)
+
+
+class PasswordChangeResponse(BaseModel):
+ message: str
+
+
+class RegisterRequest(BaseModel):
+ """ثبتنام با نام کاربری، رمز عبور و شماره موبایل."""
+
+ email: EmailStr
+ username: str = Field(..., min_length=3, max_length=150)
+ password: str = Field(..., min_length=8)
+ display_name: str | None = None
+ mobile: str = Field(..., min_length=10, max_length=15)
+
+ @field_validator("mobile")
+ @classmethod
+ def validate_mobile(cls, value: str) -> str:
+ return normalize_mobile(value)
+
+
+class MobileOtpRequest(BaseModel):
+ mobile: str = Field(..., min_length=10, max_length=15)
+ force_sms: bool = False
+
+ @field_validator("mobile")
+ @classmethod
+ def validate_mobile(cls, value: str) -> str:
+ return normalize_mobile(value)
+
+
+class MobileOtpVerify(BaseModel):
+ mobile: str = Field(..., min_length=10, max_length=15)
+ code: str = Field(..., min_length=4, max_length=8)
+
+ @field_validator("mobile")
+ @classmethod
+ def validate_mobile(cls, value: str) -> str:
+ return normalize_mobile(value)
+
+ @field_validator("code")
+ @classmethod
+ def validate_code(cls, value: str) -> str:
+ normalized = normalize_otp_digits(value)
+ if not normalized:
+ raise ValueError("کد تأیید نامعتبر است")
+ return normalized
+
+
+class MobileRegisterVerify(MobileOtpVerify):
+ """تکمیل ورود/ثبتنام یکپارچه — فیلدهای پروفایل فقط برای ثبتنام جدید."""
+
+ display_name: str | None = None
+ email: EmailStr | None = None
+ username: str | None = Field(default=None, min_length=3, max_length=150)
+ password: str | None = Field(default=None, min_length=8)
+ create_handoff: bool = False
+
+ @model_validator(mode="after")
+ def validate_credentials_bundle(self) -> "MobileRegisterVerify":
+ fields = (self.email, self.username, self.password)
+ if any(fields) and not all(fields):
+ raise ValueError("ایمیل، نام کاربری و رمز عبور باید با هم ارسال شوند")
+ return self
+
+
+class HandoffRedeemRequest(BaseModel):
+ handoff_code: str = Field(..., min_length=16)
+
+
+class OtpRequestResponse(BaseModel):
+ message: str
+ expires_in: int
+ skipped: bool = False
+
+
+class RegisterCompleteResponse(BaseModel):
+ message: str
+ mobile_verified: bool
+ user_id: str | None = None
+
+
+class AuthSessionResponse(TokenResponse):
+ """پاسخ ورود/ثبتنام موفق — شامل توکن SSO برای redirect مستقیم به داشبورد."""
+
+ message: str
+ intent: Literal["login", "register"]
+ user_id: str | None = None
+ mobile_verified: bool = True
+ redirect_url: str | None = None
+
+
+class MobileAuthStartResponse(OtpRequestResponse):
+ """شروع ورود/ثبتنام یکپارچه با موبایل — بکاند نوع عملیات را تشخیص میدهد."""
+
+ intent: Literal["login", "register"]
+
+
+class VerifyMobileResponse(BaseModel):
+ message: str
+ mobile_verified: bool
+ mobile_verified_at: datetime | None = None
diff --git a/backend/services/identity-access/app/schemas/common.py b/backend/services/identity-access/app/schemas/common.py
new file mode 100644
index 0000000..865a132
--- /dev/null
+++ b/backend/services/identity-access/app/schemas/common.py
@@ -0,0 +1,11 @@
+from pydantic import BaseModel, ConfigDict
+
+
+class ORMModel(BaseModel):
+ model_config = ConfigDict(from_attributes=True)
+
+
+class HealthResponse(BaseModel):
+ status: str = "ok"
+ service: str
+ version: str
diff --git a/backend/services/identity-access/app/schemas/user.py b/backend/services/identity-access/app/schemas/user.py
new file mode 100644
index 0000000..602530e
--- /dev/null
+++ b/backend/services/identity-access/app/schemas/user.py
@@ -0,0 +1,49 @@
+from datetime import datetime
+from uuid import UUID
+
+from pydantic import BaseModel, EmailStr, Field, field_validator
+
+from app.models.types import MembershipRole, UserStatus
+from app.schemas.common import ORMModel
+from shared.phone import normalize_mobile
+
+
+class UserCreate(BaseModel):
+ email: EmailStr
+ username: str = Field(..., min_length=3, max_length=150)
+ display_name: str | None = None
+ password: str = Field(..., min_length=8)
+ mobile: str = Field(..., min_length=10, max_length=15)
+
+ @field_validator("mobile")
+ @classmethod
+ def validate_mobile(cls, value: str) -> str:
+ return normalize_mobile(value)
+
+
+class UserRead(ORMModel):
+ id: UUID
+ keycloak_sub: str
+ email: str
+ username: str | None
+ display_name: str | None
+ mobile: str | None
+ mobile_verified: bool
+ mobile_verified_at: datetime | None
+ core_user_id: UUID | None
+ status: UserStatus
+ created_at: datetime
+
+
+class MembershipCreate(BaseModel):
+ user_id: UUID
+ role: MembershipRole = MembershipRole.TENANT_MEMBER
+
+
+class MembershipRead(ORMModel):
+ id: UUID
+ tenant_id: UUID
+ user_id: UUID
+ role: MembershipRole
+ is_active: bool
+ joined_at: datetime
diff --git a/backend/services/identity-access/app/services/auth_service.py b/backend/services/identity-access/app/services/auth_service.py
new file mode 100644
index 0000000..a8073d0
--- /dev/null
+++ b/backend/services/identity-access/app/services/auth_service.py
@@ -0,0 +1,184 @@
+from urllib.parse import urlencode
+from typing import Literal
+
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from app.core.config import settings
+from app.core.security import get_jwt_validator
+from app.repositories.user import MembershipRepository, UserRepository
+from app.schemas.auth import (
+ AuthConfigResponse,
+ AuthSessionResponse,
+ LoginUrlResponse,
+ MeResponse,
+ PasswordChangeRequest,
+ PasswordChangeResponse,
+ ProfileUpdateRequest,
+ TokenExchangeRequest,
+ TokenResponse,
+)
+from app.models.user import UserProfile
+from app.services.keycloak_client import KeycloakAdminClient
+from app.services.pkce_store import generate_pkce_pair, generate_state, pkce_store
+from app.services.user_service import UserService
+from shared.exceptions import NotFoundError, UnauthorizedError
+from shared.security import CurrentUser
+
+
+class AuthService:
+ def __init__(self, session: AsyncSession) -> None:
+ self.session = session
+ self.users = UserRepository(session)
+ self.memberships = MembershipRepository(session)
+ self.keycloak = KeycloakAdminClient()
+
+ def get_oidc_config(self) -> AuthConfigResponse:
+ return AuthConfigResponse(
+ issuer=settings.keycloak_public_realm_url,
+ client_id=settings.keycloak_frontend_client_id,
+ authorization_endpoint=settings.oidc_public_auth_url,
+ token_endpoint=settings.oidc_token_url,
+ logout_endpoint=settings.oidc_public_logout_url,
+ userinfo_endpoint=settings.oidc_public_userinfo_url,
+ callback_url=settings.frontend_callback_url,
+ )
+
+ def build_login_url(self) -> LoginUrlResponse:
+ """آدرس ورود Keycloak را با state و PKCE challenge سمت سرور میسازد."""
+ state = generate_state()
+ verifier, challenge = generate_pkce_pair()
+ pkce_store.put(state, verifier)
+ params = {
+ "client_id": settings.keycloak_frontend_client_id,
+ "redirect_uri": settings.frontend_callback_url,
+ "response_type": "code",
+ "scope": "openid profile email",
+ "state": state,
+ "code_challenge": challenge,
+ "code_challenge_method": "S256",
+ }
+ url = f"{settings.oidc_public_auth_url}?{urlencode(params)}"
+ return LoginUrlResponse(authorization_url=url, state=state)
+
+ async def exchange_token(self, data: TokenExchangeRequest) -> TokenResponse:
+ verifier = data.code_verifier
+ if data.state:
+ stored = pkce_store.pop(data.state)
+ if stored:
+ verifier = stored
+ raw = await self.keycloak.exchange_code(
+ data.code, data.redirect_uri, verifier
+ )
+ # همگامسازی پروفایل کاربر پس از لاگین موفق
+ user_claims = await get_jwt_validator().validate(raw["access_token"])
+ await UserService(self.session).get_or_create_from_sub(
+ sub=user_claims.user_id,
+ email=user_claims.email,
+ username=user_claims.username,
+ )
+ return TokenResponse(
+ access_token=raw["access_token"],
+ refresh_token=raw.get("refresh_token"),
+ expires_in=raw.get("expires_in", 3600),
+ token_type=raw.get("token_type", "Bearer"),
+ )
+
+ async def issue_session_for_profile(
+ self,
+ profile: UserProfile,
+ *,
+ message: str,
+ intent: Literal["login", "register"],
+ password: str | None = None,
+ sync_mobile_password: bool = False,
+ ) -> AuthSessionResponse:
+ """صدور توکن SSO پس از تأیید OTP یا تکمیل ثبتنام."""
+ username = profile.username or profile.mobile or ""
+ raw = await self.keycloak.issue_tokens_for_user(
+ keycloak_sub=profile.keycloak_sub,
+ username=username,
+ mobile=profile.mobile,
+ password=password,
+ sync_mobile_password=sync_mobile_password,
+ )
+ return AuthSessionResponse(
+ access_token=raw["access_token"],
+ refresh_token=raw.get("refresh_token"),
+ expires_in=raw.get("expires_in", 3600),
+ token_type=raw.get("token_type", "Bearer"),
+ message=message,
+ intent=intent,
+ user_id=str(profile.id),
+ mobile_verified=profile.mobile_verified,
+ )
+
+ async def _build_me(self, current: CurrentUser, user: UserProfile | None) -> MeResponse:
+ memberships = []
+ if user:
+ rows = await self.memberships.list_by_user(user.id)
+ memberships = [
+ {"tenant_id": str(m.tenant_id), "role": m.role.value, "is_active": m.is_active}
+ for m in rows
+ ]
+ return MeResponse(
+ user_id=current.user_id,
+ keycloak_sub=current.user_id,
+ email=(user.email if user else None) or current.email,
+ username=(user.username if user else None) or current.username,
+ display_name=user.display_name if user else None,
+ mobile=user.mobile if user else None,
+ mobile_verified=user.mobile_verified if user else False,
+ requires_mobile_verification=bool(user and not user.mobile_verified),
+ roles=current.roles,
+ tenant_memberships=memberships,
+ )
+
+ async def get_me(self, current: CurrentUser) -> MeResponse:
+ user = await self.users.get_by_sub(current.user_id)
+ return await self._build_me(current, user)
+
+ async def update_profile(
+ self, current: CurrentUser, data: ProfileUpdateRequest
+ ) -> MeResponse:
+ """بهروزرسانی پروفایل کاربر جاری + همگامسازی با Keycloak."""
+ user = await self.users.get_by_sub(current.user_id)
+ if user is None:
+ raise NotFoundError("پروفایل کاربر یافت نشد")
+
+ new_email: str | None = None
+ if data.email is not None:
+ candidate = str(data.email)
+ if candidate != user.email:
+ user.email = candidate
+ new_email = candidate
+
+ if data.display_name is not None:
+ user.display_name = data.display_name.strip() or None
+
+ await self.session.flush()
+ await self.keycloak.update_user(
+ current.user_id,
+ email=new_email,
+ first_name=user.display_name if data.display_name is not None else None,
+ )
+ await self.session.commit()
+ await self.session.refresh(user)
+ return await self._build_me(current, user)
+
+ async def change_password(
+ self, current: CurrentUser, data: PasswordChangeRequest
+ ) -> PasswordChangeResponse:
+ """تغییر/تنظیم رمز عبور کاربر جاری در Keycloak."""
+ user = await self.users.get_by_sub(current.user_id)
+ if user is None:
+ raise NotFoundError("پروفایل کاربر یافت نشد")
+
+ if data.current_password:
+ login_name = user.username or user.mobile or current.username or ""
+ try:
+ await self.keycloak.password_login(login_name, data.current_password)
+ except UnauthorizedError:
+ raise UnauthorizedError("رمز عبور فعلی نادرست است")
+
+ await self.keycloak.set_user_password(current.user_id, data.new_password)
+ return PasswordChangeResponse(message="رمز عبور با موفقیت بهروزرسانی شد")
diff --git a/backend/services/identity-access/app/services/handoff_store.py b/backend/services/identity-access/app/services/handoff_store.py
new file mode 100644
index 0000000..754e62f
--- /dev/null
+++ b/backend/services/identity-access/app/services/handoff_store.py
@@ -0,0 +1,36 @@
+"""کد یکبارمصرف برای انتقال امن session از Keycloak theme به frontend."""
+from __future__ import annotations
+
+import secrets
+import time
+from threading import Lock
+from typing import Any
+
+
+class HandoffStore:
+ def __init__(self, ttl_seconds: int = 120) -> None:
+ self._data: dict[str, tuple[dict[str, Any], float]] = {}
+ self._ttl = ttl_seconds
+ self._lock = Lock()
+
+ def _prune_locked(self) -> None:
+ now = time.time()
+ expired = [key for key, (_, exp) in self._data.items() if exp < now]
+ for key in expired:
+ self._data.pop(key, None)
+
+ def create(self, session: dict[str, Any]) -> str:
+ code = secrets.token_urlsafe(32)
+ with self._lock:
+ self._prune_locked()
+ self._data[code] = (session, time.time() + self._ttl)
+ return code
+
+ def pop(self, code: str) -> dict[str, Any] | None:
+ with self._lock:
+ self._prune_locked()
+ item = self._data.pop(code, None)
+ return item[0] if item else None
+
+
+handoff_store = HandoffStore()
diff --git a/backend/services/identity-access/app/services/keycloak_client.py b/backend/services/identity-access/app/services/keycloak_client.py
new file mode 100644
index 0000000..8d74ae2
--- /dev/null
+++ b/backend/services/identity-access/app/services/keycloak_client.py
@@ -0,0 +1,321 @@
+"""کلاینت Admin API کیکلوک برای مدیریت کاربران."""
+from __future__ import annotations
+
+from typing import Any
+
+import httpx
+
+from app.core.config import settings
+from app.core.logging import get_logger
+from shared.exceptions import UnauthorizedError
+
+logger = get_logger(__name__)
+
+
+def mobile_keycloak_password(mobile: str) -> str:
+ """رمز داخلی Keycloak برای کاربران ثبتنام با موبایل (فقط سمت سرور)."""
+ return f"Ty!{mobile[-6:]}X"
+
+
+class KeycloakAdminClient:
+ async def _get_admin_token(self) -> str:
+ url = f"{settings.keycloak_server_url}/realms/master/protocol/openid-connect/token"
+ data = {
+ "grant_type": "password",
+ "client_id": "admin-cli",
+ "username": settings.keycloak_admin_user,
+ "password": settings.keycloak_admin_password,
+ }
+ async with httpx.AsyncClient(timeout=10.0) as client:
+ resp = await client.post(url, data=data)
+ resp.raise_for_status()
+ return resp.json()["access_token"]
+
+ async def get_service_account_token(self) -> str:
+ """توکن client_credentials برای سرویس identity-access."""
+ data = {
+ "grant_type": "client_credentials",
+ "client_id": settings.keycloak_client_id,
+ "client_secret": settings.keycloak_client_secret,
+ }
+ async with httpx.AsyncClient(timeout=10.0) as client:
+ resp = await client.post(settings.oidc_token_url, data=data)
+ if resp.status_code >= 400:
+ raise UnauthorizedError("دریافت توکن سرویس از Keycloak ناموفق بود")
+ return resp.json()["access_token"]
+
+ async def exchange_token_for_user(self, keycloak_sub: str) -> dict[str, Any]:
+ """صدور access token کاربر پس از تأیید OTP (token exchange)."""
+ service_token = await self.get_service_account_token()
+ data = {
+ "grant_type": "urn:ietf:params:oauth:grant-type:token-exchange",
+ "client_id": settings.keycloak_client_id,
+ "client_secret": settings.keycloak_client_secret,
+ "subject_token": service_token,
+ "subject_token_type": "urn:ietf:params:oauth:token-type:access_token",
+ "requested_subject": keycloak_sub,
+ "audience": settings.keycloak_frontend_client_id,
+ "scope": "openid profile email",
+ }
+ async with httpx.AsyncClient(timeout=10.0) as client:
+ resp = await client.post(settings.oidc_token_url, data=data)
+ if resp.status_code >= 400:
+ detail = resp.text
+ try:
+ payload = resp.json()
+ detail = payload.get("error_description") or payload.get("error") or detail
+ except Exception:
+ pass
+ logger.warning(
+ "keycloak_token_exchange_failed",
+ extra={"detail": detail, "sub": keycloak_sub},
+ )
+ raise UnauthorizedError(f"صدور توکن SSO ناموفق بود: {detail}")
+ return resp.json()
+
+ async def ensure_user_login_ready(self, keycloak_sub: str) -> None:
+ """حذف required actions و تکمیل فیلدهای اجباری پروفایل Keycloak."""
+ token = await self._get_admin_token()
+ headers = {"Authorization": f"Bearer {token}"}
+ url = f"{settings.keycloak_admin_url}/users/{keycloak_sub}"
+ async with httpx.AsyncClient(timeout=10.0) as client:
+ resp = await client.get(url, headers=headers)
+ if resp.status_code >= 400:
+ return
+ user = resp.json()
+ user["requiredActions"] = []
+ user["emailVerified"] = True
+ user["enabled"] = True
+ if not user.get("firstName"):
+ user["firstName"] = user.get("username") or "User"
+ if not user.get("lastName"):
+ user["lastName"] = "-"
+ put = await client.put(url, json=user, headers=headers)
+ if put.status_code >= 400:
+ logger.warning(
+ "keycloak_user_setup_failed",
+ extra={"sub": keycloak_sub, "status": put.status_code},
+ )
+
+ async def update_user(
+ self,
+ keycloak_sub: str,
+ *,
+ email: str | None = None,
+ first_name: str | None = None,
+ ) -> None:
+ """بهروزرسانی فیلدهای پروفایل کاربر در Keycloak (ایمیل/نام)."""
+ if email is None and first_name is None:
+ return
+ token = await self._get_admin_token()
+ headers = {"Authorization": f"Bearer {token}"}
+ url = f"{settings.keycloak_admin_url}/users/{keycloak_sub}"
+ async with httpx.AsyncClient(timeout=10.0) as client:
+ resp = await client.get(url, headers=headers)
+ if resp.status_code >= 400:
+ raise UnauthorizedError("دریافت اطلاعات کاربر از Keycloak ناموفق بود")
+ user = resp.json()
+ if email is not None:
+ user["email"] = email
+ user["emailVerified"] = True
+ if first_name is not None:
+ user["firstName"] = first_name or user.get("username") or "User"
+ put = await client.put(url, json=user, headers=headers)
+ if put.status_code >= 400:
+ detail = put.text
+ try:
+ detail = put.json().get("errorMessage") or detail
+ except Exception:
+ pass
+ raise UnauthorizedError(
+ f"بهروزرسانی پروفایل Keycloak ناموفق بود: {detail}"
+ )
+
+ async def set_user_password(
+ self, keycloak_sub: str, password: str, *, temporary: bool = False
+ ) -> None:
+ """تنظیم رمز کاربر از Admin API (پس از تأیید OTP)."""
+ token = await self._get_admin_token()
+ url = f"{settings.keycloak_admin_url}/users/{keycloak_sub}/reset-password"
+ payload = {"type": "password", "value": password, "temporary": temporary}
+ headers = {"Authorization": f"Bearer {token}"}
+ async with httpx.AsyncClient(timeout=10.0) as client:
+ resp = await client.put(url, json=payload, headers=headers)
+ if resp.status_code not in (204, 200):
+ detail = resp.text
+ try:
+ detail = resp.json().get("errorMessage") or detail
+ except Exception:
+ pass
+ raise UnauthorizedError(f"تنظیم رمز Keycloak ناموفق بود: {detail}")
+
+ async def password_login(self, username: str, password: str) -> dict[str, Any]:
+ """ورود مستقیم (فقط از BFF، با کلاینت محرمانه identity-access)."""
+ data = {
+ "grant_type": "password",
+ "client_id": settings.keycloak_client_id,
+ "client_secret": settings.keycloak_client_secret,
+ "username": username,
+ "password": password,
+ "scope": "openid profile email",
+ }
+ async with httpx.AsyncClient(timeout=10.0) as client:
+ resp = await client.post(settings.oidc_token_url, data=data)
+ if resp.status_code >= 400:
+ detail = resp.text
+ try:
+ payload = resp.json()
+ detail = payload.get("error_description") or payload.get("error") or detail
+ except Exception:
+ pass
+ raise UnauthorizedError(f"ورود Keycloak ناموفق بود: {detail}")
+ return resp.json()
+
+ async def issue_tokens_for_user(
+ self,
+ *,
+ keycloak_sub: str,
+ username: str,
+ mobile: str | None = None,
+ password: str | None = None,
+ sync_mobile_password: bool = False,
+ ) -> dict[str, Any]:
+ """پس از تأیید هویت سمت سرور، توکن SSO کاربر را صادر میکند."""
+ errors: list[str] = []
+
+ if password:
+ try:
+ return await self.password_login(username, password)
+ except UnauthorizedError as exc:
+ errors.append(str(exc))
+
+ try:
+ return await self.exchange_token_for_user(keycloak_sub)
+ except UnauthorizedError as exc:
+ errors.append(str(exc))
+
+ if mobile:
+ internal = mobile_keycloak_password(mobile)
+ candidates = []
+ for candidate in (username, mobile):
+ if candidate and candidate not in candidates:
+ candidates.append(candidate)
+
+ await self.ensure_user_login_ready(keycloak_sub)
+
+ for candidate in candidates:
+ try:
+ return await self.password_login(candidate, internal)
+ except UnauthorizedError as exc:
+ errors.append(str(exc))
+
+ if sync_mobile_password:
+ await self.set_user_password(keycloak_sub, internal)
+ await self.ensure_user_login_ready(keycloak_sub)
+ for candidate in candidates:
+ try:
+ return await self.password_login(candidate, internal)
+ except UnauthorizedError as exc:
+ errors.append(str(exc))
+
+ detail = errors[-1] if errors else "خطای ناشناخته"
+ if "Unsupported grant_type" in detail or "unsupported_grant_type" in detail:
+ detail += " — Keycloak را با --features=token-exchange راهاندازی و scripts/enable_mobile_auth_client.py را اجرا کنید"
+ elif "direct access grants" in detail.lower() or "unauthorized_client" in detail.lower():
+ detail += " — scripts/enable_mobile_auth_client.py را اجرا کنید"
+ raise UnauthorizedError(f"صدور توکن SSO ناموفق بود: {detail}")
+
+ async def assign_realm_role(self, keycloak_sub: str, role_name: str) -> None:
+ """اختصاص نقش realm به کاربر (مثلاً platform_admin)."""
+ token = await self._get_admin_token()
+ headers = {"Authorization": f"Bearer {token}"}
+ roles_url = f"{settings.keycloak_admin_url}/roles"
+ async with httpx.AsyncClient(timeout=10.0) as client:
+ resp = await client.get(roles_url, params={"search": role_name}, headers=headers)
+ resp.raise_for_status()
+ roles = [r for r in resp.json() if r.get("name") == role_name]
+ if not roles:
+ logger.warning("keycloak_role_not_found", extra={"role": role_name})
+ return
+ mapping_url = (
+ f"{settings.keycloak_admin_url}/users/{keycloak_sub}/role-mappings/realm"
+ )
+ map_resp = await client.post(mapping_url, json=[roles[0]], headers=headers)
+ if map_resp.status_code not in (204, 200):
+ logger.warning(
+ "keycloak_role_assign_failed",
+ extra={"role": role_name, "status": map_resp.status_code},
+ )
+
+ async def create_user(
+ self,
+ *,
+ email: str,
+ username: str,
+ password: str,
+ display_name: str | None,
+ mobile: str | None = None,
+ ) -> str:
+ """ساخت کاربر در Keycloak و برگرداندن sub (شناسه)."""
+ token = await self._get_admin_token()
+ url = f"{settings.keycloak_admin_url}/users"
+ payload: dict[str, Any] = {
+ "email": email,
+ "username": username,
+ "enabled": True,
+ "emailVerified": True,
+ "requiredActions": [],
+ "credentials": [{"type": "password", "value": password, "temporary": False}],
+ }
+ if display_name:
+ payload["firstName"] = display_name
+ payload["lastName"] = "-"
+ else:
+ payload["firstName"] = username
+ payload["lastName"] = "-"
+ if mobile:
+ payload["attributes"] = {"mobile": [mobile], "mobile_verified": ["false"]}
+ headers = {"Authorization": f"Bearer {token}"}
+ async with httpx.AsyncClient(timeout=10.0) as client:
+ resp = await client.post(url, json=payload, headers=headers)
+ if resp.status_code == 409:
+ return await self._find_user_sub(username, token)
+ resp.raise_for_status()
+ location = resp.headers.get("Location", "")
+ return location.rsplit("/", 1)[-1]
+
+ async def _find_user_sub(self, username: str, token: str) -> str:
+ url = f"{settings.keycloak_admin_url}/users"
+ headers = {"Authorization": f"Bearer {token}"}
+ async with httpx.AsyncClient(timeout=10.0) as client:
+ resp = await client.get(url, params={"username": username}, headers=headers)
+ resp.raise_for_status()
+ users = resp.json()
+ if not users:
+ raise ValueError(f"کاربر یافت نشد: {username}")
+ return users[0]["id"]
+
+ async def exchange_code(
+ self, code: str, redirect_uri: str, code_verifier: str | None = None
+ ) -> dict[str, Any]:
+ """تبدیل authorization code به token (BFF برای frontend)."""
+ data = {
+ "grant_type": "authorization_code",
+ "client_id": settings.keycloak_frontend_client_id,
+ "code": code,
+ "redirect_uri": redirect_uri,
+ }
+ if code_verifier:
+ data["code_verifier"] = code_verifier
+ async with httpx.AsyncClient(timeout=10.0) as client:
+ resp = await client.post(settings.oidc_token_url, data=data)
+ if resp.status_code >= 400:
+ detail = resp.text
+ try:
+ payload = resp.json()
+ detail = payload.get("error_description") or payload.get("error") or detail
+ except Exception:
+ pass
+ logger.warning("keycloak_token_exchange_failed", extra={"detail": detail})
+ raise UnauthorizedError(f"تبادل token با Keycloak ناموفق بود: {detail}")
+ return resp.json()
diff --git a/backend/services/identity-access/app/services/mobile_auth_service.py b/backend/services/identity-access/app/services/mobile_auth_service.py
new file mode 100644
index 0000000..127f48c
--- /dev/null
+++ b/backend/services/identity-access/app/services/mobile_auth_service.py
@@ -0,0 +1,163 @@
+"""ورود/ثبتنام یکپارچه با موبایل — تشخیص خودکار login vs register."""
+from __future__ import annotations
+
+from datetime import datetime, timezone
+from uuid import UUID
+
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from app.core.config import settings
+from app.models.user import UserProfile
+from app.repositories.user import UserRepository
+from app.schemas.auth import (
+ AuthSessionResponse,
+ MobileAuthStartResponse,
+ MobileOtpRequest,
+ MobileOtpVerify,
+ MobileRegisterVerify,
+)
+from app.services.auth_service import AuthService
+from app.services.handoff_store import handoff_store
+from app.services.keycloak_client import KeycloakAdminClient, mobile_keycloak_password
+from app.services.otp_client import CoreOtpClient
+from shared.exceptions import ConflictError, UnauthorizedError
+
+
+class MobileAuthService:
+ def __init__(self, session: AsyncSession) -> None:
+ self.session = session
+ self.users = UserRepository(session)
+ self.keycloak = KeycloakAdminClient()
+ self.otp = CoreOtpClient()
+ self.auth = AuthService(session)
+
+ async def start(self, data: MobileOtpRequest) -> MobileAuthStartResponse:
+ existing = await self.users.get_by_mobile(data.mobile)
+ if existing and existing.mobile_verified:
+ intent = "login"
+ default_message = "کد ورود ارسال شد"
+ else:
+ intent = "register"
+ default_message = "کد ثبتنام ارسال شد"
+
+ result = await self.otp.request_otp(data.mobile, force_sms=data.force_sms)
+ return MobileAuthStartResponse(
+ intent=intent,
+ message=result.get("message", default_message),
+ expires_in=result.get("expires_in", 120),
+ skipped=result.get("skipped", False),
+ )
+
+ async def complete(self, data: MobileRegisterVerify) -> AuthSessionResponse:
+ existing = await self.users.get_by_mobile(data.mobile)
+ if existing and existing.mobile_verified:
+ session = await self._complete_login(data, existing)
+ else:
+ session = await self._complete_register(data)
+ return self._maybe_handoff(session, data.create_handoff)
+
+ def _maybe_handoff(self, session: AuthSessionResponse, create_handoff: bool) -> AuthSessionResponse:
+ if not create_handoff:
+ return session
+ code = handoff_store.create(session.model_dump())
+ redirect = f"{settings.frontend_callback_url}?handoff={code}"
+ return AuthSessionResponse(
+ access_token="",
+ refresh_token=None,
+ expires_in=0,
+ token_type="Bearer",
+ message=session.message,
+ intent=session.intent,
+ user_id=session.user_id,
+ mobile_verified=session.mobile_verified,
+ redirect_url=redirect,
+ )
+
+ async def complete_login(self, data: MobileOtpVerify) -> AuthSessionResponse:
+ profile = await self.users.get_by_mobile(data.mobile)
+ if profile is None or not profile.mobile_verified:
+ raise UnauthorizedError("حسابی با این شماره یافت نشد. ابتدا ثبتنام کنید")
+ return await self._complete_login(data, profile)
+
+ async def _complete_login(
+ self, data: MobileOtpVerify, profile: UserProfile
+ ) -> AuthSessionResponse:
+ await self.otp.verify_otp(
+ data.mobile,
+ data.code,
+ keycloak_sub=profile.keycloak_sub,
+ email=profile.email,
+ )
+ await self._sync_platform_admin_role(profile)
+ return await self.auth.issue_session_for_profile(
+ profile,
+ message="ورود با موفقیت انجام شد",
+ intent="login",
+ sync_mobile_password=True,
+ )
+
+ async def _complete_register(self, data: MobileRegisterVerify) -> AuthSessionResponse:
+ if await self.users.get_by_mobile(data.mobile):
+ raise ConflictError("این شماره موبایل قبلاً ثبت شده است", error_code="mobile_taken")
+
+ has_credentials = bool(data.email and data.username and data.password)
+ if has_credentials:
+ if await self.users.get_by_username(data.username):
+ raise ConflictError("نام کاربری تکراری است", error_code="username_taken")
+ username = data.username
+ email = str(data.email)
+ password = data.password
+ else:
+ username = data.mobile
+ email = f"{data.mobile[1:]}@mobile.torbatyar.local"
+ password = mobile_keycloak_password(data.mobile)
+
+ core_result = await self.otp.verify_otp(data.mobile, data.code, email=email)
+ sub = await self.keycloak.create_user(
+ email=email,
+ username=username,
+ password=password,
+ display_name=data.display_name or data.username or data.mobile,
+ mobile=data.mobile,
+ )
+ await self.keycloak.ensure_user_login_ready(sub)
+
+ profile = await self.users.get_by_sub(sub)
+ if profile is None:
+ profile = UserProfile(
+ keycloak_sub=sub,
+ email=email,
+ username=username,
+ display_name=data.display_name or data.username,
+ mobile=data.mobile,
+ mobile_verified=True,
+ mobile_verified_at=datetime.now(timezone.utc),
+ core_user_id=UUID(core_result["user_id"]),
+ )
+ await self.users.add(profile)
+ else:
+ profile.email = email
+ profile.username = username
+ profile.mobile = data.mobile
+ profile.mobile_verified = True
+ profile.mobile_verified_at = datetime.now(timezone.utc)
+ profile.core_user_id = UUID(core_result["user_id"])
+ if data.display_name:
+ profile.display_name = data.display_name
+
+ await self.session.commit()
+ await self.session.refresh(profile)
+ await self._sync_platform_admin_role(profile)
+
+ return await self.auth.issue_session_for_profile(
+ profile,
+ message="ثبتنام و ورود با موفقیت انجام شد",
+ intent="register",
+ password=password if has_credentials else None,
+ sync_mobile_password=not has_credentials,
+ )
+
+ async def _sync_platform_admin_role(self, profile: UserProfile) -> None:
+ if not profile.mobile or profile.mobile not in settings.platform_admin_mobile_set:
+ return
+ await self.keycloak.assign_realm_role(profile.keycloak_sub, "platform_admin")
diff --git a/backend/services/identity-access/app/services/otp_client.py b/backend/services/identity-access/app/services/otp_client.py
new file mode 100644
index 0000000..fbac8dd
--- /dev/null
+++ b/backend/services/identity-access/app/services/otp_client.py
@@ -0,0 +1,67 @@
+"""کلاینت OTP — فراخوانی Core Service برای ارسال/تأیید کد."""
+from __future__ import annotations
+
+import httpx
+
+from app.core.config import settings
+from shared.exceptions import AppError, UnauthorizedError
+
+
+class OtpProxyError(AppError):
+ status_code = 502
+ error_code = "otp_proxy_failed"
+
+
+class CoreOtpClient:
+ async def request_otp(self, mobile: str, *, force_sms: bool = False) -> dict:
+ payload = {"mobile": mobile, "force_sms": force_sms, "context": "public"}
+ try:
+ async with httpx.AsyncClient(timeout=15.0) as client:
+ resp = await client.post(
+ f"{settings.core_service_url}/api/v1/auth/otp/request",
+ json=payload,
+ )
+ except httpx.RequestError as exc:
+ raise OtpProxyError(
+ "اتصال به سرویس ارسال کد برقرار نشد؛ لطفاً بعداً دوباره تلاش کنید"
+ ) from exc
+ if resp.status_code >= 400:
+ body = resp.json() if resp.content else {}
+ msg = body.get("error", {}).get("message") or "ارسال کد ناموفق بود"
+ raise OtpProxyError(msg)
+ return resp.json()
+
+ async def verify_otp(
+ self,
+ mobile: str,
+ code: str,
+ *,
+ keycloak_sub: str | None = None,
+ email: str | None = None,
+ ) -> dict:
+ payload: dict = {
+ "mobile": mobile,
+ "code": code,
+ "context": "public",
+ }
+ if keycloak_sub:
+ payload["keycloak_sub"] = keycloak_sub
+ if email:
+ payload["email"] = email
+ try:
+ async with httpx.AsyncClient(timeout=15.0) as client:
+ resp = await client.post(
+ f"{settings.core_service_url}/api/v1/auth/otp/verify",
+ json=payload,
+ )
+ except httpx.RequestError as exc:
+ raise OtpProxyError(
+ "اتصال به سرویس تأیید کد برقرار نشد؛ لطفاً بعداً دوباره تلاش کنید"
+ ) from exc
+ if resp.status_code == 401:
+ raise UnauthorizedError("کد تأیید نامعتبر یا منقضی شده است")
+ if resp.status_code >= 400:
+ body = resp.json() if resp.content else {}
+ msg = body.get("error", {}).get("message") or "تأیید کد ناموفق بود"
+ raise OtpProxyError(msg)
+ return resp.json()
diff --git a/backend/services/identity-access/app/services/pkce_store.py b/backend/services/identity-access/app/services/pkce_store.py
new file mode 100644
index 0000000..5bd0e96
--- /dev/null
+++ b/backend/services/identity-access/app/services/pkce_store.py
@@ -0,0 +1,53 @@
+"""مدیریت state و PKCE verifier سمت سرور برای جریان لاگین (BFF).
+
+verifier در حافظهی سرویس نگه داشته میشود و فقط با state بازیابی میگردد؛
+بنابراین به sessionStorage/origin مرورگر وابسته نیست.
+
+نکته production: برای چند worker/instance، این store باید به Redis منتقل شود.
+"""
+from __future__ import annotations
+
+import base64
+import hashlib
+import secrets
+import time
+from threading import Lock
+
+
+def generate_pkce_pair() -> tuple[str, str]:
+ """(verifier, challenge) با روش S256 تولید میکند."""
+ verifier = base64.urlsafe_b64encode(secrets.token_bytes(32)).rstrip(b"=").decode("ascii")
+ digest = hashlib.sha256(verifier.encode("ascii")).digest()
+ challenge = base64.urlsafe_b64encode(digest).rstrip(b"=").decode("ascii")
+ return verifier, challenge
+
+
+def generate_state() -> str:
+ return secrets.token_urlsafe(24)
+
+
+class PKCEStore:
+ def __init__(self, ttl_seconds: int = 600) -> None:
+ self._data: dict[str, tuple[str, float]] = {}
+ self._ttl = ttl_seconds
+ self._lock = Lock()
+
+ def _prune_locked(self) -> None:
+ now = time.time()
+ expired = [key for key, (_, exp) in self._data.items() if exp < now]
+ for key in expired:
+ self._data.pop(key, None)
+
+ def put(self, state: str, verifier: str) -> None:
+ with self._lock:
+ self._prune_locked()
+ self._data[state] = (verifier, time.time() + self._ttl)
+
+ def pop(self, state: str) -> str | None:
+ with self._lock:
+ self._prune_locked()
+ item = self._data.pop(state, None)
+ return item[0] if item else None
+
+
+pkce_store = PKCEStore()
diff --git a/backend/services/identity-access/app/services/registration_service.py b/backend/services/identity-access/app/services/registration_service.py
new file mode 100644
index 0000000..1be5ff4
--- /dev/null
+++ b/backend/services/identity-access/app/services/registration_service.py
@@ -0,0 +1,216 @@
+"""ثبتنام یکپارچه SSO + تأیید موبایل."""
+from __future__ import annotations
+
+from datetime import datetime, timezone
+from uuid import UUID
+
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from app.models.user import UserProfile
+from app.repositories.user import UserRepository
+from app.schemas.auth import (
+ AuthSessionResponse,
+ MobileOtpRequest,
+ MobileOtpVerify,
+ MobileRegisterVerify,
+ OtpRequestResponse,
+ RegisterCompleteResponse,
+ RegisterRequest,
+ VerifyMobileResponse,
+)
+from app.services.auth_service import AuthService
+from app.services.keycloak_client import KeycloakAdminClient, mobile_keycloak_password
+from app.services.otp_client import CoreOtpClient, OtpProxyError
+from shared.exceptions import ConflictError, UnauthorizedError
+from shared.security import CurrentUser
+
+
+class RegistrationService:
+ def __init__(self, session: AsyncSession) -> None:
+ self.session = session
+ self.users = UserRepository(session)
+ self.keycloak = KeycloakAdminClient()
+ self.otp = CoreOtpClient()
+ self.auth = AuthService(session)
+
+ async def register_with_credentials(self, data: RegisterRequest) -> OtpRequestResponse:
+ if await self.users.get_by_mobile(data.mobile):
+ raise ConflictError("این شماره موبایل قبلاً ثبت شده است", error_code="mobile_taken")
+ if await self.users.get_by_username(data.username):
+ raise ConflictError("نام کاربری تکراری است", error_code="username_taken")
+
+ sub = await self.keycloak.create_user(
+ email=data.email,
+ username=data.username,
+ password=data.password,
+ display_name=data.display_name,
+ mobile=data.mobile,
+ )
+ if await self.users.get_by_sub(sub):
+ raise ConflictError("کاربر از قبل وجود دارد", error_code="user_exists")
+
+ profile = UserProfile(
+ keycloak_sub=sub,
+ email=data.email,
+ username=data.username,
+ display_name=data.display_name,
+ mobile=data.mobile,
+ mobile_verified=False,
+ )
+ await self.users.add(profile)
+ await self.session.commit()
+
+ result = await self.otp.request_otp(data.mobile)
+ return OtpRequestResponse(
+ message="حساب ایجاد شد. کد تأیید موبایل ارسال شد",
+ expires_in=result.get("expires_in", 120),
+ skipped=result.get("skipped", False),
+ )
+
+ async def complete_registration_verify(self, data: MobileOtpVerify) -> AuthSessionResponse:
+ profile = await self.users.get_by_mobile(data.mobile)
+ if profile is None:
+ raise UnauthorizedError("ثبتنام یافت نشد. ابتدا فرم ثبتنام را تکمیل کنید")
+ if profile.mobile_verified:
+ return await self.auth.issue_session_for_profile(
+ profile,
+ message="شماره موبایل قبلاً تأیید شده — وارد شدید",
+ intent="login",
+ )
+
+ core_result = await self.otp.verify_otp(
+ data.mobile,
+ data.code,
+ keycloak_sub=profile.keycloak_sub,
+ email=profile.email,
+ )
+ profile.mobile_verified = True
+ profile.mobile_verified_at = datetime.now(timezone.utc)
+ profile.core_user_id = UUID(core_result["user_id"])
+ await self.session.commit()
+ await self.session.refresh(profile)
+
+ return await self.auth.issue_session_for_profile(
+ profile,
+ message="ثبتنام با موفقیت تکمیل شد",
+ intent="register",
+ )
+
+ async def request_mobile_register_otp(self, data: MobileOtpRequest) -> OtpRequestResponse:
+ existing = await self.users.get_by_mobile(data.mobile)
+ if existing and existing.mobile_verified:
+ raise ConflictError(
+ "این شماره قبلاً ثبت شده است. از ورود با موبایل استفاده کنید",
+ error_code="mobile_exists_use_login",
+ )
+ result = await self.otp.request_otp(data.mobile, force_sms=data.force_sms)
+ return OtpRequestResponse(
+ message=result.get("message", "کد تأیید ارسال شد"),
+ expires_in=result.get("expires_in", 120),
+ skipped=result.get("skipped", False),
+ )
+
+ async def complete_mobile_register(self, data: MobileRegisterVerify) -> AuthSessionResponse:
+ profile = await self.users.get_by_mobile(data.mobile)
+ if profile and profile.mobile_verified:
+ raise ConflictError(
+ "این شماره قبلاً ثبت شده است. از ورود با موبایل استفاده کنید",
+ error_code="mobile_exists_use_login",
+ )
+
+ username = data.mobile
+ email = f"{data.mobile[1:]}@mobile.torbatyar.local"
+ temp_password = mobile_keycloak_password(data.mobile)
+
+ core_result = await self.otp.verify_otp(
+ data.mobile,
+ data.code,
+ email=email,
+ )
+
+ sub = await self.keycloak.create_user(
+ email=email,
+ username=username,
+ password=temp_password,
+ display_name=data.display_name or data.mobile,
+ mobile=data.mobile,
+ )
+
+ profile = await self.users.get_by_sub(sub)
+ if profile is None:
+ profile = UserProfile(
+ keycloak_sub=sub,
+ email=email,
+ username=username,
+ display_name=data.display_name,
+ mobile=data.mobile,
+ mobile_verified=True,
+ mobile_verified_at=datetime.now(timezone.utc),
+ core_user_id=UUID(core_result["user_id"]),
+ )
+ await self.users.add(profile)
+ else:
+ profile.mobile = data.mobile
+ profile.mobile_verified = True
+ profile.mobile_verified_at = datetime.now(timezone.utc)
+ profile.core_user_id = UUID(core_result["user_id"])
+ if data.display_name:
+ profile.display_name = data.display_name
+
+ await self.session.commit()
+ await self.session.refresh(profile)
+
+ return await self.auth.issue_session_for_profile(
+ profile,
+ message="ثبتنام و ورود با موفقیت انجام شد",
+ intent="register",
+ )
+
+ async def request_verify_mobile(self, current: CurrentUser, mobile: str) -> OtpRequestResponse:
+ profile = await self.users.get_by_sub(current.user_id)
+ if profile is None:
+ raise UnauthorizedError("پروفایل کاربر یافت نشد")
+ if profile.mobile_verified and profile.mobile == mobile:
+ return OtpRequestResponse(message="شماره موبایل قبلاً تأیید شده", expires_in=0, skipped=True)
+
+ other = await self.users.get_by_mobile(mobile)
+ if other and other.keycloak_sub != current.user_id:
+ raise ConflictError("این شماره موبایل به کاربر دیگری تعلق دارد", error_code="mobile_taken")
+
+ profile.mobile = mobile
+ profile.mobile_verified = False
+ await self.session.commit()
+
+ result = await self.otp.request_otp(mobile)
+ return OtpRequestResponse(
+ message=result.get("message", "کد تأیید ارسال شد"),
+ expires_in=result.get("expires_in", 120),
+ skipped=result.get("skipped", False),
+ )
+
+ async def verify_mobile_for_user(
+ self, current: CurrentUser, data: MobileOtpVerify
+ ) -> VerifyMobileResponse:
+ profile = await self.users.get_by_sub(current.user_id)
+ if profile is None:
+ raise UnauthorizedError("پروفایل کاربر یافت نشد")
+ if profile.mobile and profile.mobile != data.mobile:
+ raise ConflictError("شماره موبایل با درخواست قبلی مطابقت ندارد")
+
+ core_result = await self.otp.verify_otp(
+ data.mobile,
+ data.code,
+ keycloak_sub=profile.keycloak_sub,
+ email=profile.email,
+ )
+ profile.mobile = data.mobile
+ profile.mobile_verified = True
+ profile.mobile_verified_at = datetime.now(timezone.utc)
+ profile.core_user_id = UUID(core_result["user_id"])
+ await self.session.commit()
+
+ return VerifyMobileResponse(
+ message="شماره موبایل تأیید شد",
+ mobile_verified=True,
+ mobile_verified_at=profile.mobile_verified_at,
+ )
diff --git a/backend/services/identity-access/app/services/user_service.py b/backend/services/identity-access/app/services/user_service.py
new file mode 100644
index 0000000..ca87313
--- /dev/null
+++ b/backend/services/identity-access/app/services/user_service.py
@@ -0,0 +1,106 @@
+from uuid import UUID
+
+import httpx
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from app.models.membership import TenantMembership
+from app.models.types import MembershipRole
+from app.models.user import UserProfile
+from app.repositories.user import MembershipRepository, UserRepository
+from app.schemas.user import MembershipCreate, UserCreate
+from app.services.keycloak_client import KeycloakAdminClient
+from shared.exceptions import ConflictError, NotFoundError
+
+
+class UserService:
+ def __init__(self, session: AsyncSession) -> None:
+ self.session = session
+ self.users = UserRepository(session)
+ self.memberships = MembershipRepository(session)
+ self.keycloak = KeycloakAdminClient()
+
+ async def register(self, data: UserCreate) -> UserProfile:
+ if data.mobile and await self.users.get_by_mobile(data.mobile):
+ raise ConflictError("این شماره موبایل قبلاً ثبت شده است", error_code="mobile_taken")
+ sub = await self.keycloak.create_user(
+ email=data.email,
+ username=data.username,
+ password=data.password,
+ display_name=data.display_name,
+ mobile=data.mobile,
+ )
+ existing = await self.users.get_by_sub(sub)
+ if existing:
+ return existing
+ user = UserProfile(
+ keycloak_sub=sub,
+ email=data.email,
+ username=data.username,
+ display_name=data.display_name,
+ mobile=data.mobile,
+ mobile_verified=False,
+ )
+ await self.users.add(user)
+ await self.session.commit()
+ await self.session.refresh(user)
+ return user
+
+ async def get(self, user_id: UUID) -> UserProfile:
+ user = await self.users.get(user_id)
+ if user is None:
+ raise NotFoundError(f"کاربر یافت نشد: {user_id}")
+ return user
+
+ async def list_users(self, *, offset: int = 0, limit: int = 50) -> list[UserProfile]:
+ return await self.users.list(offset=offset, limit=min(limit, 100))
+
+ async def get_or_create_from_sub(
+ self, *, sub: str, email: str | None, username: str | None
+ ) -> UserProfile:
+ user = await self.users.get_by_sub(sub)
+ if user:
+ return user
+ user = UserProfile(
+ keycloak_sub=sub,
+ email=email or f"{sub}@unknown.local",
+ username=username,
+ )
+ await self.users.add(user)
+ await self.session.commit()
+ await self.session.refresh(user)
+ return user
+
+ async def add_member(
+ self, tenant_id: UUID, data: MembershipCreate, *, authorization: str | None = None
+ ) -> TenantMembership:
+ await self._assert_tenant_exists(tenant_id, authorization=authorization)
+ user = await self.get(data.user_id)
+ if await self.memberships.get(tenant_id, user.id):
+ raise ConflictError("کاربر قبلاً عضو این tenant است")
+ membership = TenantMembership(
+ tenant_id=tenant_id, user_id=user.id, role=data.role
+ )
+ await self.memberships.add(membership)
+ await self.session.commit()
+ await self.session.refresh(membership)
+ return membership
+
+ async def list_members(self, tenant_id: UUID) -> list[TenantMembership]:
+ return await self.memberships.list_by_tenant(tenant_id)
+
+ async def _assert_tenant_exists(
+ self, tenant_id: UUID, *, authorization: str | None = None
+ ) -> None:
+ from app.core.config import settings
+
+ headers = {}
+ if authorization:
+ headers["Authorization"] = authorization
+ async with httpx.AsyncClient(timeout=5.0) as client:
+ resp = await client.get(
+ f"{settings.core_service_url}/api/v1/tenants/{tenant_id}",
+ headers=headers,
+ )
+ if resp.status_code == 404:
+ raise NotFoundError(f"tenant یافت نشد: {tenant_id}")
+ resp.raise_for_status()
diff --git a/backend/services/identity-access/app/tests/conftest.py b/backend/services/identity-access/app/tests/conftest.py
new file mode 100644
index 0000000..cb20817
--- /dev/null
+++ b/backend/services/identity-access/app/tests/conftest.py
@@ -0,0 +1,37 @@
+import os
+
+os.environ["ENVIRONMENT"] = "test"
+os.environ["AUTH_REQUIRED"] = "false"
+os.environ["DATABASE_URL"] = "sqlite+aiosqlite:///./test_identity.db"
+os.environ["DATABASE_URL_SYNC"] = "sqlite:///./test_identity.db"
+os.environ["JWT_VERIFY_SIGNATURE"] = "false"
+os.environ["KEYCLOAK_ENABLED"] = "false"
+
+import pytest_asyncio
+from httpx import ASGITransport, AsyncClient
+
+import app.models # noqa
+from app.core.database import AsyncSessionLocal, Base, engine
+from app.main import app
+
+
+@pytest_asyncio.fixture
+async def db_setup():
+ async with engine.begin() as conn:
+ await conn.run_sync(Base.metadata.create_all)
+ yield
+ async with engine.begin() as conn:
+ await conn.run_sync(Base.metadata.drop_all)
+
+
+@pytest_asyncio.fixture
+async def client(db_setup):
+ transport = ASGITransport(app=app)
+ async with AsyncClient(transport=transport, base_url="http://testserver") as ac:
+ yield ac
+
+
+@pytest_asyncio.fixture
+async def session(db_setup):
+ async with AsyncSessionLocal() as s:
+ yield s
diff --git a/backend/services/identity-access/app/tests/test_auth.py b/backend/services/identity-access/app/tests/test_auth.py
new file mode 100644
index 0000000..67a4cbe
--- /dev/null
+++ b/backend/services/identity-access/app/tests/test_auth.py
@@ -0,0 +1,19 @@
+async def test_health(client):
+ resp = await client.get("/health")
+ assert resp.status_code == 200
+ assert resp.json()["service"] == "identity-access-service"
+
+
+async def test_auth_config(client):
+ resp = await client.get("/api/v1/auth/config")
+ assert resp.status_code == 200
+ body = resp.json()
+ assert "authorization_endpoint" in body
+ assert body["client_id"] == "superapp-frontend"
+
+
+async def test_auth_me_without_token_when_auth_disabled(client):
+ # با AUTH_REQUIRED=false در conftest
+ resp = await client.get("/api/v1/auth/me")
+ assert resp.status_code == 200
+ assert resp.json()["roles"]
diff --git a/backend/services/identity-access/app/tests/test_users.py b/backend/services/identity-access/app/tests/test_users.py
new file mode 100644
index 0000000..5b1d134
--- /dev/null
+++ b/backend/services/identity-access/app/tests/test_users.py
@@ -0,0 +1,16 @@
+from app.models.user import UserProfile
+from app.repositories.user import UserRepository
+
+
+async def test_create_user_profile(session):
+ repo = UserRepository(session)
+ user = UserProfile(
+ keycloak_sub="sub-123",
+ email="user@example.com",
+ username="user1",
+ )
+ await repo.add(user)
+ await session.commit()
+ found = await repo.get_by_sub("sub-123")
+ assert found is not None
+ assert found.email == "user@example.com"
diff --git a/backend/services/identity-access/pytest.ini b/backend/services/identity-access/pytest.ini
new file mode 100644
index 0000000..7914318
--- /dev/null
+++ b/backend/services/identity-access/pytest.ini
@@ -0,0 +1,3 @@
+[pytest]
+asyncio_mode = auto
+testpaths = app/tests
diff --git a/backend/services/identity-access/requirements.txt b/backend/services/identity-access/requirements.txt
new file mode 100644
index 0000000..1647b01
--- /dev/null
+++ b/backend/services/identity-access/requirements.txt
@@ -0,0 +1,14 @@
+fastapi==0.111.0
+uvicorn[standard]==0.30.1
+pydantic[email]==2.7.4
+pydantic-settings==2.3.4
+sqlalchemy==2.0.31
+alembic==1.13.2
+asyncpg==0.29.0
+psycopg[binary]==3.2.1
+httpx==0.27.0
+pyjwt[crypto]==2.8.0
+-e ../../shared-lib
+pytest==8.2.2
+pytest-asyncio==0.23.7
+aiosqlite==0.20.0
diff --git a/backend/services/identity-access/scripts/apply_realm_theme.py b/backend/services/identity-access/scripts/apply_realm_theme.py
new file mode 100644
index 0000000..2ac7ffc
--- /dev/null
+++ b/backend/services/identity-access/scripts/apply_realm_theme.py
@@ -0,0 +1,57 @@
+"""اعمال تم فارسی torbatyar و locale پیشفرض fa روی realm superapp."""
+from __future__ import annotations
+
+import os
+import sys
+
+import httpx
+
+KEYCLOAK_URL = os.getenv("KEYCLOAK_SERVER_URL", "http://localhost:8080")
+REALM = os.getenv("KEYCLOAK_REALM", "superapp")
+ADMIN_USER = os.getenv("KEYCLOAK_ADMIN", "admin")
+ADMIN_PASS = os.getenv("KEYCLOAK_ADMIN_PASSWORD", "admin")
+
+
+def main() -> int:
+ with httpx.Client(timeout=15.0) as client:
+ token_resp = client.post(
+ f"{KEYCLOAK_URL}/realms/master/protocol/openid-connect/token",
+ data={
+ "grant_type": "password",
+ "client_id": "admin-cli",
+ "username": ADMIN_USER,
+ "password": ADMIN_PASS,
+ },
+ )
+ token_resp.raise_for_status()
+ headers = {"Authorization": f"Bearer {token_resp.json()['access_token']}"}
+
+ realm_resp = client.get(f"{KEYCLOAK_URL}/admin/realms/{REALM}", headers=headers)
+ realm_resp.raise_for_status()
+ data = realm_resp.json()
+
+ data["displayName"] = "Torbatyar"
+ data["displayNameHtml"] = "Torbatyar
"
+ data["internationalizationEnabled"] = True
+ data["supportedLocales"] = ["fa", "en"]
+ data["defaultLocale"] = "fa"
+ data["loginTheme"] = "torbatyar"
+ data["accountTheme"] = "torbatyar"
+ data["emailTheme"] = "torbatyar"
+
+ update_resp = client.put(
+ f"{KEYCLOAK_URL}/admin/realms/{REALM}",
+ headers=headers,
+ json=data,
+ )
+ update_resp.raise_for_status()
+ print(f"Realm '{REALM}' updated: theme=torbatyar, locale=fa")
+ return 0
+
+
+if __name__ == "__main__":
+ try:
+ raise SystemExit(main())
+ except Exception as exc:
+ print(f"Error: {exc}", file=sys.stderr)
+ raise SystemExit(1) from exc
diff --git a/backend/services/identity-access/scripts/enable_mobile_auth_client.py b/backend/services/identity-access/scripts/enable_mobile_auth_client.py
new file mode 100644
index 0000000..8751915
--- /dev/null
+++ b/backend/services/identity-access/scripts/enable_mobile_auth_client.py
@@ -0,0 +1,116 @@
+"""فعالسازی direct grant و token exchange برای ورود موبایل (یکبار اجرا)."""
+from __future__ import annotations
+
+import os
+import sys
+
+import httpx
+
+KEYCLOAK_URL = os.getenv("KEYCLOAK_SERVER_URL", "http://keycloak:8080")
+REALM = os.getenv("KEYCLOAK_REALM", "superapp")
+ADMIN_USER = os.getenv("KEYCLOAK_ADMIN", "admin")
+ADMIN_PASSWORD = os.getenv("KEYCLOAK_ADMIN_PASSWORD", "admin")
+CLIENT_ID = os.getenv("IDENTITY_KEYCLOAK_CLIENT_ID", "identity-access-service")
+FRONTEND_CLIENT_ID = os.getenv("KEYCLOAK_FRONTEND_CLIENT_ID", "superapp-frontend")
+
+
+def main() -> None:
+ with httpx.Client(timeout=15.0) as client:
+ token_resp = client.post(
+ f"{KEYCLOAK_URL}/realms/master/protocol/openid-connect/token",
+ data={
+ "grant_type": "password",
+ "client_id": "admin-cli",
+ "username": ADMIN_USER,
+ "password": ADMIN_PASSWORD,
+ },
+ )
+ token_resp.raise_for_status()
+ token = token_resp.json()["access_token"]
+ headers = {"Authorization": f"Bearer {token}"}
+
+ clients_resp = client.get(
+ f"{KEYCLOAK_URL}/admin/realms/{REALM}/clients",
+ params={"clientId": CLIENT_ID},
+ headers=headers,
+ )
+ clients_resp.raise_for_status()
+ clients = clients_resp.json()
+ if not clients:
+ print(f"Client not found: {CLIENT_ID}", file=sys.stderr)
+ sys.exit(1)
+
+ internal_id = clients[0]["id"]
+ detail_resp = client.get(
+ f"{KEYCLOAK_URL}/admin/realms/{REALM}/clients/{internal_id}",
+ headers=headers,
+ )
+ detail_resp.raise_for_status()
+ client_body = detail_resp.json()
+ client_body["directAccessGrantsEnabled"] = True
+ client_body["serviceAccountsEnabled"] = True
+ attrs = client_body.get("attributes") or {}
+ attrs["oauth2.token.exchange.grant.enabled"] = "true"
+ client_body["attributes"] = attrs
+
+ update_resp = client.put(
+ f"{KEYCLOAK_URL}/admin/realms/{REALM}/clients/{internal_id}",
+ json=client_body,
+ headers=headers,
+ )
+ update_resp.raise_for_status()
+ print(f"Updated {CLIENT_ID}: directAccessGrants + token exchange enabled.")
+
+ sa_user_resp = client.get(
+ f"{KEYCLOAK_URL}/admin/realms/{REALM}/clients/{internal_id}/service-account-user",
+ headers=headers,
+ )
+ if sa_user_resp.status_code != 200:
+ print("Service account user not found.", file=sys.stderr)
+ return
+
+ sa_user_id = sa_user_resp.json()["id"]
+ rm_clients = client.get(
+ f"{KEYCLOAK_URL}/admin/realms/{REALM}/clients",
+ params={"clientId": "realm-management"},
+ headers=headers,
+ ).json()
+ if not rm_clients:
+ print("realm-management client not found.", file=sys.stderr)
+ return
+
+ rm_id = rm_clients[0]["id"]
+ roles = client.get(
+ f"{KEYCLOAK_URL}/admin/realms/{REALM}/clients/{rm_id}/roles",
+ headers=headers,
+ ).json()
+ role_names = {"impersonation", "token-exchange"}
+ for role in roles:
+ if role.get("name") not in role_names:
+ continue
+ map_resp = client.post(
+ f"{KEYCLOAK_URL}/admin/realms/{REALM}/users/{sa_user_id}/role-mappings/clients/{rm_id}",
+ json=[role],
+ headers=headers,
+ )
+ if map_resp.status_code in (204, 200):
+ print(f"Granted {role['name']} to service account.")
+
+ frontend_clients = client.get(
+ f"{KEYCLOAK_URL}/admin/realms/{REALM}/clients",
+ params={"clientId": FRONTEND_CLIENT_ID},
+ headers=headers,
+ ).json()
+ if frontend_clients:
+ frontend_id = frontend_clients[0]["id"]
+ perm_resp = client.get(
+ f"{KEYCLOAK_URL}/admin/realms/{REALM}/clients/{internal_id}/management/permissions",
+ headers=headers,
+ )
+ print(f"Frontend client id: {frontend_id} (token-exchange audience target).")
+ if perm_resp.status_code == 200:
+ print("Client permissions endpoint available.")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/backend/services/identity-access/scripts/ensure_db.py b/backend/services/identity-access/scripts/ensure_db.py
new file mode 100644
index 0000000..643df23
--- /dev/null
+++ b/backend/services/identity-access/scripts/ensure_db.py
@@ -0,0 +1,45 @@
+"""اطمینان از وجود دیتابیس identity_access_db قبل از migration.
+
+اگر volume قبلی PostgreSQL قبل از init-dbs.sql ساخته شده باشد،
+این اسکریپت دیتابیس را در صورت نبود ایجاد میکند.
+"""
+from __future__ import annotations
+
+import os
+import sys
+from urllib.parse import urlparse
+
+
+def main() -> None:
+ sync_url = os.environ.get("DATABASE_URL_SYNC", "")
+ if not sync_url:
+ print("DATABASE_URL_SYNC not set", file=sys.stderr)
+ return
+
+ parsed = urlparse(sync_url.replace("+psycopg", ""))
+ db_name = (parsed.path or "").lstrip("/") or "identity_access_db"
+
+ import psycopg
+
+ conn = psycopg.connect(
+ host=parsed.hostname or "localhost",
+ port=parsed.port or 5432,
+ user=parsed.username,
+ password=parsed.password,
+ dbname="postgres",
+ autocommit=True,
+ )
+ try:
+ with conn.cursor() as cur:
+ cur.execute("SELECT 1 FROM pg_database WHERE datname = %s", (db_name,))
+ if cur.fetchone() is None:
+ cur.execute(f'CREATE DATABASE "{db_name}"')
+ print(f"Created database: {db_name}")
+ else:
+ print(f"Database exists: {db_name}")
+ finally:
+ conn.close()
+
+
+if __name__ == "__main__":
+ main()
diff --git a/backend/services/identity-access/scripts/patch_keycloak_client.py b/backend/services/identity-access/scripts/patch_keycloak_client.py
new file mode 100644
index 0000000..b2b745c
--- /dev/null
+++ b/backend/services/identity-access/scripts/patch_keycloak_client.py
@@ -0,0 +1,100 @@
+"""یکبار اجرا کنید تا redirect URI دامنه dev به کلاینت frontend در Keycloak اضافه شود."""
+from __future__ import annotations
+
+import os
+import sys
+from urllib.parse import urlparse
+
+import httpx
+
+KEYCLOAK_URL = os.getenv("KEYCLOAK_SERVER_URL", "http://localhost:8080")
+REALM = os.getenv("KEYCLOAK_REALM", "superapp")
+ADMIN_USER = os.getenv("KEYCLOAK_ADMIN", "admin")
+ADMIN_PASS = os.getenv("KEYCLOAK_ADMIN_PASSWORD", "admin")
+CLIENT_ID = os.getenv("KEYCLOAK_FRONTEND_CLIENT_ID", "superapp-frontend")
+CALLBACK_URL = os.getenv(
+ "FRONTEND_CALLBACK_URL", "http://torbatyar.xyz:3000/auth/callback"
+)
+
+
+def _dev_origin(callback_url: str) -> str:
+ parsed = urlparse(callback_url)
+ if not parsed.scheme or not parsed.netloc:
+ raise ValueError(f"FRONTEND_CALLBACK_URL نامعتبر است: {callback_url}")
+ return f"{parsed.scheme}://{parsed.netloc}"
+
+
+def main() -> int:
+ origin = _dev_origin(CALLBACK_URL)
+ extra_redirect = f"{origin}/*"
+ extra_origin = origin
+
+ token_url = f"{KEYCLOAK_URL}/realms/master/protocol/openid-connect/token"
+ with httpx.Client(timeout=15.0) as client:
+ token_resp = client.post(
+ token_url,
+ data={
+ "grant_type": "password",
+ "client_id": "admin-cli",
+ "username": ADMIN_USER,
+ "password": ADMIN_PASS,
+ },
+ )
+ token_resp.raise_for_status()
+ token = token_resp.json()["access_token"]
+ headers = {"Authorization": f"Bearer {token}"}
+
+ clients_resp = client.get(
+ f"{KEYCLOAK_URL}/admin/realms/{REALM}/clients",
+ params={"clientId": CLIENT_ID},
+ headers=headers,
+ )
+ clients_resp.raise_for_status()
+ clients = clients_resp.json()
+ if not clients:
+ print(f"Client not found: {CLIENT_ID}", file=sys.stderr)
+ return 1
+
+ client_uuid = clients[0]["id"]
+ detail_resp = client.get(
+ f"{KEYCLOAK_URL}/admin/realms/{REALM}/clients/{client_uuid}",
+ headers=headers,
+ )
+ detail_resp.raise_for_status()
+ data = detail_resp.json()
+
+ redirects = list(data.get("redirectUris") or [])
+ origins = list(data.get("webOrigins") or [])
+ changed = False
+ if extra_redirect not in redirects:
+ redirects.append(extra_redirect)
+ changed = True
+ if extra_origin not in origins:
+ origins.append(extra_origin)
+ changed = True
+
+ # حذف اجبار PKCE (تبادل توکن سمت سرور توسط identity انجام میشود و
+ # crypto.subtle روی HTTP لوکال در دسترس نیست).
+ attributes = dict(data.get("attributes") or {})
+ if attributes.pop("pkce.code.challenge.method", None) is not None:
+ data["attributes"] = attributes
+ changed = True
+
+ if not changed:
+ print(f"Keycloak client already configured for {origin}.")
+ return 0
+
+ data["redirectUris"] = redirects
+ data["webOrigins"] = origins
+ update_resp = client.put(
+ f"{KEYCLOAK_URL}/admin/realms/{REALM}/clients/{client_uuid}",
+ headers=headers,
+ json=data,
+ )
+ update_resp.raise_for_status()
+ print(f"Keycloak frontend client updated for {origin}.")
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/backend/services/link_shortener/README.md b/backend/services/link_shortener/README.md
new file mode 100644
index 0000000..ba7a2a0
--- /dev/null
+++ b/backend/services/link_shortener/README.md
@@ -0,0 +1,15 @@
+# Link Shortener Service (Placeholder)
+
+> وضعیت: طراحیشده در معماری، پیادهسازی در فازهای بعدی.
+
+## مسئولیتها
+- کوتاهکننده لینک مستقل
+- دامنه اختصاصی
+- tracking
+- analytics
+- campaign attribution
+
+## اصول
+- دیتابیس مستقل (`link_shortener_db`) با `tenant_id` در جداول بیزینسی.
+- ارتباط با سایر سرویسها فقط از طریق API/Event.
+- بررسی دسترسی قابلیتها از Core (`link_shortener.*`).
diff --git a/backend/services/live_chat/README.md b/backend/services/live_chat/README.md
new file mode 100644
index 0000000..62a5761
--- /dev/null
+++ b/backend/services/live_chat/README.md
@@ -0,0 +1,15 @@
+# Live Chat Service (Placeholder)
+
+> وضعیت: طراحیشده در معماری، پیادهسازی در فازهای بعدی.
+
+## مسئولیتها
+- widget قابل embed
+- گفتگوی آنلاین
+- اتصال به CRM
+- اتصال به AI Assistant
+- history پیامها
+
+## اصول
+- دیتابیس مستقل (`live_chat_db`) با `tenant_id` در جداول بیزینسی.
+- ارتباط با سایر سرویسها فقط از طریق API/Event.
+- بررسی دسترسی قابلیتها از Core (`live_chat.*`).
diff --git a/backend/services/notification/README.md b/backend/services/notification/README.md
new file mode 100644
index 0000000..4c3681f
--- /dev/null
+++ b/backend/services/notification/README.md
@@ -0,0 +1,15 @@
+# Notification Service (Placeholder)
+
+> وضعیت: طراحیشده در معماری، پیادهسازی در فازهای بعدی.
+
+## مسئولیتها
+- ایمیل
+- پیامک
+- push
+- webhook
+- in-app notification
+
+## اصول
+- دیتابیس مستقل (`notification_db`) با `tenant_id` در جداول بیزینسی.
+- ارتباط با سایر سرویسها فقط از طریق API/Event.
+- بررسی دسترسی قابلیتها از Core (`notification.*`).
diff --git a/backend/services/restaurant/README.md b/backend/services/restaurant/README.md
new file mode 100644
index 0000000..199d500
--- /dev/null
+++ b/backend/services/restaurant/README.md
@@ -0,0 +1,16 @@
+# Restaurant / Cafe Management Service (Placeholder)
+
+> وضعیت: طراحیشده در معماری، پیادهسازی در فازهای بعدی.
+
+## مسئولیتها
+- منوی دیجیتال
+- میزها
+- سفارشها
+- آشپزخانه
+- پرداخت
+- باشگاه مشتریان
+
+## اصول
+- دیتابیس مستقل (`restaurant_db`) با `tenant_id` در جداول بیزینسی.
+- ارتباط با سایر سرویسها فقط از طریق API/Event.
+- بررسی دسترسی قابلیتها از Core (`restaurant.*`).
diff --git a/backend/services/smart_messenger/README.md b/backend/services/smart_messenger/README.md
new file mode 100644
index 0000000..b9c1334
--- /dev/null
+++ b/backend/services/smart_messenger/README.md
@@ -0,0 +1,14 @@
+# Smart Messenger Service (Placeholder)
+
+> وضعیت: طراحیشده در معماری، پیادهسازی در فازهای بعدی.
+
+## مسئولیتها
+- اتصال شبکههای اجتماعی
+- مدیریت پیامها
+- automation
+- inbox یکپارچه
+
+## اصول
+- دیتابیس مستقل (`smart_messenger_db`) با `tenant_id` در جداول بیزینسی.
+- ارتباط با سایر سرویسها فقط از طریق API/Event.
+- بررسی دسترسی قابلیتها از Core (`smart_messenger.*`).
diff --git a/backend/services/sms_panel/README.md b/backend/services/sms_panel/README.md
new file mode 100644
index 0000000..06ea45d
--- /dev/null
+++ b/backend/services/sms_panel/README.md
@@ -0,0 +1,16 @@
+# SMS Panel Service (Placeholder)
+
+> وضعیت: طراحیشده در معماری، پیادهسازی در فازهای بعدی.
+
+## مسئولیتها
+- ارسال پیامک
+- صف ارسال
+- template
+- providerهای مختلف
+- rate limit
+- retry
+
+## اصول
+- دیتابیس مستقل (`sms_panel_db`) با `tenant_id` در جداول بیزینسی.
+- ارتباط با سایر سرویسها فقط از طریق API/Event.
+- بررسی دسترسی قابلیتها از Core (`sms_panel.*`).
diff --git a/backend/services/website_builder/README.md b/backend/services/website_builder/README.md
new file mode 100644
index 0000000..445ef76
--- /dev/null
+++ b/backend/services/website_builder/README.md
@@ -0,0 +1,16 @@
+# Website Builder Service (Placeholder)
+
+> وضعیت: طراحیشده در معماری، پیادهسازی در فازهای بعدی.
+
+## مسئولیتها
+- سایت شرکتی
+- صفحهساز
+- فرمساز
+- منو
+- صفحات landing
+- مدیریت محتوا
+
+## اصول
+- دیتابیس مستقل (`website_builder_db`) با `tenant_id` در جداول بیزینسی.
+- ارتباط با سایر سرویسها فقط از طریق API/Event.
+- بررسی دسترسی قابلیتها از Core (`website_builder.*`).
diff --git a/backend/shared-lib/pyproject.toml b/backend/shared-lib/pyproject.toml
new file mode 100644
index 0000000..96f5d42
--- /dev/null
+++ b/backend/shared-lib/pyproject.toml
@@ -0,0 +1,17 @@
+[build-system]
+requires = ["setuptools>=68", "wheel"]
+build-backend = "setuptools.build_meta"
+
+[project]
+name = "superapp-shared"
+version = "0.1.0"
+description = "کتابخانه مشترک بین سرویسهای SuperApp SaaS Platform"
+requires-python = ">=3.11"
+dependencies = [
+ "pydantic>=2.5",
+ "httpx>=0.27",
+ "pyjwt[crypto]>=2.8",
+]
+
+[tool.setuptools.packages.find]
+include = ["shared*"]
diff --git a/backend/shared-lib/shared/__init__.py b/backend/shared-lib/shared/__init__.py
new file mode 100644
index 0000000..8c28866
--- /dev/null
+++ b/backend/shared-lib/shared/__init__.py
@@ -0,0 +1,14 @@
+"""shared: کتابخانه مشترک بین سرویسهای SuperApp.
+
+این پکیج شامل قراردادها و ابزارهای مشترک است تا بین همه سرویسها
+یکپارچگی حفظ شود (بدون وابستگی مستقیم به دیتابیس سرویس دیگر).
+"""
+
+__all__ = [
+ "tenant",
+ "security",
+ "events",
+ "pagination",
+ "exceptions",
+ "responses",
+]
diff --git a/backend/shared-lib/shared/auth/__init__.py b/backend/shared-lib/shared/auth/__init__.py
new file mode 100644
index 0000000..b9b293d
--- /dev/null
+++ b/backend/shared-lib/shared/auth/__init__.py
@@ -0,0 +1,12 @@
+"""ابزارهای مشترک احراز هویت و مجوزدهی بین سرویسهای backend."""
+
+from shared.auth.jwt import JWTSettings, JWTValidator
+from shared.auth.roles import PlatformRole, has_any_role, has_role
+
+__all__ = [
+ "JWTSettings",
+ "JWTValidator",
+ "PlatformRole",
+ "has_role",
+ "has_any_role",
+]
diff --git a/backend/shared-lib/shared/auth/jwt.py b/backend/shared-lib/shared/auth/jwt.py
new file mode 100644
index 0000000..a0aa801
--- /dev/null
+++ b/backend/shared-lib/shared/auth/jwt.py
@@ -0,0 +1,140 @@
+"""اعتبارسنجی JWT مشترک (Keycloak / OIDC) برای همه سرویسهای backend."""
+from __future__ import annotations
+
+import time
+from dataclasses import dataclass
+from typing import Any
+
+import httpx
+import jwt
+
+from shared.exceptions import UnauthorizedError
+from shared.security import CurrentUser
+
+
+@dataclass(frozen=True)
+class JWTSettings:
+ keycloak_enabled: bool = False
+ keycloak_server_url: str = "http://localhost:8080"
+ keycloak_realm: str = "superapp"
+ jwt_algorithm: str = "RS256"
+ jwt_audience: str = "account"
+ jwt_verify_signature: bool = False
+ # issuer واقعی داخل JWT (ممکن است با keycloak_server_url داخلی Docker متفاوت باشد)
+ jwt_issuer: str | None = None
+
+ @property
+ def jwks_url(self) -> str:
+ return (
+ f"{self.keycloak_server_url}/realms/{self.keycloak_realm}"
+ "/protocol/openid-connect/certs"
+ )
+
+ @property
+ def issuer(self) -> str:
+ if self.jwt_issuer:
+ return self.jwt_issuer
+ return f"{self.keycloak_server_url}/realms/{self.keycloak_realm}"
+
+ @property
+ def allowed_issuers(self) -> list[str]:
+ """Issuerهای مجاز — توکن ممکن است از URL داخلی Docker یا عمومی صادر شده باشد."""
+ issuers = [
+ self.issuer,
+ f"{self.keycloak_server_url}/realms/{self.keycloak_realm}",
+ ]
+ if self.jwt_issuer:
+ issuers.insert(0, self.jwt_issuer)
+ return list(dict.fromkeys(i for i in issuers if i))
+
+
+class JWTValidator:
+ """اعتبارسنج JWT با پشتیبانی JWKS و حالت development."""
+
+ def __init__(self, settings: JWTSettings) -> None:
+ self.settings = settings
+ self._jwks_cache: dict[str, Any] = {"keys": None, "fetched_at": 0.0}
+ self._jwks_ttl = 3600
+
+ async def validate(self, token: str) -> CurrentUser:
+ if not token:
+ raise UnauthorizedError("توکن احراز هویت ارائه نشده است")
+ try:
+ if self.settings.jwt_verify_signature and self.settings.keycloak_enabled:
+ claims = await self._decode_verified(token)
+ else:
+ claims = self._decode_unverified(token)
+ except UnauthorizedError:
+ raise
+ except Exception as exc:
+ raise UnauthorizedError(f"توکن نامعتبر است: {exc}") from exc
+ return self._claims_to_user(claims)
+
+ def _decode_unverified(self, token: str) -> dict[str, Any]:
+ return jwt.decode(token, options={"verify_signature": False})
+
+ async def _decode_verified(self, token: str) -> dict[str, Any]:
+ jwks = await self._get_jwks()
+ if jwks is None:
+ raise UnauthorizedError("امکان دریافت کلید عمومی برای اعتبارسنجی توکن نبود")
+ try:
+ signing_key = jwt.PyJWKClient(self.settings.jwks_url).get_signing_key_from_jwt(token)
+ claims = jwt.decode(
+ token,
+ signing_key.key,
+ algorithms=[self.settings.jwt_algorithm],
+ options={"verify_aud": False, "verify_iss": False},
+ )
+ token_iss = claims.get("iss")
+ if not self._issuer_allowed(token_iss):
+ raise UnauthorizedError(
+ f"توکن نامعتبر است: issuer نامعتبر ({token_iss})"
+ )
+ return claims
+ except jwt.PyJWTError as exc:
+ raise UnauthorizedError(f"توکن نامعتبر است: {exc}") from exc
+
+ def _issuer_allowed(self, token_iss: str | None) -> bool:
+ if not token_iss:
+ return False
+ if token_iss in self.settings.allowed_issuers:
+ return True
+ realm_suffix = f"/realms/{self.settings.keycloak_realm}"
+ return token_iss.endswith(realm_suffix)
+
+ async def _get_jwks(self) -> dict[str, Any] | None:
+ now = time.time()
+ if (
+ self._jwks_cache["keys"] is not None
+ and now - self._jwks_cache["fetched_at"] < self._jwks_ttl
+ ):
+ return self._jwks_cache["keys"]
+ try:
+ async with httpx.AsyncClient(timeout=5.0) as client:
+ resp = await client.get(self.settings.jwks_url)
+ resp.raise_for_status()
+ keys = resp.json()
+ self._jwks_cache["keys"] = keys
+ self._jwks_cache["fetched_at"] = now
+ return keys
+ except Exception:
+ return None
+
+ @staticmethod
+ def _claims_to_user(claims: dict[str, Any]) -> CurrentUser:
+ realm_access = claims.get("realm_access") or {}
+ roles = list(realm_access.get("roles", [])) if isinstance(realm_access, dict) else []
+ # نقشهای client-specific (مثلاً superapp-frontend)
+ resource_access = claims.get("resource_access") or {}
+ if isinstance(resource_access, dict):
+ for client_roles in resource_access.values():
+ if isinstance(client_roles, dict):
+ roles.extend(client_roles.get("roles", []))
+ roles = list(dict.fromkeys(roles)) # یکتا
+ return CurrentUser(
+ user_id=str(claims.get("sub", "")),
+ username=claims.get("preferred_username"),
+ email=claims.get("email"),
+ roles=roles,
+ tenant_id=claims.get("tenant_id"),
+ )
diff --git a/backend/shared-lib/shared/auth/roles.py b/backend/shared-lib/shared/auth/roles.py
new file mode 100644
index 0000000..b1aeccd
--- /dev/null
+++ b/backend/shared-lib/shared/auth/roles.py
@@ -0,0 +1,24 @@
+"""نقشهای استاندارد پلتفرم (هماهنگ با Keycloak realm)."""
+from __future__ import annotations
+
+import enum
+
+from shared.security import CurrentUser
+
+
+class PlatformRole(str, enum.Enum):
+ PLATFORM_ADMIN = "platform_admin"
+ TENANT_ADMIN = "tenant_admin"
+ TENANT_MEMBER = "tenant_member"
+ PENDING_TENANT_ADMIN = "pending_tenant_admin"
+ USER = "user"
+ SERVICE_ACCOUNT = "service_account"
+
+
+def has_role(user: CurrentUser, role: str | PlatformRole) -> bool:
+ role_value = role.value if isinstance(role, PlatformRole) else role
+ return role_value in user.roles
+
+
+def has_any_role(user: CurrentUser, *roles: str | PlatformRole) -> bool:
+ return any(has_role(user, r) for r in roles)
diff --git a/backend/shared-lib/shared/events.py b/backend/shared-lib/shared/events.py
new file mode 100644
index 0000000..37b1df4
--- /dev/null
+++ b/backend/shared-lib/shared/events.py
@@ -0,0 +1,57 @@
+"""قرارداد مشترک رویدادها (Async Events) برای الگوی Outbox/Inbox.
+
+همه سرویسها رویدادها را با این ساختار منتشر و مصرف میکنند تا
+ارتباط بدون اتصال مستقیم دیتابیس ممکن شود.
+"""
+from __future__ import annotations
+
+import enum
+from datetime import datetime, timezone
+from typing import Any
+from uuid import UUID, uuid4
+
+from pydantic import BaseModel, Field
+
+
+class EventStatus(str, enum.Enum):
+ """وضعیت پردازش یک رویداد در Outbox."""
+
+ PENDING = "pending"
+ PROCESSED = "processed"
+ FAILED = "failed"
+
+
+# نمونه انواع رویدادهای شناختهشده پلتفرم (قابل توسعه).
+class CoreEventType(str, enum.Enum):
+ TENANT_CREATED = "tenant.created"
+ TENANT_SUSPENDED = "tenant.suspended"
+ TENANT_ACTIVATED = "tenant.activated"
+ SUBSCRIPTION_CREATED = "subscription.created"
+ SUBSCRIPTION_UPDATED = "subscription.updated"
+ DOMAIN_CREATED = "domain.created"
+ FEATURE_ACCESS_CHANGED = "feature_access.changed"
+
+
+class IdentityEventType(str, enum.Enum):
+ USER_REGISTERED = "user.registered"
+ USER_UPDATED = "user.updated"
+ USER_DISABLED = "user.disabled"
+ TENANT_MEMBER_ADDED = "tenant_member.added"
+ TENANT_MEMBER_REMOVED = "tenant_member.removed"
+ TENANT_MEMBER_ROLE_CHANGED = "tenant_member.role_changed"
+
+
+class EventEnvelope(BaseModel):
+ """پاکت استاندارد یک رویداد که بین سرویسها منتقل میشود."""
+
+ event_id: UUID = Field(default_factory=uuid4)
+ event_type: str
+ aggregate_type: str
+ aggregate_id: str | None = None
+ tenant_id: UUID | None = None
+ source_service: str
+ payload: dict[str, Any] = Field(default_factory=dict)
+ occurred_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
+
+ def to_dict(self) -> dict[str, Any]:
+ return self.model_dump(mode="json")
diff --git a/backend/shared-lib/shared/exceptions.py b/backend/shared-lib/shared/exceptions.py
new file mode 100644
index 0000000..36578e6
--- /dev/null
+++ b/backend/shared-lib/shared/exceptions.py
@@ -0,0 +1,79 @@
+"""استثناهای مشترک دامنه (Domain Exceptions).
+
+این استثناها مستقل از فریمورک هستند تا در همه سرویسها قابل استفاده باشند.
+لایه API هر سرویس آنها را به پاسخ HTTP مناسب تبدیل میکند.
+"""
+from __future__ import annotations
+
+from typing import Any
+
+
+class AppError(Exception):
+ """پایه همه خطاهای دامنهای برنامه."""
+
+ status_code: int = 500
+ error_code: str = "internal_error"
+
+ def __init__(
+ self,
+ message: str | None = None,
+ *,
+ error_code: str | None = None,
+ status_code: int | None = None,
+ details: Any | None = None,
+ ) -> None:
+ self.message = message or self.__class__.__doc__ or "Application error"
+ if error_code is not None:
+ self.error_code = error_code
+ if status_code is not None:
+ self.status_code = status_code
+ self.details = details
+ super().__init__(self.message)
+
+
+class NotFoundError(AppError):
+ """منبع درخواستی یافت نشد."""
+
+ status_code = 404
+ error_code = "not_found"
+
+
+class ConflictError(AppError):
+ """تعارض داده (مثلاً مقدار تکراری)."""
+
+ status_code = 409
+ error_code = "conflict"
+
+
+class ValidationAppError(AppError):
+ """داده ورودی نامعتبر است."""
+
+ status_code = 422
+ error_code = "validation_error"
+
+
+class UnauthorizedError(AppError):
+ """احراز هویت انجام نشده است."""
+
+ status_code = 401
+ error_code = "unauthorized"
+
+
+class ForbiddenError(AppError):
+ """دسترسی مجاز نیست."""
+
+ status_code = 403
+ error_code = "forbidden"
+
+
+class TenantNotResolvedError(AppError):
+ """tenant قابل تشخیص نبود در حالی که endpoint نیازمند tenant است."""
+
+ status_code = 400
+ error_code = "tenant_not_resolved"
+
+
+class FeatureAccessDeniedError(ForbiddenError):
+ """tenant به این قابلیت دسترسی ندارد."""
+
+ error_code = "feature_access_denied"
diff --git a/backend/shared-lib/shared/pagination.py b/backend/shared-lib/shared/pagination.py
new file mode 100644
index 0000000..2cbf094
--- /dev/null
+++ b/backend/shared-lib/shared/pagination.py
@@ -0,0 +1,66 @@
+"""ابزارهای صفحهبندی مشترک (offset/limit).
+
+از این مدلها در همه سرویسها برای پاسخهای لیستی استفاده میشود.
+"""
+from __future__ import annotations
+
+from typing import Generic, Sequence, TypeVar
+
+from pydantic import BaseModel, Field
+
+T = TypeVar("T")
+
+DEFAULT_PAGE_SIZE = 20
+MAX_PAGE_SIZE = 100
+
+
+class PaginationParams(BaseModel):
+ """پارامترهای صفحهبندی ورودی."""
+
+ page: int = Field(default=1, ge=1, description="شماره صفحه از ۱")
+ page_size: int = Field(
+ default=DEFAULT_PAGE_SIZE,
+ ge=1,
+ le=MAX_PAGE_SIZE,
+ description="تعداد آیتم در هر صفحه",
+ )
+
+ @property
+ def offset(self) -> int:
+ return (self.page - 1) * self.page_size
+
+ @property
+ def limit(self) -> int:
+ return self.page_size
+
+
+class PageMeta(BaseModel):
+ page: int
+ page_size: int
+ total_items: int
+ total_pages: int
+
+
+class Page(BaseModel, Generic[T]):
+ """پاسخ لیستی صفحهبندیشده."""
+
+ items: Sequence[T]
+ meta: PageMeta
+
+ @classmethod
+ def create(
+ cls,
+ items: Sequence[T],
+ total_items: int,
+ params: PaginationParams,
+ ) -> "Page[T]":
+ total_pages = (total_items + params.page_size - 1) // params.page_size if params.page_size else 0
+ return cls(
+ items=items,
+ meta=PageMeta(
+ page=params.page,
+ page_size=params.page_size,
+ total_items=total_items,
+ total_pages=total_pages,
+ ),
+ )
diff --git a/backend/shared-lib/shared/phone.py b/backend/shared-lib/shared/phone.py
new file mode 100644
index 0000000..82071a2
--- /dev/null
+++ b/backend/shared-lib/shared/phone.py
@@ -0,0 +1,39 @@
+"""نرمالسازی شماره موبایل و کد OTP — مشترک بین Core و Identity."""
+from __future__ import annotations
+
+import re
+
+MOBILE_PATTERN = re.compile(r"^09\d{9}$")
+
+
+def normalize_otp_digits(value: str | None) -> str | None:
+ if value is None:
+ return None
+ s = str(value).strip()
+ if not s:
+ return None
+ persian = "۰۱۲۳۴۵۶۷۸۹"
+ arabic = "٠١٢٣٤٥٦٧٨٩"
+ latin = "0123456789"
+ trans = str.maketrans(persian + arabic, latin + latin)
+ s = s.translate(trans)
+ s = "".join(c for c in s if c.isdigit())
+ return s if s else None
+
+
+def normalize_mobile(raw: str) -> str:
+ """تبدیل به فرمت 09xxxxxxxxx."""
+ digits = normalize_otp_digits(raw)
+ if not digits:
+ raise ValueError("شماره موبایل نامعتبر است")
+ if digits.startswith("0098"):
+ digits = digits[4:]
+ elif digits.startswith("98") and len(digits) >= 12:
+ digits = digits[2:]
+ if len(digits) == 10 and not digits.startswith("0"):
+ digits = "0" + digits
+ if len(digits) == 11 and digits.startswith("0"):
+ if not MOBILE_PATTERN.match(digits):
+ raise ValueError("شماره موبایل باید با 09 شروع شود و ۱۱ رقم باشد")
+ return digits
+ raise ValueError("شماره موبایل باید با 09 شروع شود و ۱۱ رقم باشد")
diff --git a/backend/shared-lib/shared/responses.py b/backend/shared-lib/shared/responses.py
new file mode 100644
index 0000000..31faa02
--- /dev/null
+++ b/backend/shared-lib/shared/responses.py
@@ -0,0 +1,38 @@
+"""ساختار پاسخ استاندارد API بین همه سرویسها.
+
+هدف: یکپارچگی قالب پاسخها (موفق و خطا) در کل پلتفرم.
+"""
+from __future__ import annotations
+
+from typing import Any, Generic, TypeVar
+
+from pydantic import BaseModel, Field
+
+T = TypeVar("T")
+
+
+class ErrorDetail(BaseModel):
+ code: str = Field(..., description="کد ماشینخوان خطا")
+ message: str = Field(..., description="پیام قابل نمایش خطا")
+ details: Any | None = Field(default=None, description="جزئیات تکمیلی")
+
+
+class ErrorResponse(BaseModel):
+ """قالب استاندارد پاسخ خطا."""
+
+ success: bool = False
+ error: ErrorDetail
+
+
+class SuccessResponse(BaseModel, Generic[T]):
+ """قالب استاندارد پاسخ موفق (اختیاری برای wrap کردن داده)."""
+
+ success: bool = True
+ data: T | None = None
+
+
+class MessageResponse(BaseModel):
+ """پاسخ ساده مبتنی بر پیام."""
+
+ success: bool = True
+ message: str
diff --git a/backend/shared-lib/shared/security.py b/backend/shared-lib/shared/security.py
new file mode 100644
index 0000000..25abe85
--- /dev/null
+++ b/backend/shared-lib/shared/security.py
@@ -0,0 +1,55 @@
+"""ابزارهای امنیتی مشترک بین سرویسها.
+
+شامل ساختار کاربر جاری، اسکوپ توکنهای داخلی و توابع hash کردن توکن.
+منطق تولید/اعتبارسنجی JWT کاربر در هر سرویس (core.security) پیاده میشود.
+"""
+from __future__ import annotations
+
+import hashlib
+import hmac
+import secrets
+from dataclasses import dataclass, field
+
+
+def hash_token(token: str, *, secret: str) -> str:
+ """تولید hash امن برای ذخیره توکنهای داخلی سرویس.
+
+ توکن خام هرگز در دیتابیس ذخیره نمیشود؛ فقط hash آن نگهداری میشود.
+ """
+ return hmac.new(secret.encode("utf-8"), token.encode("utf-8"), hashlib.sha256).hexdigest()
+
+
+def verify_token(token: str, token_hash: str, *, secret: str) -> bool:
+ """مقایسه امن (constant-time) توکن خام با hash ذخیرهشده."""
+ expected = hash_token(token, secret=secret)
+ return hmac.compare_digest(expected, token_hash)
+
+
+def generate_token(length: int = 48) -> str:
+ """تولید توکن تصادفی امن بهصورت URL-safe."""
+ return secrets.token_urlsafe(length)
+
+
+@dataclass
+class CurrentUser:
+ """کاربر احراز هویتشده جاری (از JWT استخراج میشود)."""
+
+ user_id: str
+ username: str | None = None
+ email: str | None = None
+ roles: list[str] = field(default_factory=list)
+ tenant_id: str | None = None
+
+ def has_role(self, role: str) -> bool:
+ return role in self.roles
+
+
+@dataclass
+class InternalServiceIdentity:
+ """هویت یک سرویس داخلی که با InternalServiceToken احراز شده است."""
+
+ service_key: str
+ scopes: list[str] = field(default_factory=list)
+
+ def has_scope(self, scope: str) -> bool:
+ return "*" in self.scopes or scope in self.scopes
diff --git a/backend/shared-lib/shared/tenant.py b/backend/shared-lib/shared/tenant.py
new file mode 100644
index 0000000..ad8b571
--- /dev/null
+++ b/backend/shared-lib/shared/tenant.py
@@ -0,0 +1,29 @@
+"""قراردادهای مشترک مربوط به Tenant.
+
+هر سرویس برای تشخیص و انتقال tenant از این ثابتها و ساختارها استفاده میکند
+تا نام هدرها و کلیدها در همهجا یکسان باشد.
+"""
+from __future__ import annotations
+
+from dataclasses import dataclass
+from uuid import UUID
+
+# نام هدرهای استاندارد تشخیص tenant (بین همه سرویسها مشترک)
+HEADER_TENANT_ID = "X-Tenant-ID"
+HEADER_TENANT_SLUG = "X-Tenant-Slug"
+
+# کلید ذخیره tenant در request.state
+STATE_TENANT_ID = "tenant_id"
+STATE_TENANT_SLUG = "tenant_slug"
+
+
+@dataclass(frozen=True)
+class TenantContext:
+ """اطلاعات tenant جاری که در طول یک request حمل میشود."""
+
+ tenant_id: UUID | None
+ tenant_slug: str | None
+
+ @property
+ def is_resolved(self) -> bool:
+ return self.tenant_id is not None
diff --git a/docker-compose.yml b/docker-compose.yml
new file mode 100644
index 0000000..1e9478b
--- /dev/null
+++ b/docker-compose.yml
@@ -0,0 +1,213 @@
+# ============================================================
+# SuperApp SaaS Platform - Docker Compose
+# postgres, redis, keycloak, core-service, identity-access,
+# celery-worker, celery-beat, frontend
+# ============================================================
+
+services:
+ postgres:
+ image: postgres:15-alpine
+ container_name: superapp_postgres
+ restart: unless-stopped
+ environment:
+ POSTGRES_USER: ${POSTGRES_USER}
+ POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
+ POSTGRES_DB: ${POSTGRES_DB}
+ ports:
+ - "${POSTGRES_PORT:-5432}:5432"
+ volumes:
+ - postgres_data:/var/lib/postgresql/data
+ - ./infrastructure/postgres/init-dbs.sql:/docker-entrypoint-initdb.d/02-init-dbs.sql
+ healthcheck:
+ test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
+ interval: 10s
+ timeout: 5s
+ retries: 5
+ networks:
+ - superapp_net
+
+ redis:
+ image: redis:7-alpine
+ container_name: superapp_redis
+ restart: unless-stopped
+ ports:
+ - "${REDIS_PORT:-6379}:6379"
+ volumes:
+ - redis_data:/data
+ healthcheck:
+ test: ["CMD", "redis-cli", "ping"]
+ interval: 10s
+ timeout: 5s
+ retries: 5
+ networks:
+ - superapp_net
+
+ keycloak:
+ image: quay.io/keycloak/keycloak:24.0
+ container_name: superapp_keycloak
+ restart: unless-stopped
+ command: start-dev --import-realm --features=token-exchange
+ environment:
+ KEYCLOAK_ADMIN: ${KEYCLOAK_ADMIN:-admin}
+ KEYCLOAK_ADMIN_PASSWORD: ${KEYCLOAK_ADMIN_PASSWORD:-admin}
+ KC_DB: postgres
+ KC_DB_URL: jdbc:postgresql://postgres:5432/${POSTGRES_DB}
+ KC_DB_USERNAME: ${POSTGRES_USER}
+ KC_DB_PASSWORD: ${POSTGRES_PASSWORD}
+ KC_HTTP_ENABLED: "true"
+ KC_HOSTNAME_STRICT: "false"
+ KC_PROXY: edge
+ KC_HOSTNAME: ${KEYCLOAK_HOSTNAME:-auth.torbatyar.ir}
+ KC_HOSTNAME_STRICT_HTTPS: ${KEYCLOAK_HOSTNAME_STRICT_HTTPS:-false}
+ JAVA_OPTS_KC_HEAP: "-Xms256m -Xmx512m"
+ ports:
+ - "8080:8080"
+ volumes:
+ - ./infrastructure/keycloak/realm:/opt/keycloak/data/import
+ - ./infrastructure/keycloak/themes:/opt/keycloak/themes
+ depends_on:
+ postgres:
+ condition: service_healthy
+ networks:
+ - superapp_net
+
+ core-service:
+ build:
+ context: .
+ dockerfile: backend/core-service/Dockerfile.dev
+ container_name: superapp_core_service
+ restart: unless-stopped
+ env_file:
+ - .env
+ ports:
+ - "8000:8000"
+ volumes:
+ - ./backend/core-service:/app
+ - ./backend/shared-lib:/shared-lib
+ depends_on:
+ postgres:
+ condition: service_healthy
+ redis:
+ condition: service_healthy
+ keycloak:
+ condition: service_started
+ command: >
+ sh -c "alembic upgrade 0001_initial &&
+ alembic stamp head &&
+ uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload"
+ networks:
+ - superapp_net
+
+ identity-access-service:
+ build:
+ context: .
+ dockerfile: backend/services/identity-access/Dockerfile.dev
+ container_name: superapp_identity_service
+ restart: unless-stopped
+ env_file:
+ - .env
+ environment:
+ DATABASE_URL: ${IDENTITY_DATABASE_URL}
+ DATABASE_URL_SYNC: ${IDENTITY_DATABASE_URL_SYNC}
+ IDENTITY_KEYCLOAK_CLIENT_ID: identity-access-service
+ KEYCLOAK_IDENTITY_CLIENT_SECRET: ${KEYCLOAK_IDENTITY_CLIENT_SECRET:-change-me-identity}
+ PLATFORM_ADMIN_MOBILES: ${PLATFORM_ADMIN_MOBILES:-}
+ KEYCLOAK_SERVER_URL: http://keycloak:8080
+ KEYCLOAK_PUBLIC_URL: ${KEYCLOAK_PUBLIC_URL:-http://localhost:8080}
+ CORE_SERVICE_URL: ${CORE_SERVICE_URL:-http://core-service:8000}
+ CORS_ORIGINS: ${CORS_ORIGINS:-http://localhost:3000}
+ ports:
+ - "8001:8001"
+ volumes:
+ - ./backend/services/identity-access:/app
+ - ./backend/shared-lib:/shared-lib
+ depends_on:
+ postgres:
+ condition: service_healthy
+ keycloak:
+ condition: service_started
+ core-service:
+ condition: service_started
+ command: >
+ sh -c "python scripts/ensure_db.py &&
+ alembic upgrade head &&
+ python scripts/enable_mobile_auth_client.py || true &&
+ uvicorn app.main:app --host 0.0.0.0 --port 8001 --reload"
+ networks:
+ - superapp_net
+
+ frontend:
+ build:
+ context: ./frontend
+ dockerfile: Dockerfile.dev
+ container_name: superapp_frontend
+ restart: unless-stopped
+ ports:
+ - "${FRONTEND_PORT:-3000}:3000"
+ environment:
+ NEXT_PUBLIC_BACKEND_URL: ${NEXT_PUBLIC_BACKEND_URL:-http://localhost:8000}
+ NEXT_PUBLIC_API_BASE_URL: ${NEXT_PUBLIC_API_BASE_URL:-http://localhost:8000}
+ NEXT_PUBLIC_IDENTITY_API_URL: ${NEXT_PUBLIC_IDENTITY_API_URL:-http://localhost:8001}
+ NEXT_PUBLIC_KEYCLOAK_URL: ${NEXT_PUBLIC_KEYCLOAK_URL:-http://localhost:8080}
+ NEXT_PUBLIC_KEYCLOAK_REALM: ${NEXT_PUBLIC_KEYCLOAK_REALM:-superapp}
+ NEXT_PUBLIC_KEYCLOAK_CLIENT_ID: ${NEXT_PUBLIC_KEYCLOAK_CLIENT_ID:-superapp-frontend}
+ # polling برای hot-reload پایدار روی volume mount ویندوز
+ WATCHPACK_POLLING: "true"
+ CHOKIDAR_USEPOLLING: "true"
+ volumes:
+ - ./frontend:/app
+ - frontend_node_modules:/app/node_modules
+ depends_on:
+ core-service:
+ condition: service_started
+ identity-access-service:
+ condition: service_started
+ networks:
+ - superapp_net
+
+ celery-worker:
+ build:
+ context: .
+ dockerfile: backend/core-service/Dockerfile.dev
+ container_name: superapp_celery_worker
+ restart: unless-stopped
+ env_file:
+ - .env
+ volumes:
+ - ./backend/core-service:/app
+ - ./backend/shared-lib:/shared-lib
+ depends_on:
+ redis:
+ condition: service_healthy
+ postgres:
+ condition: service_healthy
+ command: celery -A app.workers.celery_app.celery_app worker --loglevel=INFO -Q default,core_events,notifications,webhooks
+ networks:
+ - superapp_net
+
+ celery-beat:
+ build:
+ context: .
+ dockerfile: backend/core-service/Dockerfile.dev
+ container_name: superapp_celery_beat
+ restart: unless-stopped
+ env_file:
+ - .env
+ volumes:
+ - ./backend/core-service:/app
+ - ./backend/shared-lib:/shared-lib
+ depends_on:
+ redis:
+ condition: service_healthy
+ command: celery -A app.workers.celery_app.celery_app beat --loglevel=INFO
+ networks:
+ - superapp_net
+
+volumes:
+ postgres_data:
+ redis_data:
+ frontend_node_modules:
+
+networks:
+ superapp_net:
+ driver: bridge
diff --git a/docs/architecture.md b/docs/architecture.md
new file mode 100644
index 0000000..628b83e
--- /dev/null
+++ b/docs/architecture.md
@@ -0,0 +1,389 @@
+# معماری کلان پلتفرم SuperApp SaaS
+
+## ۱. اهداف
+ساخت یک SuperApp SaaS **چندمستأجری (Multi-tenant)**، **ماژولار**،
+**API-first** و **microservice-ready** که ابتدا روی VPS و در آینده روی
+زیرساخت مقیاسپذیر اجرا شود.
+
+فاز ۱ تنها **Core Platform** را میسازد؛ اما ساختار پروژه بهگونهای است که
+افزودن سرویسهای بعدی بدون بازنویسی معماری ممکن باشد.
+
+## ۲. سبک معماری
+- **Service-oriented / Microservice-ready:** هر قابلیت بزرگ یک سرویس مستقل است.
+- **Database-per-service:** هر سرویس فقط دیتابیس خودش را میشناسد.
+- **API-first:** همه تعاملها از طریق API/Event تعریفشده انجام میشوند.
+- **Multi-tenancy:** همه جداول بیزینسی ستون `tenant_id` دارند.
+
+### سرویسهای اصلی (فعلی و آینده)
+Core Platform (فاز ۱)، Identity & Access، Subscription & Entitlement،
+Accounting، CRM، Ecommerce، Website Builder، Live Chat، AI Assistant،
+Smart Messenger، SMS Panel، Link Shortener، Notification، File Storage،
+Restaurant و ماژولهای آینده Marketplace.
+
+> در فاز ۱ فقط Core Platform پیاده شده است. بقیه سرویسها در پوشه `backend/services/`
+> بهصورت placeholder با README مسئولیتها موجودند.
+
+## ۲.۱ Frontend & Backend Separation (اجباری)
+
+### قانون معماری (سختگیرانه)
+
+پروژه **باید** از معماری کاملاً جدا (decoupled) پیروی کند. Backend و Frontend
+دو اپلیکیشن کاملاً مستقل هستند و **هرگز نباید با هم مخلوط شوند.**
+
+### Backend
+
+تمام کد منبع backend فقط داخل پوشه `backend/` قرار میگیرد (Core Service و
+سرویسهای آینده).
+
+**مسئولیتها:**
+- FastAPI
+- SQLAlchemy
+- Alembic
+- Business Logic
+- Authentication
+- Authorization
+- Database
+- Background Workers
+- APIs
+- Event Processing
+
+**Backend هرگز نباید شامل موارد زیر باشد:**
+- React / Next.js
+- Tailwind
+- UI Components
+- صفحات HTML
+- Frontend assets
+
+### Frontend
+
+Frontend یک اپلیکیشن Next.js کاملاً جدا در پوشه `frontend/` است.
+
+**مسئولیتها:**
+- UI
+- Dashboard
+- Forms
+- Pages
+- Components
+- Layouts
+- State Management
+- API Client
+- Theme
+
+**Frontend هرگز نباید شامل موارد زیر باشد:**
+- کد FastAPI
+- دسترسی مستقیم به دیتابیس
+- مدلهای SQLAlchemy
+- Alembic
+- Business Logic
+- کوئری مستقیم دیتابیس
+
+### ارتباط (Communication)
+
+Frontend فقط از طریق **REST API نسخهدار** (و در آینده WebSocket در صورت نیاز)
+با backend ارتباط برقرار میکند.
+
+هیچ کد منبع مشترکی بین frontend و backend وجود ندارد، **بهجز:**
+- قراردادهای API
+- اسکیماهای مشترک
+- SDKهای تولیدشده
+- تعاریف نوع (type definitions) مشترک
+
+### ساختار پوشهها (اجباری)
+
+```
+superapp-platform/
+├── backend/
+│ ├── core-service/
+│ ├── shared-lib/
+│ └── services/
+│
+├── frontend/
+│ ├── app/
+│ ├── components/
+│ ├── lib/
+│ ├── hooks/
+│ ├── styles/
+│ └── public/
+│
+├── docs/
+├── docker-compose.yml
+├── .env.example
+└── README.md
+```
+
+> این جداسازی در کل پروژه اجباری است. فایلهای frontend هرگز نباید به پوشههای
+> backend منتقل شوند و بالعکس — حتی برای راحتی. هر قابلیت جدید باید این
+> معماری را حفظ کند.
+
+## ۳. تصمیم معماری دیتابیس
+- الگوی **Database-per-service**.
+- **ارتباط مستقیم بین دیتابیس سرویسها ممنوع است.**
+- ارتباط بین سرویسها فقط از طریق:
+ - REST API
+ - Webhook
+ - Async Event
+ - الگوی Outbox/Inbox
+- در فاز ۱ فقط دیتابیس `core_platform_db` ساخته میشود.
+- طراحی اولیه دیتابیس سرویسهای آینده در `database_schema.md` آمده اما
+ migration واقعی آنها ساخته نشده است.
+
+## ۴. Core Platform Service
+مسئول مفاهیم مشترک و مرکزی پلتفرم:
+- مدیریت Tenant و Domain
+- مدیریت Plan / Feature / Subscription و بررسی دسترسی (Entitlement)
+- Service Registry و Module Registry
+- توکنهای داخلی سرویس (Internal Service Tokens)
+- الگوی Outbox/Inbox و Audit Log
+
+### لایهبندی داخلی سرویس هسته
+```
+API (routers) → Services (business logic) → Repositories → Models (DB)
+ ↑
+ Schemas (Pydantic)
+```
+- **core/**: زیرساخت (config, database, cache, security, logging)
+- **middlewares/**: تشخیص tenant
+- **workers/**: Celery و taskها
+
+## ۵. Multi-tenancy و Tenant Resolution
+ترتیب تشخیص tenant در middleware:
+1. هدر `X-Tenant-ID`
+2. هدر `X-Tenant-Slug`
+3. Subdomain (از روی Host و `base_domain`)
+4. Custom domain (از روی Host)
+
+نتیجه در `request.state.tenant_id` و `request.state.tenant_slug` قرار میگیرد.
+برای endpointهای tenant-aware از dependency `require_tenant` استفاده میشود که
+در نبود tenant خطای `tenant_not_resolved` برمیگرداند.
+
+## ۶. Entitlement (بررسی دسترسی قابلیت)
+تابع مرکزی `EntitlementService.check_feature_access(tenant_id, feature_key)`:
+1. ابتدا Redis cache بررسی میشود.
+2. در نبود cache، از دیتابیس محاسبه میشود.
+3. نتیجه با TTL در Redis ذخیره میگردد.
+
+قواعد: اگر tenant غیرفعال باشد، اشتراک غیرفعال باشد، یا قابلیت در پلن نباشد →
+دسترسی false. دسترسی سفارشی (custom access) بالاترین اولویت را دارد.
+
+## ۷. رویدادها (Outbox/Inbox)
+- رویدادهای خروجی ابتدا در جدول `outbox_events` و در **همان تراکنش** عملیات
+ بیزینسی ذخیره میشوند (تضمین atomicity).
+- یک task دورهای Celery (`process_outbox_events`) رویدادهای pending را پردازش
+ و وضعیت آنها را بهروزرسانی میکند.
+- جدول `inbox_events` برای idempotency رویدادهای ورودی است.
+
+## ۸. امنیت و SSO (فاز ۲ و ۳)
+
+### اصل یکپارچگی: شماره موبایل الزامی است
+
+در سامانه یکپارچه TorbatYar، **شماره موبایل برای همه کاربران واجب** است و
+با **OTP پیامکی** تأیید میشود.
+
+### لایههای هویت (تفکیک اجباری)
+
+| لایه | چه کسی | SSO مرکزی Keycloak |
+|------|--------|-------------------|
+| **هسته TorbatYar** | مدیر پلتفرم، صاحب/کارمند tenant | **اجباری** |
+| **زیرسیستمها (staff)** | پنل کافه، CRM، … | **اجباری** — همان JWT |
+| **مشتری tenant** | سفارشدهنده منوی دیجیتال | **اختیاری** — auth محلی tenant |
+
+- `frontend/lib/optional-sso.ts` — برای UIs زیرسیستم: اگر session مرکزی هست، login دوباره لازم نیست.
+- مشتری end-user در `restaurant_db` / `crm_db` ذخیره میشود، نه Keycloak مرکزی.
+
+**ورود مرکزی فقط از Keycloak** (تم torbatyar) با دو تب «رمز عبور» و «موبایل»؛ frontend فقط redirect میکند.
+
+```
+┌─────────────────────────────────────────────────────────────────┐
+│ F) ورود مرکزی Keycloak — تنها نقطه ورود کاربر │
+├─────────────────────────────────────────────────────────────────┤
+│ Keycloak /realms/superapp/.../auth (تم torbatyar) │
+│ تب «رمز عبور»: نام کاربری / ایمیل / موبایل + رمز │
+│ تب «موبایل»: OTP + ثبتنام inline (mobile-auth.js) │
+│ → Identity /auth/mobile/* → handoff → /auth/callback │
+│ Frontend /login و /register → redirect به Keycloak │
+│ Admin: همان SSO — /admin/login → Keycloak → /admin/tenants │
+└─────────────────────────────────────────────────────────────────┘
+```
+
+### احراز هویت OTP (Core Service)
+```
+کاربر → Frontend → POST /api/v1/auth/otp/request?context=public|admin
+ → Payamak (pattern 245189)
+ → POST /api/v1/auth/otp/verify
+ → JWT محلی (HS256)
+```
+
+- `context=public`: نقش پیشفرض `user` (ثبتنام عمومی)
+- `context=admin`: نقش `pending_tenant_admin` (پنل tenant)
+- `PLATFORM_ADMIN_MOBILES`: ارتقا به `platform_admin`
+
+جدول `users` در Core: `mobile` (unique)، `mobile_verified`، `keycloak_sub` (لینک SSO).
+
+### معماری SSO مرکزی
+```
+کاربر → Frontend → Keycloak (OIDC) → JWT
+ ↓
+ Identity /auth/me → requires_mobile_verification?
+ ↓
+ همه سرویسها JWT را validate میکنند
+```
+
+### Identity & Access Service
+- دیتابیس: `identity_access_db`
+- جدول `user_profiles`: `mobile`, `mobile_verified`, `core_user_id`
+- APIهای جدید:
+ - `POST /api/v1/auth/mobile/start` — تشخیص login/register + ارسال OTP
+ - `POST /api/v1/auth/mobile/complete` — تأیید OTP + صدور توکن SSO
+ - `POST /api/v1/auth/session/redeem` — handoff از Keycloak theme
+ - `POST /api/v1/auth/register` (legacy)
+ - `POST /api/v1/auth/register/verify-mobile`
+ - `POST /api/v1/auth/register/mobile`
+ - `POST /api/v1/auth/register/mobile/verify`
+ - `POST /api/v1/auth/mobile/request` (کاربر SSO لاگینشده)
+ - `POST /api/v1/auth/mobile/verify`
+- OTP از طریق Core Service (`CoreOtpClient`) — بدون تکرار منطق SMS
+
+### Keycloak
+- تم `torbatyar`: **ورود مرکزی** — تب رمز عبور + تب موبایل (OTP)
+- `theme.properties`: `identityApiUrl`, `frontendCallbackUrl`
+- attribute کاربر: `mobile` در Admin API
+- Realm: `superapp` — زبان پیشفرض `fa`
+
+### اعتبارسنجی JWT
+- کتابخانه مشترک: `shared/auth/jwt.py`
+- نرمالسازی موبایل مشترک: `shared/phone.py`
+
+## ۹. زیرساخت
+- Docker و docker-compose (postgres, redis, keycloak, core-service,
+ identity-access-service, frontend, celery-worker, celery-beat).
+- همه تنظیمات از `.env` خوانده میشوند؛ **هیچ چیز hardcode نمیشود**.
+- Nginx/Traefik در فازهای بعدی بهعنوان reverse proxy اضافه میشوند.
+
+## ۱۰. White-label
+برند (نام، رنگ، لوگو، ایمیل پشتیبانی) از `env`/config/دیتابیس خوانده میشود.
+Frontend در `frontend/` رنگها را از طریق **CSS Variables** و `public/theme.config.json`
+اعمال میکند تا تغییر برند بدون build مجدد ممکن باشد.
+
+## ۱۱. فاز ۴ — Tenant Onboarding و Workspace Activation
+
+> در بریف پروژه این کار با عنوان **«Phase 3: operationalizing tenant onboarding
+> and workspace activation»** معرفی شده است؛ چون در شمارهگذاری داخلی مستندات
+> پیشتر «فاز ۳» به OTP Login + Tenant Management اختصاص یافته بود (نگاه کنید
+> به `progress.md`)، این کار بهعنوان **فاز ۴** پروژه ثبت میشود تا تاریخچه
+> پیادهسازی واقعی حفظ شود. محتوای این فاز دقیقاً همان چیزی است که در بریف
+> «Phase 3» خوانده میشود.
+
+### هدف
+سیستم را از «هویت + لیست ادمین» به یک **workspace عملیاتی چندمستأجری واقعی**
+تبدیل میکند: کاربر OTP/SSO میزند، در نبود tenant به onboarding هدایت میشود،
+یک workspace (tenant) میسازد، مالک آن میشود، پلن پیشفرض و دامنه اولیه
+میگیرد، برندینگ را تنظیم میکند و در پایان وارد یک داشبورد واقعی tenant
+میشود (نه فقط صفحات لیست ادمین).
+
+### مدل عضویت Tenant (Tenant Membership)
+جدول جدید `tenant_memberships` در **`core_platform_db`** رابطهٔ واقعی
+کاربر↔tenant را نگه میدارد (مستقل از جدول همنام و سادهتر
+`tenant_memberships` در `identity_access_db` که برای مدیریت اعضای هویتی
+استفاده میشود؛ این دو جدول در دو دیتابیس/سرویس متفاوتاند و هیچ ارتباط
+مستقیمی ندارند).
+
+نقشها: `platform_admin`, `tenant_owner`, `tenant_admin`, `tenant_editor`,
+`tenant_viewer`. وضعیت عضویت: `active` / `invited` / `disabled`. هر tenant
+باید حداقل یک `tenant_owner` فعال داشته باشد (بررسی در زمان
+`POST /onboarding/tenant/{id}/complete`). یک کاربر میتواند در آینده عضو چند
+tenant باشد (`current_tenant_id` روی `users` مشخص میکند کدام tenant «جاری»
+است).
+
+### چرخهٔ عمر Tenant (Activation Lifecycle)
+`tenant.status` اکنون این مقادیر عملیاتی را پشتیبانی میکند:
+
+```
+draft → pending_activation → active → suspended / archived
+```
+
+- tenant تازهساختهشده از مسیر onboarding با `pending_activation` شروع میشود.
+- با تکمیل onboarding (`POST .../complete`) و اعتبارسنجی (نام/slug موجود و
+ حداقل یک owner فعال) به `active` تغییر میکند.
+- `suspended`/`archived` فقط توسط پنل ادمین (فازهای قبلی، `PATCH
+ /admin/tenants/{id}`) قابل تنظیماند.
+- مقادیر قدیمی `inactive`/`deleted` برای سازگاری با دادههای فاز ۱ حفظ شدهاند.
+
+### پروفایل و برندینگ Tenant
+ستونهای برندینگ/تنظیمات مستقیماً روی جدول `tenants` اضافه شدهاند (بدون
+جدول جدا، چون حجم کم و همیشه ۱به۱ با tenant است): `business_type`,
+`default_locale`, `timezone`, `primary_color`, `secondary_color`,
+`logo_url`, `favicon_url`, `onboarding_completed`.
+
+### پلن / اشتراک (بدون درگاه پرداخت)
+ساختار `plans` و `tenant_subscriptions` از فاز ۱ موجود بود؛ در این فاز:
+- یک پلن پیشفرض «FREE» (و «STARTER» برای آینده) از طریق migration seed
+ میشود (`PlanService.ensure_default_plan` idempotent است).
+- هنگام `POST /onboarding/tenant`، بهصورت خودکار یک `tenant_subscriptions`
+ با `plan=FREE` و `status=active` ساخته میشود.
+- درگاه پرداخت پیادهسازی **نشده** — فقط ساختار provisioning است.
+
+### نگاشت دامنه (Tenant Domain Mapping)
+جدول `domains` (فاز ۱) با دو ستون جدید تقویت شده: `is_primary` (دامنهٔ
+اصلی/پیشفرض tenant) و `verification_status` (`pending`/`verified`/`failed`).
+هنگام `POST /onboarding/tenant`، اگر `PLATFORM_BASE_DOMAIN` تنظیم شده باشد،
+زیردامنهٔ `{slug}.{PLATFORM_BASE_DOMAIN}` بهصورت خودکار و از پیش
+verified ساخته میشود. دامنهٔ اختصاصی (custom) از طریق
+`PATCH /onboarding/tenant/{id}/domain` با وضعیت `pending` اضافه میشود و
+تأیید واقعی آن به فازهای بعدی موکول شده است.
+
+### Tenant Resolution و Current Tenant Context
+علاوه بر middleware قبلی (`X-Tenant-ID` → `X-Tenant-Slug` → subdomain →
+custom domain)، این dependencyهای جدید اضافه شدند:
+- `get_current_core_user` / `get_optional_core_user`: رکورد واقعی
+ `users` را چه برای JWT محلی (OTP) و چه برای JWT کیکلوک (SSO، بر اساس
+ `keycloak_sub`) resolve میکنند.
+- `get_tenant_resolution`: اول هدر/دامنه (middleware)، در نبود آن
+ `current_tenant_id` کاربر جاری را برمیگرداند؛ endpointهای platform-admin
+ فعلی تحت تأثیر قرار نمیگیرند.
+- `TenantContextService.resolve_current_tenant`: برای `GET /tenant/current`
+ استفاده میشود (بر اساس `current_tenant_id` یا اولین عضویت کاربر).
+
+### رابطهٔ ورود OTP/SSO با provisioning workspace
+هر دو مسیر احراز هویت (JWT محلی OTP فاز ۱ صادرشده از Core، و JWT کیکلوک SSO
+فاز ۲ صادرشده از Identity) در نهایت باید به یک رکورد `users` در
+`core_platform_db` متصل شوند تا onboarding معنا پیدا کند:
+
+```
+JWT محلی (HS256) → users.id ──┐
+ ├─→ UserService.resolve_current() → User
+JWT کیکلوک (RS256) → users.keycloak_sub ──┘
+```
+
+اگر کاربر SSO هنوز به هیچ رکورد Core لینک نشده باشد (یعنی هرگز از مسیر OTP
+محلی Core عبور نکرده)، `resolve_current` خطای `forbidden` برمیگرداند — به
+این معنا که JIT provisioning کامل کاربر Core از JWT کیکلوک فعلاً در محدودهٔ
+این فاز پیاده نشده و به فاز بعدی موکول شده (نگاه کنید به `last_step.md`).
+
+### APIهای Onboarding
+جزئیات کامل در `services_contracts.md`؛ خلاصه:
+`GET /me`, `GET /me/tenants`, `POST /onboarding/tenant`,
+`PATCH /onboarding/tenant/{id}/branding`,
+`PATCH /onboarding/tenant/{id}/domain`,
+`POST /onboarding/tenant/{id}/complete`, `GET /tenant/current`,
+`POST /tenant/switch`.
+
+### Authorization
+`MembershipService.ensure_role` بررسی میکند که کاربر جاری روی tenant مقصد
+نقش `tenant_owner` یا `tenant_admin` داشته باشد (پیشفرض برای مدیریت
+برندینگ/دامنه/تکمیل onboarding)؛ `platform_admin` همیشه bypass میشود.
+endpointهای onboarding فقط نیاز به کاربر احرازهویتشده دارند (بدون بررسی
+نقش برای ساخت tenant جدید، چون هر کاربر میتواند workspace خودش را بسازد).
+
+### Frontend
+- **Bootstrap:** پس از ورود، `hooks/useMe.ts` نتیجهٔ `GET /api/v1/me` را
+ میگیرد. صفحهٔ `/dashboard` اگر `onboarding_required=true` باشد کاربر را
+ به `/onboarding` هدایت میکند.
+- **Onboarding Wizard:** صفحهٔ تکفایلی `app/onboarding/page.tsx` با ۴ گام
+ (اطلاعات کسبوکار → برندینگ → دامنه → بازبینی) که بهترتیب APIهای
+ onboarding را صدا میزند و در پایان به `/dashboard` هدایت میکند.
+- **Tenant Dashboard:** `app/dashboard/page.tsx` اطلاعات tenant جاری
+ (`GET /tenant/current`) را نمایش میدهد: نام، وضعیت، پلن، دامنه، وضعیت
+ onboarding و نقش کاربر.
+- **Tenant Switcher:** `components/TenantSwitcher.tsx` — فقط وقتی کاربر
+ بیش از یک عضویت داشته باشد نمایش داده میشود و از `POST /tenant/switch`
+ استفاده میکند.
diff --git a/docs/database_schema.md b/docs/database_schema.md
new file mode 100644
index 0000000..919d990
--- /dev/null
+++ b/docs/database_schema.md
@@ -0,0 +1,224 @@
+# طرح دیتابیس (Database Schema)
+
+## بخش ۱ — دیتابیس Core Platform (`core_platform_db`) — فاز ۱
+
+همه مدلها با SQLAlchemy 2.x و Alembic پیاده شدهاند. شناسهها از نوع UUID و
+زمانها timezone-aware هستند.
+
+### جدول `tenants`
+| ستون | نوع | توضیح |
+| --- | --- | --- |
+| id | UUID (PK) | |
+| name | varchar(255) | نام مستأجر (`business_name` در onboarding) |
+| slug | varchar(100) unique | شناسه یکتا برای resolve |
+| status | enum | `draft` / `pending_activation` / `active` / `suspended` / `archived` (+ مقادیر قدیمی `inactive`/`deleted` برای سازگاری) |
+| owner_user_id | UUID null | مالک (از Identity Service) |
+| business_type | varchar(100) null | نوع کسبوکار (فاز ۴ — onboarding) |
+| default_locale | varchar(20) | پیشفرض `fa-IR` |
+| timezone | varchar(50) | پیشفرض `Asia/Tehran` |
+| primary_color | varchar(20) null | برندینگ |
+| secondary_color | varchar(20) null | برندینگ |
+| logo_url | varchar(500) null | برندینگ |
+| favicon_url | varchar(500) null | برندینگ |
+| onboarding_completed | bool | پیشفرض `false`؛ با `POST .../complete` به `true` تغییر میکند |
+| created_at, updated_at | timestamptz | |
+
+ایندکسها: `slug` (unique)، `status`.
+
+> فیلدهای `business_type` تا `onboarding_completed` در migration `0005_tenant_onboarding`
+> اضافه شدهاند (فاز ۴ — Tenant Onboarding).
+
+### جدول `domains`
+| ستون | نوع | توضیح |
+| --- | --- | --- |
+| id | UUID (PK) | |
+| tenant_id | UUID (FK→tenants) | |
+| domain | varchar(255) unique | |
+| domain_type | enum | subdomain / custom_domain |
+| is_verified | bool | |
+| verified_at | timestamptz null | |
+| is_primary | bool | دامنهٔ اصلی/پیشفرض tenant (زیردامنهٔ خودکار onboarding) — فاز ۴ |
+| verification_status | enum | `pending` / `verified` / `failed` — فاز ۴ |
+| created_at | timestamptz | |
+
+ایندکسها: `domain` (unique)، `tenant_id`.
+
+### جدول `plans`
+id, code(unique), name, description, is_active, created_at. ایندکس: `code`.
+
+### جدول `features`
+id, feature_key(unique), name, description, service_key, is_active.
+ایندکسها: `feature_key` (unique)، `service_key`.
+مثال feature_key: `accounting.invoice.create`، `crm.lead.create`،
+`ecommerce.product.create`، `live_chat.widget.enable`، `ai_assistant.chat.reply`.
+
+### جدول `plan_features`
+id, plan_id(FK), feature_id(FK), limit_value(null=نامحدود),
+limit_period(enum: day/month/year/total), is_enabled.
+محدودیت یکتا: (plan_id, feature_id). ایندکسها روی هر دو کلید خارجی.
+
+### جدول `tenant_subscriptions`
+id, tenant_id(FK), plan_id(FK), status(enum: trialing/active/past_due/
+canceled/expired), starts_at, ends_at, trial_ends_at, created_at.
+ایندکسها: `tenant_id`، `status`.
+
+### جدول `tenant_feature_access`
+override سفارشی دسترسی: id, tenant_id(FK), feature_id(FK), is_enabled,
+custom_limit_value(null=نامحدود), used_value, reset_at.
+محدودیت یکتا: (tenant_id, feature_id). ایندکسها روی هر دو کلید خارجی.
+
+### جدول `service_registry`
+id, service_key(unique), name, base_url, status(enum), health_check_url,
+created_at. ایندکس: `service_key`.
+
+### جدول `module_registry`
+id, module_key(unique), name, description, is_system_module, is_active.
+ایندکس: `module_key`.
+
+### جدول `tenant_module_access`
+id, tenant_id(FK), module_id(FK), is_enabled, enabled_at, disabled_at.
+محدودیت یکتا: (tenant_id, module_id). ایندکس: `tenant_id`.
+
+### جدول `internal_service_tokens`
+id, service_key, token_hash(unique), scopes, expires_at, is_active, created_at.
+فقط hash توکن ذخیره میشود. ایندکسها: `service_key`، `token_hash`.
+
+### جدول `outbox_events`
+id, event_type, aggregate_type, aggregate_id, tenant_id, payload(JSON),
+status(enum: pending/processed/failed), retry_count, created_at, processed_at.
+ایندکسها: `status`، `tenant_id`.
+
+### جدول `inbox_events`
+id, event_id(unique), source_service, event_type, tenant_id, payload(JSON),
+processed_at. ایندکسها: `event_id`، `tenant_id`.
+
+### جدول `audit_logs`
+id, tenant_id, actor_user_id, action, resource_type, resource_id,
+metadata(JSON), ip_address, user_agent, created_at.
+ایندکسها: `tenant_id`، `created_at`.
+
+### جدول `users` (OTP + لینک SSO — فاز ۳)
+| ستون | نوع | توضیح |
+| --- | --- | --- |
+| id | UUID (PK) | |
+| mobile | varchar(15) unique | شماره موبایل (09xxxxxxxxx) — **الزامی** |
+| keycloak_sub | varchar(100) null unique | لینک به کاربر Keycloak (SSO) |
+| email | varchar(255) null | ایمیل (اختیاری، برای لینک SSO) |
+| mobile_verified | bool | تأیید OTP — true پس از verify موفق |
+| role | enum | user / pending_tenant_admin / tenant_admin / platform_admin |
+| status | enum | active / inactive / suspended |
+| current_tenant_id | UUID null (FK→tenants, `ON DELETE SET NULL`) | tenant انتخابشده جاری کاربر — فاز ۴ |
+| created_at, updated_at | timestamptz | |
+
+ایندکسها: `mobile` (unique)، `keycloak_sub` (unique)، `status`.
+
+**نقشها بر اساس context OTP:**
+- `public` → `user` (ثبتنام عمومی)
+- `admin` → `pending_tenant_admin` (پنل ادمین tenant)
+- `PLATFORM_ADMIN_MOBILES` → `platform_admin`
+
+### جدول `tenant_memberships` (فاز ۴ — Tenant Onboarding)
+
+> **توجه:** این جدول در `core_platform_db` است و با جدول همنام در
+> `identity_access_db` (بخش ۳ همین سند) **کاملاً متفاوت و بدون ارتباط
+> مستقیم** است. جدول Core عضویت واقعی کاربر↔workspace را برای جریان
+> onboarding/authorization مدیریت میکند.
+
+| ستون | نوع | توضیح |
+| --- | --- | --- |
+| id | UUID (PK) | |
+| tenant_id | UUID (FK→tenants, cascade) | |
+| user_id | UUID (FK→users, cascade) | |
+| role | enum | `platform_admin` / `tenant_owner` / `tenant_admin` / `tenant_editor` / `tenant_viewer` |
+| status | enum | `active` / `invited` / `disabled` |
+| is_owner | bool | فقط یک یا چند owner فعال در هر tenant (حداقل یکی الزامی) |
+| created_at, updated_at | timestamptz | |
+
+محدودیت یکتا: `(tenant_id, user_id)`. ایندکسها: `tenant_id`، `user_id`، `role`، `status`.
+
+**قواعد:**
+- هنگام `POST /onboarding/tenant`، کاربر سازنده بهصورت خودکار عضویت
+ `tenant_owner` / `is_owner=true` / `status=active` میگیرد.
+- `POST /onboarding/tenant/{id}/complete` بررسی میکند که حداقل یک owner
+ فعال وجود داشته باشد؛ در غیر این صورت خطای `owner_required`.
+- کاربر میتواند در آینده چند عضویت (چند tenant) داشته باشد؛ `users.current_tenant_id`
+ مشخص میکند کدامیک «جاری» است.
+
+### جدول `plans` و `tenant_subscriptions` (seed پیشفرض — فاز ۴)
+ساختار جدولها از فاز ۱ بدون تغییر باقی مانده (نگاه کنید به پایین همین
+بخش)؛ در فاز ۴ موارد زیر اضافه شد:
+- Migration `0005_tenant_onboarding` دو پلن `FREE` و `STARTER` را با
+ `ON CONFLICT (code) DO NOTHING` seed میکند (idempotent).
+- `PlanService.ensure_default_plan()` همان پلن `FREE` را در زمان اجرا نیز
+ بهصورت idempotent تضمین میکند (برای محیطهای SQLite تست که migration
+ اجرا نمیشود).
+- `POST /onboarding/tenant` بهصورت خودکار یک `tenant_subscriptions` با
+ `plan=FREE`، `status=active` میسازد.
+
+## بخش ۲ — طراحی اولیه دیتابیس سرویسهای آینده (بدون migration)
+
+> اینها فقط طراحی مفهومیاند؛ در فازهای بعدی هر سرویس دیتابیس مستقل خود را
+> با ستون `tenant_id` در جداول بیزینسی خواهد داشت.
+
+- **accounting_db:** accounts (۴ سطحی)، journal_entries، journal_lines،
+ cost_centers، projects، invoices، payments، taxes، reports.
+- **crm_db:** customers، leads، opportunities، pipelines، stages، tasks،
+ activities، automations.
+- **ecommerce_db:** products، categories، inventory، orders، order_items،
+ payments، shipments، discounts، carts.
+- **website_builder_db:** sites، pages، blocks، forms، menus، media، content.
+- **live_chat_db:** widgets، conversations، messages، agents، routing_rules.
+- **ai_assistant_db:** assistants، knowledge_bases، documents، chat_sessions،
+ messages، handoffs.
+- **smart_messenger_db:** channels، accounts، threads، messages، automations.
+- **sms_panel_db:** providers، templates، campaigns، messages، send_queue.
+- **link_shortener_db:** links، domains، clicks، campaigns.
+- **notification_db:** notifications، channels، templates، delivery_logs.
+- **file_storage_db:** files، buckets، access_policies.
+- **restaurant_db:** menus، menu_items، tables، orders، kitchen_tickets،
+ payments، loyalty.
+
+هر سرویس دسترسی قابلیتها را از Core (`check_feature_access`) استعلام میکند و
+هرگز مستقیماً به `core_platform_db` متصل نمیشود.
+
+## بخش ۳ — دیتابیس Identity & Access (`identity_access_db`) — فاز ۲
+
+### جدول `user_profiles`
+| ستون | نوع | توضیح |
+| --- | --- | --- |
+| id | UUID (PK) | |
+| keycloak_sub | varchar unique | شناسه کاربر در Keycloak |
+| email | varchar | |
+| username | varchar null | |
+| display_name | varchar null | |
+| mobile | varchar(15) null unique | شماره موبایل — **الزامی برای کاربران جدید** |
+| mobile_verified | bool | تأیید OTP |
+| mobile_verified_at | timestamptz null | زمان تأیید |
+| core_user_id | UUID null | لینک به `core_platform_db.users` |
+| status | enum | active / inactive / suspended |
+| created_at, updated_at | timestamptz | |
+
+ایندکسها: `keycloak_sub` (unique)، `email`، `mobile` (unique).
+
+**قانون:** کاربر تازهثبتنامشده تا پیش از `mobile_verified=true` کامل محسوب نمیشود.
+کاربران SSO قدیمی بدون موبایل باید از `/auth/verify-mobile` تکمیل کنند.
+
+### جدول `tenant_memberships`
+
+> **توجه:** این جدول مربوط به `identity_access_db` (لایهٔ هویت SSO) است و
+> **جدول متفاوتی** نسبت به `tenant_memberships` در `core_platform_db` است که
+> در بخش ۱ (فاز ۴ — Tenant Onboarding) مستند شده. این دو جدول در دو سرویس/
+> دیتابیس جدا زندگی میکنند و sync خودکار بین آنها وجود ندارد؛ منبع حقیقت
+> (source of truth) برای نقش عملیاتی کاربر در یک workspace، جدول Core است.
+
+| ستون | نوع | توضیح |
+| --- | --- | --- |
+| id | UUID (PK) | |
+| tenant_id | UUID | مرجع به tenant در Core (بدون FK بین DB) |
+| user_id | UUID (FK→user_profiles) | |
+| role | enum | tenant_admin / tenant_member |
+| is_active | bool | |
+| joined_at | timestamptz | |
+
+محدودیت یکتا: (tenant_id, user_id). ایندکسها: `tenant_id`, `user_id`.
+
diff --git a/docs/developer_guide.md b/docs/developer_guide.md
new file mode 100644
index 0000000..85abdc1
--- /dev/null
+++ b/docs/developer_guide.md
@@ -0,0 +1,110 @@
+# راهنمای توسعهدهنده (Developer Guide)
+
+## ۱. پیشنیازها
+- Python 3.11+ (backend)
+- Node.js 18+ (frontend)
+- Docker و Docker Compose (برای اجرای کامل)
+- PostgreSQL 15+ و Redis (در صورت اجرای بدون Docker)
+
+## ۲. قانون جداسازی Frontend/Backend (اجباری)
+- تمام کد backend فقط در `backend/` قرار دارد.
+- تمام کد frontend فقط در `frontend/` قرار دارد.
+- ارتباط فقط از طریق REST API نسخهدار (`lib/api-client.ts` در frontend).
+- جزئیات در [`architecture.md`](./architecture.md) بخش ۲.۱.
+
+## ۳. راهاندازی با Docker
+```bash
+cp .env.example .env
+docker compose up -d --build
+```
+سرویسها: Core API روی `:8000`، Identity روی `:8001`، Frontend روی `:3000`،
+Keycloak روی `:8080`. همه با `docker compose up -d --build` بالا میآیند.
+
+## ۴. راهاندازی محلی — Backend
+```bash
+cd backend/core-service
+python -m venv .venv
+# Windows: .venv\Scripts\Activate.ps1 | Linux/macOS: source .venv/bin/activate
+pip install -r requirements.txt
+alembic upgrade head
+uvicorn app.main:app --reload
+```
+
+## ۵. راهاندازی محلی — Frontend
+```bash
+cd frontend
+npm install
+npm run dev
+```
+Frontend: http://localhost:3000
+
+## ۶. متغیرهای محیطی
+همه تنظیمات در `.env` هستند (نمونه: `.env.example`). هیچ مقداری در کد
+hardcode نمیشود.
+
+**Backend:** `DATABASE_URL`, `DATABASE_URL_SYNC`, `REDIS_URL`, `CELERY_BROKER_URL`,
+`KEYCLOAK_*`, `INTERNAL_TOKEN_SECRET`, `PLATFORM_*`.
+
+**Frontend:** `NEXT_PUBLIC_API_BASE_URL` (پیشفرض: `http://localhost:8000`).
+
+## ۷. ساختار backend
+```
+backend/
+├── core-service/app/
+│ ├── api/v1/ # routerها
+│ ├── core/ # config, database, cache, security, logging
+│ ├── models/ # مدلهای SQLAlchemy
+│ ├── schemas/ # اسکیماهای Pydantic
+│ ├── services/ # منطق کسبوکار
+│ ├── repositories/ # دسترسی به داده
+│ ├── middlewares/ # تشخیص tenant
+│ ├── workers/ # Celery
+│ └── tests/ # تستها
+├── shared-lib/ # کتابخانه مشترک backend
+└── services/ # placeholder سرویسهای آینده
+```
+
+## ۸. ساختار frontend
+```
+frontend/
+├── app/ # صفحات Next.js (App Router)
+├── components/ # کامپوننتهای UI
+├── hooks/ # React hooks
+├── lib/ # API client، theme
+├── styles/ # CSS global
+└── public/ # فایلهای استاتیک
+```
+
+## ۹. Migrations (Alembic)
+```bash
+cd backend/core-service
+alembic revision --autogenerate -m "توضیح"
+alembic upgrade head
+```
+
+## ۱۰. اجرای تستها (Backend)
+```bash
+cd backend/core-service
+pytest -q
+```
+
+## ۱۱. Celery
+```bash
+cd backend/core-service
+celery -A app.workers.celery_app.celery_app worker -Q default,core_events,notifications,webhooks
+celery -A app.workers.celery_app.celery_app beat
+```
+
+## ۱۲. الگوهای کدنویسی
+- نام فایلها/کلاسها/توابع/routeها/migrationها انگلیسی استاندارد.
+- کامنتها میتوانند فارسی باشند.
+- منطق کسبوکار فقط در backend (`services/`).
+- UI فقط در frontend (`components/`, `app/`).
+- خطاها از `shared.exceptions` استفاده کنند.
+
+## ۱۳. افزودن سرویس جدید (فازهای بعدی)
+1. پوشه سرویس را در `backend/services/` توسعه دهید.
+2. دیتابیس مستقل با `tenant_id` بسازید.
+3. قابلیتها را در Core ثبت کنید.
+4. UI مربوطه را در `frontend/` بسازید (فقط از API استفاده کند).
+5. ارتباط را فقط از طریق API/Event برقرار کنید.
diff --git a/docs/last_step.md b/docs/last_step.md
new file mode 100644
index 0000000..898a57a
--- /dev/null
+++ b/docs/last_step.md
@@ -0,0 +1,86 @@
+# آخرین گام انجامشده
+
+## فاز ۴ — Tenant Onboarding و Workspace Activation
+
+> در بریف پروژه این کار «Phase 3: operationalizing tenant onboarding and
+> workspace activation» نامیده شده؛ در شمارهگذاری داخلی مستندات، چون «فاز
+> ۳» قبلاً به OTP Login + Tenant Management اختصاص داشت، این تحویل **فاز ۴**
+> ثبت شده است (نگاه کنید به `progress.md`).
+
+### مسئله
+تا پیش از این فاز، سیستم فقط «هویت + لیست ادمین» بود: کاربر با OTP/SSO وارد
+میشد و از پنل ادمین tenantها را میدید/میساخت، اما هیچ جریان واقعی
+self-service برای اینکه یک کاربر عادی workspace خودش را بسازد، مالک آن شود،
+پلن/دامنه/برندینگ بگیرد و وارد یک داشبورد عملیاتی شود، وجود نداشت.
+
+### راهحل
+
+**Backend (`core-service`):**
+- جدول جدید `tenant_memberships` (نقش `tenant_owner/tenant_admin/tenant_editor/tenant_viewer/platform_admin`،
+ وضعیت `active/invited/disabled`، `is_owner`).
+- چرخهٔ عمر tenant: `draft → pending_activation → active → suspended/archived`.
+- ستونهای برندینگ/پروفایل روی `tenants` + `is_primary`/`verification_status`
+ روی `domains` + `current_tenant_id` روی `users`.
+- Seed پلنهای `FREE`/`STARTER` و اتصال خودکار اشتراک `FREE` هنگام ساخت tenant.
+- سرویسهای جدید: `UserService` (یکپارچهسازی resolve کاربر از JWT محلی یا
+ کیکلوک)، `MembershipService`، `OnboardingService`، `TenantContextService`.
+- APIهای جدید: `GET /me`, `GET /me/tenants`, `POST /onboarding/tenant`,
+ `PATCH .../branding`, `PATCH .../domain`, `POST .../complete`,
+ `GET /tenant/current`, `POST /tenant/switch`.
+- Migration `0005_tenant_onboarding` + تستهای `test_onboarding.py` (۷ تست،
+ جریان کامل + حالات forbidden/duplicate/owner-required).
+
+**Frontend (`frontend`):**
+- `lib/api.ts` با `api.me` / `api.onboarding` / `api.tenantContext`.
+- `hooks/useMe.ts` برای بارگذاری `/api/v1/me`.
+- `app/onboarding/page.tsx` — ویزارد ۴ مرحلهای (کسبوکار → برندینگ → دامنه
+ → بازبینی) با قابلیت ازسرگیری onboarding ناتمام.
+- `app/dashboard/page.tsx` — داشبورد واقعی workspace + redirect خودکار به
+ `/onboarding` وقتی `onboarding_required=true`.
+- `components/TenantSwitcher.tsx` — سوییچر ساده چند-workspace.
+
+### محدودیتهای عمدی این فاز (خارج از scope)
+- بدون درگاه پرداخت — فقط ساختار provisioning پلن/اشتراک.
+- بدون تأیید واقعی DNS برای دامنهٔ اختصاصی (فقط رکورد `pending`).
+- مسیر قدیمی `POST /admin/tenants` (فاز ۱/۲) بازنویسی نشده و هنوز عضویت/پلن
+ خودکار نمیسازد — عمداً دستنخورده ماند تا فازهای قبلی بازسازی نشوند.
+- JIT provisioning کامل کاربر Core از JWT کیکلوک پیاده نشده (کاربر SSO بدون
+ رکورد Core فعلاً `403 forbidden` میگیرد؛ نگاه کنید به بخش بعد).
+
+---
+
+## فاز پیشنهادی بعدی: White-label Runtime Rendering
+
+### چرا این فاز و نه یک ماژول بیزینسی؟
+در همین فاز، فیلدهای برندینگ واقعی (`primary_color`, `secondary_color`,
+`logo_url`, `favicon_url`) روی هر tenant ذخیره و در onboarding از کاربر
+گرفته میشوند — اما frontend فعلاً رنگها را فقط از یک فایل استاتیک
+(`public/theme.config.json`) میخواند و **هیچ ارتباطی با tenant واقعی
+resolveشده ندارد**. یعنی خروجی onboarding (برندینگ tenant) هنوز در UI
+اعمال نمیشود. تکمیل این حلقه (ذخیره برند → نمایش برند) از نظر ارزش محصول
+و آمادگی فنی، اولویت بالاتری نسبت به شروع اولین ماژول بیزینسی (Accounting/
+CRM/...) دارد؛ چون:
+1. زیرساخت لازم (فیلدهای دیتابیس، APIهای `TenantContextRead`، CSS
+ Variables موجود در frontend) از قبل آماده است — فقط باید به هم وصل شوند.
+2. بدون این حلقه، ادعای «SaaS چندمستأجری white-label» ناقص میماند، در حالی
+ که هر ماژول بیزینسی جدید (Accounting/CRM/...) به خودی خود به این زیرساخت
+ وابسته نیست.
+3. حل تشخیص tenant از دامنه (subdomain/custom domain) که برای white-label
+ لازم است، پیشنیاز طبیعی معماری چندمستأجری برای هر ماژول آینده نیز هست.
+
+### پیشنهاد Scope فاز بعدی
+1. **Backend:** endpoint عمومی (بدون نیاز به auth کاربر) برای resolve
+ theme بر اساس Host/X-Tenant-Slug، مثلاً
+ `GET /api/v1/public/tenant-theme` که `primary_color`, `secondary_color`,
+ `logo_url`, `favicon_url`, `business_name` را برمیگرداند (از همان
+ middleware تشخیص tenant موجود استفاده کند).
+2. **Frontend:** بارگذاری theme از این endpoint در `ThemeProvider` هنگام
+ دسترسی از subdomain/custom domain یک tenant (بهجای/علاوهبر
+ `theme.config.json` استاتیک فعلی که برای برند اصلی پلتفرم باقی میماند).
+3. مسیر عمومی مشتری/ویزیتور tenant (بدون نیاز به لاگین) که برند tenant را
+ میبیند — زیرساخت لازم برای هر ماژول بیزینسی آینده (مثلاً منوی دیجیتال
+ رستوران) که باید در دامنهٔ tenant با ظاهر آن tenant نمایش داده شود.
+4. بعد از این فاز، اولین ماژول بیزینسی واقعی (پیشنهاد: «رستوران / منوی
+ دیجیتال» چون در `docs/architecture.md` بهعنوان سرویس فعلی نامبرده شده و
+ دیتابیس مفهومی آن (`restaurant_db`) از قبل طراحی شده) روی همین زیرساخت
+ white-label ساخته شود.
diff --git a/docs/progress.md b/docs/progress.md
new file mode 100644
index 0000000..b34595a
--- /dev/null
+++ b/docs/progress.md
@@ -0,0 +1,174 @@
+# پیشرفت پروژه (Progress)
+
+## فاز ۱ — Core Platform ✅
+
+### زیرساخت و ساختار
+- [x] ساختار کامل پروژه با جداسازی اجباری `backend/` و `frontend/`
+- [x] کتابخانه مشترک `backend/shared-lib`
+- [x] بخش ۲.۱ معماری: Frontend & Backend Separation
+
+### سرویس هسته (core-service)
+- [x] پیکربندی، دیتابیس، cache، logging، security پایه
+- [x] مدلها، APIها، Entitlement، Celery، Outbox
+- [x] تستها و مستندات
+
+### Frontend
+- [x] Next.js مستقل، API Client، White-label theme
+
+---
+
+## فاز ۲ — Identity & Access + SSO ✅
+
+### shared-lib
+- [x] `shared/auth/jwt.py` — JWTValidator مشترک
+- [x] `shared/auth/roles.py` — نقشهای استاندارد پلتفرم
+- [x] رویدادهای Identity در `shared/events.py`
+
+### Identity & Access Service
+- [x] سرویس مستقل در `backend/services/identity-access/`
+- [x] دیتابیس `identity_access_db` (database-per-service)
+- [x] مدلها: UserProfile, TenantMembership
+- [x] Keycloak Admin Client + token exchange (BFF)
+- [x] APIها: `/auth/config`, `/auth/token`, `/auth/me`, `/users`, `/tenants/{id}/members`
+- [x] Alembic migration + Dockerfile
+- [x] تستهای پایه
+
+### Keycloak
+- [x] Realm import: `infrastructure/keycloak/realm/superapp-realm.json`
+- [x] Clients: superapp-frontend, core-service, identity-access-service
+- [x] نقشها و کاربر admin نمونه
+- [x] docker-compose با `--import-realm`
+
+### محافظت APIهای Core
+- [x] `AUTH_REQUIRED` قابل تنظیم از env
+- [x] `require_platform_admin`, `require_tenant_admin`, `require_authenticated`
+- [x] اعمال روی همه endpointهای مدیریتی
+
+### Frontend SSO
+- [x] `lib/auth.ts` — OIDC flow
+- [x] صفحات `/login`, `/auth/callback`, `/dashboard`
+- [x] `AuthGuard` و `useAuth` hook
+- [x] API Client با Bearer token
+
+### زیرساخت
+- [x] `identity-access-service` در docker-compose (پورت 8001)
+- [x] `frontend` در docker-compose (پورت 3000، dev + hot-reload)
+- [x] ارتقای Next.js به `15.5.18` (پچ امنیتی — نسخه 14 دیگر پشتیبانی امنیتی ندارد)
+- [x] `infrastructure/postgres/init-dbs.sql` برای ساخت identity_access_db
+- [x] بهروزرسانی `.env.example`
+
+### مستندات
+- [x] architecture.md (بخش SSO فاز ۲)
+- [x] database_schema.md (identity_access_db)
+- [x] services_contracts.md (APIهای Identity)
+- [x] progress.md, last_step.md
+
+---
+
+## فاز ۳ — OTP Login + Tenant Management ✅
+
+### Backend (core-service)
+- [x] مدل `users` و migration Alembic (`0002_users`)
+- [x] OTP request/verify: `/api/v1/auth/otp/request`, `/api/v1/auth/otp/verify`
+- [x] ثبتنام خودکار کاربر + audit log
+- [x] یکپارچگی Payamak-Panel SendOtp
+- [x] JWT محلی (HS256) برای کاربران OTP
+- [x] Admin Tenant CRUD: `/api/v1/admin/tenants` با owner mapping
+- [x] فیلتر لیست tenant بر اساس مالک (Platform Admin: همه / Tenant Admin: فقط مالک)
+
+### Frontend
+- [x] `lib/api.ts` — API Client یکپارچه با Bearer token
+- [x] `/admin/login` — جریان OTP دو مرحلهای با countdown
+- [x] `/admin/tenants` — داشبورد لیست tenant
+- [x] `/admin/tenants/new` — فرم ایجاد tenant
+
+### تستها
+- [x] `test_otp_auth.py` — OTP flow و tenant با JWT
+
+### مستندات
+- [x] architecture.md (بخش OTP)
+- [x] progress.md, last_step.md
+- [x] `.env.example` (Payamak + NEXT_PUBLIC_BACKEND_URL)
+
+---
+
+## فاز ۴ — Tenant Onboarding و Workspace Activation ✅
+
+> در بریف پروژه این فاز با عنوان **«Phase 3: operationalizing tenant
+> onboarding and workspace activation»** معرفی شده است. چون در شمارهگذاری
+> داخلی این سند «فاز ۳» قبلاً به OTP Login + Tenant Management اختصاص
+> یافته بود، این تحویل بهعنوان فاز ۴ ثبت میشود (محتوا دقیقاً همان
+> «Phase 3» بریف است).
+
+### Backend (core-service)
+- [x] مدل `TenantMembership` + جدول `tenant_memberships` (نقش/وضعیت/owner)
+- [x] چرخهٔ عمر tenant: `draft` / `pending_activation` / `active` /
+ `suspended` / `archived`
+- [x] پروفایل/برندینگ روی `tenants`: `business_type`, `default_locale`,
+ `timezone`, `primary_color`, `secondary_color`, `logo_url`,
+ `favicon_url`, `onboarding_completed`
+- [x] `domains.is_primary` و `domains.verification_status`
+- [x] `users.current_tenant_id` (tenant انتخابشده جاری)
+- [x] Seed پلنهای پیشفرض `FREE`/`STARTER` (migration + `ensure_default_plan`)
+- [x] `UserService.resolve_current` — یکپارچهسازی resolve کاربر Core از JWT
+ محلی (OTP) یا JWT کیکلوک (SSO، بر اساس `keycloak_sub`)
+- [x] `MembershipService` — عضویت owner، لیست عضویتها، `ensure_role`
+ (authorization سبک با bypass برای `platform_admin`)
+- [x] `OnboardingService` — ایجاد tenant، برندینگ، دامنه، تکمیل/فعالسازی
+- [x] `TenantContextService` — ساخت `TenantContextRead` کامل برای frontend
+- [x] APIهای جدید: `GET /me`, `GET /me/tenants`, `POST /onboarding/tenant`,
+ `PATCH /onboarding/tenant/{id}/branding`,
+ `PATCH /onboarding/tenant/{id}/domain`,
+ `POST /onboarding/tenant/{id}/complete`, `GET /tenant/current`,
+ `POST /tenant/switch`
+- [x] Migration `0005_tenant_onboarding` (ستونها، جدول جدید، seed پلنها)
+- [x] `PLATFORM_BASE_DOMAIN` در config برای تخصیص خودکار زیردامنه
+
+### تستها
+- [x] `app/tests/test_onboarding.py` — جریان کامل onboarding (ساخت tenant،
+ برندینگ، دامنه، تکمیل، `/me`, `/tenant/current`, `/tenant/switch`) +
+ حالات forbidden/duplicate/owner-required
+- [x] کل test suite موجود (`test_otp_auth`, `test_tenants`, `test_domains`, ...)
+ بدون رگرسیون اجرا شد
+
+### Frontend
+- [x] `lib/api.ts` — انواع و متدهای `api.me`, `api.onboarding`,
+ `api.tenantContext` (بدون تغییر ساختار قبلی `api.tenants`/`api.auth`)
+- [x] `hooks/useMe.ts` — بارگذاری `/api/v1/me`
+- [x] `app/onboarding/page.tsx` — ویزارد تکصفحهای ۴ مرحلهای (کسبوکار →
+ برندینگ → دامنه → بازبینی) با قابلیت ازسرگیری onboarding ناتمام
+- [x] `app/dashboard/page.tsx` — داشبورد واقعی workspace (نام، وضعیت، پلن،
+ دامنه، وضعیت onboarding، نقش کاربر) + redirect خودکار به `/onboarding`
+ در صورت نیاز
+- [x] `components/TenantSwitcher.tsx` — سوییچر ساده (فقط با بیش از یک
+ عضویت نمایش داده میشود)
+- [x] Type-check تمیز (`tsc --noEmit`)
+
+### زیرساخت
+- [x] `PLATFORM_BASE_DOMAIN` در `.env` و `.env.example`
+
+### مستندات
+- [x] architecture.md (بخش ۱۱ — فاز ۴)
+- [x] database_schema.md (`tenant_memberships` جدید Core، ستونهای جدید
+ `tenants`/`domains`/`users`، توضیح تمایز با جدول همنام Identity)
+- [x] services_contracts.md (بخش ۷ — Onboarding و Tenant Context)
+- [x] progress.md, last_step.md
+
+### محدودیتهای شناختهشده (عمداً خارج از این فاز)
+- [ ] درگاه پرداخت واقعی برای اشتراک (فقط ساختار provisioning پیاده شده)
+- [ ] تأیید واقعی دامنهٔ اختصاصی (DNS/TXT) — فعلاً فقط `pending`
+- [ ] tenantهای ساختهشده از مسیر قدیمی `POST /admin/tenants` عضویت/پلن
+ خودکار نمیگیرند (آن مسیر به فاز ۱/۲ تعلق دارد و در این فاز بازنویسی
+ نشده)
+- [ ] JIT provisioning کامل کاربر Core از JWT کیکلوک (کاربر SSO بدون رکورد
+ Core فعلاً خطای `forbidden` میگیرد)
+
+## فازهای بعدی (Backlog)
+- [ ] پیادهسازی اولین ماژول بیزینسی واقعی (نگاه کنید به `last_step.md`)
+- [ ] White-label runtime rendering (تشخیص tenant از دامنه + اعمال برند در frontend)
+- [ ] JIT provisioning کامل کاربر Core از SSO کیکلوک
+- [ ] Permission management پیشرفته (نقشهای سفارشی، دعوت اعضا)
+- [ ] تأیید واقعی دامنهٔ اختصاصی (DNS/TXT) و درگاه پرداخت
+- [ ] Subscription & Entitlement بهعنوان سرویس مستقل
+- [ ] Message bus واقعی برای انتشار رویدادها
+- [ ] Reverse proxy (Nginx/Traefik) و TLS
diff --git a/docs/services_contracts.md b/docs/services_contracts.md
new file mode 100644
index 0000000..86f3ec1
--- /dev/null
+++ b/docs/services_contracts.md
@@ -0,0 +1,270 @@
+# قراردادهای ارتباطی سرویسها (Service Contracts)
+
+## ۱. قوانین بنیادی
+1. **ارتباط مستقیم بین دیتابیس سرویسها ممنوع است.** هیچ سرویسی نباید به
+ دیتابیس سرویس دیگر کوئری بزند.
+2. هر سرویس فقط دیتابیس خودش را میشناسد (Database-per-service).
+3. ارتباط بین سرویسها فقط از این چهار راه:
+ - **REST API** (همزمان)
+ - **Webhook** (اعلان به بیرون)
+ - **Async Event** (message bus)
+ - **Outbox/Inbox Pattern** (تحویل قابل اعتماد رویداد)
+4. همه درخواستهای tenant-aware باید `tenant_id` را حمل کنند
+ (هدر `X-Tenant-ID` یا داخل توکن/رویداد).
+
+## ۲. احراز هویت بین سرویسها
+- سرویسها با **Internal Service Token** یکدیگر را احراز میکنند.
+- توکن بهصورت hash در `internal_service_tokens` ذخیره میشود (خود توکن ذخیره نمیشود).
+- هر توکن دارای `scopes` است؛ سرویس فراخوان باید scope لازم را داشته باشد.
+- کاربر نهایی با JWT کیکلوک احراز میشود.
+
+## ۳. قرارداد رویداد (Event Envelope)
+همه رویدادها با ساختار مشترک `shared.events.EventEnvelope` منتشر میشوند:
+```json
+{
+ "event_id": "uuid",
+ "event_type": "tenant.created",
+ "aggregate_type": "tenant",
+ "aggregate_id": "uuid",
+ "tenant_id": "uuid|null",
+ "source_service": "core-service",
+ "payload": { },
+ "occurred_at": "ISO-8601"
+}
+```
+
+### جریان Outbox → Inbox
+1. سرویس مبدأ رویداد را در `outbox_events` (در همان تراکنش) ذخیره میکند.
+2. worker رویدادهای `pending` را منتشر و `processed` میکند.
+3. سرویس مقصد رویداد را در `inbox_events` ثبت میکند (بر اساس `event_id`
+ برای idempotency) و سپس پردازش میکند.
+
+### رویدادهای شناختهشده Core (نمونه)
+| event_type | aggregate | توضیح |
+| --- | --- | --- |
+| tenant.created | tenant | ساخت مستأجر جدید |
+| tenant.suspended | tenant | تعلیق مستأجر |
+| tenant.activated | tenant | فعالسازی مستأجر |
+| domain.created | domain | افزودن دامنه |
+| subscription.created | subscription | ایجاد اشتراک |
+| subscription.updated | subscription | تغییر اشتراک |
+| feature_access.changed | feature_access | تغییر دسترسی قابلیت |
+
+## ۴. قرارداد Entitlement (بررسی دسترسی)
+سرویسهای دیگر پیش از اجرای یک قابلیت، دسترسی را از Core استعلام میکنند:
+
+```
+POST /api/v1/tenants/{tenant_id}/features/check
+{ "feature_key": "accounting.invoice.create" }
+
+→ { "tenant_id": "...", "feature_key": "...", "has_access": true, "reason": "plan_enabled" }
+```
+
+قرارداد نامگذاری `feature_key`: `{service_key}.{resource}.{action}`
+مثال: `crm.lead.create`, `ecommerce.product.create`.
+
+## ۵. APIهای Core Platform (فاز ۱)
+| گروه | متد و مسیر |
+| --- | --- |
+| Health | `GET /health` |
+| Tenants | `POST/GET /api/v1/tenants`، `GET/PATCH /api/v1/tenants/{id}`، `POST .../suspend`، `POST .../activate` |
+| Domains | `POST/GET /api/v1/tenants/{id}/domains`، `POST /api/v1/domains/resolve` |
+| Plans & Features | `POST/GET /api/v1/plans`، `POST/GET /api/v1/features`، `POST /api/v1/plans/{id}/features` |
+| Subscription | `POST/GET /api/v1/tenants/{id}/subscription`، `POST /api/v1/tenants/{id}/features/check` |
+| Service Registry | `POST/GET /api/v1/services`، `PATCH /api/v1/services/{id}/status` |
+
+## ۶. قالب پاسخ استاندارد
+- خطاها با قالب `shared.responses.ErrorResponse` برگردانده میشوند:
+```json
+{ "success": false, "error": { "code": "not_found", "message": "...", "details": null } }
+```
+- لیستها با قالب صفحهبندی `Page` (شامل `items` و `meta`).
+
+## ۷. Onboarding و Tenant Context (فاز ۴ — Tenant Onboarding و Workspace Activation)
+
+> در بریف پروژه این مجموعه API با عنوان «Phase 3» شناخته میشود؛ در
+> شمارهگذاری داخلی مستندات فاز ۴ است (نگاه کنید به `progress.md`).
+
+همهٔ endpointهای این بخش زیر `API_V1_PREFIX` (پیشفرض `/api/v1`) هستند و به
+احراز هویت نیاز دارند (`Authorization: Bearer ` — چه JWT محلی OTP و
+چه JWT کیکلوک SSO؛ هر دو با `get_current_core_user` resolve میشوند).
+
+### `GET /api/v1/me`
+وضعیت کاربر جاری، عضویتها و نیاز به onboarding.
+
+**پاسخ ۲۰۰:**
+```json
+{
+ "user_id": "uuid",
+ "mobile": "09xxxxxxxxx",
+ "email": null,
+ "platform_role": "user",
+ "onboarding_required": true,
+ "current_tenant_id": null,
+ "memberships": [
+ {
+ "tenant_id": "uuid", "tenant_name": "...", "tenant_slug": "...",
+ "tenant_status": "active", "onboarding_completed": true,
+ "role": "tenant_owner", "status": "active", "is_owner": true
+ }
+ ]
+}
+```
+`onboarding_required=true` اگر کاربر هیچ عضویتی نداشته باشد یا tenant جاری
+هنوز `onboarding_completed=false` باشد.
+
+خطاها: `401 unauthorized` (بدون توکن)، `403 forbidden` (JWT کیکلوک بدون
+لینک به رکورد Core — نگاه کنید به `architecture.md`).
+
+### `GET /api/v1/me/tenants`
+آرایهای از `MembershipSummary` (همان ساختار `memberships` بالا) برای همهٔ
+tenantهای در دسترس کاربر جاری.
+
+### `POST /api/v1/onboarding/tenant`
+ساخت tenant جدید توسط کاربر جاری (بدون نیاز به عضویت قبلی).
+
+**بدنه درخواست:**
+```json
+{
+ "business_name": "کافه تربت",
+ "slug": "cafe-torbat",
+ "business_type": "cafe",
+ "default_locale": "fa-IR",
+ "timezone": "Asia/Tehran"
+}
+```
+`business_name` و `slug` الزامی؛ بقیه اختیاری (پیشفرضها بالا). `slug` باید
+فقط حروف کوچک انگلیسی/اعداد/خطتیره باشد.
+
+**رفتار:** ساخت tenant با `status=pending_activation` → ساخت عضویت
+`tenant_owner`/`is_owner=true` برای کاربر جاری → اتصال پلن پیشفرض `FREE`
+با `tenant_subscriptions.status=active` → (در صورت تنظیم
+`PLATFORM_BASE_DOMAIN`) ساخت زیردامنهٔ `{slug}.{base_domain}` با
+`verification_status=verified` → اگر کاربر `current_tenant_id` نداشت، همین
+tenant بهعنوان جاری تنظیم میشود.
+
+**پاسخ ۲۰۱:** شیء `TenantContextRead` (نگاه کنید پایین).
+
+خطاها: `409 slug_taken` (slug تکراری در کل پلتفرم)، `401`/`403` مشابه بالا.
+
+### `PATCH /api/v1/onboarding/tenant/{tenant_id}/branding`
+```json
+{ "primary_color": "#112233", "secondary_color": "#445566", "logo_url": null, "favicon_url": null }
+```
+همهٔ فیلدها اختیاریاند؛ فقط مقادیر ارسالشده بهروزرسانی میشوند.
+نیازمند نقش `tenant_owner`/`tenant_admin` روی tenant مقصد (یا
+`platform_admin`). پاسخ ۲۰۰: `TenantContextRead`.
+
+خطا: `403 forbidden` (بدون نقش کافی).
+
+### `PATCH /api/v1/onboarding/tenant/{tenant_id}/domain`
+```json
+{ "custom_domain": "cafe.example.com" }
+```
+اگر `custom_domain` ارسال شود، رکورد `domains` جدید با
+`domain_type=custom_domain`، `is_primary=false`،
+`verification_status=pending` ساخته میشود. پاسخ ۲۰۰: `TenantContextRead`.
+
+خطاها: `409 domain_taken` (دامنه تکراری در کل پلتفرم)، `403 forbidden`.
+
+### `POST /api/v1/onboarding/tenant/{tenant_id}/complete`
+بدون بدنه. اعتبارسنجی نهایی: نام/slug موجود باشد و حداقل یک owner فعال
+داشته باشد؛ در صورت موفقیت `onboarding_completed=true` و
+`status=active` میشود. پاسخ ۲۰۰: `TenantContextRead`.
+
+خطاها: `422 onboarding_incomplete` (نام/slug ناقص)، `422 owner_required`
+(بدون owner فعال)، `403 forbidden` (بدون نقش کافی).
+
+### `GET /api/v1/tenant/current`
+tenant جاری کاربر را برمیگرداند (بر اساس `current_tenant_id` یا اولین
+عضویت در نبود آن). پاسخ ۲۰۰: `TenantContextRead`.
+
+خطا: `404 not_found` (کاربر هیچ عضویتی ندارد).
+
+### `POST /api/v1/tenant/switch`
+```json
+{ "tenant_id": "uuid" }
+```
+`current_tenant_id` کاربر را به این tenant تغییر میدهد (فقط اگر عضویت
+`active` روی آن داشته باشد). پاسخ ۲۰۰: `TenantContextRead`.
+
+خطا: `403 forbidden` (عضو نیست یا عضویت غیرفعال).
+
+### ساختار مشترک `TenantContextRead`
+```json
+{
+ "tenant": { "id": "...", "name": "...", "slug": "...", "status": "active",
+ "business_type": null, "default_locale": "fa-IR", "timezone": "Asia/Tehran",
+ "primary_color": "#0284c7", "secondary_color": "#0f172a",
+ "logo_url": null, "favicon_url": null, "onboarding_completed": true,
+ "created_at": "...", "updated_at": "..." },
+ "role": "tenant_owner",
+ "is_owner": true,
+ "plan_code": "FREE",
+ "plan_name": "رایگان",
+ "subscription_status": "active",
+ "domains": [ { "id": "...", "tenant_id": "...", "domain": "cafe-torbat.example.com",
+ "domain_type": "subdomain", "is_verified": true, "verified_at": null,
+ "created_at": "..." } ],
+ "primary_domain": "cafe-torbat.example.com"
+}
+```
+
+## ۸. Identity & Access Service (فاز ۲)
+
+Base URL: `http://identity-access-service:8001` (Docker) یا `http://localhost:8001`
+
+| گروه | متد و مسیر | احراز هویت |
+| --- | --- | --- |
+| Health | `GET /health` | عمومی |
+| Auth Config | `GET /api/v1/auth/config` | عمومی |
+| Login URL | `GET /api/v1/auth/login-url` | عمومی |
+| Token Exchange | `POST /api/v1/auth/token` | عمومی (code) |
+| Current User | `GET /api/v1/auth/me` | JWT |
+| Register (SSO+mobile) | `POST /api/v1/auth/register` | عمومی |
+| Verify register mobile | `POST /api/v1/auth/register/verify-mobile` | عمومی |
+| Register mobile only | `POST /api/v1/auth/register/mobile` | عمومی |
+| Complete mobile register | `POST /api/v1/auth/register/mobile/verify` | عمومی |
+| **Unified mobile start** | `POST /api/v1/auth/mobile/start` | عمومی |
+| **Unified mobile complete** | `POST /api/v1/auth/mobile/complete` | عمومی |
+| **Session handoff redeem** | `POST /api/v1/auth/session/redeem` | عمومی (یکبارمصرف) |
+| SSO mobile verify request | `POST /api/v1/auth/mobile/request` | JWT |
+| SSO mobile verify | `POST /api/v1/auth/mobile/verify` | JWT |
+| Users | `POST /api/v1/users` | platform_admin |
+| Members | `POST/GET /api/v1/tenants/{id}/members` | platform_admin |
+
+### جریان SSO (Frontend)
+1. `GET /api/v1/auth/login-url` → redirect به Keycloak
+2. Callback با `code` → `POST /api/v1/auth/token`
+3. `GET /api/v1/auth/me` → اگر `requires_mobile_verification=true` → `/auth/verify-mobile`
+4. استفاده از `access_token` در هدر `Authorization: Bearer ...`
+
+### ورود مرکزی (Keycloak — تنها UX کاربر)
+1. همه لینکهای «ورود» → `GET /auth/login-url` → Keycloak
+2. تب موبایل در Keycloak → `mobile/start` + `mobile/complete?create_handoff=true`
+3. `GET /auth/callback?handoff=...` → `POST /session/redeem` → dashboard
+
+### ورود/ثبتنام یکپارچه (API)
+1. `POST /auth/mobile/start` با `{ mobile }` → `{ intent: "login"|"register", expires_in }`
+2. کاربر OTP را وارد میکند
+3. `POST /auth/mobile/complete` با `{ mobile, code, display_name?, email?, username?, password? }`
+ - اگر `intent=login`: فقط mobile + code
+ - اگر `intent=register`: فیلدهای پروفایل الزامی (یا ثبتنام ساده بدون آنها برای موبایل-only)
+4. پاسخ: `AuthSessionResponse` (access_token + refresh_token) → redirect به `/dashboard`
+
+### ثبتنام legacy
+- **SSO + موبایل:** `POST /register` → OTP → `POST /register/verify-mobile`
+- **فقط موبایل:** `POST /register/mobile` → OTP → `POST /register/mobile/verify`
+- Identity برای OTP به Core متصل میشود: `POST core /api/v1/auth/otp/*` با `context=public`
+
+### رویدادهای Identity
+| event_type | توضیح |
+| --- | --- |
+| user.registered | ثبت کاربر جدید |
+| tenant_member.added | افزودن عضو به tenant |
+| tenant_member.removed | حذف عضو از tenant |
+
+## ۹. Service Registry
+سرویسهای داخلی آینده باید خود را در `service_registry` ثبت کنند
+(`service_key`, `base_url`, `health_check_url`, `status`) تا discovery و
+health-check ممکن باشد.
diff --git a/frontend/.dockerignore b/frontend/.dockerignore
new file mode 100644
index 0000000..b696be5
--- /dev/null
+++ b/frontend/.dockerignore
@@ -0,0 +1,6 @@
+node_modules
+.next
+.git
+*.log
+.env
+.env.local
diff --git a/frontend/Dockerfile.dev b/frontend/Dockerfile.dev
new file mode 100644
index 0000000..9d36ffc
--- /dev/null
+++ b/frontend/Dockerfile.dev
@@ -0,0 +1,15 @@
+# Frontend — Development image (hot-reload)
+FROM node:20-alpine
+
+WORKDIR /app
+
+# وابستگیها ابتدا کپی میشوند تا لایه cache بهتر کار کند.
+COPY package.json ./
+RUN npm install
+
+COPY . .
+
+EXPOSE 3000
+
+# در Docker باید روی 0.0.0.0 گوش دهد تا از host قابل دسترسی باشد.
+CMD ["npm", "run", "dev"]
diff --git a/frontend/README.md b/frontend/README.md
new file mode 100644
index 0000000..be6065e
--- /dev/null
+++ b/frontend/README.md
@@ -0,0 +1,43 @@
+# Frontend (Next.js)
+
+اپلیکیشن frontend کاملاً جدا از backend است.
+
+## مسئولیتها
+- UI، Dashboard، Forms، Pages، Components
+- State Management، API Client، Theme (White-label)
+
+## قوانین
+- **هیچ** کد FastAPI، SQLAlchemy، Alembic یا Business Logic در این پوشه نیست.
+- ارتباط با backend **فقط** از طریق REST API (`lib/api-client.ts`).
+- برند از `public/theme.config.json` یا API خوانده میشود (بدون hardcode).
+
+## راهاندازی
+
+**پیشنهادی (همراه کل پلتفرم):**
+```bash
+# از ریشه پروژه
+docker compose up -d --build
+```
+
+**اجرای مستقیم (دیباگ UI):**
+```bash
+cd frontend
+npm install
+npm run dev
+```
+
+Frontend: http://localhost:3000
+
+## تکنولوژی
+- Next.js **15.5.18** (پچ امنیتی — نسخه 14 دیگر پشتیبانی CVE ندارد)
+- React 19، TypeScript، TailwindCSS
+## ساختار
+```
+frontend/
+├── app/ # صفحات و layout (Next.js App Router)
+├── components/ # کامپوننتهای UI
+├── hooks/ # React hooks
+├── lib/ # API client، theme، utilities
+├── styles/ # CSS global و Tailwind
+└── public/ # فایلهای استاتیک و theme.config.json
+```
diff --git a/frontend/app/admin/features/page.tsx b/frontend/app/admin/features/page.tsx
new file mode 100644
index 0000000..81a28dc
--- /dev/null
+++ b/frontend/app/admin/features/page.tsx
@@ -0,0 +1,179 @@
+"use client";
+
+import { FormEvent, useCallback, useEffect, useState } from "react";
+import { api, ApiError, type Feature } from "@/lib/api";
+import {
+ AdminPageHeader,
+ Banner,
+ Button,
+ EmptyState,
+ Modal,
+ StatusBadge,
+ TextField,
+ TextareaField,
+} from "@/components/admin/controls";
+
+export default function AdminFeaturesPage() {
+ const [features, setFeatures] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState("");
+ const [open, setOpen] = useState(false);
+
+ const load = useCallback(async () => {
+ setLoading(true);
+ setError("");
+ try {
+ const page = await api.features.list(1, 100);
+ setFeatures(page.items);
+ } catch (e) {
+ setError(e instanceof ApiError ? e.message : "خطا در بارگذاری قابلیتها");
+ } finally {
+ setLoading(false);
+ }
+ }, []);
+
+ useEffect(() => {
+ void load();
+ }, [load]);
+
+ return (
+
+
setOpen(true)}>قابلیت جدید}
+ />
+
+ {error && {error}}
+
+
+ {loading ? (
+
+ ) : features.length === 0 ? (
+
+ ) : (
+
+
+
+
+ | نام |
+ کلید |
+ سرویس |
+ وضعیت |
+
+
+
+ {features.map((f) => (
+
+ | {f.name} |
+
+ {f.feature_key}
+ |
+
+ {f.service_key}
+ |
+
+
+ |
+
+ ))}
+
+
+
+ )}
+
+
+ setOpen(false)}
+ onCreated={() => {
+ setOpen(false);
+ void load();
+ }}
+ />
+
+ );
+}
+
+function CreateFeatureModal({
+ open,
+ onClose,
+ onCreated,
+}: {
+ open: boolean;
+ onClose: () => void;
+ onCreated: () => void;
+}) {
+ const [featureKey, setFeatureKey] = useState("");
+ const [name, setName] = useState("");
+ const [serviceKey, setServiceKey] = useState("");
+ const [description, setDescription] = useState("");
+ const [saving, setSaving] = useState(false);
+ const [error, setError] = useState("");
+
+ const handleSubmit = async (e: FormEvent) => {
+ e.preventDefault();
+ setError("");
+ setSaving(true);
+ try {
+ await api.features.create({
+ feature_key: featureKey.trim(),
+ name: name.trim(),
+ service_key: serviceKey.trim(),
+ description: description.trim() || null,
+ });
+ setFeatureKey("");
+ setName("");
+ setServiceKey("");
+ setDescription("");
+ onCreated();
+ } catch (err) {
+ setError(err instanceof ApiError ? err.message : "ایجاد قابلیت ناموفق بود");
+ } finally {
+ setSaving(false);
+ }
+ };
+
+ return (
+
+
+
+ );
+}
diff --git a/frontend/app/admin/layout.tsx b/frontend/app/admin/layout.tsx
new file mode 100644
index 0000000..6fc8bbe
--- /dev/null
+++ b/frontend/app/admin/layout.tsx
@@ -0,0 +1,21 @@
+"use client";
+
+import { ReactNode } from "react";
+import { usePathname } from "next/navigation";
+import { AdminAuthGuard } from "@/components/AdminAuthGuard";
+import { AdminShell } from "@/components/admin/AdminShell";
+
+export default function AdminLayout({ children }: { children: ReactNode }) {
+ const pathname = usePathname();
+
+ // صفحه ورود ادمین فقط redirect است و نباید داخل shell/guard قرار گیرد.
+ if (pathname === "/admin/login") {
+ return <>{children}>;
+ }
+
+ return (
+
+ {children}
+
+ );
+}
diff --git a/frontend/app/admin/login/page.tsx b/frontend/app/admin/login/page.tsx
new file mode 100644
index 0000000..96a0c56
--- /dev/null
+++ b/frontend/app/admin/login/page.tsx
@@ -0,0 +1,17 @@
+"use client";
+
+import { useEffect } from "react";
+import { loginWithRedirect } from "@/lib/auth";
+
+/** پنل ادمین — ورود اجباری از SSO مرکزی Keycloak */
+export default function AdminLoginPage() {
+ useEffect(() => {
+ void loginWithRedirect("/admin/tenants");
+ }, []);
+
+ return (
+
+
در حال انتقال به ورود مرکزی...
+
+ );
+}
diff --git a/frontend/app/admin/page.tsx b/frontend/app/admin/page.tsx
new file mode 100644
index 0000000..fe71815
--- /dev/null
+++ b/frontend/app/admin/page.tsx
@@ -0,0 +1,108 @@
+"use client";
+
+import { useEffect, useState } from "react";
+import Link from "next/link";
+import { api } from "@/lib/api";
+import { getStoredToken } from "@/lib/auth";
+import { identityApi } from "@/lib/identity";
+import { AdminPageHeader, Banner, cardClass } from "@/components/admin/controls";
+
+interface Stats {
+ tenants: number | null;
+ users: number | null;
+ plans: number | null;
+ features: number | null;
+ services: number | null;
+}
+
+const QUICK_LINKS = [
+ { href: "/admin/tenants", title: "مدیریت Tenantها", desc: "ایجاد، ویرایش، تعلیق و فعالسازی" },
+ { href: "/admin/users", title: "مدیریت کاربران", desc: "لیست و ایجاد کاربر جدید" },
+ { href: "/admin/plans", title: "پلنها", desc: "تعریف پلن و اتصال قابلیتها" },
+ { href: "/admin/features", title: "قابلیتها", desc: "تعریف قابلیتهای سرویسها" },
+ { href: "/admin/services", title: "سرویسها", desc: "ثبت و مدیریت وضعیت سرویسها" },
+];
+
+export default function AdminOverviewPage() {
+ const [stats, setStats] = useState({
+ tenants: null,
+ users: null,
+ plans: null,
+ features: null,
+ services: null,
+ });
+ const [error, setError] = useState("");
+ const [loading, setLoading] = useState(true);
+
+ useEffect(() => {
+ const token = getStoredToken();
+ (async () => {
+ const [tenants, plans, features, services, users] = await Promise.allSettled([
+ api.platformTenants.list(1, 1),
+ api.plans.list(1, 1),
+ api.features.list(1, 1),
+ api.services.list(1, 1),
+ token ? identityApi.listUsers(token, { limit: 100 }) : Promise.resolve([]),
+ ]);
+ setStats({
+ tenants: tenants.status === "fulfilled" ? tenants.value.meta.total_items : null,
+ plans: plans.status === "fulfilled" ? plans.value.meta.total_items : null,
+ features: features.status === "fulfilled" ? features.value.meta.total_items : null,
+ services: services.status === "fulfilled" ? services.value.meta.total_items : null,
+ users: users.status === "fulfilled" ? users.value.length : null,
+ });
+ if (tenants.status === "rejected") {
+ setError(
+ tenants.reason instanceof Error ? tenants.reason.message : "خطا در بارگذاری آمار"
+ );
+ }
+ setLoading(false);
+ })();
+ }, []);
+
+ const cards = [
+ { label: "Tenantها", value: stats.tenants, href: "/admin/tenants" },
+ { label: "کاربران", value: stats.users, href: "/admin/users" },
+ { label: "پلنها", value: stats.plans, href: "/admin/plans" },
+ { label: "قابلیتها", value: stats.features, href: "/admin/features" },
+ { label: "سرویسها", value: stats.services, href: "/admin/services" },
+ ];
+
+ return (
+
+
+
+ {error &&
{error}}
+
+
+ {cards.map((c) => (
+
+
{c.label}
+
+ {loading ? "…" : c.value ?? "—"}
+
+
+ ))}
+
+
+
+
دسترسی سریع
+
+ {QUICK_LINKS.map((link) => (
+
+
{link.title}
+
{link.desc}
+
+ ))}
+
+
+
+ );
+}
diff --git a/frontend/app/admin/plans/page.tsx b/frontend/app/admin/plans/page.tsx
new file mode 100644
index 0000000..416e4b1
--- /dev/null
+++ b/frontend/app/admin/plans/page.tsx
@@ -0,0 +1,304 @@
+"use client";
+
+import { FormEvent, useCallback, useEffect, useState } from "react";
+import { api, ApiError, type Feature, type Plan } from "@/lib/api";
+import {
+ AdminPageHeader,
+ Banner,
+ Button,
+ EmptyState,
+ Modal,
+ SelectField,
+ StatusBadge,
+ TextField,
+ TextareaField,
+} from "@/components/admin/controls";
+
+export default function AdminPlansPage() {
+ const [plans, setPlans] = useState([]);
+ const [features, setFeatures] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState("");
+ const [createOpen, setCreateOpen] = useState(false);
+ const [attachPlan, setAttachPlan] = useState(null);
+
+ const load = useCallback(async () => {
+ setLoading(true);
+ setError("");
+ try {
+ const [plansPage, featuresPage] = await Promise.all([
+ api.plans.list(1, 100),
+ api.features.list(1, 100),
+ ]);
+ setPlans(plansPage.items);
+ setFeatures(featuresPage.items);
+ } catch (e) {
+ setError(e instanceof ApiError ? e.message : "خطا در بارگذاری پلنها");
+ } finally {
+ setLoading(false);
+ }
+ }, []);
+
+ useEffect(() => {
+ void load();
+ }, [load]);
+
+ return (
+
+
setCreateOpen(true)}>پلن جدید}
+ />
+
+ {error && {error}}
+
+
+ {loading ? (
+
+ ) : plans.length === 0 ? (
+
+ ) : (
+
+
+
+
+ | نام |
+ کد |
+ وضعیت |
+ عملیات |
+
+
+
+ {plans.map((p) => (
+
+ | {p.name} |
+
+ {p.code}
+ |
+
+
+ |
+
+
+ |
+
+ ))}
+
+
+
+ )}
+
+
+ setCreateOpen(false)}
+ onCreated={() => {
+ setCreateOpen(false);
+ void load();
+ }}
+ />
+
+ setAttachPlan(null)}
+ onDone={() => setAttachPlan(null)}
+ />
+
+ );
+}
+
+function CreatePlanModal({
+ open,
+ onClose,
+ onCreated,
+}: {
+ open: boolean;
+ onClose: () => void;
+ onCreated: () => void;
+}) {
+ const [code, setCode] = useState("");
+ const [name, setName] = useState("");
+ const [description, setDescription] = useState("");
+ const [isActive, setIsActive] = useState("true");
+ const [saving, setSaving] = useState(false);
+ const [error, setError] = useState("");
+
+ const handleSubmit = async (e: FormEvent) => {
+ e.preventDefault();
+ setError("");
+ setSaving(true);
+ try {
+ await api.plans.create({
+ code: code.trim(),
+ name: name.trim(),
+ description: description.trim() || null,
+ is_active: isActive === "true",
+ });
+ setCode("");
+ setName("");
+ setDescription("");
+ onCreated();
+ } catch (err) {
+ setError(err instanceof ApiError ? err.message : "ایجاد پلن ناموفق بود");
+ } finally {
+ setSaving(false);
+ }
+ };
+
+ return (
+
+
+
+ );
+}
+
+function AttachFeatureModal({
+ plan,
+ features,
+ onClose,
+ onDone,
+}: {
+ plan: Plan | null;
+ features: Feature[];
+ onClose: () => void;
+ onDone: () => void;
+}) {
+ const [featureId, setFeatureId] = useState("");
+ const [limitValue, setLimitValue] = useState("");
+ const [limitPeriod, setLimitPeriod] = useState("");
+ const [saving, setSaving] = useState(false);
+ const [error, setError] = useState("");
+ const [success, setSuccess] = useState("");
+
+ const handleSubmit = async (e: FormEvent) => {
+ e.preventDefault();
+ if (!plan) return;
+ setError("");
+ setSuccess("");
+ const fid = featureId || features[0]?.id;
+ if (!fid) {
+ setError("قابلیتی برای اتصال وجود ندارد.");
+ return;
+ }
+ setSaving(true);
+ try {
+ await api.plans.attachFeature(plan.id, {
+ feature_id: fid,
+ limit_value: limitValue.trim() ? Number(limitValue) : null,
+ limit_period: (limitPeriod || null) as
+ | "day"
+ | "month"
+ | "year"
+ | "total"
+ | null,
+ });
+ setSuccess("قابلیت با موفقیت متصل شد.");
+ setLimitValue("");
+ setLimitPeriod("");
+ } catch (err) {
+ setError(err instanceof ApiError ? err.message : "اتصال قابلیت ناموفق بود");
+ } finally {
+ setSaving(false);
+ }
+ };
+
+ return (
+
+ {features.length === 0 ? (
+
+ ابتدا از بخش «قابلیتها» یک قابلیت بسازید.
+
+ ) : (
+
+ )}
+
+ );
+}
diff --git a/frontend/app/admin/services/page.tsx b/frontend/app/admin/services/page.tsx
new file mode 100644
index 0000000..e9a7e16
--- /dev/null
+++ b/frontend/app/admin/services/page.tsx
@@ -0,0 +1,226 @@
+"use client";
+
+import { FormEvent, useCallback, useEffect, useState } from "react";
+import { api, ApiError, type ServiceEntry, type ServiceStatus } from "@/lib/api";
+import {
+ AdminPageHeader,
+ Banner,
+ Button,
+ EmptyState,
+ Modal,
+ SelectField,
+ StatusBadge,
+ TextField,
+} from "@/components/admin/controls";
+
+const STATUS_OPTIONS: { value: ServiceStatus; label: string }[] = [
+ { value: "active", label: "فعال" },
+ { value: "inactive", label: "غیرفعال" },
+ { value: "maintenance", label: "در حال تعمیر" },
+];
+
+export default function AdminServicesPage() {
+ const [services, setServices] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState("");
+ const [open, setOpen] = useState(false);
+ const [busyId, setBusyId] = useState(null);
+
+ const load = useCallback(async () => {
+ setLoading(true);
+ setError("");
+ try {
+ const page = await api.services.list(1, 100);
+ setServices(page.items);
+ } catch (e) {
+ setError(e instanceof ApiError ? e.message : "خطا در بارگذاری سرویسها");
+ } finally {
+ setLoading(false);
+ }
+ }, []);
+
+ useEffect(() => {
+ void load();
+ }, [load]);
+
+ const changeStatus = async (svc: ServiceEntry, status: ServiceStatus) => {
+ if (status === svc.status) return;
+ setBusyId(svc.id);
+ setError("");
+ try {
+ await api.services.updateStatus(svc.id, status);
+ await load();
+ } catch (e) {
+ setError(e instanceof ApiError ? e.message : "تغییر وضعیت ناموفق بود");
+ } finally {
+ setBusyId(null);
+ }
+ };
+
+ return (
+
+
setOpen(true)}>ثبت سرویس}
+ />
+
+ {error && {error}}
+
+
+ {loading ? (
+
+ ) : services.length === 0 ? (
+
+ ) : (
+
+
+
+
+ | نام |
+ کلید |
+ آدرس |
+ وضعیت |
+ تغییر وضعیت |
+
+
+
+ {services.map((s) => (
+
+ | {s.name} |
+
+ {s.service_key}
+ |
+
+ {s.base_url || "—"}
+ |
+
+
+ |
+
+
+ |
+
+ ))}
+
+
+
+ )}
+
+
+ setOpen(false)}
+ onCreated={() => {
+ setOpen(false);
+ void load();
+ }}
+ />
+
+ );
+}
+
+function CreateServiceModal({
+ open,
+ onClose,
+ onCreated,
+}: {
+ open: boolean;
+ onClose: () => void;
+ onCreated: () => void;
+}) {
+ const [serviceKey, setServiceKey] = useState("");
+ const [name, setName] = useState("");
+ const [baseUrl, setBaseUrl] = useState("");
+ const [healthUrl, setHealthUrl] = useState("");
+ const [status, setStatus] = useState("active");
+ const [saving, setSaving] = useState(false);
+ const [error, setError] = useState("");
+
+ const handleSubmit = async (e: FormEvent) => {
+ e.preventDefault();
+ setError("");
+ setSaving(true);
+ try {
+ await api.services.create({
+ service_key: serviceKey.trim(),
+ name: name.trim(),
+ base_url: baseUrl.trim() || null,
+ health_check_url: healthUrl.trim() || null,
+ status,
+ });
+ setServiceKey("");
+ setName("");
+ setBaseUrl("");
+ setHealthUrl("");
+ onCreated();
+ } catch (err) {
+ setError(err instanceof ApiError ? err.message : "ثبت سرویس ناموفق بود");
+ } finally {
+ setSaving(false);
+ }
+ };
+
+ return (
+
+
+
+ );
+}
diff --git a/frontend/app/admin/settings/page.tsx b/frontend/app/admin/settings/page.tsx
new file mode 100644
index 0000000..a97c230
--- /dev/null
+++ b/frontend/app/admin/settings/page.tsx
@@ -0,0 +1,7 @@
+"use client";
+
+import { AccountSettings } from "@/components/settings/AccountSettings";
+
+export default function AdminSettingsPage() {
+ return ;
+}
diff --git a/frontend/app/admin/tenants/[id]/page.tsx b/frontend/app/admin/tenants/[id]/page.tsx
new file mode 100644
index 0000000..74f6827
--- /dev/null
+++ b/frontend/app/admin/tenants/[id]/page.tsx
@@ -0,0 +1,492 @@
+"use client";
+
+import { FormEvent, useCallback, useEffect, useState } from "react";
+import Link from "next/link";
+import { useParams } from "next/navigation";
+import {
+ api,
+ ApiError,
+ type Tenant,
+ type TenantDomain,
+ type Subscription,
+ type Plan,
+} from "@/lib/api";
+import { getStoredToken } from "@/lib/auth";
+import { identityApi, type AdminUser, type TenantMember } from "@/lib/identity";
+import {
+ AdminPageHeader,
+ Banner,
+ Button,
+ Field,
+ SelectField,
+ StatusBadge,
+ TextField,
+ cardClass,
+ inputClass,
+} from "@/components/admin/controls";
+
+const TENANT_STATUSES = [
+ "draft",
+ "pending_activation",
+ "active",
+ "suspended",
+ "archived",
+ "inactive",
+].map((s) => ({ value: s, label: s }));
+
+const SUBSCRIPTION_STATUSES = [
+ "trialing",
+ "active",
+ "past_due",
+ "canceled",
+ "expired",
+].map((s) => ({ value: s, label: s }));
+
+export default function TenantDetailPage() {
+ const params = useParams<{ id: string }>();
+ const tenantId = params.id;
+
+ const [tenant, setTenant] = useState(null);
+ const [domains, setDomains] = useState([]);
+ const [subscription, setSubscription] = useState(null);
+ const [members, setMembers] = useState([]);
+ const [plans, setPlans] = useState([]);
+ const [users, setUsers] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState("");
+
+ const load = useCallback(async () => {
+ setLoading(true);
+ setError("");
+ const token = getStoredToken();
+ try {
+ const t = await api.platformTenants.get(tenantId);
+ setTenant(t);
+ } catch (e) {
+ setError(e instanceof ApiError ? e.message : "دریافت Tenant ناموفق بود");
+ setLoading(false);
+ return;
+ }
+ const [d, sub, plansPage, mem, usersRes] = await Promise.allSettled([
+ api.domains.listForTenant(tenantId),
+ api.subscriptions.get(tenantId),
+ api.plans.list(1, 100),
+ token ? identityApi.listMembers(token, tenantId) : Promise.resolve([]),
+ token ? identityApi.listUsers(token, { limit: 100 }) : Promise.resolve([]),
+ ]);
+ if (d.status === "fulfilled") setDomains(d.value);
+ setSubscription(sub.status === "fulfilled" ? sub.value : null);
+ if (plansPage.status === "fulfilled") setPlans(plansPage.value.items);
+ if (mem.status === "fulfilled") setMembers(mem.value);
+ if (usersRes.status === "fulfilled") setUsers(usersRes.value);
+ setLoading(false);
+ }, [tenantId]);
+
+ useEffect(() => {
+ void load();
+ }, [load]);
+
+ if (loading) {
+ return در حال بارگذاری...
;
+ }
+
+ if (!tenant) {
+ return (
+
+ {error || "Tenant یافت نشد"}
+
+ ← بازگشت به لیست
+
+
+ );
+ }
+
+ return (
+
+
+
+ ← بازگشت به لیست
+
+
+
}
+ />
+
+ {error &&
{error}}
+
+
+
+
+
+
+
+
+ );
+}
+
+function TenantEditCard({
+ tenant,
+ onUpdated,
+ onError,
+}: {
+ tenant: Tenant;
+ onUpdated: (t: Tenant) => void;
+ onError: (m: string) => void;
+}) {
+ const [name, setName] = useState(tenant.name);
+ const [status, setStatus] = useState(tenant.status);
+ const [ownerUserId, setOwnerUserId] = useState(tenant.owner_user_id || "");
+ const [saving, setSaving] = useState(false);
+ const [success, setSuccess] = useState("");
+
+ const handleSubmit = async (e: FormEvent) => {
+ e.preventDefault();
+ setSuccess("");
+ onError("");
+ setSaving(true);
+ try {
+ const updated = await api.platformTenants.update(tenant.id, {
+ name,
+ status,
+ owner_user_id: ownerUserId.trim() || null,
+ });
+ onUpdated(updated);
+ setSuccess("تغییرات ذخیره شد.");
+ } catch (err) {
+ onError(err instanceof ApiError ? err.message : "ذخیره ناموفق بود");
+ } finally {
+ setSaving(false);
+ }
+ };
+
+ return (
+
+ );
+}
+
+function DomainsCard({
+ tenantId,
+ domains,
+ onChanged,
+}: {
+ tenantId: string;
+ domains: TenantDomain[];
+ onChanged: () => void;
+}) {
+ const [domain, setDomain] = useState("");
+ const [type, setType] = useState<"subdomain" | "custom_domain">("custom_domain");
+ const [saving, setSaving] = useState(false);
+ const [error, setError] = useState("");
+
+ const handleAdd = async (e: FormEvent) => {
+ e.preventDefault();
+ setError("");
+ if (!domain.trim()) return;
+ setSaving(true);
+ try {
+ await api.domains.create(tenantId, { domain: domain.trim(), domain_type: type });
+ setDomain("");
+ onChanged();
+ } catch (err) {
+ setError(err instanceof ApiError ? err.message : "افزودن دامنه ناموفق بود");
+ } finally {
+ setSaving(false);
+ }
+ };
+
+ return (
+
+
دامنهها
+ {domains.length === 0 ? (
+
دامنهای ثبت نشده است.
+ ) : (
+
+ {domains.map((d) => (
+ -
+
+ {d.domain}
+
+
+ {d.domain_type}
+
+
+
+ ))}
+
+ )}
+
+ {error &&
{error}}
+
+
+
+ );
+}
+
+function SubscriptionCard({
+ tenantId,
+ subscription,
+ plans,
+ onChanged,
+}: {
+ tenantId: string;
+ subscription: Subscription | null;
+ plans: Plan[];
+ onChanged: () => void;
+}) {
+ const [planId, setPlanId] = useState(plans[0]?.id || "");
+ const [status, setStatus] = useState("active");
+ const [saving, setSaving] = useState(false);
+ const [error, setError] = useState("");
+
+ useEffect(() => {
+ if (!planId && plans[0]) setPlanId(plans[0].id);
+ }, [plans, planId]);
+
+ const handleSubmit = async (e: FormEvent) => {
+ e.preventDefault();
+ setError("");
+ if (!planId) {
+ setError("ابتدا یک پلن انتخاب کنید.");
+ return;
+ }
+ setSaving(true);
+ try {
+ await api.subscriptions.create(tenantId, {
+ plan_id: planId,
+ status: status as Subscription["status"],
+ });
+ onChanged();
+ } catch (err) {
+ setError(err instanceof ApiError ? err.message : "ثبت اشتراک ناموفق بود");
+ } finally {
+ setSaving(false);
+ }
+ };
+
+ const planName = subscription
+ ? plans.find((p) => p.id === subscription.plan_id)?.name || subscription.plan_id
+ : null;
+
+ return (
+
+
اشتراک
+ {subscription ? (
+
+
+ پلن
+ {planName}
+
+
+ وضعیت
+
+
+
+ ) : (
+
اشتراک فعالی ثبت نشده است.
+ )}
+
+ {error &&
{error}}
+
+
+
+ );
+}
+
+function MembersCard({
+ tenantId,
+ members,
+ users,
+ onChanged,
+}: {
+ tenantId: string;
+ members: TenantMember[];
+ users: AdminUser[];
+ onChanged: () => void;
+}) {
+ const [userId, setUserId] = useState("");
+ const [role, setRole] = useState<"tenant_admin" | "tenant_member">("tenant_member");
+ const [saving, setSaving] = useState(false);
+ const [error, setError] = useState("");
+
+ const userLabel = (id: string) => {
+ const u = users.find((x) => x.id === id);
+ return u ? u.display_name || u.username || u.mobile || u.email : id;
+ };
+
+ const handleAdd = async (e: FormEvent) => {
+ e.preventDefault();
+ setError("");
+ const token = getStoredToken();
+ if (!token) {
+ setError("توکن یافت نشد.");
+ return;
+ }
+ if (!userId) {
+ setError("یک کاربر انتخاب کنید.");
+ return;
+ }
+ setSaving(true);
+ try {
+ await identityApi.addMember(token, tenantId, { user_id: userId, role });
+ setUserId("");
+ onChanged();
+ } catch (err) {
+ setError(err instanceof Error ? err.message : "افزودن عضو ناموفق بود");
+ } finally {
+ setSaving(false);
+ }
+ };
+
+ return (
+
+
اعضا
+ {members.length === 0 ? (
+
عضوی ثبت نشده است.
+ ) : (
+
+ {members.map((m) => (
+ -
+ {userLabel(m.user_id)}
+
+
+ {m.role === "tenant_admin" ? "مدیر" : "عضو"}
+
+
+
+
+ ))}
+
+ )}
+
+ {error &&
{error}}
+
+
+
+ );
+}
diff --git a/frontend/app/admin/tenants/new/page.tsx b/frontend/app/admin/tenants/new/page.tsx
new file mode 100644
index 0000000..aa554e1
--- /dev/null
+++ b/frontend/app/admin/tenants/new/page.tsx
@@ -0,0 +1,113 @@
+"use client";
+
+import { FormEvent, useState } from "react";
+import Link from "next/link";
+import { useRouter } from "next/navigation";
+import { api, ApiError } from "@/lib/api";
+import { AdminPageHeader, Banner, Button, TextField, cardClass } from "@/components/admin/controls";
+
+function slugify(value: string): string {
+ return value
+ .trim()
+ .toLowerCase()
+ .replace(/\s+/g, "-")
+ .replace(/[^a-z0-9-]/g, "")
+ .replace(/-+/g, "-")
+ .replace(/^-|-$/g, "");
+}
+
+export default function NewTenantPage() {
+ const router = useRouter();
+ const [name, setName] = useState("");
+ const [slug, setSlug] = useState("");
+ const [ownerUserId, setOwnerUserId] = useState("");
+ const [customDomain, setCustomDomain] = useState("");
+ const [slugTouched, setSlugTouched] = useState(false);
+ const [loading, setLoading] = useState(false);
+ const [error, setError] = useState("");
+
+ const handleNameChange = (value: string) => {
+ setName(value);
+ if (!slugTouched) setSlug(slugify(value));
+ };
+
+ const handleSubmit = async (e: FormEvent) => {
+ e.preventDefault();
+ setError("");
+ setLoading(true);
+ let keepLoading = false;
+ try {
+ await api.platformTenants.create({
+ name,
+ slug,
+ owner_user_id: ownerUserId.trim() || null,
+ custom_domain: customDomain.trim() || null,
+ });
+ keepLoading = true;
+ router.push("/admin/tenants");
+ } catch (err) {
+ setError(err instanceof ApiError ? err.message : "ایجاد Tenant ناموفق بود");
+ } finally {
+ if (!keepLoading) setLoading(false);
+ }
+ };
+
+ return (
+
+
+
+ ← بازگشت به لیست
+
+
+
+
+ {error &&
{error}}
+
+
+
+ );
+}
diff --git a/frontend/app/admin/tenants/page.tsx b/frontend/app/admin/tenants/page.tsx
new file mode 100644
index 0000000..3ef3d18
--- /dev/null
+++ b/frontend/app/admin/tenants/page.tsx
@@ -0,0 +1,124 @@
+"use client";
+
+import { useCallback, useEffect, useState } from "react";
+import Link from "next/link";
+import { api, ApiError, type Tenant } from "@/lib/api";
+import {
+ AdminPageHeader,
+ Banner,
+ Button,
+ EmptyState,
+ StatusBadge,
+} from "@/components/admin/controls";
+
+export default function AdminTenantsPage() {
+ const [tenants, setTenants] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState("");
+ const [busyId, setBusyId] = useState(null);
+
+ const load = useCallback(async () => {
+ setLoading(true);
+ setError("");
+ try {
+ const page = await api.platformTenants.list(1, 100);
+ setTenants(page.items);
+ } catch (e) {
+ setError(e instanceof ApiError ? e.message : "خطا در بارگذاری Tenantها");
+ } finally {
+ setLoading(false);
+ }
+ }, []);
+
+ useEffect(() => {
+ void load();
+ }, [load]);
+
+ const toggleStatus = async (t: Tenant) => {
+ setBusyId(t.id);
+ setError("");
+ try {
+ if (t.status === "suspended") {
+ await api.platformTenants.activate(t.id);
+ } else {
+ await api.platformTenants.suspend(t.id);
+ }
+ await load();
+ } catch (e) {
+ setError(e instanceof ApiError ? e.message : "تغییر وضعیت ناموفق بود");
+ } finally {
+ setBusyId(null);
+ }
+ };
+
+ return (
+
+
+
+
+ }
+ />
+
+ {error && {error}}
+
+
+ {loading ? (
+
+ ) : tenants.length === 0 ? (
+
+ ) : (
+
+
+
+
+ | نام |
+ Slug |
+ وضعیت |
+ Onboarding |
+ عملیات |
+
+
+
+ {tenants.map((t) => (
+
+ | {t.name} |
+
+ {t.slug}
+ |
+
+
+ |
+
+ {t.onboarding_completed ? "تکمیلشده" : "ناتمام"}
+ |
+
+
+
+
+
+
+
+ |
+
+ ))}
+
+
+
+ )}
+
+
+ );
+}
diff --git a/frontend/app/admin/users/page.tsx b/frontend/app/admin/users/page.tsx
new file mode 100644
index 0000000..2c28baa
--- /dev/null
+++ b/frontend/app/admin/users/page.tsx
@@ -0,0 +1,223 @@
+"use client";
+
+import { FormEvent, useCallback, useEffect, useState } from "react";
+import { getStoredToken } from "@/lib/auth";
+import { identityApi, type AdminUser } from "@/lib/identity";
+import { normalizePhone } from "@/lib/phone";
+import {
+ AdminPageHeader,
+ Banner,
+ Button,
+ EmptyState,
+ Modal,
+ StatusBadge,
+ TextField,
+} from "@/components/admin/controls";
+
+export default function AdminUsersPage() {
+ const [users, setUsers] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState("");
+ const [modalOpen, setModalOpen] = useState(false);
+
+ const load = useCallback(async () => {
+ const token = getStoredToken();
+ if (!token) {
+ setError("توکن یافت نشد.");
+ setLoading(false);
+ return;
+ }
+ setLoading(true);
+ setError("");
+ try {
+ const rows = await identityApi.listUsers(token, { limit: 100 });
+ setUsers(rows);
+ } catch (e) {
+ setError(e instanceof Error ? e.message : "خطا در بارگذاری کاربران");
+ } finally {
+ setLoading(false);
+ }
+ }, []);
+
+ useEffect(() => {
+ void load();
+ }, [load]);
+
+ return (
+
+
setModalOpen(true)}>کاربر جدید}
+ />
+
+ {error && {error}}
+
+
+ {loading ? (
+
+ ) : users.length === 0 ? (
+
+ ) : (
+
+
+
+
+ | نام |
+ نام کاربری |
+ موبایل |
+ ایمیل |
+ وضعیت |
+
+
+
+ {users.map((u) => (
+
+ |
+ {u.display_name || u.username || "بدون نام"}
+ |
+
+ {u.username || "—"}
+ |
+
+ {u.mobile || "—"}
+ |
+
+ {u.email}
+ |
+
+
+
+
+
+ |
+
+ ))}
+
+
+
+ )}
+
+
+ setModalOpen(false)}
+ onCreated={() => {
+ setModalOpen(false);
+ void load();
+ }}
+ />
+
+ );
+}
+
+function CreateUserModal({
+ open,
+ onClose,
+ onCreated,
+}: {
+ open: boolean;
+ onClose: () => void;
+ onCreated: () => void;
+}) {
+ const [displayName, setDisplayName] = useState("");
+ const [username, setUsername] = useState("");
+ const [email, setEmail] = useState("");
+ const [mobile, setMobile] = useState("");
+ const [password, setPassword] = useState("");
+ const [saving, setSaving] = useState(false);
+ const [error, setError] = useState("");
+
+ const handleSubmit = async (e: FormEvent) => {
+ e.preventDefault();
+ setError("");
+ const token = getStoredToken();
+ if (!token) {
+ setError("توکن یافت نشد.");
+ return;
+ }
+ if (password.length < 8) {
+ setError("رمز عبور باید حداقل ۸ کاراکتر باشد.");
+ return;
+ }
+ setSaving(true);
+ try {
+ await identityApi.createUser(token, {
+ email: email.trim(),
+ username: username.trim(),
+ password,
+ mobile: normalizePhone(mobile),
+ display_name: displayName.trim() || undefined,
+ });
+ setDisplayName("");
+ setUsername("");
+ setEmail("");
+ setMobile("");
+ setPassword("");
+ onCreated();
+ } catch (err) {
+ setError(err instanceof Error ? err.message : "ایجاد کاربر ناموفق بود");
+ } finally {
+ setSaving(false);
+ }
+ };
+
+ return (
+
+
+
+ );
+}
diff --git a/frontend/app/auth/callback/page.tsx b/frontend/app/auth/callback/page.tsx
new file mode 100644
index 0000000..075e1b7
--- /dev/null
+++ b/frontend/app/auth/callback/page.tsx
@@ -0,0 +1,68 @@
+"use client";
+
+import {
+ exchangeCode,
+ consumePostLoginRedirect,
+ fetchAuthConfig,
+ fetchMe,
+ redeemHandoff,
+ storeTokens,
+} from "@/lib/auth";
+import { useRouter, useSearchParams } from "next/navigation";
+import { Suspense, useEffect, useRef, useState } from "react";
+
+function CallbackInner() {
+ const params = useSearchParams();
+ const router = useRouter();
+ const [error, setError] = useState(null);
+ const exchangeStarted = useRef(false);
+
+ useEffect(() => {
+ if (exchangeStarted.current) return;
+
+ const handoff = params.get("handoff");
+ const code = params.get("code");
+
+ if (!handoff && !code) {
+ setError("کد احراز هویت دریافت نشد");
+ return;
+ }
+
+ exchangeStarted.current = true;
+ (async () => {
+ try {
+ let tokens;
+ if (handoff) {
+ tokens = await redeemHandoff(handoff);
+ } else {
+ const config = await fetchAuthConfig();
+ tokens = await exchangeCode(code!, config.callback_url, params.get("state"));
+ }
+ storeTokens(tokens);
+ const me = await fetchMe(tokens.access_token);
+ if (me.requires_mobile_verification) {
+ router.replace("/auth/verify-mobile");
+ return;
+ }
+ router.replace(consumePostLoginRedirect("/dashboard"));
+ } catch (e) {
+ exchangeStarted.current = false;
+ setError(e instanceof Error ? e.message : "خطای ناشناخته");
+ }
+ })();
+ }, [params, router]);
+
+ if (error) {
+ return {error}
;
+ }
+
+ return در حال تکمیل ورود...
;
+}
+
+export default function AuthCallbackPage() {
+ return (
+ ...
}>
+
+
+ );
+}
diff --git a/frontend/app/auth/verify-mobile/page.tsx b/frontend/app/auth/verify-mobile/page.tsx
new file mode 100644
index 0000000..2aa8bc6
--- /dev/null
+++ b/frontend/app/auth/verify-mobile/page.tsx
@@ -0,0 +1,134 @@
+"use client";
+
+import { useCallback, useEffect, useRef, useState } from "react";
+import { useRouter } from "next/navigation";
+import { Button, Container } from "@/components/ui";
+import { getStoredToken, loginWithRedirect } from "@/lib/auth";
+import { identityApi } from "@/lib/identity";
+import {
+ isValidMobile,
+ normalizeOtpCode,
+ normalizePhone,
+ persianToEnglish,
+} from "@/lib/phone";
+
+export default function VerifyMobilePage() {
+ const router = useRouter();
+ const [mobile, setMobile] = useState("");
+ const [code, setCode] = useState("");
+ const [countdown, setCountdown] = useState(0);
+ const [step, setStep] = useState<"mobile" | "code">("mobile");
+ const [loading, setLoading] = useState(false);
+ const [error, setError] = useState("");
+ const verifyingRef = useRef(false);
+
+ useEffect(() => {
+ if (!getStoredToken()) {
+ void loginWithRedirect();
+ }
+ }, []);
+
+ useEffect(() => {
+ if (countdown <= 0) return;
+ const timer = setTimeout(() => setCountdown((c) => c - 1), 1000);
+ return () => clearTimeout(timer);
+ }, [countdown]);
+
+ const handleRequestOtp = async () => {
+ const token = getStoredToken();
+ if (!token) return;
+ setError("");
+ const normalized = normalizePhone(mobile);
+ if (!isValidMobile(normalized)) {
+ setError("شماره موبایل صحیح نیست");
+ return;
+ }
+ setMobile(normalized);
+ setLoading(true);
+ try {
+ const res = await identityApi.requestMobileVerify(normalized, token);
+ setCountdown(res.expires_in);
+ setStep("code");
+ } catch (e) {
+ setError(e instanceof Error ? e.message : "ارسال کد ناموفق بود");
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ const handleVerify = useCallback(async () => {
+ const token = getStoredToken();
+ if (!token || verifyingRef.current) return;
+ const normalizedCode = normalizeOtpCode(code);
+ if (normalizedCode.length !== 4) {
+ setError("کد تأیید باید ۴ رقم باشد");
+ return;
+ }
+ verifyingRef.current = true;
+ setLoading(true);
+ let keepLoading = false;
+ try {
+ await identityApi.verifyMobile(mobile, normalizedCode, token);
+ keepLoading = true;
+ router.replace("/dashboard");
+ } catch (e) {
+ setError(e instanceof Error ? e.message : "تأیید ناموفق بود");
+ } finally {
+ if (!keepLoading) {
+ setLoading(false);
+ verifyingRef.current = false;
+ }
+ }
+ }, [code, mobile, router]);
+
+ return (
+
+
+
+ );
+}
diff --git a/frontend/app/dashboard/page.tsx b/frontend/app/dashboard/page.tsx
new file mode 100644
index 0000000..39d32ec
--- /dev/null
+++ b/frontend/app/dashboard/page.tsx
@@ -0,0 +1,206 @@
+"use client";
+
+import { useCallback, useEffect, useState } from "react";
+import { useRouter } from "next/navigation";
+import Link from "next/link";
+import { AuthGuard } from "@/components/AuthGuard";
+import { TenantSwitcher } from "@/components/TenantSwitcher";
+import { useAuth } from "@/hooks/useAuth";
+import { useMe } from "@/hooks/useMe";
+import { api, ApiError, type TenantContext } from "@/lib/api";
+import { Badge, Button, Container, SectionTitle } from "@/components/ui";
+
+const STATUS_LABELS: Record = {
+ draft: "پیشنویس",
+ pending_activation: "در انتظار فعالسازی",
+ active: "فعال",
+ suspended: "معلق",
+ archived: "بایگانیشده",
+};
+
+const ROLE_LABELS: Record = {
+ platform_admin: "مدیر پلتفرم",
+ tenant_owner: "مالک workspace",
+ tenant_admin: "مدیر workspace",
+ tenant_editor: "ویرایشگر",
+ tenant_viewer: "بیننده",
+};
+
+const QUICK_LINKS = [
+ { title: "کاربران و دسترسیها", desc: "مدیریت اعضای workspace (بهزودی)" },
+ { title: "برندینگ و ظاهر", desc: "شخصیسازی رنگ و لوگو (بهزودی)" },
+ { title: "دامنهها", desc: "مدیریت زیردامنه و دامنه اختصاصی (بهزودی)" },
+ { title: "اشتراک و پلن", desc: "مشاهده و ارتقاء پلن (بهزودی)" },
+];
+
+function DashboardContent() {
+ const router = useRouter();
+ const { user, signOut } = useAuth();
+ const { me, loading: meLoading, error: meError, reload: reloadMe } = useMe();
+
+ const [ctx, setCtx] = useState(null);
+ const [loadingCtx, setLoadingCtx] = useState(true);
+ const [ctxError, setCtxError] = useState("");
+
+ const loadContext = useCallback(async () => {
+ setLoadingCtx(true);
+ setCtxError("");
+ try {
+ const data = await api.tenantContext.current();
+ setCtx(data);
+ } catch (err) {
+ setCtxError(
+ err instanceof ApiError ? err.message : "دریافت اطلاعات workspace ناموفق بود"
+ );
+ setCtx(null);
+ } finally {
+ setLoadingCtx(false);
+ }
+ }, []);
+
+ useEffect(() => {
+ if (meLoading) return;
+ if (!me) return;
+ if (me.onboarding_required) {
+ router.replace("/onboarding");
+ return;
+ }
+ void loadContext();
+ }, [me, meLoading, router, loadContext]);
+
+ const handleSwitched = async () => {
+ await reloadMe();
+ await loadContext();
+ };
+
+ if (meLoading || (me && me.onboarding_required)) {
+ return (
+
+ در حال بارگذاری...
+
+ );
+ }
+
+ return (
+
+
+
+
داشبورد workspace
+
+ خوش آمدید {user?.username || user?.email || "کاربر"}
+
+
+
+ {me && (
+
+ )}
+
+
+
+
+
+
+
+ {(meError || ctxError) && (
+
+ {meError || ctxError}
+
+ )}
+
+ {loadingCtx ? (
+ در حال بارگذاری workspace...
+ ) : ctx ? (
+
+
+
+
+
{ctx.tenant.name}
+
+ {ctx.primary_domain || ctx.tenant.slug}
+
+
+
+ {STATUS_LABELS[ctx.tenant.status] || ctx.tenant.status}
+
+
+
+
+
+
- پلن فعلی
+ -
+ {ctx.plan_name || "—"}
+
+
+
+
- وضعیت اشتراک
+ -
+ {ctx.subscription_status || "—"}
+
+
+
+
- نقش شما
+ -
+ {(ctx.role && ROLE_LABELS[ctx.role]) || ctx.role || "—"}
+
+
+
+
- وضعیت onboarding
+ -
+ {ctx.tenant.onboarding_completed ? "تکمیلشده" : "ناتمام"}
+
+
+
+
+ {ctx.domains.length > 0 && (
+
+
دامنهها
+
+ {ctx.domains.map((d) => (
+
+
+ {d.domain}
+
+
+ ))}
+
+
+ )}
+
+
+
+
+
+ {QUICK_LINKS.map((link) => (
+
+
{link.title}
+
{link.desc}
+
+ ))}
+
+
+
+ ) : (
+ !ctxError && (
+ اطلاعات workspace در دسترس نیست.
+ )
+ )}
+
+ );
+}
+
+export default function DashboardPage() {
+ return (
+
+
+
+ );
+}
diff --git a/frontend/app/dashboard/settings/page.tsx b/frontend/app/dashboard/settings/page.tsx
new file mode 100644
index 0000000..c6bf59b
--- /dev/null
+++ b/frontend/app/dashboard/settings/page.tsx
@@ -0,0 +1,12 @@
+"use client";
+
+import { AuthGuard } from "@/components/AuthGuard";
+import { AccountSettings } from "@/components/settings/AccountSettings";
+
+export default function DashboardSettingsPage() {
+ return (
+
+
+
+ );
+}
diff --git a/frontend/app/layout.tsx b/frontend/app/layout.tsx
new file mode 100644
index 0000000..fc26c20
--- /dev/null
+++ b/frontend/app/layout.tsx
@@ -0,0 +1,26 @@
+import type { Metadata } from "next";
+import "@/styles/globals.css";
+import { SiteHeader } from "@/components/SiteHeader";
+import { ThemeProvider } from "@/components/ThemeProvider";
+
+export const metadata: Metadata = {
+ title: "Torbatyar — SuperApp Platform",
+ description: "پلتفرم SaaS چندسرویسی با SSO یکپارچه",
+};
+
+export default function RootLayout({
+ children,
+}: {
+ children: React.ReactNode;
+}) {
+ return (
+
+
+
+
+ {children}
+
+
+
+ );
+}
diff --git a/frontend/app/login/page.tsx b/frontend/app/login/page.tsx
new file mode 100644
index 0000000..5b893cc
--- /dev/null
+++ b/frontend/app/login/page.tsx
@@ -0,0 +1,31 @@
+"use client";
+
+import { Suspense, useEffect } from "react";
+import { useSearchParams } from "next/navigation";
+import { AuthPanel } from "@/components/auth/AuthPanel";
+import { setPostLoginRedirect } from "@/lib/auth";
+
+function LoginInner() {
+ const params = useSearchParams();
+ const redirectParam = params.get("redirect");
+
+ useEffect(() => {
+ if (redirectParam) setPostLoginRedirect(redirectParam);
+ }, [redirectParam]);
+
+ return ;
+}
+
+export default function LoginPage() {
+ return (
+
+ در حال بارگذاری...
+
+ }
+ >
+
+
+ );
+}
diff --git a/frontend/app/onboarding/page.tsx b/frontend/app/onboarding/page.tsx
new file mode 100644
index 0000000..6568455
--- /dev/null
+++ b/frontend/app/onboarding/page.tsx
@@ -0,0 +1,438 @@
+"use client";
+
+import { FormEvent, useEffect, useState } from "react";
+import { useRouter } from "next/navigation";
+import { AuthGuard } from "@/components/AuthGuard";
+import { Button, Container } from "@/components/ui";
+import { api, ApiError, type TenantContext } from "@/lib/api";
+import { useMe } from "@/hooks/useMe";
+
+type Step = "business" | "branding" | "domain" | "review";
+
+const STEPS: { id: Step; label: string }[] = [
+ { id: "business", label: "اطلاعات کسبوکار" },
+ { id: "branding", label: "برندینگ" },
+ { id: "domain", label: "دامنه" },
+ { id: "review", label: "بازبینی و تکمیل" },
+];
+
+function slugify(value: string): string {
+ return value
+ .trim()
+ .toLowerCase()
+ .replace(/\s+/g, "-")
+ .replace(/[^a-z0-9-]/g, "")
+ .replace(/-+/g, "-")
+ .replace(/^-|-$/g, "");
+}
+
+const inputClass =
+ "mt-1 w-full rounded-xl border border-gray-200 px-4 py-2.5 focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary disabled:bg-gray-50";
+
+function StepIndicator({ current }: { current: Step }) {
+ const currentIndex = STEPS.findIndex((s) => s.id === current);
+ return (
+
+ {STEPS.map((step, index) => {
+ const isActive = step.id === current;
+ const isDone = index < currentIndex;
+ return (
+ -
+
+ {index + 1}
+
+
+ {step.label}
+
+ {index < STEPS.length - 1 && /}
+
+ );
+ })}
+
+ );
+}
+
+function OnboardingWizard() {
+ const router = useRouter();
+ const { me, loading: meLoading } = useMe();
+
+ const [step, setStep] = useState("business");
+ const [ctx, setCtx] = useState(null);
+ const [bootstrapping, setBootstrapping] = useState(true);
+ const [error, setError] = useState("");
+ const [loading, setLoading] = useState(false);
+
+ // فرم گام ۱
+ const [businessName, setBusinessName] = useState("");
+ const [slug, setSlug] = useState("");
+ const [slugTouched, setSlugTouched] = useState(false);
+ const [businessType, setBusinessType] = useState("");
+
+ // فرم گام ۲
+ const [primaryColor, setPrimaryColor] = useState("#0284c7");
+ const [secondaryColor, setSecondaryColor] = useState("#0f172a");
+ const [logoUrl, setLogoUrl] = useState("");
+
+ // فرم گام ۳
+ const [customDomain, setCustomDomain] = useState("");
+
+ // اگر کاربر tenant ناتمام داشته باشد، از همانجا ادامه بده؛ اگر onboarding کامل است برو به داشبورد.
+ useEffect(() => {
+ if (meLoading) return;
+ if (!me) {
+ setBootstrapping(false);
+ return;
+ }
+ if (!me.onboarding_required) {
+ router.replace("/dashboard");
+ return;
+ }
+ if (me.current_tenant_id) {
+ api.tenantContext
+ .current()
+ .then((tenantCtx) => {
+ setCtx(tenantCtx);
+ setStep("branding");
+ })
+ .catch(() => {
+ /* اگر دریافت نشد، از ابتدا شروع کن */
+ })
+ .finally(() => setBootstrapping(false));
+ } else {
+ setBootstrapping(false);
+ }
+ }, [me, meLoading, router]);
+
+ const handleBusinessNameChange = (value: string) => {
+ setBusinessName(value);
+ if (!slugTouched) setSlug(slugify(value));
+ };
+
+ const handleCreateTenant = async (e: FormEvent) => {
+ e.preventDefault();
+ setError("");
+ setLoading(true);
+ try {
+ const result = await api.onboarding.createTenant({
+ business_name: businessName,
+ slug,
+ business_type: businessType.trim() || undefined,
+ });
+ setCtx(result);
+ setStep("branding");
+ } catch (err) {
+ setError(
+ err instanceof ApiError ? err.message : "ایجاد workspace ناموفق بود"
+ );
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ const handleBranding = async (e: FormEvent) => {
+ e.preventDefault();
+ if (!ctx) return;
+ setError("");
+ setLoading(true);
+ try {
+ const result = await api.onboarding.updateBranding(ctx.tenant.id, {
+ primary_color: primaryColor || undefined,
+ secondary_color: secondaryColor || undefined,
+ logo_url: logoUrl.trim() || undefined,
+ });
+ setCtx(result);
+ setStep("domain");
+ } catch (err) {
+ setError(err instanceof ApiError ? err.message : "ذخیره برندینگ ناموفق بود");
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ const handleDomain = async (e: FormEvent) => {
+ e.preventDefault();
+ if (!ctx) return;
+ setError("");
+ setLoading(true);
+ try {
+ let result = ctx;
+ if (customDomain.trim()) {
+ result = await api.onboarding.updateDomain(ctx.tenant.id, {
+ custom_domain: customDomain.trim(),
+ });
+ setCtx(result);
+ }
+ setStep("review");
+ } catch (err) {
+ setError(err instanceof ApiError ? err.message : "ثبت دامنه ناموفق بود");
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ const handleComplete = async () => {
+ if (!ctx) return;
+ setError("");
+ setLoading(true);
+ try {
+ await api.onboarding.complete(ctx.tenant.id);
+ router.push("/dashboard");
+ } catch (err) {
+ setError(err instanceof ApiError ? err.message : "تکمیل onboarding ناموفق بود");
+ setLoading(false);
+ }
+ };
+
+ if (meLoading || bootstrapping) {
+ return (
+
+ در حال بارگذاری...
+
+ );
+ }
+
+ return (
+
+
+
راهاندازی workspace
+
+ چند مرحله ساده تا آمادهشدن workspace شما باقی مانده است.
+
+
+
+
+
+
+ {error && (
+
+ {error}
+
+ )}
+
+
+ {step === "business" && (
+
+ )}
+
+ {step === "branding" && ctx && (
+
+ )}
+
+ {step === "domain" && ctx && (
+
+ )}
+
+ {step === "review" && ctx && (
+
+
+
+
+
- نام کسبوکار
+ - {ctx.tenant.name}
+
+
+
- آدرس
+ -
+ {ctx.tenant.slug}
+
+
+
+
- زیردامنه
+ -
+ {ctx.primary_domain || "—"}
+
+
+
+
- پلن
+ -
+ {ctx.plan_name || "—"}
+
+
+
+
- رنگ اصلی
+ -
+
+
+ {ctx.tenant.primary_color || "—"}
+
+
+
+
+
+
+
+
+ )}
+
+
+
+ );
+}
+
+export default function OnboardingPage() {
+ return (
+
+
+
+ );
+}
diff --git a/frontend/app/page.tsx b/frontend/app/page.tsx
new file mode 100644
index 0000000..2b30bb0
--- /dev/null
+++ b/frontend/app/page.tsx
@@ -0,0 +1,62 @@
+"use client";
+
+import Link from "next/link";
+import { AppStoreGrid } from "@/components/AppStoreGrid";
+import { Badge, Button, Container } from "@/components/ui";
+import { useAuth } from "@/hooks/useAuth";
+import { useTheme } from "@/hooks/useTheme";
+
+export default function HomePage() {
+ const { theme } = useTheme();
+ const { isAuthenticated, login, loading } = useAuth();
+
+ return (
+
+ {/* Hero */}
+
+
+
+
+ نسل جدید SuperApp
+
+
+ {theme?.site_name ?? "Torbatyar"}
+
+
+ یک پلتفرم، دهها سرویس — حسابداری، CRM، فروشگاه، پیامک و بیشتر.
+ همه با یک ورود واحد (SSO).
+
+
+ {isAuthenticated ? (
+
+
+
+ ) : (
+
+ )}
+
+
+
+
+
+
+
+ {/* App Store Grid */}
+
+
+
+
+ {/* Footer strip */}
+
+
+ );
+}
diff --git a/frontend/app/register/page.tsx b/frontend/app/register/page.tsx
new file mode 100644
index 0000000..66b400b
--- /dev/null
+++ b/frontend/app/register/page.tsx
@@ -0,0 +1,31 @@
+"use client";
+
+import { Suspense, useEffect } from "react";
+import { useSearchParams } from "next/navigation";
+import { AuthPanel } from "@/components/auth/AuthPanel";
+import { setPostLoginRedirect } from "@/lib/auth";
+
+function RegisterInner() {
+ const params = useSearchParams();
+ const redirectParam = params.get("redirect");
+
+ useEffect(() => {
+ if (redirectParam) setPostLoginRedirect(redirectParam);
+ }, [redirectParam]);
+
+ return ;
+}
+
+export default function RegisterPage() {
+ return (
+
+ در حال بارگذاری...
+
+ }
+ >
+
+
+ );
+}
diff --git a/frontend/components/AdminAuthGuard.tsx b/frontend/components/AdminAuthGuard.tsx
new file mode 100644
index 0000000..3436988
--- /dev/null
+++ b/frontend/components/AdminAuthGuard.tsx
@@ -0,0 +1,41 @@
+"use client";
+
+import { useAuth } from "@/hooks/useAuth";
+import { getStoredToken, loginWithRedirect } from "@/lib/auth";
+import { useEffect } from "react";
+
+const ADMIN_ROLES = ["platform_admin", "tenant_admin"];
+
+/** محافظ پنل ادمین — SSO مرکزی + نقش platform_admin یا tenant_admin. */
+export function AdminAuthGuard({ children }: { children: React.ReactNode }) {
+ const { loading, user } = useAuth();
+ const hasAdminRole = ADMIN_ROLES.some((r) => user?.roles?.includes(r));
+
+ useEffect(() => {
+ if (loading) return;
+ if (!getStoredToken()) {
+ void loginWithRedirect("/admin/tenants");
+ }
+ }, [loading]);
+
+ if (loading) {
+ return در حال بررسی ورود...
;
+ }
+
+ if (!getStoredToken()) {
+ return در حال هدایت به ورود مرکزی...
;
+ }
+
+ if (!hasAdminRole) {
+ return (
+
+
دسترسی به پنل ادمین ندارید
+
+ فقط مدیر پلتفرم یا مدیر tenant میتوانند وارد این بخش شوند.
+
+
+ );
+ }
+
+ return <>{children}>;
+}
diff --git a/frontend/components/AppStoreGrid.tsx b/frontend/components/AppStoreGrid.tsx
new file mode 100644
index 0000000..28f4b0e
--- /dev/null
+++ b/frontend/components/AppStoreGrid.tsx
@@ -0,0 +1,20 @@
+"use client";
+
+import { PLATFORM_APPS } from "@/lib/apps-catalog";
+import { AppTile, SectionTitle } from "@/components/ui";
+
+export function AppStoreGrid() {
+ return (
+
+
+
+ {PLATFORM_APPS.map((app) => (
+
+ ))}
+
+
+ );
+}
diff --git a/frontend/components/AuthGuard.tsx b/frontend/components/AuthGuard.tsx
new file mode 100644
index 0000000..a0e2b92
--- /dev/null
+++ b/frontend/components/AuthGuard.tsx
@@ -0,0 +1,26 @@
+"use client";
+
+import { useAuth } from "@/hooks/useAuth";
+import { useRouter } from "next/navigation";
+import { useEffect } from "react";
+
+export function AuthGuard({ children }: { children: React.ReactNode }) {
+ const { isAuthenticated, loading, login } = useAuth();
+ const router = useRouter();
+
+ useEffect(() => {
+ if (!loading && !isAuthenticated) {
+ login();
+ }
+ }, [loading, isAuthenticated, login]);
+
+ if (loading) {
+ return در حال بررسی احراز هویت...
;
+ }
+
+ if (!isAuthenticated) {
+ return در حال هدایت به صفحه ورود...
;
+ }
+
+ return <>{children}>;
+}
diff --git a/frontend/components/HealthStatus.tsx b/frontend/components/HealthStatus.tsx
new file mode 100644
index 0000000..1ca5196
--- /dev/null
+++ b/frontend/components/HealthStatus.tsx
@@ -0,0 +1,21 @@
+"use client";
+
+import { useTheme } from "@/hooks/useTheme";
+
+/** نمایش وضعیت سلامت backend از طریق API Client. */
+export function HealthStatus() {
+ const { theme, loading } = useTheme();
+
+ if (loading) return در حال بارگذاری تم...
;
+
+ return (
+
+
+ {theme?.site_name ?? "SuperApp"}
+
+
+ Frontend جدا از Backend — ارتباط فقط از طریق REST API
+
+
+ );
+}
diff --git a/frontend/components/SiteHeader.tsx b/frontend/components/SiteHeader.tsx
new file mode 100644
index 0000000..67dc3d8
--- /dev/null
+++ b/frontend/components/SiteHeader.tsx
@@ -0,0 +1,69 @@
+"use client";
+
+import Link from "next/link";
+import { useAuth } from "@/hooks/useAuth";
+import { useHeaderSession } from "@/hooks/useHeaderSession";
+import { useTheme } from "@/hooks/useTheme";
+import { Button, Container } from "@/components/ui";
+
+export function SiteHeader() {
+ const { theme } = useTheme();
+ const { login, loading, isAuthenticated, user } = useAuth();
+ const { isSsoAuth, isLoggedIn } = useHeaderSession();
+ const isPlatformAdmin =
+ user?.roles?.includes("platform_admin") || user?.roles?.includes("tenant_admin");
+
+ return (
+
+ );
+}
diff --git a/frontend/components/TenantSwitcher.tsx b/frontend/components/TenantSwitcher.tsx
new file mode 100644
index 0000000..f3a7946
--- /dev/null
+++ b/frontend/components/TenantSwitcher.tsx
@@ -0,0 +1,57 @@
+"use client";
+
+import { useState } from "react";
+import { api, ApiError, type MembershipSummary } from "@/lib/api";
+
+export interface TenantSwitcherProps {
+ memberships: MembershipSummary[];
+ currentTenantId: string | null;
+ onSwitched: () => void;
+}
+
+/** انتخابگر ساده tenant جاری — فقط زمانی نمایش داده میشود که کاربر بیش از یک workspace داشته باشد. */
+export function TenantSwitcher({
+ memberships,
+ currentTenantId,
+ onSwitched,
+}: TenantSwitcherProps) {
+ const [loading, setLoading] = useState(false);
+ const [error, setError] = useState("");
+
+ if (memberships.length <= 1) return null;
+
+ const handleChange = async (tenantId: string) => {
+ if (tenantId === currentTenantId) return;
+ setError("");
+ setLoading(true);
+ try {
+ await api.tenantContext.switchTenant(tenantId);
+ onSwitched();
+ } catch (err) {
+ setError(err instanceof ApiError ? err.message : "تغییر workspace ناموفق بود");
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ return (
+
+
+ {error &&
{error}
}
+
+ );
+}
diff --git a/frontend/components/ThemeProvider.tsx b/frontend/components/ThemeProvider.tsx
new file mode 100644
index 0000000..4363b2f
--- /dev/null
+++ b/frontend/components/ThemeProvider.tsx
@@ -0,0 +1,18 @@
+"use client";
+
+import type { ReactNode } from "react";
+import { useTheme } from "@/hooks/useTheme";
+
+export function ThemeProvider({ children }: { children: ReactNode }) {
+ const { loading } = useTheme();
+
+ if (loading) {
+ return (
+
+ );
+ }
+
+ return <>{children}>;
+}
diff --git a/frontend/components/admin/AdminShell.tsx b/frontend/components/admin/AdminShell.tsx
new file mode 100644
index 0000000..f00844d
--- /dev/null
+++ b/frontend/components/admin/AdminShell.tsx
@@ -0,0 +1,89 @@
+"use client";
+
+import { ReactNode } from "react";
+import Link from "next/link";
+import { usePathname } from "next/navigation";
+import { clearTokens, loginWithRedirect } from "@/lib/auth";
+
+const NAV_ITEMS: { href: string; label: string; icon: string }[] = [
+ { href: "/admin", label: "نمای کلی", icon: "▦" },
+ { href: "/admin/tenants", label: "Tenantها", icon: "🏢" },
+ { href: "/admin/users", label: "کاربران", icon: "👤" },
+ { href: "/admin/plans", label: "پلنها", icon: "📦" },
+ { href: "/admin/features", label: "قابلیتها", icon: "🧩" },
+ { href: "/admin/services", label: "سرویسها", icon: "🛠" },
+ { href: "/admin/settings", label: "حساب کاربری", icon: "⚙" },
+];
+
+function isActive(pathname: string, href: string): boolean {
+ if (href === "/admin") return pathname === "/admin";
+ return pathname === href || pathname.startsWith(`${href}/`);
+}
+
+export function AdminShell({ children }: { children: ReactNode }) {
+ const pathname = usePathname();
+
+ const handleLogout = () => {
+ clearTokens();
+ void loginWithRedirect("/admin");
+ };
+
+ return (
+
+
+
+ {/* ناوبری موبایل */}
+
+
+ {NAV_ITEMS.map((item) => {
+ const active = isActive(pathname, item.href);
+ return (
+
+ {item.label}
+
+ );
+ })}
+
+
{children}
+
+
+ );
+}
diff --git a/frontend/components/admin/controls.tsx b/frontend/components/admin/controls.tsx
new file mode 100644
index 0000000..ca0c459
--- /dev/null
+++ b/frontend/components/admin/controls.tsx
@@ -0,0 +1,238 @@
+"use client";
+
+import {
+ ChangeEvent,
+ ReactNode,
+ SelectHTMLAttributes,
+ TextareaHTMLAttributes,
+ InputHTMLAttributes,
+} from "react";
+import { Button } from "@/components/ui";
+
+export const inputClass =
+ "mt-1 w-full rounded-xl border border-gray-200 px-4 py-2.5 text-sm focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary disabled:bg-gray-50";
+
+export const labelClass = "block text-sm font-medium text-secondary";
+
+export const cardClass =
+ "rounded-2xl border border-gray-100 bg-white p-6 shadow-sm";
+
+export function Field({
+ label,
+ hint,
+ children,
+ required,
+}: {
+ label: string;
+ hint?: string;
+ required?: boolean;
+ children: ReactNode;
+}) {
+ return (
+
+ );
+}
+
+interface TextFieldProps extends InputHTMLAttributes {
+ label: string;
+ hint?: string;
+ onValue?: (v: string) => void;
+}
+
+export function TextField({ label, hint, required, onValue, onChange, className, ...props }: TextFieldProps) {
+ return (
+
+ ) => {
+ onValue?.(e.target.value);
+ onChange?.(e);
+ }}
+ {...props}
+ />
+
+ );
+}
+
+interface TextareaFieldProps extends TextareaHTMLAttributes {
+ label: string;
+ hint?: string;
+ onValue?: (v: string) => void;
+}
+
+export function TextareaField({ label, hint, required, onValue, onChange, ...props }: TextareaFieldProps) {
+ return (
+
+
+ );
+}
+
+interface SelectFieldProps extends SelectHTMLAttributes {
+ label: string;
+ hint?: string;
+ options: { value: string; label: string }[];
+ onValue?: (v: string) => void;
+}
+
+export function SelectField({ label, hint, required, options, onValue, onChange, ...props }: SelectFieldProps) {
+ return (
+
+
+
+ );
+}
+
+export function Banner({
+ variant,
+ children,
+}: {
+ variant: "error" | "success" | "info";
+ children: ReactNode;
+}) {
+ if (!children) return null;
+ const styles = {
+ error: "bg-red-50 text-red-600",
+ success: "bg-green-50 text-green-700",
+ info: "bg-blue-50 text-blue-700",
+ }[variant];
+ return (
+ {children}
+ );
+}
+
+export function Modal({
+ open,
+ title,
+ onClose,
+ children,
+ footer,
+}: {
+ open: boolean;
+ title: string;
+ onClose: () => void;
+ children: ReactNode;
+ footer?: ReactNode;
+}) {
+ if (!open) return null;
+ return (
+
+
e.stopPropagation()}
+ >
+
+
{title}
+
+
+ {children}
+ {footer &&
{footer}
}
+
+
+ );
+}
+
+const STATUS_STYLES: Record = {
+ active: "bg-green-100 text-green-700",
+ trialing: "bg-blue-100 text-blue-700",
+ pending_activation: "bg-amber-100 text-amber-700",
+ draft: "bg-gray-100 text-gray-600",
+ suspended: "bg-red-100 text-red-700",
+ inactive: "bg-gray-100 text-gray-600",
+ maintenance: "bg-amber-100 text-amber-700",
+ archived: "bg-gray-100 text-gray-500",
+ deleted: "bg-red-100 text-red-700",
+ canceled: "bg-red-100 text-red-700",
+ expired: "bg-gray-100 text-gray-500",
+ past_due: "bg-amber-100 text-amber-700",
+};
+
+const STATUS_LABELS: Record = {
+ active: "فعال",
+ trialing: "آزمایشی",
+ pending_activation: "در انتظار فعالسازی",
+ draft: "پیشنویس",
+ suspended: "معلق",
+ inactive: "غیرفعال",
+ maintenance: "در حال تعمیر",
+ archived: "بایگانیشده",
+ deleted: "حذفشده",
+ canceled: "لغوشده",
+ expired: "منقضی",
+ past_due: "معوق",
+};
+
+export function StatusBadge({ status }: { status: string }) {
+ const style = STATUS_STYLES[status] || "bg-gray-100 text-gray-600";
+ return (
+
+ {STATUS_LABELS[status] || status}
+
+ );
+}
+
+export function EmptyState({ message }: { message: string }) {
+ return {message}
;
+}
+
+export function AdminPageHeader({
+ title,
+ subtitle,
+ action,
+}: {
+ title: string;
+ subtitle?: string;
+ action?: ReactNode;
+}) {
+ return (
+
+
+
{title}
+ {subtitle &&
{subtitle}
}
+
+ {action &&
{action}
}
+
+ );
+}
+
+export { Button };
diff --git a/frontend/components/auth/AuthPanel.tsx b/frontend/components/auth/AuthPanel.tsx
new file mode 100644
index 0000000..fc9e860
--- /dev/null
+++ b/frontend/components/auth/AuthPanel.tsx
@@ -0,0 +1,435 @@
+"use client";
+
+import { FormEvent, useCallback, useEffect, useRef, useState } from "react";
+import Link from "next/link";
+import { useRouter } from "next/navigation";
+import { storeTokens, consumePostLoginRedirect, fetchLoginUrl } from "@/lib/auth";
+import { identityApi } from "@/lib/identity";
+import {
+ isValidMobile,
+ normalizeOtpCode,
+ normalizePhone,
+ persianToEnglish,
+} from "@/lib/phone";
+import { useAuth } from "@/hooks/useAuth";
+import { Button } from "@/components/ui";
+
+type Step = "phone" | "code";
+
+export interface AuthPanelProps {
+ /** true = نمایش فیلدهای ثبتنام از ابتدا */
+ initialSignup?: boolean;
+ /** مسیر بعد از ورود؛ اگر نباشد از sessionStorage خوانده میشود */
+ redirectTo?: string;
+}
+
+function ProfileFields({
+ displayName,
+ setDisplayName,
+ email,
+ setEmail,
+ username,
+ setUsername,
+ password,
+ setPassword,
+ disabled,
+}: {
+ displayName: string;
+ setDisplayName: (v: string) => void;
+ email: string;
+ setEmail: (v: string) => void;
+ username: string;
+ setUsername: (v: string) => void;
+ password: string;
+ setPassword: (v: string) => void;
+ disabled?: boolean;
+}) {
+ return (
+
+ );
+}
+
+export function AuthPanel({ initialSignup = false, redirectTo }: AuthPanelProps) {
+ const router = useRouter();
+ const { isAuthenticated, reload } = useAuth();
+ const [passwordLoading, setPasswordLoading] = useState(false);
+
+ const [step, setStep] = useState("phone");
+ const [signupMode, setSignupMode] = useState(initialSignup);
+ const [intent, setIntent] = useState<"login" | "register">("login");
+ const [mobile, setMobile] = useState("");
+ const [displayName, setDisplayName] = useState("");
+ const [email, setEmail] = useState("");
+ const [username, setUsername] = useState("");
+ const [password, setPassword] = useState("");
+ const [code, setCode] = useState("");
+ const [countdown, setCountdown] = useState(0);
+ const [loading, setLoading] = useState(false);
+ const [error, setError] = useState("");
+ const verifyingRef = useRef(false);
+
+ const showProfileFields =
+ signupMode || (step === "code" && intent === "register");
+
+ const profileComplete =
+ displayName.trim().length > 0 &&
+ email.trim().length > 0 &&
+ username.trim().length >= 3 &&
+ password.length >= 8;
+
+ useEffect(() => {
+ const params = new URLSearchParams(window.location.search);
+ if (params.get("signup") === "1") setSignupMode(true);
+ const prefilled = params.get("mobile");
+ if (prefilled) setMobile(prefilled);
+ }, []);
+
+ useEffect(() => {
+ if (countdown <= 0) return;
+ const timer = setTimeout(() => setCountdown((c) => c - 1), 1000);
+ return () => clearTimeout(timer);
+ }, [countdown]);
+
+ const finishSession = useCallback(
+ async (tokens: {
+ access_token: string;
+ refresh_token?: string;
+ expires_in: number;
+ token_type: string;
+ }) => {
+ storeTokens(tokens);
+ await reload();
+ router.replace(redirectTo ?? consumePostLoginRedirect("/dashboard"));
+ },
+ [redirectTo, reload, router]
+ );
+
+ const validateProfile = useCallback(() => {
+ if (!profileComplete) {
+ setError("نام، ایمیل، نام کاربری (حداقل ۳ کاراکتر) و رمز (حداقل ۸) الزامی است");
+ return false;
+ }
+ return true;
+ }, [profileComplete]);
+
+ const handleRequestOtp = async (e?: FormEvent) => {
+ e?.preventDefault();
+ setError("");
+ const normalized = normalizePhone(mobile);
+ if (!isValidMobile(normalized)) {
+ setError("شماره موبایل صحیح نیست");
+ return;
+ }
+ if (signupMode && !validateProfile()) return;
+
+ setMobile(normalized);
+ setLoading(true);
+ try {
+ const res = await identityApi.mobileStart(normalized);
+ setIntent(res.intent);
+ if (res.intent === "login" && signupMode) {
+ setSignupMode(false);
+ setError("این شماره قبلاً ثبت شده — کد ورود ارسال شد");
+ }
+ setCountdown(res.expires_in);
+ setStep("code");
+ } catch (err) {
+ setError(err instanceof Error ? err.message : "ارسال کد ناموفق بود");
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ const handleVerify = useCallback(
+ async (codeOverride?: string) => {
+ if (verifyingRef.current) return;
+ const normalizedCode = normalizeOtpCode(codeOverride ?? code);
+ if (normalizedCode.length !== 4) {
+ setError("کد تأیید باید ۴ رقم باشد");
+ return;
+ }
+ if (intent === "register" && !validateProfile()) return;
+
+ verifyingRef.current = true;
+ setLoading(true);
+ let keepLoading = false;
+ try {
+ const session = await identityApi.mobileComplete({
+ mobile,
+ code: normalizedCode,
+ display_name: displayName.trim() || undefined,
+ email: showProfileFields && profileComplete ? email.trim() : undefined,
+ username: showProfileFields && profileComplete ? username.trim() : undefined,
+ password: showProfileFields && profileComplete ? password : undefined,
+ });
+ keepLoading = true;
+ await finishSession(session);
+ } catch (err) {
+ setError(err instanceof Error ? err.message : "تأیید کد ناموفق بود");
+ } finally {
+ if (!keepLoading) {
+ setLoading(false);
+ verifyingRef.current = false;
+ }
+ }
+ },
+ [
+ code,
+ displayName,
+ email,
+ finishSession,
+ intent,
+ mobile,
+ password,
+ profileComplete,
+ showProfileFields,
+ username,
+ validateProfile,
+ ]
+ );
+
+ const handleCodeChange = (value: string) => {
+ const next = normalizeOtpCode(value);
+ setCode(next);
+ const canAutoVerify =
+ intent === "login" || (intent === "register" && profileComplete);
+ if (next.length === 4 && canAutoVerify && !verifyingRef.current && !loading) {
+ void handleVerify(next);
+ }
+ };
+
+ if (isAuthenticated) {
+ return (
+
+
+
شما وارد شدهاید.
+
+
+
+
+
+ );
+ }
+
+ return (
+
+
+
+
+
+ {signupMode ? "ثبتنام" : "ورود"}
+
+
+ {step === "phone"
+ ? "شماره موبایل خود را وارد کنید"
+ : intent === "login"
+ ? "کد ورود را وارد کنید"
+ : "کد تأیید و اطلاعات حساب را تکمیل کنید"}
+
+
+
+ {error && (
+ {error}
+ )}
+
+ {step === "phone" ? (
+
+ ) : (
+
+
+ کد به{" "}
+
+ {mobile}
+ {" "}
+ ارسال شد
+
+
+ {countdown > 0 && (
+
ارسال مجدد تا {countdown} ثانیه
+ )}
+
+
handleCodeChange(e.target.value)}
+ className="w-full rounded-xl border border-gray-200 px-4 py-3 text-center font-mono text-lg tracking-[0.4em]"
+ placeholder="••••"
+ />
+
+ {intent === "register" && !signupMode && (
+
+ )}
+
+
+
+
+
+ )}
+
+
+ بازگشت به صفحه اصلی
+
+
+
+ );
+}
diff --git a/frontend/components/settings/AccountSettings.tsx b/frontend/components/settings/AccountSettings.tsx
new file mode 100644
index 0000000..eeece2c
--- /dev/null
+++ b/frontend/components/settings/AccountSettings.tsx
@@ -0,0 +1,331 @@
+"use client";
+
+import { FormEvent, useCallback, useEffect, useState } from "react";
+import Link from "next/link";
+import { getStoredToken } from "@/lib/auth";
+import { identityApi, type AccountProfile } from "@/lib/identity";
+import { Button, Container } from "@/components/ui";
+
+const inputClass =
+ "mt-1 w-full rounded-xl border border-gray-200 px-4 py-2.5 focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary disabled:bg-gray-50";
+
+const labelClass = "block text-sm font-medium text-secondary";
+
+interface AccountSettingsProps {
+ backHref?: string;
+ backLabel?: string;
+}
+
+export function AccountSettings({ backHref, backLabel }: AccountSettingsProps) {
+ const [profile, setProfile] = useState(null);
+ const [loading, setLoading] = useState(true);
+ const [loadError, setLoadError] = useState("");
+
+ const load = useCallback(async () => {
+ const token = getStoredToken();
+ if (!token) {
+ setLoadError("توکن احراز هویت یافت نشد. لطفاً دوباره وارد شوید.");
+ setLoading(false);
+ return;
+ }
+ setLoading(true);
+ setLoadError("");
+ try {
+ const data = await identityApi.profile(token);
+ setProfile(data);
+ } catch (err) {
+ setLoadError(err instanceof Error ? err.message : "دریافت پروفایل ناموفق بود");
+ } finally {
+ setLoading(false);
+ }
+ }, []);
+
+ useEffect(() => {
+ void load();
+ }, [load]);
+
+ return (
+
+
+
+
تنظیمات حساب
+
+ مدیریت پروفایل و رمز عبور حساب کاربری شما
+
+
+ {backHref && (
+
+
+
+ )}
+
+
+ {loadError && (
+
+ {loadError}
+
+ )}
+
+ {loading ? (
+ در حال بارگذاری...
+ ) : profile ? (
+
+ ) : null}
+
+ );
+}
+
+function ProfileForm({
+ profile,
+ onUpdated,
+}: {
+ profile: AccountProfile;
+ onUpdated: (p: AccountProfile) => void;
+}) {
+ const [displayName, setDisplayName] = useState(profile.display_name || "");
+ const [email, setEmail] = useState(profile.email || "");
+ const [saving, setSaving] = useState(false);
+ const [error, setError] = useState("");
+ const [success, setSuccess] = useState("");
+
+ const handleSubmit = async (e: FormEvent) => {
+ e.preventDefault();
+ setError("");
+ setSuccess("");
+ const token = getStoredToken();
+ if (!token) {
+ setError("توکن احراز هویت یافت نشد.");
+ return;
+ }
+ setSaving(true);
+ try {
+ const updated = await identityApi.updateProfile(token, {
+ display_name: displayName.trim() || null,
+ email: email.trim() || undefined,
+ });
+ onUpdated(updated);
+ setSuccess("پروفایل با موفقیت ذخیره شد.");
+ } catch (err) {
+ setError(err instanceof Error ? err.message : "ذخیره پروفایل ناموفق بود");
+ } finally {
+ setSaving(false);
+ }
+ };
+
+ return (
+
+ );
+}
+
+function PasswordForm() {
+ const [currentPassword, setCurrentPassword] = useState("");
+ const [newPassword, setNewPassword] = useState("");
+ const [confirmPassword, setConfirmPassword] = useState("");
+ const [saving, setSaving] = useState(false);
+ const [error, setError] = useState("");
+ const [success, setSuccess] = useState("");
+
+ const handleSubmit = async (e: FormEvent) => {
+ e.preventDefault();
+ setError("");
+ setSuccess("");
+
+ if (newPassword.length < 8) {
+ setError("رمز عبور جدید باید حداقل ۸ کاراکتر باشد.");
+ return;
+ }
+ if (newPassword !== confirmPassword) {
+ setError("رمز عبور جدید و تکرار آن یکسان نیستند.");
+ return;
+ }
+
+ const token = getStoredToken();
+ if (!token) {
+ setError("توکن احراز هویت یافت نشد.");
+ return;
+ }
+
+ setSaving(true);
+ try {
+ await identityApi.changePassword(token, {
+ current_password: currentPassword.trim() || undefined,
+ new_password: newPassword,
+ });
+ setSuccess("رمز عبور با موفقیت بهروزرسانی شد.");
+ setCurrentPassword("");
+ setNewPassword("");
+ setConfirmPassword("");
+ } catch (err) {
+ setError(err instanceof Error ? err.message : "تغییر رمز عبور ناموفق بود");
+ } finally {
+ setSaving(false);
+ }
+ };
+
+ return (
+
+ );
+}
diff --git a/frontend/components/ui/AppTile.tsx b/frontend/components/ui/AppTile.tsx
new file mode 100644
index 0000000..885ce21
--- /dev/null
+++ b/frontend/components/ui/AppTile.tsx
@@ -0,0 +1,37 @@
+import type { PlatformApp } from "@/lib/apps-catalog";
+import { Badge } from "./Badge";
+
+export interface AppTileProps {
+ app: PlatformApp;
+ onClick?: () => void;
+}
+
+export function AppTile({ app, onClick }: AppTileProps) {
+ const isAvailable = app.status === "available";
+
+ return (
+
+ );
+}
diff --git a/frontend/components/ui/Badge.tsx b/frontend/components/ui/Badge.tsx
new file mode 100644
index 0000000..f393c47
--- /dev/null
+++ b/frontend/components/ui/Badge.tsx
@@ -0,0 +1,23 @@
+import type { ReactNode } from "react";
+
+export interface BadgeProps {
+ children: ReactNode;
+ variant?: "default" | "primary" | "muted";
+ className?: string;
+}
+
+const variants = {
+ default: "bg-gray-100 text-gray-700",
+ primary: "bg-[var(--color-primary-muted)] text-primary",
+ muted: "bg-[var(--color-secondary-muted)] text-secondary/70",
+};
+
+export function Badge({ children, variant = "default", className = "" }: BadgeProps) {
+ return (
+
+ {children}
+
+ );
+}
diff --git a/frontend/components/ui/Button.tsx b/frontend/components/ui/Button.tsx
new file mode 100644
index 0000000..23f997f
--- /dev/null
+++ b/frontend/components/ui/Button.tsx
@@ -0,0 +1,59 @@
+import type { ButtonHTMLAttributes, ReactNode } from "react";
+
+type ButtonVariant = "primary" | "secondary" | "ghost" | "outline";
+type ButtonSize = "sm" | "md" | "lg";
+
+export interface ButtonProps extends ButtonHTMLAttributes {
+ variant?: ButtonVariant;
+ size?: ButtonSize;
+ children: ReactNode;
+ loading?: boolean;
+ loadingText?: string;
+}
+
+const variantClasses: Record = {
+ primary: "bg-primary text-white hover:opacity-90 shadow-sm",
+ secondary: "bg-secondary text-white hover:opacity-90",
+ ghost: "bg-transparent text-secondary hover:bg-[var(--color-secondary-muted)]",
+ outline:
+ "border border-gray-200 bg-white text-secondary hover:border-primary hover:text-primary",
+};
+
+const sizeClasses: Record = {
+ sm: "px-3 py-1.5 text-sm",
+ md: "px-5 py-2.5 text-sm",
+ lg: "px-6 py-3 text-base",
+};
+
+export function Button({
+ variant = "primary",
+ size = "md",
+ className = "",
+ children,
+ loading = false,
+ loadingText,
+ disabled,
+ ...props
+}: ButtonProps) {
+ return (
+
+ );
+}
diff --git a/frontend/components/ui/Container.tsx b/frontend/components/ui/Container.tsx
new file mode 100644
index 0000000..23edaaa
--- /dev/null
+++ b/frontend/components/ui/Container.tsx
@@ -0,0 +1,14 @@
+import type { ReactNode } from "react";
+
+export interface ContainerProps {
+ children: ReactNode;
+ className?: string;
+}
+
+export function Container({ children, className = "" }: ContainerProps) {
+ return (
+
+ {children}
+
+ );
+}
diff --git a/frontend/components/ui/SectionTitle.tsx b/frontend/components/ui/SectionTitle.tsx
new file mode 100644
index 0000000..2f9bd3e
--- /dev/null
+++ b/frontend/components/ui/SectionTitle.tsx
@@ -0,0 +1,19 @@
+import type { ReactNode } from "react";
+
+export interface SectionTitleProps {
+ title: string;
+ subtitle?: string;
+ action?: ReactNode;
+}
+
+export function SectionTitle({ title, subtitle, action }: SectionTitleProps) {
+ return (
+
+
+
{title}
+ {subtitle ?
{subtitle}
: null}
+
+ {action}
+
+ );
+}
diff --git a/frontend/components/ui/index.ts b/frontend/components/ui/index.ts
new file mode 100644
index 0000000..b37828a
--- /dev/null
+++ b/frontend/components/ui/index.ts
@@ -0,0 +1,10 @@
+export { AppTile } from "./AppTile";
+export type { AppTileProps } from "./AppTile";
+export { Badge } from "./Badge";
+export type { BadgeProps } from "./Badge";
+export { Button } from "./Button";
+export type { ButtonProps } from "./Button";
+export { Container } from "./Container";
+export type { ContainerProps } from "./Container";
+export { SectionTitle } from "./SectionTitle";
+export type { SectionTitleProps } from "./SectionTitle";
diff --git a/frontend/hooks/useAuth.ts b/frontend/hooks/useAuth.ts
new file mode 100644
index 0000000..04cd315
--- /dev/null
+++ b/frontend/hooks/useAuth.ts
@@ -0,0 +1,71 @@
+"use client";
+
+import { useCallback, useEffect, useState } from "react";
+import {
+ fetchAuthConfig,
+ fetchLoginUrl,
+ loginWithRedirect,
+ fetchMe,
+ getStoredToken,
+ clearTokens,
+ logout,
+ type AuthConfig,
+} from "@/lib/auth";
+
+interface AuthUser {
+ user_id: string;
+ email: string | null;
+ username: string | null;
+ roles: string[];
+}
+
+export function useAuth() {
+ const [config, setConfig] = useState(null);
+ const [user, setUser] = useState(null);
+ const [loading, setLoading] = useState(true);
+ const [isAuthenticated, setIsAuthenticated] = useState(false);
+
+ const load = useCallback(async () => {
+ setLoading(true);
+ try {
+ const cfg = await fetchAuthConfig();
+ setConfig(cfg);
+ const token = getStoredToken();
+ if (token) {
+ try {
+ const me = await fetchMe(token);
+ setUser(me);
+ setIsAuthenticated(true);
+ } catch {
+ clearTokens();
+ setUser(null);
+ setIsAuthenticated(false);
+ }
+ } else {
+ setUser(null);
+ setIsAuthenticated(false);
+ }
+ } catch {
+ clearTokens();
+ setUser(null);
+ setIsAuthenticated(false);
+ } finally {
+ setLoading(false);
+ }
+ }, []);
+
+ useEffect(() => {
+ load();
+ }, [load]);
+
+ const login = async (redirectPath?: string) => {
+ await loginWithRedirect(redirectPath);
+ };
+
+ const signOut = async () => {
+ const cfg = config || (await fetchAuthConfig());
+ await logout(cfg);
+ };
+
+ return { user, config, loading, isAuthenticated, login, signOut, reload: load };
+}
diff --git a/frontend/hooks/useHeaderSession.ts b/frontend/hooks/useHeaderSession.ts
new file mode 100644
index 0000000..0f5de3b
--- /dev/null
+++ b/frontend/hooks/useHeaderSession.ts
@@ -0,0 +1,30 @@
+"use client";
+
+import { useEffect, useState } from "react";
+import { usePathname } from "next/navigation";
+import { getStoredToken } from "@/lib/auth";
+
+/** تشخیص ورود SSO مرکزی. */
+export function useHeaderSession() {
+ const pathname = usePathname();
+ const [isSsoAuth, setIsSsoAuth] = useState(false);
+
+ useEffect(() => {
+ const sync = () => {
+ setIsSsoAuth(!!getStoredToken());
+ };
+ sync();
+ window.addEventListener("focus", sync);
+ window.addEventListener("storage", sync);
+ return () => {
+ window.removeEventListener("focus", sync);
+ window.removeEventListener("storage", sync);
+ };
+ }, [pathname]);
+
+ return {
+ isAdminAuth: false,
+ isSsoAuth,
+ isLoggedIn: isSsoAuth,
+ };
+}
diff --git a/frontend/hooks/useMe.ts b/frontend/hooks/useMe.ts
new file mode 100644
index 0000000..60cc0c8
--- /dev/null
+++ b/frontend/hooks/useMe.ts
@@ -0,0 +1,37 @@
+"use client";
+
+import { useCallback, useEffect, useState } from "react";
+import { api, type MeResponse } from "@/lib/api";
+import { getStoredToken } from "@/lib/auth";
+
+/** بارگذاری وضعیت کاربر جاری (عضویتها و نیاز به onboarding) از `/api/v1/me`. */
+export function useMe() {
+ const [me, setMe] = useState(null);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState("");
+
+ const load = useCallback(async () => {
+ if (!getStoredToken()) {
+ setMe(null);
+ setLoading(false);
+ return;
+ }
+ setLoading(true);
+ setError("");
+ try {
+ const data = await api.me.get();
+ setMe(data);
+ } catch (e) {
+ setError(e instanceof Error ? e.message : "خطا در دریافت اطلاعات کاربر");
+ setMe(null);
+ } finally {
+ setLoading(false);
+ }
+ }, []);
+
+ useEffect(() => {
+ load();
+ }, [load]);
+
+ return { me, loading, error, reload: load };
+}
diff --git a/frontend/hooks/useTheme.ts b/frontend/hooks/useTheme.ts
new file mode 100644
index 0000000..f9f1f20
--- /dev/null
+++ b/frontend/hooks/useTheme.ts
@@ -0,0 +1,20 @@
+"use client";
+
+import { useEffect, useState } from "react";
+import { applyTheme, loadTheme, type ThemeConfig } from "@/lib/theme";
+
+/** بارگذاری و اعمال تم White-label در زمان mount. */
+export function useTheme() {
+ const [theme, setTheme] = useState(null);
+ const [loading, setLoading] = useState(true);
+
+ useEffect(() => {
+ loadTheme().then((t) => {
+ applyTheme(t);
+ setTheme(t);
+ setLoading(false);
+ });
+ }, []);
+
+ return { theme, loading };
+}
diff --git a/frontend/lib/api-client.ts b/frontend/lib/api-client.ts
new file mode 100644
index 0000000..05edad1
--- /dev/null
+++ b/frontend/lib/api-client.ts
@@ -0,0 +1,61 @@
+/**
+ * API Client — تنها راه ارتباط frontend با backend.
+ * توکن SSO از sessionStorage به هدر Authorization اضافه میشود.
+ */
+
+import { getStoredToken } from "./auth";
+
+const API_BASE =
+ process.env.NEXT_PUBLIC_API_BASE_URL || "http://localhost:8000";
+
+export class ApiError extends Error {
+ constructor(
+ public status: number,
+ public code: string,
+ message: string
+ ) {
+ super(message);
+ this.name = "ApiError";
+ }
+}
+
+interface RequestOptions extends RequestInit {
+ tenantId?: string;
+ tenantSlug?: string;
+}
+
+async function request(path: string, options: RequestOptions = {}): Promise {
+ const { tenantId, tenantSlug, headers: customHeaders, ...init } = options;
+
+ const headers = new Headers(customHeaders);
+ headers.set("Content-Type", "application/json");
+ const token = getStoredToken();
+ if (token) headers.set("Authorization", `Bearer ${token}`);
+ if (tenantId) headers.set("X-Tenant-ID", tenantId);
+ if (tenantSlug) headers.set("X-Tenant-Slug", tenantSlug);
+
+ const res = await fetch(`${API_BASE}${path}`, { ...init, headers });
+
+ if (!res.ok) {
+ const body = await res.json().catch(() => ({}));
+ throw new ApiError(
+ res.status,
+ body?.error?.code || "unknown_error",
+ body?.error?.message || res.statusText
+ );
+ }
+
+ return res.json() as Promise;
+}
+
+export const api = {
+ health: () => request<{ status: string; service: string }>("/health"),
+
+ tenants: {
+ list: (page = 1, pageSize = 20) =>
+ request(`/api/v1/tenants?page=${page}&page_size=${pageSize}`),
+ get: (id: string) => request(`/api/v1/tenants/${id}`),
+ create: (data: { name: string; slug: string }) =>
+ request("/api/v1/tenants", { method: "POST", body: JSON.stringify(data) }),
+ },
+};
diff --git a/frontend/lib/api.ts b/frontend/lib/api.ts
new file mode 100644
index 0000000..9361a22
--- /dev/null
+++ b/frontend/lib/api.ts
@@ -0,0 +1,464 @@
+/**
+ * API Client — ارتباط frontend با Core Backend (پنل ادمین / tenant).
+ * توکن SSO مرکزی (Keycloak) از sessionStorage استفاده میشود.
+ */
+
+import { getStoredToken } from "@/lib/auth";
+
+const API_BASE =
+ process.env.NEXT_PUBLIC_API_BASE_URL ||
+ process.env.NEXT_PUBLIC_BACKEND_URL ||
+ "http://localhost:8000";
+
+export class ApiError extends Error {
+ constructor(
+ public status: number,
+ public code: string,
+ message: string
+ ) {
+ super(message);
+ this.name = "ApiError";
+ }
+}
+
+interface RequestOptions extends RequestInit {
+ auth?: boolean;
+}
+
+async function request(
+ path: string,
+ options: RequestOptions = {}
+): Promise {
+ const { auth = true, headers: customHeaders, ...init } = options;
+
+ const headers = new Headers(customHeaders);
+ headers.set("Content-Type", "application/json");
+
+ if (auth) {
+ const token = getStoredToken();
+ if (token) headers.set("Authorization", `Bearer ${token}`);
+ }
+
+ let res: Response;
+ try {
+ res = await fetch(`${API_BASE}${path}`, { ...init, headers });
+ } catch {
+ throw new Error(`اتصال به سرور برقرار نشد (${API_BASE})`);
+ }
+
+ if (!res.ok) {
+ const body = await res.json().catch(() => ({}));
+ throw new ApiError(
+ res.status,
+ body?.error?.code || "unknown_error",
+ body?.error?.message || res.statusText
+ );
+ }
+
+ if (res.status === 204) return undefined as T;
+ return res.json() as Promise;
+}
+
+export interface OtpRequestResult {
+ message: string;
+ expires_in: number;
+ skipped?: boolean;
+}
+
+type OtpContext = "public" | "admin";
+
+export interface OtpVerifyResult {
+ access_token: string;
+ refresh_token: string;
+ token_type: string;
+ expires_in: number;
+ is_new_user: boolean;
+ user_id: string;
+ role: string;
+}
+
+export interface Tenant {
+ id: string;
+ name: string;
+ slug: string;
+ status: string;
+ owner_user_id: string | null;
+ business_type?: string | null;
+ default_locale?: string;
+ timezone?: string;
+ primary_color?: string | null;
+ secondary_color?: string | null;
+ logo_url?: string | null;
+ favicon_url?: string | null;
+ onboarding_completed?: boolean;
+ created_at: string;
+ updated_at: string;
+}
+
+export interface Page {
+ items: T[];
+ meta: {
+ page: number;
+ page_size: number;
+ total_items: number;
+ total_pages: number;
+ };
+}
+
+// ---- فاز ۳: Onboarding و Tenant Context ----
+
+export interface MembershipSummary {
+ tenant_id: string;
+ tenant_name: string;
+ tenant_slug: string;
+ tenant_status: string;
+ onboarding_completed: boolean;
+ role: string;
+ status: string;
+ is_owner: boolean;
+}
+
+export interface MeResponse {
+ user_id: string;
+ mobile: string;
+ email: string | null;
+ platform_role: string;
+ onboarding_required: boolean;
+ current_tenant_id: string | null;
+ memberships: MembershipSummary[];
+}
+
+export interface TenantDomain {
+ id: string;
+ tenant_id: string;
+ domain: string;
+ domain_type: string;
+ is_verified: boolean;
+ verified_at: string | null;
+ created_at: string;
+}
+
+export interface TenantContext {
+ tenant: Tenant;
+ role: string | null;
+ is_owner: boolean;
+ plan_code: string | null;
+ plan_name: string | null;
+ subscription_status: string | null;
+ domains: TenantDomain[];
+ primary_domain: string | null;
+}
+
+export interface OnboardingTenantCreateInput {
+ business_name: string;
+ slug: string;
+ business_type?: string;
+ default_locale?: string;
+ timezone?: string;
+}
+
+export interface OnboardingBrandingInput {
+ primary_color?: string;
+ secondary_color?: string;
+ logo_url?: string;
+ favicon_url?: string;
+}
+
+export interface OnboardingDomainInput {
+ custom_domain?: string;
+}
+
+// ---- مدیریت پلتفرم (پنل ادمین): پلن، قابلیت، اشتراک، سرویس ----
+
+export interface Plan {
+ id: string;
+ code: string;
+ name: string;
+ description: string | null;
+ is_active: boolean;
+ created_at: string;
+}
+
+export interface Feature {
+ id: string;
+ feature_key: string;
+ name: string;
+ description: string | null;
+ service_key: string;
+ is_active: boolean;
+}
+
+export type LimitPeriod = "day" | "month" | "year" | "total";
+
+export interface PlanFeature {
+ id: string;
+ plan_id: string;
+ feature_id: string;
+ limit_value: number | null;
+ limit_period: LimitPeriod | null;
+ is_enabled: boolean;
+}
+
+export type SubscriptionStatus =
+ | "trialing"
+ | "active"
+ | "past_due"
+ | "canceled"
+ | "expired";
+
+export interface Subscription {
+ id: string;
+ tenant_id: string;
+ plan_id: string;
+ status: SubscriptionStatus;
+ starts_at: string | null;
+ ends_at: string | null;
+ trial_ends_at: string | null;
+ created_at: string;
+}
+
+export type ServiceStatus = "active" | "inactive" | "maintenance";
+
+export interface ServiceEntry {
+ id: string;
+ service_key: string;
+ name: string;
+ base_url: string | null;
+ status: ServiceStatus;
+ health_check_url: string | null;
+ created_at: string;
+}
+
+export interface FeatureCheckResult {
+ tenant_id: string;
+ feature_key: string;
+ has_access: boolean;
+ reason: string | null;
+}
+
+export const api = {
+ auth: {
+ requestOtp: (mobile: string, options?: { forceSms?: boolean; context?: OtpContext }) =>
+ request("/api/v1/auth/otp/request", {
+ method: "POST",
+ auth: false,
+ body: JSON.stringify({
+ mobile,
+ context: options?.context ?? "admin",
+ ...(options?.forceSms ? { force_sms: true } : {}),
+ }),
+ }),
+
+ verifyOtp: (mobile: string, code: string, context: OtpContext = "admin") =>
+ request("/api/v1/auth/otp/verify", {
+ method: "POST",
+ auth: false,
+ body: JSON.stringify({ mobile, code, context }),
+ }),
+ },
+
+ tenants: {
+ list: (page = 1, pageSize = 20) =>
+ request>(
+ `/api/v1/admin/tenants?page=${page}&page_size=${pageSize}`
+ ),
+
+ get: (id: string) => request(`/api/v1/admin/tenants/${id}`),
+
+ create: (data: { name: string; slug: string; custom_domain?: string }) =>
+ request("/api/v1/admin/tenants", {
+ method: "POST",
+ body: JSON.stringify(data),
+ }),
+
+ update: (id: string, data: { name?: string }) =>
+ request(`/api/v1/admin/tenants/${id}`, {
+ method: "PATCH",
+ body: JSON.stringify(data),
+ }),
+ },
+
+ me: {
+ get: () => request("/api/v1/me"),
+ tenants: () => request("/api/v1/me/tenants"),
+ },
+
+ onboarding: {
+ createTenant: (data: OnboardingTenantCreateInput) =>
+ request("/api/v1/onboarding/tenant", {
+ method: "POST",
+ body: JSON.stringify(data),
+ }),
+
+ updateBranding: (tenantId: string, data: OnboardingBrandingInput) =>
+ request(`/api/v1/onboarding/tenant/${tenantId}/branding`, {
+ method: "PATCH",
+ body: JSON.stringify(data),
+ }),
+
+ updateDomain: (tenantId: string, data: OnboardingDomainInput) =>
+ request(`/api/v1/onboarding/tenant/${tenantId}/domain`, {
+ method: "PATCH",
+ body: JSON.stringify(data),
+ }),
+
+ complete: (tenantId: string) =>
+ request(`/api/v1/onboarding/tenant/${tenantId}/complete`, {
+ method: "POST",
+ }),
+ },
+
+ tenantContext: {
+ current: () => request("/api/v1/tenant/current"),
+
+ switchTenant: (tenantId: string) =>
+ request("/api/v1/tenant/switch", {
+ method: "POST",
+ body: JSON.stringify({ tenant_id: tenantId }),
+ }),
+ },
+
+ // مدیریت پلتفرمی tenantها (نیازمند نقش platform_admin)
+ platformTenants: {
+ list: (page = 1, pageSize = 50) =>
+ request>(`/api/v1/tenants?page=${page}&page_size=${pageSize}`),
+
+ get: (id: string) => request(`/api/v1/tenants/${id}`),
+
+ create: (data: {
+ name: string;
+ slug: string;
+ owner_user_id?: string | null;
+ custom_domain?: string | null;
+ }) =>
+ request("/api/v1/tenants", {
+ method: "POST",
+ body: JSON.stringify(data),
+ }),
+
+ update: (
+ id: string,
+ data: { name?: string; status?: string; owner_user_id?: string | null }
+ ) =>
+ request(`/api/v1/tenants/${id}`, {
+ method: "PATCH",
+ body: JSON.stringify(data),
+ }),
+
+ suspend: (id: string) =>
+ request(`/api/v1/tenants/${id}/suspend`, { method: "POST" }),
+
+ activate: (id: string) =>
+ request(`/api/v1/tenants/${id}/activate`, { method: "POST" }),
+ },
+
+ plans: {
+ list: (page = 1, pageSize = 50) =>
+ request>(`/api/v1/plans?page=${page}&page_size=${pageSize}`),
+
+ create: (data: {
+ code: string;
+ name: string;
+ description?: string | null;
+ is_active?: boolean;
+ }) =>
+ request("/api/v1/plans", {
+ method: "POST",
+ body: JSON.stringify(data),
+ }),
+
+ attachFeature: (
+ planId: string,
+ data: {
+ feature_id: string;
+ limit_value?: number | null;
+ limit_period?: LimitPeriod | null;
+ is_enabled?: boolean;
+ }
+ ) =>
+ request(`/api/v1/plans/${planId}/features`, {
+ method: "POST",
+ body: JSON.stringify(data),
+ }),
+ },
+
+ features: {
+ list: (page = 1, pageSize = 100) =>
+ request>(`/api/v1/features?page=${page}&page_size=${pageSize}`),
+
+ create: (data: {
+ feature_key: string;
+ name: string;
+ description?: string | null;
+ service_key: string;
+ is_active?: boolean;
+ }) =>
+ request("/api/v1/features", {
+ method: "POST",
+ body: JSON.stringify(data),
+ }),
+ },
+
+ domains: {
+ listForTenant: (tenantId: string) =>
+ request(`/api/v1/tenants/${tenantId}/domains`),
+
+ create: (
+ tenantId: string,
+ data: { domain: string; domain_type?: "subdomain" | "custom_domain" }
+ ) =>
+ request(`/api/v1/tenants/${tenantId}/domains`, {
+ method: "POST",
+ body: JSON.stringify(data),
+ }),
+ },
+
+ subscriptions: {
+ get: (tenantId: string) =>
+ request(`/api/v1/tenants/${tenantId}/subscription`),
+
+ create: (
+ tenantId: string,
+ data: {
+ plan_id: string;
+ status?: SubscriptionStatus;
+ starts_at?: string | null;
+ ends_at?: string | null;
+ trial_ends_at?: string | null;
+ }
+ ) =>
+ request(`/api/v1/tenants/${tenantId}/subscription`, {
+ method: "POST",
+ body: JSON.stringify(data),
+ }),
+
+ checkFeature: (tenantId: string, featureKey: string) =>
+ request(`/api/v1/tenants/${tenantId}/features/check`, {
+ method: "POST",
+ body: JSON.stringify({ feature_key: featureKey }),
+ }),
+ },
+
+ services: {
+ list: (page = 1, pageSize = 100) =>
+ request>(`/api/v1/services?page=${page}&page_size=${pageSize}`),
+
+ create: (data: {
+ service_key: string;
+ name: string;
+ base_url?: string | null;
+ health_check_url?: string | null;
+ status?: ServiceStatus;
+ }) =>
+ request("/api/v1/services", {
+ method: "POST",
+ body: JSON.stringify(data),
+ }),
+
+ updateStatus: (serviceId: string, status: ServiceStatus) =>
+ request(`/api/v1/services/${serviceId}/status`, {
+ method: "PATCH",
+ body: JSON.stringify({ status }),
+ }),
+ },
+};
diff --git a/frontend/lib/apps-catalog.ts b/frontend/lib/apps-catalog.ts
new file mode 100644
index 0000000..c78b15f
--- /dev/null
+++ b/frontend/lib/apps-catalog.ts
@@ -0,0 +1,112 @@
+/** کاتالوگ اپهای پلتفرم — منبع واحد برای صفحهٔ اصلی و منوها. */
+
+export type AppStatus = "available" | "coming_soon";
+
+export interface PlatformApp {
+ id: string;
+ name: string;
+ description: string;
+ icon: string;
+ gradient: string;
+ status: AppStatus;
+ href?: string;
+}
+
+export const PLATFORM_APPS: PlatformApp[] = [
+ {
+ id: "accounting",
+ name: "حسابداری آنلاین",
+ description: "فاکتور، دفتر کل، گزارش مالی",
+ icon: "📊",
+ gradient: "from-sky-500 to-blue-600",
+ status: "coming_soon",
+ },
+ {
+ id: "crm",
+ name: "سیستم CRM",
+ description: "مدیریت مشتری و فروش",
+ icon: "🤝",
+ gradient: "from-violet-500 to-purple-600",
+ status: "coming_soon",
+ },
+ {
+ id: "ecommerce",
+ name: "فروشگاه آنلاین",
+ description: "کاتالوگ، سبد خرید، پرداخت",
+ icon: "🛒",
+ gradient: "from-emerald-500 to-teal-600",
+ status: "coming_soon",
+ },
+ {
+ id: "website_builder",
+ name: "سایتساز",
+ description: "ساخت صفحات و لندینگ",
+ icon: "🌐",
+ gradient: "from-cyan-500 to-sky-600",
+ status: "coming_soon",
+ },
+ {
+ id: "restaurant",
+ name: "رستوران و کافه",
+ description: "منو، سفارش، آشپزخانه",
+ icon: "🍽️",
+ gradient: "from-orange-500 to-amber-600",
+ status: "coming_soon",
+ },
+ {
+ id: "live_chat",
+ name: "گفتگوی زنده",
+ description: "پشتیبانی آنلاین real-time",
+ icon: "💬",
+ gradient: "from-indigo-500 to-blue-600",
+ status: "coming_soon",
+ },
+ {
+ id: "smart_messenger",
+ name: "پیامرسان هوشمند",
+ description: "چت تیم و کانالها",
+ icon: "📱",
+ gradient: "from-fuchsia-500 to-pink-600",
+ status: "coming_soon",
+ },
+ {
+ id: "sms_panel",
+ name: "پنل پیامک",
+ description: "ارسال انبوه و قالب پیام",
+ icon: "📨",
+ gradient: "from-lime-500 to-green-600",
+ status: "coming_soon",
+ },
+ {
+ id: "notification",
+ name: "اعلانها",
+ description: "Push، ایمیل، SMS",
+ icon: "🔔",
+ gradient: "from-yellow-500 to-orange-500",
+ status: "coming_soon",
+ },
+ {
+ id: "file_storage",
+ name: "فضای ابری",
+ description: "آپلود، اشتراک، مدیریت فایل",
+ icon: "☁️",
+ gradient: "from-slate-500 to-gray-600",
+ status: "coming_soon",
+ },
+ {
+ id: "link_shortener",
+ name: "کوتاهکننده لینک",
+ description: "لینک کوتاه و آمار کلیک",
+ icon: "🔗",
+ gradient: "from-rose-500 to-red-600",
+ status: "coming_soon",
+ },
+ {
+ id: "ai_assistant",
+ name: "دستیار هوشمند",
+ description: "AI برای کسبوکار شما",
+ icon: "✨",
+ gradient: "from-purple-500 to-indigo-600",
+ status: "coming_soon",
+ },
+];
diff --git a/frontend/lib/auth.ts b/frontend/lib/auth.ts
new file mode 100644
index 0000000..3ba57e7
--- /dev/null
+++ b/frontend/lib/auth.ts
@@ -0,0 +1,170 @@
+/**
+ * مدیریت SSO — ارتباط با Identity Service و Keycloak.
+ * PKCE کاملاً سمت سرور (Identity Service) مدیریت میشود تا به origin/مرورگر
+ * وابسته نباشد. توکنها فقط در sessionStorage نگهداری میشوند.
+ */
+
+const IDENTITY_API =
+ process.env.NEXT_PUBLIC_IDENTITY_API_URL || "http://localhost:8001";
+
+const TOKEN_KEY = "superapp_access_token";
+const REFRESH_KEY = "superapp_refresh_token";
+
+export interface AuthConfig {
+ issuer: string;
+ client_id: string;
+ authorization_endpoint: string;
+ token_endpoint: string;
+ logout_endpoint: string;
+ callback_url: string;
+}
+
+export interface LoginUrlResponse {
+ authorization_url: string;
+ state: string;
+}
+
+export interface TokenBundle {
+ access_token: string;
+ refresh_token?: string;
+ expires_in: number;
+ token_type: string;
+}
+
+export function getStoredToken(): string | null {
+ if (typeof window === "undefined") return null;
+ return sessionStorage.getItem(TOKEN_KEY);
+}
+
+export function storeTokens(tokens: TokenBundle): void {
+ sessionStorage.setItem(TOKEN_KEY, tokens.access_token);
+ if (tokens.refresh_token) {
+ sessionStorage.setItem(REFRESH_KEY, tokens.refresh_token);
+ }
+}
+
+export function clearTokens(): void {
+ sessionStorage.removeItem(TOKEN_KEY);
+ sessionStorage.removeItem(REFRESH_KEY);
+}
+
+export async function fetchAuthConfig(): Promise {
+ let res: Response;
+ try {
+ res = await fetch(`${IDENTITY_API}/api/v1/auth/config`);
+ } catch {
+ throw new Error(
+ `اتصال به سرویس احراز هویت برقرار نشد (${IDENTITY_API}). مطمئن شوید identity-access-service در حال اجراست.`
+ );
+ }
+ if (!res.ok) throw new Error("دریافت پیکربندی SSO ناموفق بود");
+ return res.json();
+}
+
+const POST_LOGIN_REDIRECT_KEY = "superapp_post_login_redirect";
+
+export function setPostLoginRedirect(path: string): void {
+ if (typeof window === "undefined") return;
+ sessionStorage.setItem(POST_LOGIN_REDIRECT_KEY, path);
+}
+
+export function consumePostLoginRedirect(fallback = "/dashboard"): string {
+ if (typeof window === "undefined") return fallback;
+ const path = sessionStorage.getItem(POST_LOGIN_REDIRECT_KEY) || fallback;
+ sessionStorage.removeItem(POST_LOGIN_REDIRECT_KEY);
+ return path;
+}
+
+export async function loginWithRedirect(redirectPath?: string): Promise {
+ if (redirectPath) setPostLoginRedirect(redirectPath);
+ const qs = redirectPath ? `?redirect=${encodeURIComponent(redirectPath)}` : "";
+ window.location.href = `/login${qs}`;
+}
+
+/** آدرس ورود (شامل state و PKCE challenge) را از سرور میگیرد. */
+export async function fetchLoginUrl(): Promise {
+ let res: Response;
+ try {
+ res = await fetch(`${IDENTITY_API}/api/v1/auth/login-url`);
+ } catch {
+ throw new Error(
+ `اتصال به سرویس احراز هویت برقرار نشد (${IDENTITY_API}). مطمئن شوید identity-access-service در حال اجراست.`
+ );
+ }
+ if (!res.ok) throw new Error("ساخت آدرس ورود ناموفق بود");
+ const data: LoginUrlResponse = await res.json();
+ return data.authorization_url;
+}
+
+export async function redeemHandoff(handoffCode: string): Promise {
+ const res = await fetch(`${IDENTITY_API}/api/v1/auth/session/redeem`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ handoff_code: handoffCode }),
+ });
+ if (!res.ok) {
+ let detail = "";
+ try {
+ const body = await res.json();
+ detail = body?.error?.message || body?.detail || "";
+ } catch {
+ /* ignore */
+ }
+ throw new Error(detail || "تبدیل session ناموفق بود");
+ }
+ return res.json();
+}
+
+export async function exchangeCode(
+ code: string,
+ redirectUri: string,
+ state: string | null
+): Promise {
+ const res = await fetch(`${IDENTITY_API}/api/v1/auth/token`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ code,
+ redirect_uri: redirectUri,
+ state,
+ }),
+ });
+ if (!res.ok) {
+ let detail = "";
+ try {
+ const body = await res.json();
+ detail = body?.error?.message || body?.detail || "";
+ } catch {
+ /* ignore */
+ }
+ throw new Error(
+ detail ? `تبدیل code به token ناموفق بود: ${detail}` : "تبدیل code به token ناموفق بود"
+ );
+ }
+ return res.json();
+}
+
+export async function fetchMe(token: string) {
+ const res = await fetch(`${IDENTITY_API}/api/v1/auth/me`, {
+ headers: { Authorization: `Bearer ${token}` },
+ });
+ if (!res.ok) throw new Error("دریافت اطلاعات کاربر ناموفق بود");
+ return res.json() as Promise<{
+ user_id: string;
+ email: string | null;
+ username: string | null;
+ mobile: string | null;
+ mobile_verified: boolean;
+ requires_mobile_verification: boolean;
+ roles: string[];
+ }>;
+}
+
+export async function logout(config: AuthConfig): Promise {
+ clearTokens();
+ const params = new URLSearchParams({
+ client_id: config.client_id,
+ post_logout_redirect_uri: window.location.origin,
+ });
+ window.location.href = `${config.logout_endpoint}?${params.toString()}`;
+}
diff --git a/frontend/lib/identity.ts b/frontend/lib/identity.ts
new file mode 100644
index 0000000..209902c
--- /dev/null
+++ b/frontend/lib/identity.ts
@@ -0,0 +1,247 @@
+/**
+ * API ثبتنام و تأیید موبایل — Identity Service
+ */
+
+import type { TokenBundle } from "@/lib/auth";
+
+const IDENTITY_API =
+ process.env.NEXT_PUBLIC_IDENTITY_API_URL || "http://localhost:8001";
+
+export interface OtpRequestResult {
+ message: string;
+ expires_in: number;
+ skipped?: boolean;
+}
+
+export interface MobileAuthStartResult extends OtpRequestResult {
+ intent: "login" | "register";
+}
+
+export interface AuthSessionResult extends TokenBundle {
+ message: string;
+ intent: "login" | "register";
+ user_id?: string;
+ mobile_verified: boolean;
+}
+
+export interface RegisterCompleteResult {
+ message: string;
+ mobile_verified: boolean;
+ user_id?: string;
+}
+
+export interface MeProfile {
+ user_id: string;
+ mobile: string | null;
+ mobile_verified: boolean;
+ requires_mobile_verification: boolean;
+}
+
+export interface TenantMembershipSummary {
+ tenant_id: string;
+ role: string;
+ is_active: boolean;
+}
+
+export interface AccountProfile {
+ user_id: string;
+ keycloak_sub: string;
+ email: string | null;
+ username: string | null;
+ display_name: string | null;
+ mobile: string | null;
+ mobile_verified: boolean;
+ requires_mobile_verification: boolean;
+ roles: string[];
+ tenant_memberships: TenantMembershipSummary[];
+}
+
+export interface AdminUser {
+ id: string;
+ keycloak_sub: string;
+ email: string;
+ username: string | null;
+ display_name: string | null;
+ mobile: string | null;
+ mobile_verified: boolean;
+ mobile_verified_at: string | null;
+ core_user_id: string | null;
+ status: string;
+ created_at: string;
+}
+
+export type MembershipRole = "tenant_admin" | "tenant_member";
+
+export interface TenantMember {
+ id: string;
+ tenant_id: string;
+ user_id: string;
+ role: MembershipRole;
+ is_active: boolean;
+ joined_at: string;
+}
+
+function parseIdentityError(body: Record, fallback: string): string {
+ const err = body?.error as { message?: string } | undefined;
+ if (err?.message) return err.message;
+ if (typeof body?.detail === "string") return body.detail;
+ if (Array.isArray(body?.detail) && body.detail[0]?.msg) {
+ return String(body.detail[0].msg);
+ }
+ return fallback;
+}
+
+async function identityRequest(
+ path: string,
+ options: RequestInit & { token?: string | null } = {}
+): Promise {
+ const { token, headers: customHeaders, ...init } = options;
+ const headers = new Headers(customHeaders);
+ headers.set("Content-Type", "application/json");
+ if (token) headers.set("Authorization", `Bearer ${token}`);
+
+ let res: Response;
+ try {
+ res = await fetch(`${IDENTITY_API}${path}`, { ...init, headers });
+ } catch {
+ throw new Error(`اتصال به سرویس هویت برقرار نشد (${IDENTITY_API})`);
+ }
+
+ if (!res.ok) {
+ const body = (await res.json().catch(() => ({}))) as Record;
+ throw new Error(parseIdentityError(body, res.statusText));
+ }
+ return res.json() as Promise;
+}
+
+export const identityApi = {
+ mobileStart: (mobile: string, forceSms = false) =>
+ identityRequest("/api/v1/auth/mobile/start", {
+ method: "POST",
+ body: JSON.stringify({ mobile, force_sms: forceSms }),
+ }),
+
+ mobileComplete: (payload: {
+ mobile: string;
+ code: string;
+ display_name?: string;
+ email?: string;
+ username?: string;
+ password?: string;
+ }) =>
+ identityRequest("/api/v1/auth/mobile/complete", {
+ method: "POST",
+ body: JSON.stringify(payload),
+ }),
+
+ register: (data: {
+ email: string;
+ username: string;
+ password: string;
+ mobile: string;
+ display_name?: string;
+ }) =>
+ identityRequest("/api/v1/auth/register", {
+ method: "POST",
+ body: JSON.stringify(data),
+ }),
+
+ verifyRegistrationMobile: (mobile: string, code: string) =>
+ identityRequest("/api/v1/auth/register/verify-mobile", {
+ method: "POST",
+ body: JSON.stringify({ mobile, code }),
+ }),
+
+ registerMobileRequest: (mobile: string, forceSms = false) =>
+ identityRequest("/api/v1/auth/register/mobile", {
+ method: "POST",
+ body: JSON.stringify({ mobile, force_sms: forceSms }),
+ }),
+
+ registerMobileVerify: (mobile: string, code: string, display_name?: string) =>
+ identityRequest("/api/v1/auth/register/mobile/verify", {
+ method: "POST",
+ body: JSON.stringify({ mobile, code, display_name }),
+ }),
+
+ requestMobileVerify: (mobile: string, token: string) =>
+ identityRequest("/api/v1/auth/mobile/request", {
+ method: "POST",
+ token,
+ body: JSON.stringify({ mobile }),
+ }),
+
+ verifyMobile: (mobile: string, code: string, token: string) =>
+ identityRequest("/api/v1/auth/mobile/verify", {
+ method: "POST",
+ token,
+ body: JSON.stringify({ mobile, code }),
+ }),
+
+ me: (token: string) =>
+ identityRequest("/api/v1/auth/me", { token }),
+
+ profile: (token: string) =>
+ identityRequest("/api/v1/auth/me", { token }),
+
+ updateProfile: (
+ token: string,
+ data: { display_name?: string | null; email?: string }
+ ) =>
+ identityRequest("/api/v1/auth/me", {
+ method: "PATCH",
+ token,
+ body: JSON.stringify(data),
+ }),
+
+ changePassword: (
+ token: string,
+ data: { current_password?: string; new_password: string }
+ ) =>
+ identityRequest<{ message: string }>("/api/v1/auth/password", {
+ method: "POST",
+ token,
+ body: JSON.stringify(data),
+ }),
+
+ listUsers: (token: string, options?: { offset?: number; limit?: number }) => {
+ const params = new URLSearchParams({
+ offset: String(options?.offset ?? 0),
+ limit: String(options?.limit ?? 50),
+ });
+ return identityRequest(`/api/v1/users?${params.toString()}`, { token });
+ },
+
+ getUser: (token: string, userId: string) =>
+ identityRequest(`/api/v1/users/${userId}`, { token }),
+
+ createUser: (
+ token: string,
+ data: {
+ email: string;
+ username: string;
+ password: string;
+ mobile: string;
+ display_name?: string;
+ }
+ ) =>
+ identityRequest("/api/v1/users", {
+ method: "POST",
+ token,
+ body: JSON.stringify(data),
+ }),
+
+ listMembers: (token: string, tenantId: string) =>
+ identityRequest(`/api/v1/tenants/${tenantId}/members`, { token }),
+
+ addMember: (
+ token: string,
+ tenantId: string,
+ data: { user_id: string; role?: MembershipRole }
+ ) =>
+ identityRequest(`/api/v1/tenants/${tenantId}/members`, {
+ method: "POST",
+ token,
+ body: JSON.stringify(data),
+ }),
+};
diff --git a/frontend/lib/optional-sso.ts b/frontend/lib/optional-sso.ts
new file mode 100644
index 0000000..4769c98
--- /dev/null
+++ b/frontend/lib/optional-sso.ts
@@ -0,0 +1,29 @@
+/**
+ * SSO اختیاری برای زیرسیستمها (مثلاً منوی دیجیتال کافه).
+ * اگر کاربر قبلاً در هسته مرکزی login کرده، دوباره login لازم نیست.
+ */
+
+import { getStoredToken, loginWithRedirect } from "@/lib/auth";
+
+/** آیا کاربر در SSO مرکزی احراز شده؟ */
+export function hasCentralSsoSession(): boolean {
+ return !!getStoredToken();
+}
+
+/**
+ * اگر SSO لازم باشد و session نباشد، به Keycloak هدایت میکند.
+ * @returns true اگر session موجود است (میتوان ادامه داد)
+ */
+export async function ensureCentralSsoOrRedirect(returnPath: string): Promise {
+ if (hasCentralSsoSession()) return true;
+ await loginWithRedirect(returnPath);
+ return false;
+}
+
+/**
+ * برای API زیرسیستم — هدر Authorization با توکن مرکزی (در صورت وجود).
+ */
+export function optionalSsoAuthHeader(): Record {
+ const token = getStoredToken();
+ return token ? { Authorization: `Bearer ${token}` } : {};
+}
diff --git a/frontend/lib/phone.ts b/frontend/lib/phone.ts
new file mode 100644
index 0000000..ab872d6
--- /dev/null
+++ b/frontend/lib/phone.ts
@@ -0,0 +1,47 @@
+/** نرمالسازی شماره موبایل — هممنطق torbatkar MinimalLogin */
+
+const PERSIAN = "۰۱۲۳۴۵۶۷۸۹";
+const ARABIC = "٠١٢٣٤٥٦٧٨٩";
+const LATIN = "0123456789";
+
+export function persianToEnglish(str: string): string {
+ if (!str) return "";
+ let result = str;
+ for (let i = 0; i < PERSIAN.length; i++) {
+ result = result.replace(new RegExp(PERSIAN[i], "g"), LATIN[i]);
+ result = result.replace(new RegExp(ARABIC[i], "g"), LATIN[i]);
+ }
+ return result;
+}
+
+export function normalizePhone(phone: string): string {
+ if (!phone?.trim()) return "";
+
+ let normalized = persianToEnglish(phone)
+ .trim()
+ .replace(/\s/g, "")
+ .replace(/-/g, "")
+ .replace(/\D/g, "");
+
+ if (!normalized) return "";
+
+ if (normalized.startsWith("0098")) normalized = normalized.slice(4);
+ else if (normalized.startsWith("98")) normalized = normalized.slice(2);
+
+ if (!normalized.startsWith("0")) {
+ if (normalized.length > 10) normalized = normalized.slice(-10);
+ normalized = "0" + normalized;
+ } else if (normalized.length > 11) {
+ normalized = normalized.slice(0, 11);
+ }
+
+ return normalized;
+}
+
+export function isValidMobile(phone: string): boolean {
+ return phone.startsWith("09") && phone.length === 11;
+}
+
+export function normalizeOtpCode(value: string): string {
+ return persianToEnglish(value).replace(/\D/g, "").slice(0, 4);
+}
diff --git a/frontend/lib/theme.ts b/frontend/lib/theme.ts
new file mode 100644
index 0000000..bd6a63f
--- /dev/null
+++ b/frontend/lib/theme.ts
@@ -0,0 +1,50 @@
+/**
+ * ابزار بارگذاری و اعمال تم White-label.
+ * برند از theme.config.json یا API تنظیمات خوانده میشود.
+ */
+
+export interface ThemeConfig {
+ site_name: string;
+ primary_color: string;
+ secondary_color: string;
+ logo_url: string;
+ support_email: string;
+}
+
+export const DEFAULT_THEME: ThemeConfig = {
+ site_name: "SuperApp",
+ primary_color: "#0284c7",
+ secondary_color: "#0f172a",
+ logo_url: "/assets/logo.png",
+ support_email: "support@example.com",
+};
+
+export function applyTheme(
+ theme: ThemeConfig,
+ root: HTMLElement = document.documentElement
+): void {
+ root.style.setProperty("--color-primary", theme.primary_color);
+ root.style.setProperty("--color-secondary", theme.secondary_color);
+ root.style.setProperty("--color-primary-muted", hexWithAlpha(theme.primary_color, 0.12));
+ root.style.setProperty("--color-primary-soft", hexWithAlpha(theme.primary_color, 0.08));
+ root.style.setProperty("--color-secondary-muted", hexWithAlpha(theme.secondary_color, 0.06));
+}
+
+function hexWithAlpha(hex: string, alpha: number): string {
+ const normalized = hex.replace("#", "");
+ if (normalized.length !== 6) return hex;
+ const r = parseInt(normalized.slice(0, 2), 16);
+ const g = parseInt(normalized.slice(2, 4), 16);
+ const b = parseInt(normalized.slice(4, 6), 16);
+ return `rgba(${r}, ${g}, ${b}, ${alpha})`;
+}
+
+export async function loadTheme(url = "/theme.config.json"): Promise {
+ try {
+ const res = await fetch(url);
+ if (!res.ok) return DEFAULT_THEME;
+ return { ...DEFAULT_THEME, ...(await res.json()) };
+ } catch {
+ return DEFAULT_THEME;
+ }
+}
diff --git a/frontend/next-env.d.ts b/frontend/next-env.d.ts
new file mode 100644
index 0000000..2a4d166
--- /dev/null
+++ b/frontend/next-env.d.ts
@@ -0,0 +1,6 @@
+///
+///
+///
+
+// NOTE: This file should not be edited
+// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
diff --git a/frontend/next.config.mjs b/frontend/next.config.mjs
new file mode 100644
index 0000000..3440d92
--- /dev/null
+++ b/frontend/next.config.mjs
@@ -0,0 +1,20 @@
+/** @type {import('next').NextConfig} */
+const nextConfig = {
+ // متغیرهای NEXT_PUBLIC_* از env کانتینر/محیط خوانده میشوند.
+ env: {
+ NEXT_PUBLIC_BACKEND_URL:
+ process.env.NEXT_PUBLIC_BACKEND_URL || "http://localhost:8000",
+ NEXT_PUBLIC_API_BASE_URL:
+ process.env.NEXT_PUBLIC_API_BASE_URL || "http://localhost:8000",
+ NEXT_PUBLIC_IDENTITY_API_URL:
+ process.env.NEXT_PUBLIC_IDENTITY_API_URL || "http://localhost:8001",
+ NEXT_PUBLIC_KEYCLOAK_URL:
+ process.env.NEXT_PUBLIC_KEYCLOAK_URL || "http://localhost:8080",
+ NEXT_PUBLIC_KEYCLOAK_REALM:
+ process.env.NEXT_PUBLIC_KEYCLOAK_REALM || "superapp",
+ NEXT_PUBLIC_KEYCLOAK_CLIENT_ID:
+ process.env.NEXT_PUBLIC_KEYCLOAK_CLIENT_ID || "superapp-frontend",
+ },
+};
+
+export default nextConfig;
diff --git a/frontend/package-lock.json b/frontend/package-lock.json
new file mode 100644
index 0000000..03dae11
--- /dev/null
+++ b/frontend/package-lock.json
@@ -0,0 +1,1641 @@
+{
+ "name": "superapp-frontend",
+ "version": "0.1.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "superapp-frontend",
+ "version": "0.1.0",
+ "dependencies": {
+ "next": "^14.2.0",
+ "react": "^18.3.0",
+ "react-dom": "^18.3.0"
+ },
+ "devDependencies": {
+ "@types/node": "^20.0.0",
+ "@types/react": "^18.3.0",
+ "@types/react-dom": "^18.3.0",
+ "autoprefixer": "^10.4.0",
+ "postcss": "^8.4.0",
+ "tailwindcss": "^3.4.0",
+ "typescript": "^5.4.0"
+ }
+ },
+ "node_modules/@alloc/quick-lru": {
+ "version": "5.2.0",
+ "resolved": "https://mirror.abrha.net/repository/npm/@alloc/quick-lru/-/quick-lru-5.2.0.tgz",
+ "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/@jridgewell/gen-mapping": {
+ "version": "0.3.13",
+ "resolved": "https://mirror.abrha.net/repository/npm/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
+ "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/sourcemap-codec": "^1.5.0",
+ "@jridgewell/trace-mapping": "^0.3.24"
+ }
+ },
+ "node_modules/@jridgewell/resolve-uri": {
+ "version": "3.1.2",
+ "resolved": "https://mirror.abrha.net/repository/npm/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
+ "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/@jridgewell/sourcemap-codec": {
+ "version": "1.5.5",
+ "resolved": "https://mirror.abrha.net/repository/npm/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
+ "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@jridgewell/trace-mapping": {
+ "version": "0.3.31",
+ "resolved": "https://mirror.abrha.net/repository/npm/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
+ "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/resolve-uri": "^3.1.0",
+ "@jridgewell/sourcemap-codec": "^1.4.14"
+ }
+ },
+ "node_modules/@next/env": {
+ "version": "14.2.35",
+ "resolved": "https://mirror.abrha.net/repository/npm/@next/env/-/env-14.2.35.tgz",
+ "integrity": "sha512-DuhvCtj4t9Gwrx80dmz2F4t/zKQ4ktN8WrMwOuVzkJfBilwAwGr6v16M5eI8yCuZ63H9TTuEU09Iu2HqkzFPVQ==",
+ "license": "MIT"
+ },
+ "node_modules/@next/swc-darwin-arm64": {
+ "version": "14.2.33",
+ "resolved": "https://mirror.abrha.net/repository/npm/@next/swc-darwin-arm64/-/swc-darwin-arm64-14.2.33.tgz",
+ "integrity": "sha512-HqYnb6pxlsshoSTubdXKu15g3iivcbsMXg4bYpjL2iS/V6aQot+iyF4BUc2qA/J/n55YtvE4PHMKWBKGCF/+wA==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@next/swc-darwin-x64": {
+ "version": "14.2.33",
+ "resolved": "https://mirror.abrha.net/repository/npm/@next/swc-darwin-x64/-/swc-darwin-x64-14.2.33.tgz",
+ "integrity": "sha512-8HGBeAE5rX3jzKvF593XTTFg3gxeU4f+UWnswa6JPhzaR6+zblO5+fjltJWIZc4aUalqTclvN2QtTC37LxvZAA==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@next/swc-linux-arm64-gnu": {
+ "version": "14.2.33",
+ "resolved": "https://mirror.abrha.net/repository/npm/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-14.2.33.tgz",
+ "integrity": "sha512-JXMBka6lNNmqbkvcTtaX8Gu5by9547bukHQvPoLe9VRBx1gHwzf5tdt4AaezW85HAB3pikcvyqBToRTDA4DeLw==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@next/swc-linux-arm64-musl": {
+ "version": "14.2.33",
+ "resolved": "https://mirror.abrha.net/repository/npm/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-14.2.33.tgz",
+ "integrity": "sha512-Bm+QulsAItD/x6Ih8wGIMfRJy4G73tu1HJsrccPW6AfqdZd0Sfm5Imhgkgq2+kly065rYMnCOxTBvmvFY1BKfg==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@next/swc-linux-x64-gnu": {
+ "version": "14.2.33",
+ "resolved": "https://mirror.abrha.net/repository/npm/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-14.2.33.tgz",
+ "integrity": "sha512-FnFn+ZBgsVMbGDsTqo8zsnRzydvsGV8vfiWwUo1LD8FTmPTdV+otGSWKc4LJec0oSexFnCYVO4hX8P8qQKaSlg==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@next/swc-linux-x64-musl": {
+ "version": "14.2.33",
+ "resolved": "https://mirror.abrha.net/repository/npm/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-14.2.33.tgz",
+ "integrity": "sha512-345tsIWMzoXaQndUTDv1qypDRiebFxGYx9pYkhwY4hBRaOLt8UGfiWKr9FSSHs25dFIf8ZqIFaPdy5MljdoawA==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@next/swc-win32-arm64-msvc": {
+ "version": "14.2.33",
+ "resolved": "https://mirror.abrha.net/repository/npm/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-14.2.33.tgz",
+ "integrity": "sha512-nscpt0G6UCTkrT2ppnJnFsYbPDQwmum4GNXYTeoTIdsmMydSKFz9Iny2jpaRupTb+Wl298+Rh82WKzt9LCcqSQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@next/swc-win32-ia32-msvc": {
+ "version": "14.2.33",
+ "resolved": "https://mirror.abrha.net/repository/npm/@next/swc-win32-ia32-msvc/-/swc-win32-ia32-msvc-14.2.33.tgz",
+ "integrity": "sha512-pc9LpGNKhJ0dXQhZ5QMmYxtARwwmWLpeocFmVG5Z0DzWq5Uf0izcI8tLc+qOpqxO1PWqZ5A7J1blrUIKrIFc7Q==",
+ "cpu": [
+ "ia32"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@next/swc-win32-x64-msvc": {
+ "version": "14.2.33",
+ "resolved": "https://mirror.abrha.net/repository/npm/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-14.2.33.tgz",
+ "integrity": "sha512-nOjfZMy8B94MdisuzZo9/57xuFVLHJaDj5e/xrduJp9CV2/HrfxTRH2fbyLe+K9QT41WBLUd4iXX3R7jBp0EUg==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@nodelib/fs.scandir": {
+ "version": "2.1.5",
+ "resolved": "https://mirror.abrha.net/repository/npm/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
+ "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@nodelib/fs.stat": "2.0.5",
+ "run-parallel": "^1.1.9"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/@nodelib/fs.stat": {
+ "version": "2.0.5",
+ "resolved": "https://mirror.abrha.net/repository/npm/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz",
+ "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/@nodelib/fs.walk": {
+ "version": "1.2.8",
+ "resolved": "https://mirror.abrha.net/repository/npm/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz",
+ "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@nodelib/fs.scandir": "2.1.5",
+ "fastq": "^1.6.0"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/@swc/counter": {
+ "version": "0.1.3",
+ "resolved": "https://mirror.abrha.net/repository/npm/@swc/counter/-/counter-0.1.3.tgz",
+ "integrity": "sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==",
+ "license": "Apache-2.0"
+ },
+ "node_modules/@swc/helpers": {
+ "version": "0.5.5",
+ "resolved": "https://mirror.abrha.net/repository/npm/@swc/helpers/-/helpers-0.5.5.tgz",
+ "integrity": "sha512-KGYxvIOXcceOAbEk4bi/dVLEK9z8sZ0uBB3Il5b1rhfClSpcX0yfRO0KmTkqR2cnQDymwLB+25ZyMzICg/cm/A==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@swc/counter": "^0.1.3",
+ "tslib": "^2.4.0"
+ }
+ },
+ "node_modules/@types/node": {
+ "version": "20.19.43",
+ "resolved": "https://mirror.abrha.net/repository/npm/@types/node/-/node-20.19.43.tgz",
+ "integrity": "sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "undici-types": "~6.21.0"
+ }
+ },
+ "node_modules/@types/prop-types": {
+ "version": "15.7.15",
+ "resolved": "https://mirror.abrha.net/repository/npm/@types/prop-types/-/prop-types-15.7.15.tgz",
+ "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/react": {
+ "version": "18.3.31",
+ "resolved": "https://mirror.abrha.net/repository/npm/@types/react/-/react-18.3.31.tgz",
+ "integrity": "sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "@types/prop-types": "*",
+ "csstype": "^3.2.2"
+ }
+ },
+ "node_modules/@types/react-dom": {
+ "version": "18.3.7",
+ "resolved": "https://mirror.abrha.net/repository/npm/@types/react-dom/-/react-dom-18.3.7.tgz",
+ "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==",
+ "dev": true,
+ "license": "MIT",
+ "peerDependencies": {
+ "@types/react": "^18.0.0"
+ }
+ },
+ "node_modules/any-promise": {
+ "version": "1.3.0",
+ "resolved": "https://mirror.abrha.net/repository/npm/any-promise/-/any-promise-1.3.0.tgz",
+ "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/anymatch": {
+ "version": "3.1.3",
+ "resolved": "https://mirror.abrha.net/repository/npm/anymatch/-/anymatch-3.1.3.tgz",
+ "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "normalize-path": "^3.0.0",
+ "picomatch": "^2.0.4"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/arg": {
+ "version": "5.0.2",
+ "resolved": "https://mirror.abrha.net/repository/npm/arg/-/arg-5.0.2.tgz",
+ "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/autoprefixer": {
+ "version": "10.5.2",
+ "resolved": "https://mirror.abrha.net/repository/npm/autoprefixer/-/autoprefixer-10.5.2.tgz",
+ "integrity": "sha512-rD5t5DwOjJdmSORcTq64j8MawTC+tbQ+HHqjR4NDumamy/ambn1UJrlKL+KdwujWxMkFjPM3pPHOEA9tl4767Q==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/autoprefixer"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "browserslist": "^4.28.4",
+ "caniuse-lite": "^1.0.30001799",
+ "fraction.js": "^5.3.4",
+ "picocolors": "^1.1.1",
+ "postcss-value-parser": "^4.2.0"
+ },
+ "bin": {
+ "autoprefixer": "bin/autoprefixer"
+ },
+ "engines": {
+ "node": "^10 || ^12 || >=14"
+ },
+ "peerDependencies": {
+ "postcss": "^8.1.0"
+ }
+ },
+ "node_modules/baseline-browser-mapping": {
+ "version": "2.10.42",
+ "resolved": "https://mirror.abrha.net/repository/npm/baseline-browser-mapping/-/baseline-browser-mapping-2.10.42.tgz",
+ "integrity": "sha512-c/jurFrDLyui7o1J86yLkRu4LMsTYcBohveus7/I2Hzdn9KIP2bdJPTue/lR1KH46enoPbD77GKeSYNdyPoD3Q==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "baseline-browser-mapping": "dist/cli.cjs"
+ },
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/binary-extensions": {
+ "version": "2.3.0",
+ "resolved": "https://mirror.abrha.net/repository/npm/binary-extensions/-/binary-extensions-2.3.0.tgz",
+ "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/braces": {
+ "version": "3.0.3",
+ "resolved": "https://mirror.abrha.net/repository/npm/braces/-/braces-3.0.3.tgz",
+ "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "fill-range": "^7.1.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/browserslist": {
+ "version": "4.28.4",
+ "resolved": "https://mirror.abrha.net/repository/npm/browserslist/-/browserslist-4.28.4.tgz",
+ "integrity": "sha512-MTc8i/x9jBQd1iMw2CFGS+rwMa07eYjLR0CCTLDACl9xhxy+nIs3KeML/biicXtk9JrZ6dnnTatmc7ErPXIxqw==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/browserslist"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/browserslist"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "baseline-browser-mapping": "^2.10.38",
+ "caniuse-lite": "^1.0.30001799",
+ "electron-to-chromium": "^1.5.376",
+ "node-releases": "^2.0.48",
+ "update-browserslist-db": "^1.2.3"
+ },
+ "bin": {
+ "browserslist": "cli.js"
+ },
+ "engines": {
+ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
+ }
+ },
+ "node_modules/busboy": {
+ "version": "1.6.0",
+ "resolved": "https://mirror.abrha.net/repository/npm/busboy/-/busboy-1.6.0.tgz",
+ "integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==",
+ "dependencies": {
+ "streamsearch": "^1.1.0"
+ },
+ "engines": {
+ "node": ">=10.16.0"
+ }
+ },
+ "node_modules/camelcase-css": {
+ "version": "2.0.1",
+ "resolved": "https://mirror.abrha.net/repository/npm/camelcase-css/-/camelcase-css-2.0.1.tgz",
+ "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/caniuse-lite": {
+ "version": "1.0.30001800",
+ "resolved": "https://mirror.abrha.net/repository/npm/caniuse-lite/-/caniuse-lite-1.0.30001800.tgz",
+ "integrity": "sha512-MMHtuAz9Ys840zAY5F4k6fV5GaivZ9sPk+nz0mY+GYVzRBnYkN0mpqkSR92oWRQ19yQWo4HvBV/FnC16AJX8MA==",
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/browserslist"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/caniuse-lite"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "CC-BY-4.0"
+ },
+ "node_modules/chokidar": {
+ "version": "3.6.0",
+ "resolved": "https://mirror.abrha.net/repository/npm/chokidar/-/chokidar-3.6.0.tgz",
+ "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "anymatch": "~3.1.2",
+ "braces": "~3.0.2",
+ "glob-parent": "~5.1.2",
+ "is-binary-path": "~2.1.0",
+ "is-glob": "~4.0.1",
+ "normalize-path": "~3.0.0",
+ "readdirp": "~3.6.0"
+ },
+ "engines": {
+ "node": ">= 8.10.0"
+ },
+ "funding": {
+ "url": "https://paulmillr.com/funding/"
+ },
+ "optionalDependencies": {
+ "fsevents": "~2.3.2"
+ }
+ },
+ "node_modules/chokidar/node_modules/glob-parent": {
+ "version": "5.1.2",
+ "resolved": "https://mirror.abrha.net/repository/npm/glob-parent/-/glob-parent-5.1.2.tgz",
+ "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "is-glob": "^4.0.1"
+ },
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/client-only": {
+ "version": "0.0.1",
+ "resolved": "https://mirror.abrha.net/repository/npm/client-only/-/client-only-0.0.1.tgz",
+ "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==",
+ "license": "MIT"
+ },
+ "node_modules/commander": {
+ "version": "4.1.1",
+ "resolved": "https://mirror.abrha.net/repository/npm/commander/-/commander-4.1.1.tgz",
+ "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/cssesc": {
+ "version": "3.0.0",
+ "resolved": "https://mirror.abrha.net/repository/npm/cssesc/-/cssesc-3.0.0.tgz",
+ "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "cssesc": "bin/cssesc"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/csstype": {
+ "version": "3.2.3",
+ "resolved": "https://mirror.abrha.net/repository/npm/csstype/-/csstype-3.2.3.tgz",
+ "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/didyoumean": {
+ "version": "1.2.2",
+ "resolved": "https://mirror.abrha.net/repository/npm/didyoumean/-/didyoumean-1.2.2.tgz",
+ "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==",
+ "dev": true,
+ "license": "Apache-2.0"
+ },
+ "node_modules/dlv": {
+ "version": "1.1.3",
+ "resolved": "https://mirror.abrha.net/repository/npm/dlv/-/dlv-1.1.3.tgz",
+ "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/electron-to-chromium": {
+ "version": "1.5.387",
+ "resolved": "https://mirror.abrha.net/repository/npm/electron-to-chromium/-/electron-to-chromium-1.5.387.tgz",
+ "integrity": "sha512-TaxwufTFDufvPEoXdhwVrA3UdFWBeWGkYoJ1K8ldF1xe6gKfth6iRNS5lTQ5JPNOHdGQm8PT1QYKUqFLCiUefQ==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/es-errors": {
+ "version": "1.3.0",
+ "resolved": "https://mirror.abrha.net/repository/npm/es-errors/-/es-errors-1.3.0.tgz",
+ "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/escalade": {
+ "version": "3.2.0",
+ "resolved": "https://mirror.abrha.net/repository/npm/escalade/-/escalade-3.2.0.tgz",
+ "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/fast-glob": {
+ "version": "3.3.3",
+ "resolved": "https://mirror.abrha.net/repository/npm/fast-glob/-/fast-glob-3.3.3.tgz",
+ "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@nodelib/fs.stat": "^2.0.2",
+ "@nodelib/fs.walk": "^1.2.3",
+ "glob-parent": "^5.1.2",
+ "merge2": "^1.3.0",
+ "micromatch": "^4.0.8"
+ },
+ "engines": {
+ "node": ">=8.6.0"
+ }
+ },
+ "node_modules/fast-glob/node_modules/glob-parent": {
+ "version": "5.1.2",
+ "resolved": "https://mirror.abrha.net/repository/npm/glob-parent/-/glob-parent-5.1.2.tgz",
+ "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "is-glob": "^4.0.1"
+ },
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/fastq": {
+ "version": "1.20.1",
+ "resolved": "https://mirror.abrha.net/repository/npm/fastq/-/fastq-1.20.1.tgz",
+ "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "reusify": "^1.0.4"
+ }
+ },
+ "node_modules/fill-range": {
+ "version": "7.1.1",
+ "resolved": "https://mirror.abrha.net/repository/npm/fill-range/-/fill-range-7.1.1.tgz",
+ "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "to-regex-range": "^5.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/fraction.js": {
+ "version": "5.3.4",
+ "resolved": "https://mirror.abrha.net/repository/npm/fraction.js/-/fraction.js-5.3.4.tgz",
+ "integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "*"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/rawify"
+ }
+ },
+ "node_modules/fsevents": {
+ "version": "2.3.3",
+ "resolved": "https://mirror.abrha.net/repository/npm/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://mirror.abrha.net/repository/npm/function-bind/-/function-bind-1.1.2.tgz",
+ "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
+ "dev": true,
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/glob-parent": {
+ "version": "6.0.2",
+ "resolved": "https://mirror.abrha.net/repository/npm/glob-parent/-/glob-parent-6.0.2.tgz",
+ "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "is-glob": "^4.0.3"
+ },
+ "engines": {
+ "node": ">=10.13.0"
+ }
+ },
+ "node_modules/graceful-fs": {
+ "version": "4.2.11",
+ "resolved": "https://mirror.abrha.net/repository/npm/graceful-fs/-/graceful-fs-4.2.11.tgz",
+ "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
+ "license": "ISC"
+ },
+ "node_modules/hasown": {
+ "version": "2.0.4",
+ "resolved": "https://mirror.abrha.net/repository/npm/hasown/-/hasown-2.0.4.tgz",
+ "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "function-bind": "^1.1.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/is-binary-path": {
+ "version": "2.1.0",
+ "resolved": "https://mirror.abrha.net/repository/npm/is-binary-path/-/is-binary-path-2.1.0.tgz",
+ "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "binary-extensions": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/is-core-module": {
+ "version": "2.16.2",
+ "resolved": "https://mirror.abrha.net/repository/npm/is-core-module/-/is-core-module-2.16.2.tgz",
+ "integrity": "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "hasown": "^2.0.3"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-extglob": {
+ "version": "2.1.1",
+ "resolved": "https://mirror.abrha.net/repository/npm/is-extglob/-/is-extglob-2.1.1.tgz",
+ "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/is-glob": {
+ "version": "4.0.3",
+ "resolved": "https://mirror.abrha.net/repository/npm/is-glob/-/is-glob-4.0.3.tgz",
+ "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "is-extglob": "^2.1.1"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/is-number": {
+ "version": "7.0.0",
+ "resolved": "https://mirror.abrha.net/repository/npm/is-number/-/is-number-7.0.0.tgz",
+ "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.12.0"
+ }
+ },
+ "node_modules/jiti": {
+ "version": "1.21.7",
+ "resolved": "https://mirror.abrha.net/repository/npm/jiti/-/jiti-1.21.7.tgz",
+ "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "bin": {
+ "jiti": "bin/jiti.js"
+ }
+ },
+ "node_modules/js-tokens": {
+ "version": "4.0.0",
+ "resolved": "https://mirror.abrha.net/repository/npm/js-tokens/-/js-tokens-4.0.0.tgz",
+ "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
+ "license": "MIT"
+ },
+ "node_modules/lilconfig": {
+ "version": "3.1.3",
+ "resolved": "https://mirror.abrha.net/repository/npm/lilconfig/-/lilconfig-3.1.3.tgz",
+ "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=14"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/antonk52"
+ }
+ },
+ "node_modules/lines-and-columns": {
+ "version": "1.2.4",
+ "resolved": "https://mirror.abrha.net/repository/npm/lines-and-columns/-/lines-and-columns-1.2.4.tgz",
+ "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/loose-envify": {
+ "version": "1.4.0",
+ "resolved": "https://mirror.abrha.net/repository/npm/loose-envify/-/loose-envify-1.4.0.tgz",
+ "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
+ "license": "MIT",
+ "dependencies": {
+ "js-tokens": "^3.0.0 || ^4.0.0"
+ },
+ "bin": {
+ "loose-envify": "cli.js"
+ }
+ },
+ "node_modules/merge2": {
+ "version": "1.4.1",
+ "resolved": "https://mirror.abrha.net/repository/npm/merge2/-/merge2-1.4.1.tgz",
+ "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/micromatch": {
+ "version": "4.0.8",
+ "resolved": "https://mirror.abrha.net/repository/npm/micromatch/-/micromatch-4.0.8.tgz",
+ "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "braces": "^3.0.3",
+ "picomatch": "^2.3.1"
+ },
+ "engines": {
+ "node": ">=8.6"
+ }
+ },
+ "node_modules/mz": {
+ "version": "2.7.0",
+ "resolved": "https://mirror.abrha.net/repository/npm/mz/-/mz-2.7.0.tgz",
+ "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "any-promise": "^1.0.0",
+ "object-assign": "^4.0.1",
+ "thenify-all": "^1.0.0"
+ }
+ },
+ "node_modules/nanoid": {
+ "version": "3.3.15",
+ "resolved": "https://mirror.abrha.net/repository/npm/nanoid/-/nanoid-3.3.15.tgz",
+ "integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "bin": {
+ "nanoid": "bin/nanoid.cjs"
+ },
+ "engines": {
+ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
+ }
+ },
+ "node_modules/next": {
+ "version": "14.2.35",
+ "resolved": "https://mirror.abrha.net/repository/npm/next/-/next-14.2.35.tgz",
+ "integrity": "sha512-KhYd2Hjt/O1/1aZVX3dCwGXM1QmOV4eNM2UTacK5gipDdPN/oHHK/4oVGy7X8GMfPMsUTUEmGlsy0EY1YGAkig==",
+ "license": "MIT",
+ "dependencies": {
+ "@next/env": "14.2.35",
+ "@swc/helpers": "0.5.5",
+ "busboy": "1.6.0",
+ "caniuse-lite": "^1.0.30001579",
+ "graceful-fs": "^4.2.11",
+ "postcss": "8.4.31",
+ "styled-jsx": "5.1.1"
+ },
+ "bin": {
+ "next": "dist/bin/next"
+ },
+ "engines": {
+ "node": ">=18.17.0"
+ },
+ "optionalDependencies": {
+ "@next/swc-darwin-arm64": "14.2.33",
+ "@next/swc-darwin-x64": "14.2.33",
+ "@next/swc-linux-arm64-gnu": "14.2.33",
+ "@next/swc-linux-arm64-musl": "14.2.33",
+ "@next/swc-linux-x64-gnu": "14.2.33",
+ "@next/swc-linux-x64-musl": "14.2.33",
+ "@next/swc-win32-arm64-msvc": "14.2.33",
+ "@next/swc-win32-ia32-msvc": "14.2.33",
+ "@next/swc-win32-x64-msvc": "14.2.33"
+ },
+ "peerDependencies": {
+ "@opentelemetry/api": "^1.1.0",
+ "@playwright/test": "^1.41.2",
+ "react": "^18.2.0",
+ "react-dom": "^18.2.0",
+ "sass": "^1.3.0"
+ },
+ "peerDependenciesMeta": {
+ "@opentelemetry/api": {
+ "optional": true
+ },
+ "@playwright/test": {
+ "optional": true
+ },
+ "sass": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/next/node_modules/postcss": {
+ "version": "8.4.31",
+ "resolved": "https://mirror.abrha.net/repository/npm/postcss/-/postcss-8.4.31.tgz",
+ "integrity": "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==",
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/postcss"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "nanoid": "^3.3.6",
+ "picocolors": "^1.0.0",
+ "source-map-js": "^1.0.2"
+ },
+ "engines": {
+ "node": "^10 || ^12 || >=14"
+ }
+ },
+ "node_modules/node-releases": {
+ "version": "2.0.50",
+ "resolved": "https://mirror.abrha.net/repository/npm/node-releases/-/node-releases-2.0.50.tgz",
+ "integrity": "sha512-J6l92tKHX6w8Jy5nO1Vuc01NoIiRGi/d6qBKVxh+IQ8Cr3b6HbVNfKiF8ZpFKufTwpwxMmce2W3iQZ861ZRyTg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/normalize-path": {
+ "version": "3.0.0",
+ "resolved": "https://mirror.abrha.net/repository/npm/normalize-path/-/normalize-path-3.0.0.tgz",
+ "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/object-assign": {
+ "version": "4.1.1",
+ "resolved": "https://mirror.abrha.net/repository/npm/object-assign/-/object-assign-4.1.1.tgz",
+ "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/object-hash": {
+ "version": "3.0.0",
+ "resolved": "https://mirror.abrha.net/repository/npm/object-hash/-/object-hash-3.0.0.tgz",
+ "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/path-parse": {
+ "version": "1.0.7",
+ "resolved": "https://mirror.abrha.net/repository/npm/path-parse/-/path-parse-1.0.7.tgz",
+ "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/picocolors": {
+ "version": "1.1.1",
+ "resolved": "https://mirror.abrha.net/repository/npm/picocolors/-/picocolors-1.1.1.tgz",
+ "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
+ "license": "ISC"
+ },
+ "node_modules/picomatch": {
+ "version": "2.3.2",
+ "resolved": "https://mirror.abrha.net/repository/npm/picomatch/-/picomatch-2.3.2.tgz",
+ "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8.6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
+ "node_modules/pify": {
+ "version": "2.3.0",
+ "resolved": "https://mirror.abrha.net/repository/npm/pify/-/pify-2.3.0.tgz",
+ "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/pirates": {
+ "version": "4.0.7",
+ "resolved": "https://mirror.abrha.net/repository/npm/pirates/-/pirates-4.0.7.tgz",
+ "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/postcss": {
+ "version": "8.5.16",
+ "resolved": "https://mirror.abrha.net/repository/npm/postcss/-/postcss-8.5.16.tgz",
+ "integrity": "sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/postcss"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "nanoid": "^3.3.12",
+ "picocolors": "^1.1.1",
+ "source-map-js": "^1.2.1"
+ },
+ "engines": {
+ "node": "^10 || ^12 || >=14"
+ }
+ },
+ "node_modules/postcss-import": {
+ "version": "15.1.0",
+ "resolved": "https://mirror.abrha.net/repository/npm/postcss-import/-/postcss-import-15.1.0.tgz",
+ "integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "postcss-value-parser": "^4.0.0",
+ "read-cache": "^1.0.0",
+ "resolve": "^1.1.7"
+ },
+ "engines": {
+ "node": ">=14.0.0"
+ },
+ "peerDependencies": {
+ "postcss": "^8.0.0"
+ }
+ },
+ "node_modules/postcss-js": {
+ "version": "4.1.0",
+ "resolved": "https://mirror.abrha.net/repository/npm/postcss-js/-/postcss-js-4.1.0.tgz",
+ "integrity": "sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "camelcase-css": "^2.0.1"
+ },
+ "engines": {
+ "node": "^12 || ^14 || >= 16"
+ },
+ "peerDependencies": {
+ "postcss": "^8.4.21"
+ }
+ },
+ "node_modules/postcss-load-config": {
+ "version": "6.0.1",
+ "resolved": "https://mirror.abrha.net/repository/npm/postcss-load-config/-/postcss-load-config-6.0.1.tgz",
+ "integrity": "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "lilconfig": "^3.1.1"
+ },
+ "engines": {
+ "node": ">= 18"
+ },
+ "peerDependencies": {
+ "jiti": ">=1.21.0",
+ "postcss": ">=8.0.9",
+ "tsx": "^4.8.1",
+ "yaml": "^2.4.2"
+ },
+ "peerDependenciesMeta": {
+ "jiti": {
+ "optional": true
+ },
+ "postcss": {
+ "optional": true
+ },
+ "tsx": {
+ "optional": true
+ },
+ "yaml": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/postcss-nested": {
+ "version": "6.2.0",
+ "resolved": "https://mirror.abrha.net/repository/npm/postcss-nested/-/postcss-nested-6.2.0.tgz",
+ "integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "postcss-selector-parser": "^6.1.1"
+ },
+ "engines": {
+ "node": ">=12.0"
+ },
+ "peerDependencies": {
+ "postcss": "^8.2.14"
+ }
+ },
+ "node_modules/postcss-selector-parser": {
+ "version": "6.1.4",
+ "resolved": "https://mirror.abrha.net/repository/npm/postcss-selector-parser/-/postcss-selector-parser-6.1.4.tgz",
+ "integrity": "sha512-bIoJLOmjCO1S9XdY/DcnR5hJxvrDir1PbGChrzXG3vw0/FOliy/fA3dmdhQ441kah4gKv+TwckGzex6wNS5cnQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "cssesc": "^3.0.0",
+ "util-deprecate": "^1.0.2"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/postcss-value-parser": {
+ "version": "4.2.0",
+ "resolved": "https://mirror.abrha.net/repository/npm/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz",
+ "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/queue-microtask": {
+ "version": "1.2.3",
+ "resolved": "https://mirror.abrha.net/repository/npm/queue-microtask/-/queue-microtask-1.2.3.tgz",
+ "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "MIT"
+ },
+ "node_modules/react": {
+ "version": "18.3.1",
+ "resolved": "https://mirror.abrha.net/repository/npm/react/-/react-18.3.1.tgz",
+ "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "loose-envify": "^1.1.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/react-dom": {
+ "version": "18.3.1",
+ "resolved": "https://mirror.abrha.net/repository/npm/react-dom/-/react-dom-18.3.1.tgz",
+ "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "loose-envify": "^1.1.0",
+ "scheduler": "^0.23.2"
+ },
+ "peerDependencies": {
+ "react": "^18.3.1"
+ }
+ },
+ "node_modules/read-cache": {
+ "version": "1.0.0",
+ "resolved": "https://mirror.abrha.net/repository/npm/read-cache/-/read-cache-1.0.0.tgz",
+ "integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "pify": "^2.3.0"
+ }
+ },
+ "node_modules/readdirp": {
+ "version": "3.6.0",
+ "resolved": "https://mirror.abrha.net/repository/npm/readdirp/-/readdirp-3.6.0.tgz",
+ "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "picomatch": "^2.2.1"
+ },
+ "engines": {
+ "node": ">=8.10.0"
+ }
+ },
+ "node_modules/resolve": {
+ "version": "1.22.12",
+ "resolved": "https://mirror.abrha.net/repository/npm/resolve/-/resolve-1.22.12.tgz",
+ "integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "is-core-module": "^2.16.1",
+ "path-parse": "^1.0.7",
+ "supports-preserve-symlinks-flag": "^1.0.0"
+ },
+ "bin": {
+ "resolve": "bin/resolve"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/reusify": {
+ "version": "1.1.0",
+ "resolved": "https://mirror.abrha.net/repository/npm/reusify/-/reusify-1.1.0.tgz",
+ "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "iojs": ">=1.0.0",
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/run-parallel": {
+ "version": "1.2.0",
+ "resolved": "https://mirror.abrha.net/repository/npm/run-parallel/-/run-parallel-1.2.0.tgz",
+ "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "queue-microtask": "^1.2.2"
+ }
+ },
+ "node_modules/scheduler": {
+ "version": "0.23.2",
+ "resolved": "https://mirror.abrha.net/repository/npm/scheduler/-/scheduler-0.23.2.tgz",
+ "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==",
+ "license": "MIT",
+ "dependencies": {
+ "loose-envify": "^1.1.0"
+ }
+ },
+ "node_modules/source-map-js": {
+ "version": "1.2.1",
+ "resolved": "https://mirror.abrha.net/repository/npm/source-map-js/-/source-map-js-1.2.1.tgz",
+ "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/streamsearch": {
+ "version": "1.1.0",
+ "resolved": "https://mirror.abrha.net/repository/npm/streamsearch/-/streamsearch-1.1.0.tgz",
+ "integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==",
+ "engines": {
+ "node": ">=10.0.0"
+ }
+ },
+ "node_modules/styled-jsx": {
+ "version": "5.1.1",
+ "resolved": "https://mirror.abrha.net/repository/npm/styled-jsx/-/styled-jsx-5.1.1.tgz",
+ "integrity": "sha512-pW7uC1l4mBZ8ugbiZrcIsiIvVx1UmTfw7UkC3Um2tmfUq9Bhk8IiyEIPl6F8agHgjzku6j0xQEZbfA5uSgSaCw==",
+ "license": "MIT",
+ "dependencies": {
+ "client-only": "0.0.1"
+ },
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "peerDependencies": {
+ "react": ">= 16.8.0 || 17.x.x || ^18.0.0-0"
+ },
+ "peerDependenciesMeta": {
+ "@babel/core": {
+ "optional": true
+ },
+ "babel-plugin-macros": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/sucrase": {
+ "version": "3.35.1",
+ "resolved": "https://mirror.abrha.net/repository/npm/sucrase/-/sucrase-3.35.1.tgz",
+ "integrity": "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/gen-mapping": "^0.3.2",
+ "commander": "^4.0.0",
+ "lines-and-columns": "^1.1.6",
+ "mz": "^2.7.0",
+ "pirates": "^4.0.1",
+ "tinyglobby": "^0.2.11",
+ "ts-interface-checker": "^0.1.9"
+ },
+ "bin": {
+ "sucrase": "bin/sucrase",
+ "sucrase-node": "bin/sucrase-node"
+ },
+ "engines": {
+ "node": ">=16 || 14 >=14.17"
+ }
+ },
+ "node_modules/supports-preserve-symlinks-flag": {
+ "version": "1.0.0",
+ "resolved": "https://mirror.abrha.net/repository/npm/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz",
+ "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/tailwindcss": {
+ "version": "3.4.19",
+ "resolved": "https://mirror.abrha.net/repository/npm/tailwindcss/-/tailwindcss-3.4.19.tgz",
+ "integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@alloc/quick-lru": "^5.2.0",
+ "arg": "^5.0.2",
+ "chokidar": "^3.6.0",
+ "didyoumean": "^1.2.2",
+ "dlv": "^1.1.3",
+ "fast-glob": "^3.3.2",
+ "glob-parent": "^6.0.2",
+ "is-glob": "^4.0.3",
+ "jiti": "^1.21.7",
+ "lilconfig": "^3.1.3",
+ "micromatch": "^4.0.8",
+ "normalize-path": "^3.0.0",
+ "object-hash": "^3.0.0",
+ "picocolors": "^1.1.1",
+ "postcss": "^8.4.47",
+ "postcss-import": "^15.1.0",
+ "postcss-js": "^4.0.1",
+ "postcss-load-config": "^4.0.2 || ^5.0 || ^6.0",
+ "postcss-nested": "^6.2.0",
+ "postcss-selector-parser": "^6.1.2",
+ "resolve": "^1.22.8",
+ "sucrase": "^3.35.0"
+ },
+ "bin": {
+ "tailwind": "lib/cli.js",
+ "tailwindcss": "lib/cli.js"
+ },
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
+ "node_modules/thenify": {
+ "version": "3.3.1",
+ "resolved": "https://mirror.abrha.net/repository/npm/thenify/-/thenify-3.3.1.tgz",
+ "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "any-promise": "^1.0.0"
+ }
+ },
+ "node_modules/thenify-all": {
+ "version": "1.6.0",
+ "resolved": "https://mirror.abrha.net/repository/npm/thenify-all/-/thenify-all-1.6.0.tgz",
+ "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "thenify": ">= 3.1.0 < 4"
+ },
+ "engines": {
+ "node": ">=0.8"
+ }
+ },
+ "node_modules/tinyglobby": {
+ "version": "0.2.17",
+ "resolved": "https://mirror.abrha.net/repository/npm/tinyglobby/-/tinyglobby-0.2.17.tgz",
+ "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "fdir": "^6.5.0",
+ "picomatch": "^4.0.4"
+ },
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/SuperchupuDev"
+ }
+ },
+ "node_modules/tinyglobby/node_modules/fdir": {
+ "version": "6.5.0",
+ "resolved": "https://mirror.abrha.net/repository/npm/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/tinyglobby/node_modules/picomatch": {
+ "version": "4.0.5",
+ "resolved": "https://mirror.abrha.net/repository/npm/picomatch/-/picomatch-4.0.5.tgz",
+ "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
+ "node_modules/to-regex-range": {
+ "version": "5.0.1",
+ "resolved": "https://mirror.abrha.net/repository/npm/to-regex-range/-/to-regex-range-5.0.1.tgz",
+ "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "is-number": "^7.0.0"
+ },
+ "engines": {
+ "node": ">=8.0"
+ }
+ },
+ "node_modules/ts-interface-checker": {
+ "version": "0.1.13",
+ "resolved": "https://mirror.abrha.net/repository/npm/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz",
+ "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==",
+ "dev": true,
+ "license": "Apache-2.0"
+ },
+ "node_modules/tslib": {
+ "version": "2.8.1",
+ "resolved": "https://mirror.abrha.net/repository/npm/tslib/-/tslib-2.8.1.tgz",
+ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
+ "license": "0BSD"
+ },
+ "node_modules/typescript": {
+ "version": "5.9.3",
+ "resolved": "https://mirror.abrha.net/repository/npm/typescript/-/typescript-5.9.3.tgz",
+ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "tsc": "bin/tsc",
+ "tsserver": "bin/tsserver"
+ },
+ "engines": {
+ "node": ">=14.17"
+ }
+ },
+ "node_modules/undici-types": {
+ "version": "6.21.0",
+ "resolved": "https://mirror.abrha.net/repository/npm/undici-types/-/undici-types-6.21.0.tgz",
+ "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/update-browserslist-db": {
+ "version": "1.2.3",
+ "resolved": "https://mirror.abrha.net/repository/npm/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
+ "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/browserslist"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/browserslist"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "escalade": "^3.2.0",
+ "picocolors": "^1.1.1"
+ },
+ "bin": {
+ "update-browserslist-db": "cli.js"
+ },
+ "peerDependencies": {
+ "browserslist": ">= 4.21.0"
+ }
+ },
+ "node_modules/util-deprecate": {
+ "version": "1.0.2",
+ "resolved": "https://mirror.abrha.net/repository/npm/util-deprecate/-/util-deprecate-1.0.2.tgz",
+ "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
+ "dev": true,
+ "license": "MIT"
+ }
+ }
+}
diff --git a/frontend/package.json b/frontend/package.json
new file mode 100644
index 0000000..8f9b93e
--- /dev/null
+++ b/frontend/package.json
@@ -0,0 +1,25 @@
+{
+ "name": "superapp-frontend",
+ "version": "0.1.0",
+ "private": true,
+ "scripts": {
+ "dev": "next dev --hostname 0.0.0.0 --port 3000",
+ "build": "next build",
+ "start": "next start --hostname 0.0.0.0 --port 3000",
+ "lint": "next lint"
+ },
+ "dependencies": {
+ "next": "15.5.18",
+ "react": "^19.0.0",
+ "react-dom": "^19.0.0"
+ },
+ "devDependencies": {
+ "@types/node": "^20.0.0",
+ "@types/react": "^19.0.0",
+ "@types/react-dom": "^19.0.0",
+ "autoprefixer": "^10.4.0",
+ "postcss": "^8.4.0",
+ "tailwindcss": "^3.4.0",
+ "typescript": "^5.4.0"
+ }
+}
diff --git a/frontend/postcss.config.mjs b/frontend/postcss.config.mjs
new file mode 100644
index 0000000..7455d3e
--- /dev/null
+++ b/frontend/postcss.config.mjs
@@ -0,0 +1,9 @@
+/** @type {import('postcss-load-config').Config} */
+const config = {
+ plugins: {
+ tailwindcss: {},
+ autoprefixer: {},
+ },
+};
+
+export default config;
diff --git a/frontend/public/fonts/yekan-bakh/YekanBakhFaNum-Bold.woff b/frontend/public/fonts/yekan-bakh/YekanBakhFaNum-Bold.woff
new file mode 100644
index 0000000..d1c239f
Binary files /dev/null and b/frontend/public/fonts/yekan-bakh/YekanBakhFaNum-Bold.woff differ
diff --git a/frontend/public/fonts/yekan-bakh/YekanBakhFaNum-ExtraBold.woff b/frontend/public/fonts/yekan-bakh/YekanBakhFaNum-ExtraBold.woff
new file mode 100644
index 0000000..8a4f634
Binary files /dev/null and b/frontend/public/fonts/yekan-bakh/YekanBakhFaNum-ExtraBold.woff differ
diff --git a/frontend/public/fonts/yekan-bakh/YekanBakhFaNum-Light.woff b/frontend/public/fonts/yekan-bakh/YekanBakhFaNum-Light.woff
new file mode 100644
index 0000000..4a1d8dd
Binary files /dev/null and b/frontend/public/fonts/yekan-bakh/YekanBakhFaNum-Light.woff differ
diff --git a/frontend/public/fonts/yekan-bakh/YekanBakhFaNum-Regular.woff b/frontend/public/fonts/yekan-bakh/YekanBakhFaNum-Regular.woff
new file mode 100644
index 0000000..3eda900
Binary files /dev/null and b/frontend/public/fonts/yekan-bakh/YekanBakhFaNum-Regular.woff differ
diff --git a/frontend/public/fonts/yekan-bakh/YekanBakhFaNum-SemiBold.woff b/frontend/public/fonts/yekan-bakh/YekanBakhFaNum-SemiBold.woff
new file mode 100644
index 0000000..aa69a78
Binary files /dev/null and b/frontend/public/fonts/yekan-bakh/YekanBakhFaNum-SemiBold.woff differ
diff --git a/frontend/public/fonts/yekan-bakh/yekan-bakh.css b/frontend/public/fonts/yekan-bakh/yekan-bakh.css
new file mode 100644
index 0000000..57adf27
--- /dev/null
+++ b/frontend/public/fonts/yekan-bakh/yekan-bakh.css
@@ -0,0 +1,66 @@
+/* Yekan Bakh FaNum Font Family */
+@font-face {
+ font-family: 'Yekan Bakh FaNum';
+ font-style: normal;
+ font-weight: 300;
+ font-display: swap;
+ src: url('./YekanBakhFaNum-Light.woff') format('woff');
+}
+
+@font-face {
+ font-family: 'Yekan Bakh FaNum';
+ font-style: normal;
+ font-weight: 400;
+ font-display: swap;
+ src: url('./YekanBakhFaNum-Regular.woff') format('woff');
+}
+
+@font-face {
+ font-family: 'Yekan Bakh FaNum';
+ font-style: normal;
+ font-weight: 600;
+ font-display: swap;
+ src: url('./YekanBakhFaNum-SemiBold.woff') format('woff');
+}
+
+@font-face {
+ font-family: 'Yekan Bakh FaNum';
+ font-style: normal;
+ font-weight: 700;
+ font-display: swap;
+ src: url('./YekanBakhFaNum-Bold.woff') format('woff');
+}
+
+@font-face {
+ font-family: 'Yekan Bakh FaNum';
+ font-style: normal;
+ font-weight: 800;
+ font-display: swap;
+ src: url('./YekanBakhFaNum-ExtraBold.woff') format('woff');
+}
+
+/* Font Family Classes */
+.font-yekan-light {
+ font-family: 'Yekan Bakh FaNum', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
+ font-weight: 300;
+}
+
+.font-yekan-regular {
+ font-family: 'Yekan Bakh FaNum', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
+ font-weight: 400;
+}
+
+.font-yekan-semibold {
+ font-family: 'Yekan Bakh FaNum', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
+ font-weight: 600;
+}
+
+.font-yekan-bold {
+ font-family: 'Yekan Bakh FaNum', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
+ font-weight: 700;
+}
+
+.font-yekan-extrabold {
+ font-family: 'Yekan Bakh FaNum', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
+ font-weight: 800;
+}
diff --git a/frontend/public/theme.config.json b/frontend/public/theme.config.json
new file mode 100644
index 0000000..52cf46b
--- /dev/null
+++ b/frontend/public/theme.config.json
@@ -0,0 +1,7 @@
+{
+ "site_name": "Torbatyar",
+ "primary_color": "#0284c7",
+ "secondary_color": "#0f172a",
+ "logo_url": "/assets/logo.png",
+ "support_email": "support@example.com"
+}
diff --git a/frontend/styles/globals.css b/frontend/styles/globals.css
new file mode 100644
index 0000000..8135db2
--- /dev/null
+++ b/frontend/styles/globals.css
@@ -0,0 +1,27 @@
+@import url("/fonts/yekan-bakh/yekan-bakh.css");
+
+@tailwind base;
+@tailwind components;
+@tailwind utilities;
+
+:root {
+ --color-primary: #0284c7;
+ --color-secondary: #0f172a;
+ --color-primary-muted: rgba(2, 132, 199, 0.12);
+ --color-primary-soft: rgba(2, 132, 199, 0.08);
+ --color-secondary-muted: rgba(15, 23, 42, 0.06);
+}
+
+body {
+ font-family: "Yekan Bakh FaNum", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
+ @apply bg-white text-secondary antialiased;
+}
+
+@layer utilities {
+ .line-clamp-2 {
+ display: -webkit-box;
+ -webkit-line-clamp: 2;
+ -webkit-box-orient: vertical;
+ overflow: hidden;
+ }
+}
diff --git a/frontend/tailwind.config.ts b/frontend/tailwind.config.ts
new file mode 100644
index 0000000..bb95e69
--- /dev/null
+++ b/frontend/tailwind.config.ts
@@ -0,0 +1,27 @@
+import type { Config } from "tailwindcss";
+
+/**
+ * پیکربندی Tailwind با رویکرد White-label.
+ * رنگها به CSS Variables متصل هستند تا با تغییر theme.config.json
+ * برند بدون build مجدد قابل تغییر باشد.
+ */
+const config: Config = {
+ content: [
+ "./app/**/*.{ts,tsx}",
+ "./components/**/*.{ts,tsx}",
+ ],
+ theme: {
+ extend: {
+ fontFamily: {
+ sans: ['"Yekan Bakh FaNum"', "system-ui", "sans-serif"],
+ },
+ colors: {
+ primary: "var(--color-primary)",
+ secondary: "var(--color-secondary)",
+ },
+ },
+ },
+ plugins: [],
+};
+
+export default config;
diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json
new file mode 100644
index 0000000..42f1dd0
--- /dev/null
+++ b/frontend/tsconfig.json
@@ -0,0 +1,21 @@
+{
+ "compilerOptions": {
+ "target": "ES2017",
+ "lib": ["dom", "dom.iterable", "esnext"],
+ "allowJs": true,
+ "skipLibCheck": true,
+ "strict": true,
+ "noEmit": true,
+ "esModuleInterop": true,
+ "module": "esnext",
+ "moduleResolution": "bundler",
+ "resolveJsonModule": true,
+ "isolatedModules": true,
+ "jsx": "preserve",
+ "incremental": true,
+ "plugins": [{ "name": "next" }],
+ "paths": { "@/*": ["./*"] }
+ },
+ "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
+ "exclude": ["node_modules"]
+}
diff --git a/frontend/tsconfig.tsbuildinfo b/frontend/tsconfig.tsbuildinfo
new file mode 100644
index 0000000..74efe1a
--- /dev/null
+++ b/frontend/tsconfig.tsbuildinfo
@@ -0,0 +1 @@
+{"fileNames":["./node_modules/typescript/lib/lib.es5.d.ts","./node_modules/typescript/lib/lib.es2015.d.ts","./node_modules/typescript/lib/lib.es2016.d.ts","./node_modules/typescript/lib/lib.es2017.d.ts","./node_modules/typescript/lib/lib.es2018.d.ts","./node_modules/typescript/lib/lib.es2019.d.ts","./node_modules/typescript/lib/lib.es2020.d.ts","./node_modules/typescript/lib/lib.es2021.d.ts","./node_modules/typescript/lib/lib.es2022.d.ts","./node_modules/typescript/lib/lib.es2023.d.ts","./node_modules/typescript/lib/lib.es2024.d.ts","./node_modules/typescript/lib/lib.esnext.d.ts","./node_modules/typescript/lib/lib.dom.d.ts","./node_modules/typescript/lib/lib.dom.iterable.d.ts","./node_modules/typescript/lib/lib.es2015.core.d.ts","./node_modules/typescript/lib/lib.es2015.collection.d.ts","./node_modules/typescript/lib/lib.es2015.generator.d.ts","./node_modules/typescript/lib/lib.es2015.iterable.d.ts","./node_modules/typescript/lib/lib.es2015.promise.d.ts","./node_modules/typescript/lib/lib.es2015.proxy.d.ts","./node_modules/typescript/lib/lib.es2015.reflect.d.ts","./node_modules/typescript/lib/lib.es2015.symbol.d.ts","./node_modules/typescript/lib/lib.es2015.symbol.wellknown.d.ts","./node_modules/typescript/lib/lib.es2016.array.include.d.ts","./node_modules/typescript/lib/lib.es2016.intl.d.ts","./node_modules/typescript/lib/lib.es2017.arraybuffer.d.ts","./node_modules/typescript/lib/lib.es2017.date.d.ts","./node_modules/typescript/lib/lib.es2017.object.d.ts","./node_modules/typescript/lib/lib.es2017.sharedmemory.d.ts","./node_modules/typescript/lib/lib.es2017.string.d.ts","./node_modules/typescript/lib/lib.es2017.intl.d.ts","./node_modules/typescript/lib/lib.es2017.typedarrays.d.ts","./node_modules/typescript/lib/lib.es2018.asyncgenerator.d.ts","./node_modules/typescript/lib/lib.es2018.asynciterable.d.ts","./node_modules/typescript/lib/lib.es2018.intl.d.ts","./node_modules/typescript/lib/lib.es2018.promise.d.ts","./node_modules/typescript/lib/lib.es2018.regexp.d.ts","./node_modules/typescript/lib/lib.es2019.array.d.ts","./node_modules/typescript/lib/lib.es2019.object.d.ts","./node_modules/typescript/lib/lib.es2019.string.d.ts","./node_modules/typescript/lib/lib.es2019.symbol.d.ts","./node_modules/typescript/lib/lib.es2019.intl.d.ts","./node_modules/typescript/lib/lib.es2020.bigint.d.ts","./node_modules/typescript/lib/lib.es2020.date.d.ts","./node_modules/typescript/lib/lib.es2020.promise.d.ts","./node_modules/typescript/lib/lib.es2020.sharedmemory.d.ts","./node_modules/typescript/lib/lib.es2020.string.d.ts","./node_modules/typescript/lib/lib.es2020.symbol.wellknown.d.ts","./node_modules/typescript/lib/lib.es2020.intl.d.ts","./node_modules/typescript/lib/lib.es2020.number.d.ts","./node_modules/typescript/lib/lib.es2021.promise.d.ts","./node_modules/typescript/lib/lib.es2021.string.d.ts","./node_modules/typescript/lib/lib.es2021.weakref.d.ts","./node_modules/typescript/lib/lib.es2021.intl.d.ts","./node_modules/typescript/lib/lib.es2022.array.d.ts","./node_modules/typescript/lib/lib.es2022.error.d.ts","./node_modules/typescript/lib/lib.es2022.intl.d.ts","./node_modules/typescript/lib/lib.es2022.object.d.ts","./node_modules/typescript/lib/lib.es2022.string.d.ts","./node_modules/typescript/lib/lib.es2022.regexp.d.ts","./node_modules/typescript/lib/lib.es2023.array.d.ts","./node_modules/typescript/lib/lib.es2023.collection.d.ts","./node_modules/typescript/lib/lib.es2023.intl.d.ts","./node_modules/typescript/lib/lib.es2024.arraybuffer.d.ts","./node_modules/typescript/lib/lib.es2024.collection.d.ts","./node_modules/typescript/lib/lib.es2024.object.d.ts","./node_modules/typescript/lib/lib.es2024.promise.d.ts","./node_modules/typescript/lib/lib.es2024.regexp.d.ts","./node_modules/typescript/lib/lib.es2024.sharedmemory.d.ts","./node_modules/typescript/lib/lib.es2024.string.d.ts","./node_modules/typescript/lib/lib.esnext.array.d.ts","./node_modules/typescript/lib/lib.esnext.collection.d.ts","./node_modules/typescript/lib/lib.esnext.intl.d.ts","./node_modules/typescript/lib/lib.esnext.disposable.d.ts","./node_modules/typescript/lib/lib.esnext.promise.d.ts","./node_modules/typescript/lib/lib.esnext.decorators.d.ts","./node_modules/typescript/lib/lib.esnext.iterator.d.ts","./node_modules/typescript/lib/lib.esnext.float16.d.ts","./node_modules/typescript/lib/lib.esnext.error.d.ts","./node_modules/typescript/lib/lib.esnext.sharedmemory.d.ts","./node_modules/typescript/lib/lib.decorators.d.ts","./node_modules/typescript/lib/lib.decorators.legacy.d.ts","./.next/types/routes.d.ts","./node_modules/@types/react/global.d.ts","./node_modules/csstype/index.d.ts","./node_modules/@types/react/index.d.ts","./node_modules/next/dist/styled-jsx/types/css.d.ts","./node_modules/next/dist/styled-jsx/types/macro.d.ts","./node_modules/next/dist/styled-jsx/types/style.d.ts","./node_modules/next/dist/styled-jsx/types/global.d.ts","./node_modules/next/dist/styled-jsx/types/index.d.ts","./node_modules/next/dist/shared/lib/amp.d.ts","./node_modules/next/amp.d.ts","./node_modules/next/dist/server/get-page-files.d.ts","./node_modules/@types/node/compatibility/disposable.d.ts","./node_modules/@types/node/compatibility/indexable.d.ts","./node_modules/@types/node/compatibility/iterators.d.ts","./node_modules/@types/node/compatibility/index.d.ts","./node_modules/@types/node/globals.typedarray.d.ts","./node_modules/@types/node/buffer.buffer.d.ts","./node_modules/@types/node/globals.d.ts","./node_modules/@types/node/web-globals/abortcontroller.d.ts","./node_modules/@types/node/web-globals/domexception.d.ts","./node_modules/@types/node/web-globals/events.d.ts","./node_modules/undici-types/header.d.ts","./node_modules/undici-types/readable.d.ts","./node_modules/undici-types/file.d.ts","./node_modules/undici-types/fetch.d.ts","./node_modules/undici-types/formdata.d.ts","./node_modules/undici-types/connector.d.ts","./node_modules/undici-types/client.d.ts","./node_modules/undici-types/errors.d.ts","./node_modules/undici-types/dispatcher.d.ts","./node_modules/undici-types/global-dispatcher.d.ts","./node_modules/undici-types/global-origin.d.ts","./node_modules/undici-types/pool-stats.d.ts","./node_modules/undici-types/pool.d.ts","./node_modules/undici-types/handlers.d.ts","./node_modules/undici-types/balanced-pool.d.ts","./node_modules/undici-types/agent.d.ts","./node_modules/undici-types/mock-interceptor.d.ts","./node_modules/undici-types/mock-agent.d.ts","./node_modules/undici-types/mock-client.d.ts","./node_modules/undici-types/mock-pool.d.ts","./node_modules/undici-types/mock-errors.d.ts","./node_modules/undici-types/proxy-agent.d.ts","./node_modules/undici-types/env-http-proxy-agent.d.ts","./node_modules/undici-types/retry-handler.d.ts","./node_modules/undici-types/retry-agent.d.ts","./node_modules/undici-types/api.d.ts","./node_modules/undici-types/interceptors.d.ts","./node_modules/undici-types/util.d.ts","./node_modules/undici-types/cookies.d.ts","./node_modules/undici-types/patch.d.ts","./node_modules/undici-types/websocket.d.ts","./node_modules/undici-types/eventsource.d.ts","./node_modules/undici-types/filereader.d.ts","./node_modules/undici-types/diagnostics-channel.d.ts","./node_modules/undici-types/content-type.d.ts","./node_modules/undici-types/cache.d.ts","./node_modules/undici-types/index.d.ts","./node_modules/@types/node/web-globals/fetch.d.ts","./node_modules/@types/node/assert.d.ts","./node_modules/@types/node/assert/strict.d.ts","./node_modules/@types/node/async_hooks.d.ts","./node_modules/@types/node/buffer.d.ts","./node_modules/@types/node/child_process.d.ts","./node_modules/@types/node/cluster.d.ts","./node_modules/@types/node/console.d.ts","./node_modules/@types/node/constants.d.ts","./node_modules/@types/node/crypto.d.ts","./node_modules/@types/node/dgram.d.ts","./node_modules/@types/node/diagnostics_channel.d.ts","./node_modules/@types/node/dns.d.ts","./node_modules/@types/node/dns/promises.d.ts","./node_modules/@types/node/domain.d.ts","./node_modules/@types/node/events.d.ts","./node_modules/@types/node/fs.d.ts","./node_modules/@types/node/fs/promises.d.ts","./node_modules/@types/node/http.d.ts","./node_modules/@types/node/http2.d.ts","./node_modules/@types/node/https.d.ts","./node_modules/@types/node/inspector.generated.d.ts","./node_modules/@types/node/module.d.ts","./node_modules/@types/node/net.d.ts","./node_modules/@types/node/os.d.ts","./node_modules/@types/node/path.d.ts","./node_modules/@types/node/perf_hooks.d.ts","./node_modules/@types/node/process.d.ts","./node_modules/@types/node/punycode.d.ts","./node_modules/@types/node/querystring.d.ts","./node_modules/@types/node/readline.d.ts","./node_modules/@types/node/readline/promises.d.ts","./node_modules/@types/node/repl.d.ts","./node_modules/@types/node/sea.d.ts","./node_modules/@types/node/stream.d.ts","./node_modules/@types/node/stream/promises.d.ts","./node_modules/@types/node/stream/consumers.d.ts","./node_modules/@types/node/stream/web.d.ts","./node_modules/@types/node/string_decoder.d.ts","./node_modules/@types/node/test.d.ts","./node_modules/@types/node/timers.d.ts","./node_modules/@types/node/timers/promises.d.ts","./node_modules/@types/node/tls.d.ts","./node_modules/@types/node/trace_events.d.ts","./node_modules/@types/node/tty.d.ts","./node_modules/@types/node/url.d.ts","./node_modules/@types/node/util.d.ts","./node_modules/@types/node/v8.d.ts","./node_modules/@types/node/vm.d.ts","./node_modules/@types/node/wasi.d.ts","./node_modules/@types/node/worker_threads.d.ts","./node_modules/@types/node/zlib.d.ts","./node_modules/@types/node/index.d.ts","./node_modules/@types/react/canary.d.ts","./node_modules/@types/react/experimental.d.ts","./node_modules/@types/react-dom/index.d.ts","./node_modules/@types/react-dom/canary.d.ts","./node_modules/@types/react-dom/experimental.d.ts","./node_modules/next/dist/lib/fallback.d.ts","./node_modules/next/dist/compiled/webpack/webpack.d.ts","./node_modules/next/dist/server/config.d.ts","./node_modules/next/dist/lib/load-custom-routes.d.ts","./node_modules/next/dist/shared/lib/image-config.d.ts","./node_modules/next/dist/build/webpack/plugins/subresource-integrity-plugin.d.ts","./node_modules/next/dist/server/body-streams.d.ts","./node_modules/next/dist/server/lib/cache-control.d.ts","./node_modules/next/dist/lib/setup-exception-listeners.d.ts","./node_modules/next/dist/lib/worker.d.ts","./node_modules/next/dist/lib/constants.d.ts","./node_modules/next/dist/client/components/app-router-headers.d.ts","./node_modules/next/dist/build/rendering-mode.d.ts","./node_modules/next/dist/server/lib/router-utils/build-prefetch-segment-data-route.d.ts","./node_modules/next/dist/server/require-hook.d.ts","./node_modules/next/dist/server/lib/experimental/ppr.d.ts","./node_modules/next/dist/build/webpack/plugins/app-build-manifest-plugin.d.ts","./node_modules/next/dist/lib/page-types.d.ts","./node_modules/next/dist/build/segment-config/app/app-segment-config.d.ts","./node_modules/next/dist/build/segment-config/pages/pages-segment-config.d.ts","./node_modules/next/dist/build/analysis/get-page-static-info.d.ts","./node_modules/next/dist/build/webpack/loaders/get-module-build-info.d.ts","./node_modules/next/dist/build/webpack/plugins/middleware-plugin.d.ts","./node_modules/next/dist/server/node-polyfill-crypto.d.ts","./node_modules/next/dist/server/node-environment-baseline.d.ts","./node_modules/next/dist/server/node-environment-extensions/error-inspect.d.ts","./node_modules/next/dist/server/node-environment-extensions/random.d.ts","./node_modules/next/dist/server/node-environment-extensions/date.d.ts","./node_modules/next/dist/server/node-environment-extensions/web-crypto.d.ts","./node_modules/next/dist/server/node-environment-extensions/node-crypto.d.ts","./node_modules/next/dist/server/node-environment.d.ts","./node_modules/next/dist/build/page-extensions-type.d.ts","./node_modules/next/dist/build/webpack/plugins/flight-manifest-plugin.d.ts","./node_modules/next/dist/server/instrumentation/types.d.ts","./node_modules/next/dist/lib/coalesced-function.d.ts","./node_modules/next/dist/shared/lib/router/utils/middleware-route-matcher.d.ts","./node_modules/next/dist/server/lib/router-utils/types.d.ts","./node_modules/next/dist/shared/lib/modern-browserslist-target.d.ts","./node_modules/next/dist/shared/lib/constants.d.ts","./node_modules/next/dist/trace/types.d.ts","./node_modules/next/dist/trace/trace.d.ts","./node_modules/next/dist/trace/shared.d.ts","./node_modules/next/dist/trace/index.d.ts","./node_modules/next/dist/build/load-jsconfig.d.ts","./node_modules/@next/env/dist/index.d.ts","./node_modules/next/dist/build/webpack/plugins/telemetry-plugin/use-cache-tracker-utils.d.ts","./node_modules/next/dist/build/webpack/plugins/telemetry-plugin/telemetry-plugin.d.ts","./node_modules/next/dist/telemetry/storage.d.ts","./node_modules/next/dist/build/build-context.d.ts","./node_modules/next/dist/shared/lib/bloom-filter.d.ts","./node_modules/next/dist/build/webpack-config.d.ts","./node_modules/next/dist/server/route-kind.d.ts","./node_modules/next/dist/server/route-definitions/route-definition.d.ts","./node_modules/next/dist/build/swc/generated-native.d.ts","./node_modules/next/dist/build/swc/types.d.ts","./node_modules/next/dist/server/dev/parse-version-info.d.ts","./node_modules/next/dist/next-devtools/shared/types.d.ts","./node_modules/next/dist/server/dev/dev-indicator-server-state.d.ts","./node_modules/next/dist/server/lib/parse-stack.d.ts","./node_modules/next/dist/next-devtools/server/shared.d.ts","./node_modules/next/dist/next-devtools/shared/stack-frame.d.ts","./node_modules/next/dist/next-devtools/dev-overlay/utils/get-error-by-type.d.ts","./node_modules/@types/react/jsx-runtime.d.ts","./node_modules/next/dist/next-devtools/dev-overlay/container/runtime-error/render-error.d.ts","./node_modules/next/dist/next-devtools/dev-overlay/shared.d.ts","./node_modules/next/dist/server/dev/hot-reloader-types.d.ts","./node_modules/next/dist/server/lib/cache-handlers/types.d.ts","./node_modules/next/dist/server/response-cache/types.d.ts","./node_modules/next/dist/server/resume-data-cache/cache-store.d.ts","./node_modules/next/dist/server/resume-data-cache/resume-data-cache.d.ts","./node_modules/next/dist/server/render-result.d.ts","./node_modules/next/dist/server/lib/i18n-provider.d.ts","./node_modules/next/dist/server/web/next-url.d.ts","./node_modules/next/dist/compiled/@edge-runtime/cookies/index.d.ts","./node_modules/next/dist/server/web/spec-extension/cookies.d.ts","./node_modules/next/dist/server/web/spec-extension/request.d.ts","./node_modules/next/dist/server/after/builtin-request-context.d.ts","./node_modules/next/dist/server/web/spec-extension/fetch-event.d.ts","./node_modules/next/dist/server/web/spec-extension/response.d.ts","./node_modules/next/dist/build/segment-config/middleware/middleware-config.d.ts","./node_modules/next/dist/server/web/types.d.ts","./node_modules/next/dist/build/webpack/plugins/pages-manifest-plugin.d.ts","./node_modules/next/dist/shared/lib/router/utils/parse-url.d.ts","./node_modules/next/dist/server/base-http/node.d.ts","./node_modules/next/dist/build/webpack/plugins/next-font-manifest-plugin.d.ts","./node_modules/next/dist/server/route-definitions/locale-route-definition.d.ts","./node_modules/next/dist/server/route-definitions/pages-route-definition.d.ts","./node_modules/next/dist/shared/lib/mitt.d.ts","./node_modules/next/dist/client/with-router.d.ts","./node_modules/next/dist/client/router.d.ts","./node_modules/next/dist/client/route-loader.d.ts","./node_modules/next/dist/client/page-loader.d.ts","./node_modules/next/dist/shared/lib/router/router.d.ts","./node_modules/next/dist/shared/lib/router-context.shared-runtime.d.ts","./node_modules/next/dist/shared/lib/loadable-context.shared-runtime.d.ts","./node_modules/next/dist/shared/lib/loadable.shared-runtime.d.ts","./node_modules/next/dist/shared/lib/image-config-context.shared-runtime.d.ts","./node_modules/next/dist/shared/lib/hooks-client-context.shared-runtime.d.ts","./node_modules/next/dist/shared/lib/head-manager-context.shared-runtime.d.ts","./node_modules/next/dist/server/route-definitions/app-page-route-definition.d.ts","./node_modules/next/dist/build/webpack/loaders/metadata/types.d.ts","./node_modules/next/dist/build/webpack/loaders/next-app-loader/index.d.ts","./node_modules/next/dist/server/lib/app-dir-module.d.ts","./node_modules/next/dist/server/web/spec-extension/adapters/request-cookies.d.ts","./node_modules/next/dist/server/async-storage/draft-mode-provider.d.ts","./node_modules/next/dist/server/web/spec-extension/adapters/headers.d.ts","./node_modules/next/dist/server/app-render/cache-signal.d.ts","./node_modules/next/dist/server/app-render/dynamic-rendering.d.ts","./node_modules/next/dist/server/request/fallback-params.d.ts","./node_modules/next/dist/server/app-render/work-unit-async-storage-instance.d.ts","./node_modules/next/dist/server/response-cache/index.d.ts","./node_modules/next/dist/server/lib/lazy-result.d.ts","./node_modules/next/dist/server/lib/implicit-tags.d.ts","./node_modules/next/dist/server/app-render/work-unit-async-storage.external.d.ts","./node_modules/next/dist/shared/lib/deep-readonly.d.ts","./node_modules/next/dist/shared/lib/router/utils/parse-relative-url.d.ts","./node_modules/next/dist/server/app-render/app-render.d.ts","./node_modules/next/dist/shared/lib/server-inserted-html.shared-runtime.d.ts","./node_modules/next/dist/shared/lib/amp-context.shared-runtime.d.ts","./node_modules/next/dist/server/route-modules/app-page/vendored/contexts/entrypoints.d.ts","./node_modules/next/dist/server/route-modules/app-page/module.compiled.d.ts","./node_modules/next/dist/client/components/error-boundary.d.ts","./node_modules/next/dist/client/components/layout-router.d.ts","./node_modules/next/dist/client/components/render-from-template-context.d.ts","./node_modules/next/dist/server/app-render/action-async-storage-instance.d.ts","./node_modules/next/dist/server/app-render/action-async-storage.external.d.ts","./node_modules/next/dist/client/components/client-page.d.ts","./node_modules/next/dist/client/components/client-segment.d.ts","./node_modules/next/dist/server/request/search-params.d.ts","./node_modules/next/dist/client/components/hooks-server-context.d.ts","./node_modules/next/dist/client/components/http-access-fallback/error-boundary.d.ts","./node_modules/next/dist/lib/metadata/types/alternative-urls-types.d.ts","./node_modules/next/dist/lib/metadata/types/extra-types.d.ts","./node_modules/next/dist/lib/metadata/types/metadata-types.d.ts","./node_modules/next/dist/lib/metadata/types/manifest-types.d.ts","./node_modules/next/dist/lib/metadata/types/opengraph-types.d.ts","./node_modules/next/dist/lib/metadata/types/twitter-types.d.ts","./node_modules/next/dist/lib/metadata/types/metadata-interface.d.ts","./node_modules/next/dist/lib/metadata/types/resolvers.d.ts","./node_modules/next/dist/lib/metadata/types/icons.d.ts","./node_modules/next/dist/lib/metadata/resolve-metadata.d.ts","./node_modules/next/dist/lib/metadata/metadata.d.ts","./node_modules/next/dist/lib/framework/boundary-components.d.ts","./node_modules/next/dist/server/app-render/rsc/preloads.d.ts","./node_modules/next/dist/server/app-render/rsc/postpone.d.ts","./node_modules/next/dist/server/app-render/rsc/taint.d.ts","./node_modules/next/dist/shared/lib/segment-cache/segment-value-encoding.d.ts","./node_modules/next/dist/server/app-render/collect-segment-data.d.ts","./node_modules/next/dist/next-devtools/userspace/app/segment-explorer-node.d.ts","./node_modules/next/dist/server/app-render/entry-base.d.ts","./node_modules/next/dist/build/templates/app-page.d.ts","./node_modules/@types/react/jsx-dev-runtime.d.ts","./node_modules/@types/react/compiler-runtime.d.ts","./node_modules/next/dist/server/route-modules/app-page/vendored/rsc/entrypoints.d.ts","./node_modules/@types/react-dom/client.d.ts","./node_modules/@types/react-dom/static.d.ts","./node_modules/@types/react-dom/server.d.ts","./node_modules/next/dist/server/route-modules/app-page/vendored/ssr/entrypoints.d.ts","./node_modules/next/dist/server/route-modules/app-page/module.d.ts","./node_modules/next/dist/server/web/adapter.d.ts","./node_modules/next/dist/server/use-cache/cache-life.d.ts","./node_modules/next/dist/server/app-render/types.d.ts","./node_modules/next/dist/client/components/router-reducer/router-reducer-types.d.ts","./node_modules/next/dist/client/flight-data-helpers.d.ts","./node_modules/next/dist/client/components/router-reducer/fetch-server-response.d.ts","./node_modules/next/dist/shared/lib/app-router-context.shared-runtime.d.ts","./node_modules/next/dist/server/route-modules/pages/vendored/contexts/entrypoints.d.ts","./node_modules/next/dist/server/route-modules/pages/module.compiled.d.ts","./node_modules/next/dist/build/templates/pages.d.ts","./node_modules/next/dist/server/route-modules/pages/module.d.ts","./node_modules/next/dist/next-devtools/userspace/pages/pages-dev-overlay-setup.d.ts","./node_modules/next/dist/server/render.d.ts","./node_modules/next/dist/server/route-definitions/pages-api-route-definition.d.ts","./node_modules/next/dist/server/route-matches/pages-api-route-match.d.ts","./node_modules/next/dist/server/route-matchers/route-matcher.d.ts","./node_modules/next/dist/server/route-matcher-providers/route-matcher-provider.d.ts","./node_modules/next/dist/server/route-matcher-managers/route-matcher-manager.d.ts","./node_modules/next/dist/server/normalizers/normalizer.d.ts","./node_modules/next/dist/server/normalizers/locale-route-normalizer.d.ts","./node_modules/next/dist/server/normalizers/request/pathname-normalizer.d.ts","./node_modules/next/dist/server/normalizers/request/suffix.d.ts","./node_modules/next/dist/server/normalizers/request/rsc.d.ts","./node_modules/next/dist/server/normalizers/request/prefetch-rsc.d.ts","./node_modules/next/dist/server/normalizers/request/next-data.d.ts","./node_modules/next/dist/server/normalizers/request/segment-prefix-rsc.d.ts","./node_modules/next/dist/build/static-paths/types.d.ts","./node_modules/next/dist/server/base-server.d.ts","./node_modules/next/dist/server/lib/async-callback-set.d.ts","./node_modules/next/dist/shared/lib/router/utils/route-regex.d.ts","./node_modules/next/dist/shared/lib/router/utils/route-matcher.d.ts","./node_modules/sharp/lib/index.d.ts","./node_modules/next/dist/server/image-optimizer.d.ts","./node_modules/next/dist/server/next-server.d.ts","./node_modules/next/dist/server/lib/types.d.ts","./node_modules/next/dist/server/lib/lru-cache.d.ts","./node_modules/next/dist/server/lib/dev-bundler-service.d.ts","./node_modules/next/dist/server/dev/static-paths-worker.d.ts","./node_modules/next/dist/server/dev/next-dev-server.d.ts","./node_modules/next/dist/server/next.d.ts","./node_modules/next/dist/server/lib/render-server.d.ts","./node_modules/next/dist/server/lib/router-server.d.ts","./node_modules/next/dist/shared/lib/router/utils/path-match.d.ts","./node_modules/next/dist/server/lib/router-utils/filesystem.d.ts","./node_modules/next/dist/server/lib/router-utils/setup-dev-bundler.d.ts","./node_modules/next/dist/server/lib/router-utils/router-server-context.d.ts","./node_modules/next/dist/server/route-modules/route-module.d.ts","./node_modules/next/dist/server/load-components.d.ts","./node_modules/next/dist/server/route-definitions/app-route-route-definition.d.ts","./node_modules/next/dist/server/async-storage/work-store.d.ts","./node_modules/next/dist/server/web/http.d.ts","./node_modules/next/dist/server/route-modules/app-route/shared-modules.d.ts","./node_modules/next/dist/client/components/redirect-status-code.d.ts","./node_modules/next/dist/client/components/redirect-error.d.ts","./node_modules/next/dist/build/templates/app-route.d.ts","./node_modules/next/dist/server/route-modules/app-route/module.d.ts","./node_modules/next/dist/server/route-modules/app-route/module.compiled.d.ts","./node_modules/next/dist/build/segment-config/app/app-segments.d.ts","./node_modules/next/dist/build/utils.d.ts","./node_modules/next/dist/build/turborepo-access-trace/types.d.ts","./node_modules/next/dist/build/turborepo-access-trace/result.d.ts","./node_modules/next/dist/build/turborepo-access-trace/helpers.d.ts","./node_modules/next/dist/build/turborepo-access-trace/index.d.ts","./node_modules/next/dist/export/routes/types.d.ts","./node_modules/next/dist/export/types.d.ts","./node_modules/next/dist/export/worker.d.ts","./node_modules/next/dist/build/worker.d.ts","./node_modules/next/dist/build/index.d.ts","./node_modules/next/dist/server/lib/incremental-cache/index.d.ts","./node_modules/next/dist/server/after/after.d.ts","./node_modules/next/dist/server/after/after-context.d.ts","./node_modules/next/dist/server/app-render/work-async-storage-instance.d.ts","./node_modules/next/dist/server/app-render/work-async-storage.external.d.ts","./node_modules/next/dist/server/request/params.d.ts","./node_modules/next/dist/server/route-matches/route-match.d.ts","./node_modules/next/dist/server/request-meta.d.ts","./node_modules/next/dist/cli/next-test.d.ts","./node_modules/next/dist/server/config-shared.d.ts","./node_modules/next/dist/server/base-http/index.d.ts","./node_modules/next/dist/server/api-utils/index.d.ts","./node_modules/next/dist/types.d.ts","./node_modules/next/dist/shared/lib/html-context.shared-runtime.d.ts","./node_modules/next/dist/shared/lib/utils.d.ts","./node_modules/next/dist/pages/_app.d.ts","./node_modules/next/app.d.ts","./node_modules/next/dist/server/web/spec-extension/unstable-cache.d.ts","./node_modules/next/dist/server/web/spec-extension/revalidate.d.ts","./node_modules/next/dist/server/web/spec-extension/unstable-no-store.d.ts","./node_modules/next/dist/server/use-cache/cache-tag.d.ts","./node_modules/next/cache.d.ts","./node_modules/next/dist/shared/lib/runtime-config.external.d.ts","./node_modules/next/config.d.ts","./node_modules/next/dist/pages/_document.d.ts","./node_modules/next/document.d.ts","./node_modules/next/dist/shared/lib/dynamic.d.ts","./node_modules/next/dynamic.d.ts","./node_modules/next/dist/pages/_error.d.ts","./node_modules/next/error.d.ts","./node_modules/next/dist/shared/lib/head.d.ts","./node_modules/next/head.d.ts","./node_modules/next/dist/server/request/cookies.d.ts","./node_modules/next/dist/server/request/headers.d.ts","./node_modules/next/dist/server/request/draft-mode.d.ts","./node_modules/next/headers.d.ts","./node_modules/next/dist/shared/lib/get-img-props.d.ts","./node_modules/next/dist/client/image-component.d.ts","./node_modules/next/dist/shared/lib/image-external.d.ts","./node_modules/next/image.d.ts","./node_modules/next/dist/client/link.d.ts","./node_modules/next/link.d.ts","./node_modules/next/dist/client/components/redirect.d.ts","./node_modules/next/dist/client/components/not-found.d.ts","./node_modules/next/dist/client/components/forbidden.d.ts","./node_modules/next/dist/client/components/unauthorized.d.ts","./node_modules/next/dist/client/components/unstable-rethrow.server.d.ts","./node_modules/next/dist/client/components/unstable-rethrow.d.ts","./node_modules/next/dist/client/components/navigation.react-server.d.ts","./node_modules/next/dist/client/components/unrecognized-action-error.d.ts","./node_modules/next/dist/client/components/navigation.d.ts","./node_modules/next/navigation.d.ts","./node_modules/next/router.d.ts","./node_modules/next/dist/client/script.d.ts","./node_modules/next/script.d.ts","./node_modules/next/dist/server/web/spec-extension/user-agent.d.ts","./node_modules/next/dist/compiled/@edge-runtime/primitives/url.d.ts","./node_modules/next/dist/server/web/spec-extension/image-response.d.ts","./node_modules/next/dist/compiled/@vercel/og/satori/index.d.ts","./node_modules/next/dist/compiled/@vercel/og/emoji/index.d.ts","./node_modules/next/dist/compiled/@vercel/og/types.d.ts","./node_modules/next/dist/server/after/index.d.ts","./node_modules/next/dist/server/request/root-params.d.ts","./node_modules/next/dist/server/request/connection.d.ts","./node_modules/next/server.d.ts","./node_modules/next/types/global.d.ts","./node_modules/next/types/compiled.d.ts","./node_modules/next/types.d.ts","./node_modules/next/index.d.ts","./node_modules/next/image-types/global.d.ts","./next-env.d.ts","./node_modules/source-map-js/source-map.d.ts","./node_modules/postcss/lib/previous-map.d.ts","./node_modules/postcss/lib/input.d.ts","./node_modules/postcss/lib/css-syntax-error.d.ts","./node_modules/postcss/lib/declaration.d.ts","./node_modules/postcss/lib/root.d.ts","./node_modules/postcss/lib/warning.d.ts","./node_modules/postcss/lib/lazy-result.d.ts","./node_modules/postcss/lib/no-work-result.d.ts","./node_modules/postcss/lib/processor.d.ts","./node_modules/postcss/lib/result.d.ts","./node_modules/postcss/lib/document.d.ts","./node_modules/postcss/lib/rule.d.ts","./node_modules/postcss/lib/node.d.ts","./node_modules/postcss/lib/comment.d.ts","./node_modules/postcss/lib/container.d.ts","./node_modules/postcss/lib/at-rule.d.ts","./node_modules/postcss/lib/list.d.ts","./node_modules/postcss/lib/postcss.d.ts","./node_modules/postcss/lib/postcss.d.mts","./node_modules/tailwindcss/types/generated/corePluginList.d.ts","./node_modules/tailwindcss/types/generated/colors.d.ts","./node_modules/tailwindcss/types/config.d.ts","./node_modules/tailwindcss/types/index.d.ts","./tailwind.config.ts","./lib/apps-catalog.ts","./components/ui/Badge.tsx","./components/ui/AppTile.tsx","./components/ui/Button.tsx","./components/ui/Container.tsx","./components/ui/SectionTitle.tsx","./components/ui/index.ts","./lib/auth.ts","./hooks/useAuth.ts","./hooks/useHeaderSession.ts","./lib/api.ts","./hooks/useMe.ts","./lib/theme.ts","./hooks/useTheme.ts","./lib/api-client.ts","./lib/identity.ts","./lib/optional-sso.ts","./lib/phone.ts","./components/SiteHeader.tsx","./components/ThemeProvider.tsx","./app/layout.tsx","./components/AppStoreGrid.tsx","./app/page.tsx","./components/AdminAuthGuard.tsx","./components/admin/AdminShell.tsx","./app/admin/layout.tsx","./components/admin/controls.tsx","./app/admin/page.tsx","./app/admin/features/page.tsx","./app/admin/login/page.tsx","./app/admin/plans/page.tsx","./app/admin/services/page.tsx","./components/settings/AccountSettings.tsx","./app/admin/settings/page.tsx","./app/admin/tenants/page.tsx","./app/admin/tenants/[id]/page.tsx","./app/admin/tenants/new/page.tsx","./app/admin/users/page.tsx","./app/auth/callback/page.tsx","./app/auth/verify-mobile/page.tsx","./components/AuthGuard.tsx","./components/TenantSwitcher.tsx","./app/dashboard/page.tsx","./app/dashboard/settings/page.tsx","./components/auth/AuthPanel.tsx","./app/login/page.tsx","./app/onboarding/page.tsx","./app/register/page.tsx","./components/HealthStatus.tsx","./.next/types/cache-life.d.ts","./.next/types/validator.ts","./.next/types/app/layout.ts","./.next/types/app/page.ts","./.next/types/app/admin/layout.ts","./.next/types/app/admin/login/page.ts","./.next/types/app/admin/tenants/page.ts","./.next/types/app/dashboard/page.ts","./.next/types/app/dashboard/settings/page.ts","./.next/types/app/login/page.ts","./.next/types/app/onboarding/page.ts"],"fileIdsList":[[100,146,337,548],[100,146,337,552],[100,146,337,557],[100,146,337,565],[100,146,337,566],[100,146,337,543],[100,146,337,568],[100,146,337,569],[100,146,337,545],[100,146,444,445,446,447],[100,146],[83,100,146,494,543,545,548,550,551,552,553,554,556,557,558,559,560,561,562,565,566,568,569,570],[86,100,146,533,549],[86,100,146,478,546,547],[86,100,146,530],[86,100,146,468,530,533,538,549],[100,146,555],[86,100,146,468,478,530,533,538,549],[86,100,146,468,478,533,549],[86,100,146,468,533,549],[86,100,146,530,538,540,549],[86,100,146,478,530],[86,100,146,478,529,530,538,540],[86,100,146,468,478,529,531,533,534,563,564],[100,146,555,563],[100,146,495,541,542],[86,100,146,478,530,567],[86,100,146,478,529,533,534,563],[100,146,468,529,531,536,544],[86,100,146,530,531],[100,146,523,529],[86,100,146,478,531],[100,146,536],[100,146,468,529,531,532,536],[86,100,146,533],[86,100,146,536],[86,100,146,468,478,530],[86,100,146,529],[86,100,146,468,478,529,530,531,538,540],[86,100,146,468,529,530,538],[100,146,523,524],[86,100,146],[100,146,524,525,526,527,528],[86,100,146,530,533],[86,100,146,535],[100,146,530],[83,100,146,495,496],[100,143,146],[100,145,146],[146],[100,146,151,179],[100,146,147,152,157,165,176,187],[100,146,147,148,157,165],[95,96,97,100,146],[100,146,149,188],[100,146,150,151,158,166],[100,146,151,176,184],[100,146,152,154,157,165],[100,145,146,153],[100,146,154,155],[100,146,156,157],[100,145,146,157],[100,146,157,158,159,176,187],[100,146,157,158,159,172,176,179],[100,146,154,157,160,165,176,187],[100,146,157,158,160,161,165,176,184,187],[100,146,160,162,176,184,187],[98,99,100,101,102,103,104,142,143,144,145,146,147,148,149,150,151,152,153,154,155,156,157,158,159,160,161,162,163,164,165,166,167,168,169,170,171,172,173,174,175,176,177,178,179,180,181,182,183,184,185,186,187,188,189,190,191,192,193],[100,146,157,163],[100,146,164,187,192],[100,146,154,157,165,176],[100,146,166],[100,146,167],[100,145,146,168],[100,143,144,145,146,147,148,149,150,151,152,153,154,155,156,157,158,159,160,161,162,163,164,165,166,167,168,169,170,171,172,173,174,175,176,177,178,179,180,181,182,183,184,185,186,187,188,189,190,191,192,193],[100,146,170],[100,146,171],[100,146,157,172,173],[100,146,172,174,188,190],[100,146,157,176,177,179],[100,146,178,179],[100,146,176,177],[100,146,179],[100,146,180],[100,143,146,176,181],[100,146,157,182,183],[100,146,182,183],[100,146,151,165,176,184],[100,146,185],[100,146,165,186],[100,146,160,171,187],[100,146,151,188],[100,146,176,189],[100,146,164,190],[100,146,191],[100,141,146],[100,141,146,157,159,168,176,179,187,190,192],[100,146,176,193],[86,90,100,146,195,196,197,199,439,487],[86,90,100,146,195,196,197,198,354,439,487],[86,90,100,146,195,196,198,199,439,487],[86,100,146,199,354,355],[86,100,146,199,354],[86,90,100,146,196,197,198,199,439,487],[86,90,100,146,195,197,198,199,439,487],[84,85,100,146],[92,100,146],[100,146,442],[100,146,449],[100,146,203,217,218,219,221,436],[100,146,203,242,244,246,247,250,436,438],[100,146,203,207,209,210,211,212,213,425,436,438],[100,146,436],[100,146,218,320,406,415,432],[100,146,203],[100,146,200,432],[100,146,254],[100,146,253,436,438],[100,146,160,302,320,349,493],[100,146,160,313,329,415,431],[100,146,160,367],[100,146,419],[100,146,418,419,420],[100,146,418],[94,100,146,160,200,203,207,210,214,215,216,218,222,230,231,360,385,416,436,439],[100,146,203,220,238,242,243,248,249,436,493],[100,146,220,493],[100,146,231,238,300,436,493],[100,146,493],[100,146,203,220,221,493],[100,146,245,493],[100,146,214,417,424],[100,146,171,262,432],[100,146,262,432],[86,100,146,262],[86,100,146,321],[100,146,317,365,432,475,476],[100,146,412,469,470,471,472,474],[100,146,411],[100,146,411,412],[100,146,211,361,362,363],[100,146,361,364,365],[100,146,473],[100,146,361,365],[86,100,146,204,463],[86,100,146,187],[86,100,146,220,290],[86,100,146,220],[100,146,288,292],[86,100,146,289,441],[86,90,100,146,160,194,195,196,197,198,199,439,485,486],[100,146,160],[100,146,160,207,269,361,371,386,406,421,422,436,437,493],[100,146,230,423],[100,146,439],[100,146,202],[86,100,146,302,316,328,338,340,431],[100,146,171,302,316,337,338,339,431,492],[100,146,331,332,333,334,335,336],[100,146,333],[100,146,337],[100,146,260,261,262,264],[86,100,146,255,256,257,263],[100,146,260,263],[100,146,258],[100,146,259],[86,100,146,262,289,441],[86,100,146,262,440,441],[86,100,146,262,441],[100,146,386,428],[100,146,428],[100,146,160,437,441],[100,146,325],[100,145,146,324],[100,146,232,270,308,310,312,313,314,315,358,361,431,434,437],[100,146,232,346,361,365],[100,146,313,431],[86,100,146,313,322,323,325,326,327,328,329,330,341,342,343,344,345,347,348,431,432,493],[100,146,307],[100,146,160,171,232,233,269,284,314,358,359,360,365,386,406,427,436,437,438,439,493],[100,146,431],[100,145,146,218,311,314,360,427,429,430,437],[100,146,313],[100,145,146,269,274,303,304,305,306,307,308,309,310,312,431,432],[100,146,160,274,275,303,437,438],[100,146,218,360,361,386,427,431,437],[100,146,160,436,438],[100,146,160,176,434,437,438],[100,146,160,171,187,200,207,220,232,233,235,270,271,276,281,284,310,314,361,371,373,376,378,381,382,383,384,385,406,426,427,432,434,436,437,438],[100,146,160,176],[100,146,203,204,205,207,212,215,220,238,426,434,435,439,441,493],[100,146,160,176,187,250,252,254,255,256,257,264,493],[100,146,171,187,200,242,252,280,281,282,283,310,361,376,385,386,392,395,396,406,427,432,434],[100,146,214,215,230,360,385,427,436],[100,146,160,187,204,207,310,390,434,436],[100,146,301],[100,146,160,393,394,403],[100,146,434,436],[100,146,308,311],[100,146,310,314,426,441],[100,146,160,171,236,242,283,376,386,392,395,398,434],[100,146,160,214,230,242,399],[100,146,203,235,401,426,436],[100,146,160,187,436],[100,146,160,220,234,235,236,247,265,400,402,426,436],[94,100,146,232,314,405,439,441],[100,146,160,171,187,207,214,222,230,233,270,276,280,281,282,283,284,310,361,373,386,387,389,391,406,426,427,432,433,434,441],[100,146,160,176,214,392,397,403,434],[100,146,225,226,227,228,229],[100,146,271,377],[100,146,379],[100,146,377],[100,146,379,380],[100,146,160,207,210,211,269,437],[100,146,160,171,202,204,232,270,284,314,369,370,406,434,438,439,441],[100,146,160,171,187,206,211,310,370,433,437],[100,146,303],[100,146,304],[100,146,305],[100,146,432],[100,146,251,267],[100,146,160,207,251,270],[100,146,266,267],[100,146,268],[100,146,251,252],[100,146,251,285],[100,146,251],[100,146,271,375,433],[100,146,374],[100,146,252,432,433],[100,146,372,433],[100,146,252,432],[100,146,358],[100,146,207,212,270,299,302,308,310,314,316,319,350,353,357,361,405,426,434,437],[100,146,293,296,297,298,317,318,365],[86,100,146,197,199,262,351,352],[86,100,146,197,199,262,351,352,356],[100,146,414],[100,146,218,275,313,314,325,329,361,405,407,408,409,410,412,413,416,426,431,436],[100,146,365],[100,146,369],[100,146,160,270,286,366,368,371,405,434,439,441],[100,146,293,294,295,296,297,298,317,318,365,440],[94,100,146,160,171,187,233,251,252,284,310,314,403,404,406,426,427,436,437,439],[100,146,275,277,280,427],[100,146,160,271,436],[100,146,274,313],[100,146,273],[100,146,275,276],[100,146,272,274,436],[100,146,160,206,275,277,278,279,436,437],[86,100,146,361,362,364],[100,146,237],[86,100,146,204],[86,100,146,432],[86,94,100,146,284,314,439,441],[100,146,204,463,464],[86,100,146,292],[86,100,146,171,187,202,249,287,289,291,441],[100,146,220,432,437],[100,146,388,432],[100,146,361],[86,100,146,158,160,171,202,238,244,292,439,440],[86,100,146,195,196,197,198,199,439,487],[86,87,88,89,90,100,146],[100,146,151],[100,146,239,240,241],[100,146,239],[86,90,100,146,160,162,171,194,195,196,197,198,199,200,202,233,337,398,436,438,441,487],[100,146,451],[100,146,453],[100,146,455],[100,146,457],[100,146,459,460,461],[100,146,465],[91,93,100,146,443,448,450,452,454,456,458,462,466,468,478,479,481,491,492,493,494],[100,146,467],[100,146,477],[100,146,289],[100,146,480],[100,145,146,275,277,278,280,328,432,482,483,484,487,488,489,490],[100,146,194],[100,146,513],[100,146,511,513],[100,146,502,510,511,512,514,516],[100,146,500],[100,146,503,508,513,516],[100,146,499,516],[100,146,503,504,507,508,509,516],[100,146,503,504,505,507,508,516],[100,146,500,501,502,503,504,508,509,510,512,513,514,516],[100,146,516],[100,146,498,500,501,502,503,504,505,507,508,509,510,511,512,513,514,515],[100,146,498,516],[100,146,503,505,506,508,509,516],[100,146,507,516],[100,146,508,509,513,516],[100,146,501,511],[100,146,176,194],[100,146,518,519],[100,146,517,520],[100,113,117,146,187],[100,113,146,176,187],[100,108,146],[100,110,113,146,184,187],[100,146,165,184],[100,108,146,194],[100,110,113,146,165,187],[100,105,106,109,112,146,157,176,187],[100,113,120,146],[100,105,111,146],[100,113,134,135,146],[100,109,113,146,179,187,194],[100,134,146,194],[100,107,108,146,194],[100,113,146],[100,107,108,109,110,111,112,113,114,115,117,118,119,120,121,122,123,124,125,126,127,128,129,130,131,132,133,135,136,137,138,139,140,146],[100,113,128,146],[100,113,120,121,146],[100,111,113,121,122,146],[100,112,146],[100,105,108,113,146],[100,113,117,121,122,146],[100,117,146],[100,111,113,116,146,187],[100,105,110,113,120,146],[100,146,176],[100,108,113,134,146,192,194],[100,146,521]],"fileInfos":[{"version":"c430d44666289dae81f30fa7b2edebf186ecc91a2d4c71266ea6ae76388792e1","affectsGlobalScope":true,"impliedFormat":1},{"version":"45b7ab580deca34ae9729e97c13cfd999df04416a79116c3bfb483804f85ded4","impliedFormat":1},{"version":"3facaf05f0c5fc569c5649dd359892c98a85557e3e0c847964caeb67076f4d75","impliedFormat":1},{"version":"e44bb8bbac7f10ecc786703fe0a6a4b952189f908707980ba8f3c8975a760962","impliedFormat":1},{"version":"5e1c4c362065a6b95ff952c0eab010f04dcd2c3494e813b493ecfd4fcb9fc0d8","impliedFormat":1},{"version":"68d73b4a11549f9c0b7d352d10e91e5dca8faa3322bfb77b661839c42b1ddec7","impliedFormat":1},{"version":"5efce4fc3c29ea84e8928f97adec086e3dc876365e0982cc8479a07954a3efd4","impliedFormat":1},{"version":"feecb1be483ed332fad555aff858affd90a48ab19ba7272ee084704eb7167569","impliedFormat":1},{"version":"ee7bad0c15b58988daa84371e0b89d313b762ab83cb5b31b8a2d1162e8eb41c2","impliedFormat":1},{"version":"27bdc30a0e32783366a5abeda841bc22757c1797de8681bbe81fbc735eeb1c10","impliedFormat":1},{"version":"8fd575e12870e9944c7e1d62e1f5a73fcf23dd8d3a321f2a2c74c20d022283fe","impliedFormat":1},{"version":"2ab096661c711e4a81cc464fa1e6feb929a54f5340b46b0a07ac6bbf857471f0","impliedFormat":1},{"version":"080941d9f9ff9307f7e27a83bcd888b7c8270716c39af943532438932ec1d0b9","affectsGlobalScope":true,"impliedFormat":1},{"version":"2e80ee7a49e8ac312cc11b77f1475804bee36b3b2bc896bead8b6e1266befb43","affectsGlobalScope":true,"impliedFormat":1},{"version":"c57796738e7f83dbc4b8e65132f11a377649c00dd3eee333f672b8f0a6bea671","affectsGlobalScope":true,"impliedFormat":1},{"version":"dc2df20b1bcdc8c2d34af4926e2c3ab15ffe1160a63e58b7e09833f616efff44","affectsGlobalScope":true,"impliedFormat":1},{"version":"515d0b7b9bea2e31ea4ec968e9edd2c39d3eebf4a2d5cbd04e88639819ae3b71","affectsGlobalScope":true,"impliedFormat":1},{"version":"0559b1f683ac7505ae451f9a96ce4c3c92bdc71411651ca6ddb0e88baaaad6a3","affectsGlobalScope":true,"impliedFormat":1},{"version":"0dc1e7ceda9b8b9b455c3a2d67b0412feab00bd2f66656cd8850e8831b08b537","affectsGlobalScope":true,"impliedFormat":1},{"version":"ce691fb9e5c64efb9547083e4a34091bcbe5bdb41027e310ebba8f7d96a98671","affectsGlobalScope":true,"impliedFormat":1},{"version":"8d697a2a929a5fcb38b7a65594020fcef05ec1630804a33748829c5ff53640d0","affectsGlobalScope":true,"impliedFormat":1},{"version":"4ff2a353abf8a80ee399af572debb8faab2d33ad38c4b4474cff7f26e7653b8d","affectsGlobalScope":true,"impliedFormat":1},{"version":"fb0f136d372979348d59b3f5020b4cdb81b5504192b1cacff5d1fbba29378aa1","affectsGlobalScope":true,"impliedFormat":1},{"version":"d15bea3d62cbbdb9797079416b8ac375ae99162a7fba5de2c6c505446486ac0a","affectsGlobalScope":true,"impliedFormat":1},{"version":"68d18b664c9d32a7336a70235958b8997ebc1c3b8505f4f1ae2b7e7753b87618","affectsGlobalScope":true,"impliedFormat":1},{"version":"eb3d66c8327153d8fa7dd03f9c58d351107fe824c79e9b56b462935176cdf12a","affectsGlobalScope":true,"impliedFormat":1},{"version":"38f0219c9e23c915ef9790ab1d680440d95419ad264816fa15009a8851e79119","affectsGlobalScope":true,"impliedFormat":1},{"version":"69ab18c3b76cd9b1be3d188eaf8bba06112ebbe2f47f6c322b5105a6fbc45a2e","affectsGlobalScope":true,"impliedFormat":1},{"version":"a680117f487a4d2f30ea46f1b4b7f58bef1480456e18ba53ee85c2746eeca012","affectsGlobalScope":true,"impliedFormat":1},{"version":"2f11ff796926e0832f9ae148008138ad583bd181899ab7dd768a2666700b1893","affectsGlobalScope":true,"impliedFormat":1},{"version":"4de680d5bb41c17f7f68e0419412ca23c98d5749dcaaea1896172f06435891fc","affectsGlobalScope":true,"impliedFormat":1},{"version":"954296b30da6d508a104a3a0b5d96b76495c709785c1d11610908e63481ee667","affectsGlobalScope":true,"impliedFormat":1},{"version":"ac9538681b19688c8eae65811b329d3744af679e0bdfa5d842d0e32524c73e1c","affectsGlobalScope":true,"impliedFormat":1},{"version":"0a969edff4bd52585473d24995c5ef223f6652d6ef46193309b3921d65dd4376","affectsGlobalScope":true,"impliedFormat":1},{"version":"9e9fbd7030c440b33d021da145d3232984c8bb7916f277e8ffd3dc2e3eae2bdb","affectsGlobalScope":true,"impliedFormat":1},{"version":"811ec78f7fefcabbda4bfa93b3eb67d9ae166ef95f9bff989d964061cbf81a0c","affectsGlobalScope":true,"impliedFormat":1},{"version":"717937616a17072082152a2ef351cb51f98802fb4b2fdabd32399843875974ca","affectsGlobalScope":true,"impliedFormat":1},{"version":"d7e7d9b7b50e5f22c915b525acc5a49a7a6584cf8f62d0569e557c5cfc4b2ac2","affectsGlobalScope":true,"impliedFormat":1},{"version":"71c37f4c9543f31dfced6c7840e068c5a5aacb7b89111a4364b1d5276b852557","affectsGlobalScope":true,"impliedFormat":1},{"version":"576711e016cf4f1804676043e6a0a5414252560eb57de9faceee34d79798c850","affectsGlobalScope":true,"impliedFormat":1},{"version":"89c1b1281ba7b8a96efc676b11b264de7a8374c5ea1e6617f11880a13fc56dc6","affectsGlobalScope":true,"impliedFormat":1},{"version":"74f7fa2d027d5b33eb0471c8e82a6c87216223181ec31247c357a3e8e2fddc5b","affectsGlobalScope":true,"impliedFormat":1},{"version":"d6d7ae4d1f1f3772e2a3cde568ed08991a8ae34a080ff1151af28b7f798e22ca","affectsGlobalScope":true,"impliedFormat":1},{"version":"063600664504610fe3e99b717a1223f8b1900087fab0b4cad1496a114744f8df","affectsGlobalScope":true,"impliedFormat":1},{"version":"934019d7e3c81950f9a8426d093458b65d5aff2c7c1511233c0fd5b941e608ab","affectsGlobalScope":true,"impliedFormat":1},{"version":"52ada8e0b6e0482b728070b7639ee42e83a9b1c22d205992756fe020fd9f4a47","affectsGlobalScope":true,"impliedFormat":1},{"version":"3bdefe1bfd4d6dee0e26f928f93ccc128f1b64d5d501ff4a8cf3c6371200e5e6","affectsGlobalScope":true,"impliedFormat":1},{"version":"59fb2c069260b4ba00b5643b907ef5d5341b167e7d1dbf58dfd895658bda2867","affectsGlobalScope":true,"impliedFormat":1},{"version":"639e512c0dfc3fad96a84caad71b8834d66329a1f28dc95e3946c9b58176c73a","affectsGlobalScope":true,"impliedFormat":1},{"version":"368af93f74c9c932edd84c58883e736c9e3d53cec1fe24c0b0ff451f529ceab1","affectsGlobalScope":true,"impliedFormat":1},{"version":"af3dd424cf267428f30ccfc376f47a2c0114546b55c44d8c0f1d57d841e28d74","affectsGlobalScope":true,"impliedFormat":1},{"version":"995c005ab91a498455ea8dfb63aa9f83fa2ea793c3d8aa344be4a1678d06d399","affectsGlobalScope":true,"impliedFormat":1},{"version":"959d36cddf5e7d572a65045b876f2956c973a586da58e5d26cde519184fd9b8a","affectsGlobalScope":true,"impliedFormat":1},{"version":"965f36eae237dd74e6cca203a43e9ca801ce38824ead814728a2807b1910117d","affectsGlobalScope":true,"impliedFormat":1},{"version":"3925a6c820dcb1a06506c90b1577db1fdbf7705d65b62b99dce4be75c637e26b","affectsGlobalScope":true,"impliedFormat":1},{"version":"0a3d63ef2b853447ec4f749d3f368ce642264246e02911fcb1590d8c161b8005","affectsGlobalScope":true,"impliedFormat":1},{"version":"8cdf8847677ac7d20486e54dd3fcf09eda95812ac8ace44b4418da1bbbab6eb8","affectsGlobalScope":true,"impliedFormat":1},{"version":"8444af78980e3b20b49324f4a16ba35024fef3ee069a0eb67616ea6ca821c47a","affectsGlobalScope":true,"impliedFormat":1},{"version":"3287d9d085fbd618c3971944b65b4be57859f5415f495b33a6adc994edd2f004","affectsGlobalScope":true,"impliedFormat":1},{"version":"b4b67b1a91182421f5df999988c690f14d813b9850b40acd06ed44691f6727ad","affectsGlobalScope":true,"impliedFormat":1},{"version":"df83c2a6c73228b625b0beb6669c7ee2a09c914637e2d35170723ad49c0f5cd4","affectsGlobalScope":true,"impliedFormat":1},{"version":"436aaf437562f276ec2ddbee2f2cdedac7664c1e4c1d2c36839ddd582eeb3d0a","affectsGlobalScope":true,"impliedFormat":1},{"version":"8e3c06ea092138bf9fa5e874a1fdbc9d54805d074bee1de31b99a11e2fec239d","affectsGlobalScope":true,"impliedFormat":1},{"version":"87dc0f382502f5bbce5129bdc0aea21e19a3abbc19259e0b43ae038a9fc4e326","affectsGlobalScope":true,"impliedFormat":1},{"version":"b1cb28af0c891c8c96b2d6b7be76bd394fddcfdb4709a20ba05a7c1605eea0f9","affectsGlobalScope":true,"impliedFormat":1},{"version":"2fef54945a13095fdb9b84f705f2b5994597640c46afeb2ce78352fab4cb3279","affectsGlobalScope":true,"impliedFormat":1},{"version":"ac77cb3e8c6d3565793eb90a8373ee8033146315a3dbead3bde8db5eaf5e5ec6","affectsGlobalScope":true,"impliedFormat":1},{"version":"56e4ed5aab5f5920980066a9409bfaf53e6d21d3f8d020c17e4de584d29600ad","affectsGlobalScope":true,"impliedFormat":1},{"version":"4ece9f17b3866cc077099c73f4983bddbcb1dc7ddb943227f1ec070f529dedd1","affectsGlobalScope":true,"impliedFormat":1},{"version":"0a6282c8827e4b9a95f4bf4f5c205673ada31b982f50572d27103df8ceb8013c","affectsGlobalScope":true,"impliedFormat":1},{"version":"1c9319a09485199c1f7b0498f2988d6d2249793ef67edda49d1e584746be9032","affectsGlobalScope":true,"impliedFormat":1},{"version":"e3a2a0cee0f03ffdde24d89660eba2685bfbdeae955a6c67e8c4c9fd28928eeb","affectsGlobalScope":true,"impliedFormat":1},{"version":"811c71eee4aa0ac5f7adf713323a5c41b0cf6c4e17367a34fbce379e12bbf0a4","affectsGlobalScope":true,"impliedFormat":1},{"version":"51ad4c928303041605b4d7ae32e0c1ee387d43a24cd6f1ebf4a2699e1076d4fa","affectsGlobalScope":true,"impliedFormat":1},{"version":"60037901da1a425516449b9a20073aa03386cce92f7a1fd902d7602be3a7c2e9","affectsGlobalScope":true,"impliedFormat":1},{"version":"d4b1d2c51d058fc21ec2629fff7a76249dec2e36e12960ea056e3ef89174080f","affectsGlobalScope":true,"impliedFormat":1},{"version":"22adec94ef7047a6c9d1af3cb96be87a335908bf9ef386ae9fd50eeb37f44c47","affectsGlobalScope":true,"impliedFormat":1},{"version":"196cb558a13d4533a5163286f30b0509ce0210e4b316c56c38d4c0fd2fb38405","affectsGlobalScope":true,"impliedFormat":1},{"version":"73f78680d4c08509933daf80947902f6ff41b6230f94dd002ae372620adb0f60","affectsGlobalScope":true,"impliedFormat":1},{"version":"c5239f5c01bcfa9cd32f37c496cf19c61d69d37e48be9de612b541aac915805b","affectsGlobalScope":true,"impliedFormat":1},{"version":"8e7f8264d0fb4c5339605a15daadb037bf238c10b654bb3eee14208f860a32ea","affectsGlobalScope":true,"impliedFormat":1},{"version":"782dec38049b92d4e85c1585fbea5474a219c6984a35b004963b00beb1aab538","affectsGlobalScope":true,"impliedFormat":1},{"version":"3e9a03c100af46779ffaf38dde5f3cc853061f899d4d1d0f17a92138891fbf4a","affectsGlobalScope":true},{"version":"7e29f41b158de217f94cb9676bf9cbd0cd9b5a46e1985141ed36e075c52bf6ad","affectsGlobalScope":true,"impliedFormat":1},{"version":"ac51dd7d31333793807a6abaa5ae168512b6131bd41d9c5b98477fc3b7800f9f","impliedFormat":1},{"version":"dc782ff85b2cb10075ecffc158af7bfb27ff97bf8491c917efea0c3d622d5ac4","impliedFormat":1},{"version":"acd8fd5090ac73902278889c38336ff3f48af6ba03aa665eb34a75e7ba1dccc4","impliedFormat":1},{"version":"d6258883868fb2680d2ca96bc8b1352cab69874581493e6d52680c5ffecdb6cc","impliedFormat":1},{"version":"1b61d259de5350f8b1e5db06290d31eaebebc6baafd5f79d314b5af9256d7153","impliedFormat":1},{"version":"f258e3960f324a956fc76a3d3d9e964fff2244ff5859dcc6ce5951e5413ca826","impliedFormat":1},{"version":"643f7232d07bf75e15bd8f658f664d6183a0efaca5eb84b48201c7671a266979","impliedFormat":1},{"version":"0f6666b58e9276ac3a38fdc80993d19208442d6027ab885580d93aec76b4ef00","impliedFormat":1},{"version":"05fd364b8ef02fb1e174fbac8b825bdb1e5a36a016997c8e421f5fab0a6da0a0","impliedFormat":1},{"version":"631eff75b0e35d1b1b31081d55209abc43e16b49426546ab5a9b40bdd40b1f60","impliedFormat":1},{"version":"70521b6ab0dcba37539e5303104f29b721bfb2940b2776da4cc818c07e1fefc1","affectsGlobalScope":true,"impliedFormat":1},{"version":"ab41ef1f2cdafb8df48be20cd969d875602483859dc194e9c97c8a576892c052","affectsGlobalScope":true,"impliedFormat":1},{"version":"d153a11543fd884b596587ccd97aebbeed950b26933ee000f94009f1ab142848","affectsGlobalScope":true,"impliedFormat":1},{"version":"21d819c173c0cf7cc3ce57c3276e77fd9a8a01d35a06ad87158781515c9a438a","impliedFormat":1},{"version":"98cffbf06d6bab333473c70a893770dbe990783904002c4f1a960447b4b53dca","affectsGlobalScope":true,"impliedFormat":1},{"version":"ba481bca06f37d3f2c137ce343c7d5937029b2468f8e26111f3c9d9963d6568d","affectsGlobalScope":true,"impliedFormat":1},{"version":"6d9ef24f9a22a88e3e9b3b3d8c40ab1ddb0853f1bfbd5c843c37800138437b61","affectsGlobalScope":true,"impliedFormat":1},{"version":"1db0b7dca579049ca4193d034d835f6bfe73096c73663e5ef9a0b5779939f3d0","affectsGlobalScope":true,"impliedFormat":1},{"version":"9798340ffb0d067d69b1ae5b32faa17ab31b82466a3fc00d8f2f2df0c8554aaa","affectsGlobalScope":true,"impliedFormat":1},{"version":"f26b11d8d8e4b8028f1c7d618b22274c892e4b0ef5b3678a8ccbad85419aef43","affectsGlobalScope":true,"impliedFormat":1},{"version":"5929864ce17fba74232584d90cb721a89b7ad277220627cc97054ba15a98ea8f","impliedFormat":1},{"version":"763fe0f42b3d79b440a9b6e51e9ba3f3f91352469c1e4b3b67bfa4ff6352f3f4","impliedFormat":1},{"version":"25c8056edf4314820382a5fdb4bb7816999acdcb929c8f75e3f39473b87e85bc","impliedFormat":1},{"version":"c464d66b20788266e5353b48dc4aa6bc0dc4a707276df1e7152ab0c9ae21fad8","impliedFormat":1},{"version":"78d0d27c130d35c60b5e5566c9f1e5be77caf39804636bc1a40133919a949f21","impliedFormat":1},{"version":"c6fd2c5a395f2432786c9cb8deb870b9b0e8ff7e22c029954fabdd692bff6195","impliedFormat":1},{"version":"1d6e127068ea8e104a912e42fc0a110e2aa5a66a356a917a163e8cf9a65e4a75","impliedFormat":1},{"version":"5ded6427296cdf3b9542de4471d2aa8d3983671d4cac0f4bf9c637208d1ced43","impliedFormat":1},{"version":"7f182617db458e98fc18dfb272d40aa2fff3a353c44a89b2c0ccb3937709bfb5","impliedFormat":1},{"version":"cadc8aced301244057c4e7e73fbcae534b0f5b12a37b150d80e5a45aa4bebcbd","impliedFormat":1},{"version":"385aab901643aa54e1c36f5ef3107913b10d1b5bb8cbcd933d4263b80a0d7f20","impliedFormat":1},{"version":"9670d44354bab9d9982eca21945686b5c24a3f893db73c0dae0fd74217a4c219","impliedFormat":1},{"version":"0b8a9268adaf4da35e7fa830c8981cfa22adbbe5b3f6f5ab91f6658899e657a7","impliedFormat":1},{"version":"11396ed8a44c02ab9798b7dca436009f866e8dae3c9c25e8c1fbc396880bf1bb","impliedFormat":1},{"version":"ba7bc87d01492633cb5a0e5da8a4a42a1c86270e7b3d2dea5d156828a84e4882","impliedFormat":1},{"version":"4893a895ea92c85345017a04ed427cbd6a1710453338df26881a6019432febdd","impliedFormat":1},{"version":"c21dc52e277bcfc75fac0436ccb75c204f9e1b3fa5e12729670910639f27343e","impliedFormat":1},{"version":"13f6f39e12b1518c6650bbb220c8985999020fe0f21d818e28f512b7771d00f9","impliedFormat":1},{"version":"9b5369969f6e7175740bf51223112ff209f94ba43ecd3bb09eefff9fd675624a","impliedFormat":1},{"version":"4fe9e626e7164748e8769bbf74b538e09607f07ed17c2f20af8d680ee49fc1da","impliedFormat":1},{"version":"24515859bc0b836719105bb6cc3d68255042a9f02a6022b3187948b204946bd2","impliedFormat":1},{"version":"ea0148f897b45a76544ae179784c95af1bd6721b8610af9ffa467a518a086a43","impliedFormat":1},{"version":"24c6a117721e606c9984335f71711877293a9651e44f59f3d21c1ea0856f9cc9","impliedFormat":1},{"version":"dd3273ead9fbde62a72949c97dbec2247ea08e0c6952e701a483d74ef92d6a17","impliedFormat":1},{"version":"405822be75ad3e4d162e07439bac80c6bcc6dbae1929e179cf467ec0b9ee4e2e","impliedFormat":1},{"version":"0db18c6e78ea846316c012478888f33c11ffadab9efd1cc8bcc12daded7a60b6","impliedFormat":1},{"version":"e61be3f894b41b7baa1fbd6a66893f2579bfad01d208b4ff61daef21493ef0a8","impliedFormat":1},{"version":"bd0532fd6556073727d28da0edfd1736417a3f9f394877b6d5ef6ad88fba1d1a","impliedFormat":1},{"version":"89167d696a849fce5ca508032aabfe901c0868f833a8625d5a9c6e861ef935d2","impliedFormat":1},{"version":"615ba88d0128ed16bf83ef8ccbb6aff05c3ee2db1cc0f89ab50a4939bfc1943f","impliedFormat":1},{"version":"a4d551dbf8746780194d550c88f26cf937caf8d56f102969a110cfaed4b06656","impliedFormat":1},{"version":"8bd86b8e8f6a6aa6c49b71e14c4ffe1211a0e97c80f08d2c8cc98838006e4b88","impliedFormat":1},{"version":"317e63deeb21ac07f3992f5b50cdca8338f10acd4fbb7257ebf56735bf52ab00","impliedFormat":1},{"version":"4732aec92b20fb28c5fe9ad99521fb59974289ed1e45aecb282616202184064f","impliedFormat":1},{"version":"2e85db9e6fd73cfa3d7f28e0ab6b55417ea18931423bd47b409a96e4a169e8e6","impliedFormat":1},{"version":"c46e079fe54c76f95c67fb89081b3e399da2c7d109e7dca8e4b58d83e332e605","impliedFormat":1},{"version":"bf67d53d168abc1298888693338cb82854bdb2e69ef83f8a0092093c2d562107","impliedFormat":1},{"version":"b52476feb4a0cbcb25e5931b930fc73cb6643fb1a5060bf8a3dda0eeae5b4b68","affectsGlobalScope":true,"impliedFormat":1},{"version":"e2677634fe27e87348825bb041651e22d50a613e2fdf6a4a3ade971d71bac37e","impliedFormat":1},{"version":"7394959e5a741b185456e1ef5d64599c36c60a323207450991e7a42e08911419","impliedFormat":1},{"version":"8c0bcd6c6b67b4b503c11e91a1fb91522ed585900eab2ab1f61bba7d7caa9d6f","impliedFormat":1},{"version":"8cd19276b6590b3ebbeeb030ac271871b9ed0afc3074ac88a94ed2449174b776","affectsGlobalScope":true,"impliedFormat":1},{"version":"696eb8d28f5949b87d894b26dc97318ef944c794a9a4e4f62360cd1d1958014b","impliedFormat":1},{"version":"3f8fa3061bd7402970b399300880d55257953ee6d3cd408722cb9ac20126460c","impliedFormat":1},{"version":"35ec8b6760fd7138bbf5809b84551e31028fb2ba7b6dc91d95d098bf212ca8b4","affectsGlobalScope":true,"impliedFormat":1},{"version":"5524481e56c48ff486f42926778c0a3cce1cc85dc46683b92b1271865bcf015a","impliedFormat":1},{"version":"68bd56c92c2bd7d2339457eb84d63e7de3bd56a69b25f3576e1568d21a162398","affectsGlobalScope":true,"impliedFormat":1},{"version":"3e93b123f7c2944969d291b35fed2af79a6e9e27fdd5faa99748a51c07c02d28","impliedFormat":1},{"version":"9d19808c8c291a9010a6c788e8532a2da70f811adb431c97520803e0ec649991","impliedFormat":1},{"version":"87aad3dd9752067dc875cfaa466fc44246451c0c560b820796bdd528e29bef40","impliedFormat":1},{"version":"4aacb0dd020eeaef65426153686cc639a78ec2885dc72ad220be1d25f1a439df","impliedFormat":1},{"version":"f0bd7e6d931657b59605c44112eaf8b980ba7f957a5051ed21cb93d978cf2f45","impliedFormat":1},{"version":"8db0ae9cb14d9955b14c214f34dae1b9ef2baee2fe4ce794a4cd3ac2531e3255","affectsGlobalScope":true,"impliedFormat":1},{"version":"15fc6f7512c86810273af28f224251a5a879e4261b4d4c7e532abfbfc3983134","impliedFormat":1},{"version":"58adba1a8ab2d10b54dc1dced4e41f4e7c9772cbbac40939c0dc8ce2cdb1d442","impliedFormat":1},{"version":"641942a78f9063caa5d6b777c99304b7d1dc7328076038c6d94d8a0b81fc95c1","impliedFormat":1},{"version":"1123a83f35cf56c97de746f0a7250012153c61a167e4a61668bf50e558162d14","impliedFormat":1},{"version":"855cd5f7eb396f5f1ab1bc0f8580339bff77b68a770f84c6b254e319bbfd1ac7","impliedFormat":1},{"version":"5650cf3dace09e7c25d384e3e6b818b938f68f4e8de96f52d9c5a1b3db068e86","impliedFormat":1},{"version":"1354ca5c38bd3fd3836a68e0f7c9f91f172582ba30ab15bb8c075891b91502b7","affectsGlobalScope":true,"impliedFormat":1},{"version":"7e20d899c28ca26a2a7afc98beaa69e63ff7fba0a8bc47b4e3bf3ede5e09e424","impliedFormat":1},{"version":"2d2fcaab481b31a5882065c7951255703ddbe1c0e507af56ea42d79ac3911201","impliedFormat":1},{"version":"a192fe8ec33f75edbc8d8f3ed79f768dfae11ff5735e7fe52bfa69956e46d78d","impliedFormat":1},{"version":"ca867399f7db82df981d6915bcbb2d81131d7d1ef683bc782b59f71dda59bc85","affectsGlobalScope":true,"impliedFormat":1},{"version":"372413016d17d804e1d139418aca0c68e47a83fb6669490857f4b318de8cccb3","affectsGlobalScope":true,"impliedFormat":1},{"version":"9e043a1bc8fbf2a255bccf9bf27e0f1caf916c3b0518ea34aa72357c0afd42ec","impliedFormat":1},{"version":"b4f70ec656a11d570e1a9edce07d118cd58d9760239e2ece99306ee9dfe61d02","impliedFormat":1},{"version":"3bc2f1e2c95c04048212c569ed38e338873f6a8593930cf5a7ef24ffb38fc3b6","impliedFormat":1},{"version":"6e70e9570e98aae2b825b533aa6292b6abd542e8d9f6e9475e88e1d7ba17c866","impliedFormat":1},{"version":"f9d9d753d430ed050dc1bf2667a1bab711ccbb1c1507183d794cc195a5b085cc","impliedFormat":1},{"version":"9eece5e586312581ccd106d4853e861aaaa1a39f8e3ea672b8c3847eedd12f6e","impliedFormat":1},{"version":"085f552d005479e2e6a7311cdbbe5d8c55c497b4d19274285df161ee9684cd9c","impliedFormat":1},{"version":"37ba7b45141a45ce6e80e66f2a96c8a5ab1bcef0fc2d0f56bb58df96ec67e972","impliedFormat":1},{"version":"45650f47bfb376c8a8ed39d4bcda5902ab899a3150029684ee4c10676d9fbaee","impliedFormat":1},{"version":"007faacc9268357caa21d24169f3f3f2497af3e9241308df2d89f6e6d9bb3f2e","affectsGlobalScope":true,"impliedFormat":1},{"version":"74cf591a0f63db318651e0e04cb55f8791385f86e987a67fd4d2eaab8191f730","impliedFormat":1},{"version":"5eab9b3dc9b34f185417342436ec3f106898da5f4801992d8ff38ab3aff346b5","impliedFormat":1},{"version":"12ed4559eba17cd977aa0db658d25c4047067444b51acfdcbf38470630642b23","affectsGlobalScope":true,"impliedFormat":1},{"version":"f3ffabc95802521e1e4bcba4c88d8615176dc6e09111d920c7a213bdda6e1d65","impliedFormat":1},{"version":"809821b8a065e3234a55b3a9d7846231ed18d66dd749f2494c66288d890daf7f","impliedFormat":1},{"version":"ae56f65caf3be91108707bd8dfbccc2a57a91feb5daabf7165a06a945545ed26","impliedFormat":1},{"version":"a136d5de521da20f31631a0a96bf712370779d1c05b7015d7019a9b2a0446ca9","impliedFormat":1},{"version":"c3b41e74b9a84b88b1dca61ec39eee25c0dbc8e7d519ba11bb070918cfacf656","affectsGlobalScope":true,"impliedFormat":1},{"version":"4737a9dc24d0e68b734e6cfbcea0c15a2cfafeb493485e27905f7856988c6b29","affectsGlobalScope":true,"impliedFormat":1},{"version":"36d8d3e7506b631c9582c251a2c0b8a28855af3f76719b12b534c6edf952748d","impliedFormat":1},{"version":"1ca69210cc42729e7ca97d3a9ad48f2e9cb0042bada4075b588ae5387debd318","impliedFormat":1},{"version":"f5ebe66baaf7c552cfa59d75f2bfba679f329204847db3cec385acda245e574e","impliedFormat":1},{"version":"ed59add13139f84da271cafd32e2171876b0a0af2f798d0c663e8eeb867732cf","affectsGlobalScope":true,"impliedFormat":1},{"version":"b7c5e2ea4a9749097c347454805e933844ed207b6eefec6b7cfd418b5f5f7b28","impliedFormat":1},{"version":"b1810689b76fd473bd12cc9ee219f8e62f54a7d08019a235d07424afbf074d25","impliedFormat":1},{"version":"2beff543f6e9a9701df88daeee3cdd70a34b4a1c11cb4c734472195a5cb2af54","impliedFormat":1},{"version":"2e07abf27aa06353d46f4448c0bbac73431f6065eef7113128a5cd804d0c384d","impliedFormat":1},{"version":"be1cc4d94ea60cbe567bc29ed479d42587bf1e6cba490f123d329976b0fe4ee5","impliedFormat":1},{"version":"42bc0e1a903408137c3df2b06dfd7e402cdab5bbfa5fcfb871b22ebfdb30bd0b","impliedFormat":1},{"version":"9894dafe342b976d251aac58e616ac6df8db91fb9d98934ff9dd103e9e82578f","impliedFormat":1},{"version":"413df52d4ea14472c2fa5bee62f7a40abd1eb49be0b9722ee01ee4e52e63beb2","impliedFormat":1},{"version":"db6d2d9daad8a6d83f281af12ce4355a20b9a3e71b82b9f57cddcca0a8964a96","impliedFormat":1},{"version":"829b9e6028b29e6a8b1c01ddb713efe59da04d857089298fa79acbdb3cfcfdef","impliedFormat":1},{"version":"24f8562308dd8ba6013120557fa7b44950b619610b2c6cb8784c79f11e3c4f90","impliedFormat":1},{"version":"5f90b8c733a1bda63e42160b15a2301051e83a6f9d5332a59d16eb12f463270d","impliedFormat":1},{"version":"a86f82d646a739041d6702101afa82dcb935c416dd93cbca7fd754fd0282ce1f","impliedFormat":1},{"version":"ad0d1d75d129b1c80f911be438d6b61bfa8703930a8ff2be2f0e1f8a91841c64","impliedFormat":1},{"version":"ce75b1aebb33d510ff28af960a9221410a3eaf7f18fc5f21f9404075fba77256","impliedFormat":1},{"version":"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855","impliedFormat":1},{"version":"496bbf339f3838c41f164238543e9fe5f1f10659cb30b68903851618464b98ba","impliedFormat":1},{"version":"5178eb4415a172c287c711dc60a619e110c3fd0b7de01ed0627e51a5336aa09c","impliedFormat":1},{"version":"ca6e5264278b53345bc1ce95f42fb0a8b733a09e3d6479c6ccfca55cdc45038c","impliedFormat":1},{"version":"9e2739b32f741859263fdba0244c194ca8e96da49b430377930b8f721d77c000","impliedFormat":1},{"version":"fb1d8e814a3eeb5101ca13515e0548e112bd1ff3fb358ece535b93e94adf5a3a","impliedFormat":1},{"version":"ffa495b17a5ef1d0399586b590bd281056cee6ce3583e34f39926f8dcc6ecdb5","impliedFormat":1},{"version":"98b18458acb46072947aabeeeab1e410f047e0cacc972943059ca5500b0a5e95","impliedFormat":1},{"version":"361e2b13c6765d7f85bb7600b48fde782b90c7c41105b7dab1f6e7871071ba20","impliedFormat":1},{"version":"c86fe861cf1b4c46a0fb7d74dffe596cf679a2e5e8b1456881313170f092e3fa","impliedFormat":1},{"version":"b6db56e4903e9c32e533b78ac85522de734b3d3a8541bf24d256058d464bf04b","impliedFormat":1},{"version":"24daa0366f837d22c94a5c0bad5bf1fd0f6b29e1fae92dc47c3072c3fdb2fbd5","impliedFormat":1},{"version":"570bb5a00836ffad3e4127f6adf581bfc4535737d8ff763a4d6f4cc877e60d98","impliedFormat":1},{"version":"889c00f3d32091841268f0b994beba4dceaa5df7573be12c2c829d7c5fbc232c","impliedFormat":1},{"version":"65f43099ded6073336e697512d9b80f2d4fec3182b7b2316abf712e84104db00","impliedFormat":1},{"version":"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855","impliedFormat":1},{"version":"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855","impliedFormat":1},{"version":"8e609bb71c20b858c77f0e9f90bb1319db8477b13f9f965f1a1e18524bf50881","impliedFormat":1},{"version":"acf5a2ac47b59ca07afa9abbd2b31d001bf7448b041927befae2ea5b1951d9f9","impliedFormat":1},{"version":"8e609bb71c20b858c77f0e9f90bb1319db8477b13f9f965f1a1e18524bf50881","impliedFormat":1},{"version":"d71291eff1e19d8762a908ba947e891af44749f3a2cbc5bd2ec4b72f72ea795f","impliedFormat":1},{"version":"c0480e03db4b816dff2682b347c95f2177699525c54e7e6f6aa8ded890b76be7","impliedFormat":1},{"version":"27ab780875bcbb65e09da7496f2ca36288b0c541abaa75c311450a077d54ec15","impliedFormat":1},{"version":"b620391fe8060cf9bedc176a4d01366e6574d7a71e0ac0ab344a4e76576fcbb8","impliedFormat":1},{"version":"380647d8f3b7f852cca6d154a376dbf8ac620a2f12b936594504a8a852e71d2f","impliedFormat":1},{"version":"208c9af9429dd3c76f5927b971263174aaa4bc7621ddec63f163640cbd3c473c","impliedFormat":1},{"version":"6459054aabb306821a043e02b89d54da508e3a6966601a41e71c166e4ea1474f","impliedFormat":1},{"version":"a23185bc5ef590c287c28a91baf280367b50ae4ea40327366ad01f6f4a8edbc5","impliedFormat":1},{"version":"bb37588926aba35c9283fe8d46ebf4e79ffe976343105f5c6d45f282793352b2","impliedFormat":1},{"version":"002eae065e6960458bda3cf695e578b0d1e2785523476f8a9170b103c709cd4f","impliedFormat":1},{"version":"c83bb0c9c5645a46c68356c2f73fdc9de339ce77f7f45a954f560c7e0b8d5ebb","impliedFormat":1},{"version":"05c97cddbaf99978f83d96de2d8af86aded9332592f08ce4a284d72d0952c391","impliedFormat":1},{"version":"72179f9dd22a86deaad4cc3490eb0fe69ee084d503b686985965654013f1391b","impliedFormat":1},{"version":"2e6114a7dd6feeef85b2c80120fdbfb59a5529c0dcc5bfa8447b6996c97a69f5","impliedFormat":1},{"version":"7b6ff760c8a240b40dab6e4419b989f06a5b782f4710d2967e67c695ef3e93c4","impliedFormat":1},{"version":"c8f004e6036aa1c764ad4ec543cf89a5c1893a9535c80ef3f2b653e370de45e6","impliedFormat":1},{"version":"dd80b1e600d00f5c6a6ba23f455b84a7db121219e68f89f10552c54ba46e4dc9","impliedFormat":1},{"version":"b064c36f35de7387d71c599bfcf28875849a1dbc733e82bd26cae3d1cd060521","impliedFormat":1},{"version":"6a148329edecbda07c21098639ef4254ef7869fb25a69f58e5d6a8b7b69d4236","impliedFormat":1},{"version":"8de9fe97fa9e00ec00666fa77ab6e91b35d25af8ca75dabcb01e14ad3299b150","impliedFormat":1},{"version":"f63ab283a1c8f5c79fabe7ca4ef85f9633339c4f0e822fce6a767f9d59282af2","impliedFormat":1},{"version":"dba114fb6a32b355a9cfc26ca2276834d72fe0e94cd2c3494005547025015369","impliedFormat":1},{"version":"a54c996c8870ef1728a2c1fa9b8eaec0bf4a8001cd2583c02dd5869289465b10","impliedFormat":1},{"version":"3e7efde639c6a6c3edb9847b3f61e308bf7a69685b92f665048c45132f51c218","impliedFormat":1},{"version":"df45ca1176e6ac211eae7ddf51336dc075c5314bc5c253651bae639defd5eec5","impliedFormat":1},{"version":"3754982006a3b32c502cff0867ca83584f7a43b1035989ca73603f400de13c96","impliedFormat":1},{"version":"a30ae9bb8a8fa7b90f24b8a0496702063ae4fe75deb27da731ed4a03b2eb6631","impliedFormat":1},{"version":"f974e4a06953682a2c15d5bd5114c0284d5abf8bc0fe4da25cb9159427b70072","impliedFormat":1},{"version":"50256e9c31318487f3752b7ac12ff365c8949953e04568009c8705db802776fb","impliedFormat":1},{"version":"7d73b24e7bf31dfb8a931ca6c4245f6bb0814dfae17e4b60c9e194a631fe5f7b","impliedFormat":1},{"version":"413586add0cfe7369b64979d4ec2ed56c3f771c0667fbde1bf1f10063ede0b08","impliedFormat":1},{"version":"06472528e998d152375ad3bd8ebcb69ff4694fd8d2effaf60a9d9f25a37a097a","impliedFormat":1},{"version":"50b5bc34ce6b12eccb76214b51aadfa56572aa6cc79c2b9455cdbb3d6c76af1d","impliedFormat":1},{"version":"b7e16ef7f646a50991119b205794ebfd3a4d8f8e0f314981ebbe991639023d0e","impliedFormat":1},{"version":"b838d4c72740eb0afd284bf7575b74c624b105eff2e8c7b4aeead57e7ac320ff","impliedFormat":1},{"version":"a401617604fa1f6ce437b81689563dfdc377069e4c58465dbd8d16069aede0a5","impliedFormat":1},{"version":"e9dd71cf12123419c60dab867d44fbee5c358169f99529121eaef277f5c83531","impliedFormat":1},{"version":"5b6a189ba3a0befa1f5d9cb028eb9eec2af2089c32f04ff50e2411f63d70f25d","impliedFormat":1},{"version":"d6e73f8010935b7b4c7487b6fb13ea197cc610f0965b759bec03a561ccf8423a","impliedFormat":1},{"version":"174f3864e398f3f33f9a446a4f403d55a892aa55328cf6686135dfaf9e171657","impliedFormat":1},{"version":"824c76aec8d8c7e65769688cbee102238c0ef421ed6686f41b2a7d8e7e78a931","impliedFormat":1},{"version":"75b868be3463d5a8cfc0d9396f0a3d973b8c297401d00bfb008a42ab16643f13","impliedFormat":1},{"version":"15a234e5031b19c48a69ccc1607522d6e4b50f57d308ecb7fe863d44cd9f9eb3","impliedFormat":1},{"version":"d682336018141807fb602709e2d95a192828fcb8d5ba06dda3833a8ea98f69e3","impliedFormat":1},{"version":"6124e973eab8c52cabf3c07575204efc1784aca6b0a30c79eb85fe240a857efa","impliedFormat":1},{"version":"0d891735a21edc75df51f3eb995e18149e119d1ce22fd40db2b260c5960b914e","impliedFormat":1},{"version":"3b414b99a73171e1c4b7b7714e26b87d6c5cb03d200352da5342ab4088a54c85","impliedFormat":1},{"version":"4fbd3116e00ed3a6410499924b6403cc9367fdca303e34838129b328058ede40","impliedFormat":1},{"version":"b01bd582a6e41457bc56e6f0f9de4cb17f33f5f3843a7cf8210ac9c18472fb0f","impliedFormat":1},{"version":"0a437ae178f999b46b6153d79095b60c42c996bc0458c04955f1c996dc68b971","impliedFormat":1},{"version":"74b2a5e5197bd0f2e0077a1ea7c07455bbea67b87b0869d9786d55104006784f","impliedFormat":1},{"version":"4a7baeb6325920044f66c0f8e5e6f1f52e06e6d87588d837bdf44feb6f35c664","impliedFormat":1},{"version":"6dcf60530c25194a9ee0962230e874ff29d34c59605d8e069a49928759a17e0a","impliedFormat":1},{"version":"7274fbffbd7c9589d8d0ffba68157237afd5cecff1e99881ea3399127e60572f","impliedFormat":1},{"version":"1a42d2ec31a1fe62fdc51591768695ed4a2dc64c01be113e7ff22890bebb5e3f","impliedFormat":1},{"version":"1a82deef4c1d39f6882f28d275cad4c01f907b9b39be9cbc472fcf2cf051e05b","impliedFormat":1},{"version":"c5426dbfc1cf90532f66965a7aa8c1136a78d4d0f96d8180ecbfc11d7722f1a5","impliedFormat":1},{"version":"65a15fc47900787c0bd18b603afb98d33ede930bed1798fc984d5ebb78b26cf9","impliedFormat":1},{"version":"9d202701f6e0744adb6314d03d2eb8fc994798fc83d91b691b75b07626a69801","impliedFormat":1},{"version":"de9d2df7663e64e3a91bf495f315a7577e23ba088f2949d5ce9ec96f44fba37d","impliedFormat":1},{"version":"c7af78a2ea7cb1cd009cfb5bdb48cd0b03dad3b54f6da7aab615c2e9e9d570c5","impliedFormat":1},{"version":"1ee45496b5f8bdee6f7abc233355898e5bf9bd51255db65f5ff7ede617ca0027","impliedFormat":1},{"version":"0c7c947ff881c4274c0800deaa0086971e0bfe51f89a33bd3048eaa3792d4876","affectsGlobalScope":true,"impliedFormat":1},{"version":"db01d18853469bcb5601b9fc9826931cc84cc1a1944b33cad76fd6f1e3d8c544","affectsGlobalScope":true,"impliedFormat":1},{"version":"a8f8e6ab2fa07b45251f403548b78eaf2022f3c2254df3dc186cb2671fe4996d","affectsGlobalScope":true,"impliedFormat":1},{"version":"fa6c12a7c0f6b84d512f200690bfc74819e99efae69e4c95c4cd30f6884c526e","impliedFormat":1},{"version":"f1c32f9ce9c497da4dc215c3bc84b722ea02497d35f9134db3bb40a8d918b92b","impliedFormat":1},{"version":"b73c319af2cc3ef8f6421308a250f328836531ea3761823b4cabbd133047aefa","affectsGlobalScope":true,"impliedFormat":1},{"version":"e433b0337b8106909e7953015e8fa3f2d30797cea27141d1c5b135365bb975a6","impliedFormat":1},{"version":"15b36126e0089bfef173ab61329e8286ce74af5e809d8a72edcafd0cc049057f","impliedFormat":1},{"version":"ddff7fc6edbdc5163a09e22bf8df7bef75f75369ebd7ecea95ba55c4386e2441","impliedFormat":1},{"version":"106c6025f1d99fd468fd8bf6e5bda724e11e5905a4076c5d29790b6c3745e50c","impliedFormat":1},{"version":"a57b1802794433adec9ff3fed12aa79d671faed86c49b09e02e1ac41b4f1d33a","impliedFormat":1},{"version":"ad10d4f0517599cdeca7755b930f148804e3e0e5b5a3847adce0f1f71bbccd74","impliedFormat":1},{"version":"1042064ece5bb47d6aba91648fbe0635c17c600ebdf567588b4ca715602f0a9d","impliedFormat":1},{"version":"c49469a5349b3cc1965710b5b0f98ed6c028686aa8450bcb3796728873eb923e","impliedFormat":1},{"version":"4a889f2c763edb4d55cb624257272ac10d04a1cad2ed2948b10ed4a7fda2a428","impliedFormat":1},{"version":"7bb79aa2fead87d9d56294ef71e056487e848d7b550c9a367523ee5416c44cfa","impliedFormat":1},{"version":"72d63643a657c02d3e51cd99a08b47c9b020a565c55f246907050d3c8a5e77fb","impliedFormat":1},{"version":"1d415445ea58f8033ba199703e55ff7483c52ac6742075b803bd3e7bbe9f5d61","impliedFormat":1},{"version":"d6406c629bb3efc31aedb2de809bef471e475c86c7e67f3ef9b676b5d7e0d6b2","impliedFormat":1},{"version":"27ff4196654e6373c9af16b6165120e2dd2169f9ad6abb5c935af5abd8c7938c","impliedFormat":1},{"version":"71d8ba39a9e024d9e4bb922464d18542ed8d2c25ee78efa7890c27213cc6e5d3","impliedFormat":1},{"version":"8c030e515014c10a2b98f9f48408e3ba18023dfd3f56e3312c6c2f3ae1f55a16","impliedFormat":1},{"version":"dafc31e9e8751f437122eb8582b93d477e002839864410ff782504a12f2a550c","impliedFormat":1},{"version":"754498c5208ce3c5134f6eabd49b25cf5e1a042373515718953581636491f3c3","impliedFormat":1},{"version":"9c82171d836c47486074e4ca8e059735bf97b205e70b196535b5efd40cbe1bc5","impliedFormat":1},{"version":"f56bdc6884648806d34bc66d31cdb787c4718d04105ce2cd88535db214631f82","impliedFormat":1},{"version":"633d58a237f4bb25ec7d565e4ffa32cecdcee8660ac12189c4351c52557cee9e","impliedFormat":1},{"version":"2e4f37ffe8862b14d8e24ae8763daaa8340c0df0b859d9a9733def0eee7562d9","impliedFormat":1},{"version":"13283350547389802aa35d9f2188effaeac805499169a06ef5cd77ce2a0bd63f","impliedFormat":1},{"version":"ce791f6ea807560f08065d1af6014581eeb54a05abd73294777a281b6dfd73c2","impliedFormat":1},{"version":"6ac6715916fa75a1f7ebdfeacac09513b4d904b667d827b7535e84ff59679aff","impliedFormat":1},{"version":"49f95e989b4632c6c2a578cc0078ee19a5831832d79cc59abecf5160ea71abad","impliedFormat":1},{"version":"9666533332f26e8995e4d6fe472bdeec9f15d405693723e6497bf94120c566c8","impliedFormat":1},{"version":"ce0df82a9ae6f914ba08409d4d883983cc08e6d59eb2df02d8e4d68309e7848b","impliedFormat":1},{"version":"796273b2edc72e78a04e86d7c58ae94d370ab93a0ddf40b1aa85a37a1c29ecd7","impliedFormat":1},{"version":"5df15a69187d737d6d8d066e189ae4f97e41f4d53712a46b2710ff9f8563ec9f","impliedFormat":1},{"version":"e17cd049a1448de4944800399daa4a64c5db8657cc9be7ef46be66e2a2cd0e7c","impliedFormat":1},{"version":"43fa6ea8714e18adc312b30450b13562949ba2f205a1972a459180fa54471018","impliedFormat":1},{"version":"6e89c2c177347d90916bad67714d0fb473f7e37fb3ce912f4ed521fe2892cd0d","impliedFormat":1},{"version":"43ba4f2fa8c698f5c304d21a3ef596741e8e85a810b7c1f9b692653791d8d97a","impliedFormat":1},{"version":"4d4927cbee21750904af7acf940c5e3c491b4d5ebc676530211e389dd375607a","impliedFormat":1},{"version":"72105519d0390262cf0abe84cf41c926ade0ff475d35eb21307b2f94de985778","impliedFormat":1},{"version":"8a97e578a9bc40eb4f1b0ca78f476f2e9154ecbbfd5567ee72943bab37fc156a","impliedFormat":1},{"version":"c857e0aae3f5f444abd791ec81206020fbcc1223e187316677e026d1c1d6fe08","impliedFormat":1},{"version":"ccf6dd45b708fb74ba9ed0f2478d4eb9195c9dfef0ff83a6092fa3cf2ff53b4f","impliedFormat":1},{"version":"2d7db1d73456e8c5075387d4240c29a2a900847f9c1bff106a2e490da8fbd457","impliedFormat":1},{"version":"2b15c805f48e4e970f8ec0b1915f22d13ca6212375e8987663e2ef5f0205e832","impliedFormat":1},{"version":"f22d05663d873ee7a600faf78abb67f3f719d32266803440cf11d5db7ac0cab2","impliedFormat":1},{"version":"d93c544ad20197b3976b0716c6d5cd5994e71165985d31dcab6e1f77feb4b8f2","impliedFormat":1},{"version":"35069c2c417bd7443ae7c7cafd1de02f665bf015479fec998985ffbbf500628c","impliedFormat":1},{"version":"a8b1c79a833ee148251e88a2553d02ce1641d71d2921cce28e79678f3d8b96aa","impliedFormat":1},{"version":"126d4f950d2bba0bd45b3a86c76554d4126c16339e257e6d2fabf8b6bf1ce00c","impliedFormat":1},{"version":"7e0b7f91c5ab6e33f511efc640d36e6f933510b11be24f98836a20a2dc914c2d","impliedFormat":1},{"version":"045b752f44bf9bbdcaffd882424ab0e15cb8d11fa94e1448942e338c8ef19fba","impliedFormat":1},{"version":"2894c56cad581928bb37607810af011764a2f511f575d28c9f4af0f2ef02d1ab","impliedFormat":1},{"version":"0a72186f94215d020cb386f7dca81d7495ab6c17066eb07d0f44a5bf33c1b21a","impliedFormat":1},{"version":"2d3cc2211f352f46ea6b7cf2c751c141ffcdf514d6e7ae7ee20b7b6742da313f","impliedFormat":1},{"version":"c75445151ff8b77d9923191efed7203985b1a9e09eccf4b054e7be864e27923d","impliedFormat":1},{"version":"0aedb02516baf3e66b2c1db9fef50666d6ed257edac0f866ea32f1aa05aa474f","impliedFormat":1},{"version":"fa8a8fbf91ee2a4779496225f0312aac6635b0f21aa09cdafa4283fe32d519c5","affectsGlobalScope":true,"impliedFormat":1},{"version":"0e8aef93d79b000deb6ec336b5645c87de167168e184e84521886f9ecc69a4b5","impliedFormat":1},{"version":"c30436b130b6218b7714314dc41d3f459590db4bdf099eecd51cb1bda32109a8","impliedFormat":1},{"version":"20fa37b636fdcc1746ea0738f733d0aed17890d1cd7cb1b2f37010222c23f13e","impliedFormat":1},{"version":"d90b9f1520366d713a73bd30c5a9eb0040d0fb6076aff370796bc776fd705943","impliedFormat":1},{"version":"bc03c3c352f689e38c0ddd50c39b1e65d59273991bfc8858a9e3c0ebb79c023b","impliedFormat":1},{"version":"19df3488557c2fc9b4d8f0bac0fd20fb59aa19dec67c81f93813951a81a867f8","affectsGlobalScope":true,"impliedFormat":1},{"version":"b25350193e103ae90423c5418ddb0ad1168dc9c393c9295ef34980b990030617","affectsGlobalScope":true,"impliedFormat":1},{"version":"bef86adb77316505c6b471da1d9b8c9e428867c2566270e8894d4d773a1c4dc2","impliedFormat":1},{"version":"de7052bfee2981443498239a90c04ea5cc07065d5b9bb61b12cb6c84313ad4ef","impliedFormat":1},{"version":"a3e7d932dc9c09daa99141a8e4800fc6c58c625af0d4bbb017773dc36da75426","impliedFormat":1},{"version":"43e96a3d5d1411ab40ba2f61d6a3192e58177bcf3b133a80ad2a16591611726d","impliedFormat":1},{"version":"4a2edd238d9104eac35b60d727f1123de5062f452b70ed8e0366cb36387dfdfd","impliedFormat":1},{"version":"ca921bf56756cb6fe957f6af693a35251b134fb932dc13f3dfff0bb7106f80b4","impliedFormat":1},{"version":"fee92c97f1aa59eb7098a0cc34ff4df7e6b11bae71526aca84359a2575f313d8","impliedFormat":1},{"version":"0bd0297484aacea217d0b76e55452862da3c5d9e33b24430e0719d1161657225","impliedFormat":1},{"version":"2ab6d334bcbf2aff3acfc4fd8c73ecd82b981d3c3aa47b3f3b89281772286904","impliedFormat":1},{"version":"d07cbc787a997d83f7bde3877fec5fb5b12ce8c1b7047eb792996ed9726b4dde","impliedFormat":1},{"version":"6ac6715916fa75a1f7ebdfeacac09513b4d904b667d827b7535e84ff59679aff","impliedFormat":1},{"version":"4805f6161c2c8cefb8d3b8bd96a080c0fe8dbc9315f6ad2e53238f9a79e528a6","impliedFormat":1},{"version":"b83cb14474fa60c5f3ec660146b97d122f0735627f80d82dd03e8caa39b4388c","impliedFormat":1},{"version":"f374cb24e93e7798c4d9e83ff872fa52d2cdb36306392b840a6ddf46cb925cb6","impliedFormat":1},{"version":"49179c6a23701c642bd99abe30d996919748014848b738d8e85181fc159685ff","impliedFormat":1},{"version":"b73cbf0a72c8800cf8f96a9acfe94f3ad32ca71342a8908b8ae484d61113f647","impliedFormat":1},{"version":"bae6dd176832f6423966647382c0d7ba9e63f8c167522f09a982f086cd4e8b23","impliedFormat":1},{"version":"20865ac316b8893c1a0cc383ccfc1801443fbcc2a7255be166cf90d03fac88c9","impliedFormat":1},{"version":"c9958eb32126a3843deedda8c22fb97024aa5d6dd588b90af2d7f2bfac540f23","impliedFormat":1},{"version":"461d0ad8ae5f2ff981778af912ba71b37a8426a33301daa00f21c6ccb27f8156","impliedFormat":1},{"version":"e927c2c13c4eaf0a7f17e6022eee8519eb29ef42c4c13a31e81a611ab8c95577","impliedFormat":1},{"version":"fcafff163ca5e66d3b87126e756e1b6dfa8c526aa9cd2a2b0a9da837d81bbd72","impliedFormat":1},{"version":"70246ad95ad8a22bdfe806cb5d383a26c0c6e58e7207ab9c431f1cb175aca657","impliedFormat":1},{"version":"f00f3aa5d64ff46e600648b55a79dcd1333458f7a10da2ed594d9f0a44b76d0b","impliedFormat":1},{"version":"772d8d5eb158b6c92412c03228bd9902ccb1457d7a705b8129814a5d1a6308fc","impliedFormat":1},{"version":"45490817629431853543adcb91c0673c25af52a456479588b6486daba34f68bb","impliedFormat":1},{"version":"802e797bcab5663b2c9f63f51bdf67eff7c41bc64c0fd65e6da3e7941359e2f7","impliedFormat":1},{"version":"8b4327413e5af38cd8cb97c59f48c3c866015d5d642f28518e3a891c469f240e","impliedFormat":1},{"version":"8514c62ce38e58457d967e9e73f128eedc1378115f712b9eef7127f7c88f82ae","impliedFormat":1},{"version":"f1289e05358c546a5b664fbb35a27738954ec2cc6eb4137350353099d154fc62","impliedFormat":1},{"version":"4b20fcf10a5413680e39f5666464859fc56b1003e7dfe2405ced82371ebd49b6","impliedFormat":1},{"version":"1d17ba45cfbe77a9c7e0df92f7d95f3eefd49ee23d1104d0548b215be56945ad","impliedFormat":1},{"version":"f7d628893c9fa52ba3ab01bcb5e79191636c4331ee5667ecc6373cbccff8ae12","impliedFormat":1},{"version":"1d879125d1ec570bf04bc1f362fdbe0cb538315c7ac4bcfcdf0c1e9670846aa6","impliedFormat":1},{"version":"bd5f641cc4616eee49497a362c4cb401e9346265bc52670448c4452b4d9be401","impliedFormat":1},{"version":"46273e8c29816125d0d0b56ce9a849cc77f60f9a5ba627447501d214466f0ff3","impliedFormat":1},{"version":"d663134457d8d669ae0df34eabd57028bddc04fc444c4bc04bc5215afc91e1f4","impliedFormat":1},{"version":"e91f7b1344577a02f051b9b471f33044fef8334a76dc9e1de003d17595a5219b","impliedFormat":1},{"version":"3af3584f79c57853028ef9421ec172539e1fe01853296dc05a9d615ade4ffaf6","impliedFormat":1},{"version":"f82579d87701d639ff4e3930a9b24f4ee13ca74221a9a3a792feb47f01881a9c","impliedFormat":1},{"version":"d7e5d5245a8ba34a274717d085174b2c9827722778129b0081fefd341cca8f55","impliedFormat":1},{"version":"d9d32f94056181c31f553b32ce41d0ef75004912e27450738d57efcd2409c324","impliedFormat":1},{"version":"752513f35f6cff294ffe02d6027c41373adf7bfa35e593dbfd53d95c203635ee","impliedFormat":1},{"version":"6c800b281b9e89e69165fd11536195488de3ff53004e55905e6c0059a2d8591e","impliedFormat":1},{"version":"7d4254b4c6c67a29d5e7f65e67d72540480ac2cfb041ca484847f5ae70480b62","impliedFormat":1},{"version":"1a7e2ea171726446850ec72f4d1525d547ff7e86724cc9e7eec509725752a758","impliedFormat":1},{"version":"8c901126d73f09ecdea4785e9a187d1ac4e793e07da308009db04a7283ec2f37","impliedFormat":1},{"version":"c1de754ab5f3b0f4036d6893c74a0fc984c7fcb07936086f19bbe2974406775b","impliedFormat":1},{"version":"aab290b8e4b7c399f2c09b957666fc95335eb4522b2dd9ead1bf0cb64da6d6ee","impliedFormat":1},{"version":"94fe3281392e1015b22f39535878610b4fa6f1388dc8d78746be3bc4e4bb8950","impliedFormat":1},{"version":"2652448ac55a2010a1f71dd141f828b682298d39728f9871e1cdf8696ef443fd","impliedFormat":1},{"version":"06c25ddfc2242bd06c19f66c9eae4c46d937349a267810f89783680a1d7b5259","impliedFormat":1},{"version":"120599fd965257b1f4d0ff794bc696162832d9d8467224f4665f713a3119078b","impliedFormat":1},{"version":"5433f33b0a20300cca35d2f229a7fc20b0e8477c44be2affeb21cb464af60c76","impliedFormat":1},{"version":"db036c56f79186da50af66511d37d9fe77fa6793381927292d17f81f787bb195","impliedFormat":1},{"version":"bd4131091b773973ca5d2326c60b789ab1f5e02d8843b3587effe6e1ea7c9d86","impliedFormat":1},{"version":"c7f6485931085bf010fbaf46880a9b9ec1a285ad9dc8c695a9e936f5a48f34b4","impliedFormat":1},{"version":"14f6b927888a1112d662877a5966b05ac1bf7ed25d6c84386db4c23c95a5363b","impliedFormat":1},{"version":"6ac6715916fa75a1f7ebdfeacac09513b4d904b667d827b7535e84ff59679aff","impliedFormat":1},{"version":"0427df5c06fafc5fe126d14b9becd24160a288deff40e838bfbd92a35f8d0d00","impliedFormat":1},{"version":"90c54a02432d04e4246c87736e53a6a83084357acfeeba7a489c5422b22f5c7a","impliedFormat":1},{"version":"49c346823ba6d4b12278c12c977fb3a31c06b9ca719015978cb145eb86da1c61","impliedFormat":1},{"version":"bfac6e50eaa7e73bb66b7e052c38fdc8ccfc8dbde2777648642af33cf349f7f1","impliedFormat":1},{"version":"92f7c1a4da7fbfd67a2228d1687d5c2e1faa0ba865a94d3550a3941d7527a45d","impliedFormat":1},{"version":"f53b120213a9289d9a26f5af90c4c686dd71d91487a0aa5451a38366c70dc64b","impliedFormat":1},{"version":"83fe880c090afe485a5c02262c0b7cdd76a299a50c48d9bde02be8e908fb4ae6","impliedFormat":1},{"version":"0a372c2d12a259da78e21b25974d2878502f14d89c6d16b97bd9c5017ab1bc12","impliedFormat":1},{"version":"57d67b72e06059adc5e9454de26bbfe567d412b962a501d263c75c2db430f40e","impliedFormat":1},{"version":"6511e4503cf74c469c60aafd6589e4d14d5eb0a25f9bf043dcbecdf65f261972","impliedFormat":1},{"version":"ec1ca97598eda26b7a5e6c8053623acbd88e43be7c4d29c77ccd57abc4c43999","impliedFormat":1},{"version":"6e2261cd9836b2c25eecb13940d92c024ebed7f8efe23c4b084145cd3a13b8a6","impliedFormat":1},{"version":"a67b87d0281c97dfc1197ef28dfe397fc2c865ccd41f7e32b53f647184cc7307","impliedFormat":1},{"version":"771ffb773f1ddd562492a6b9aaca648192ac3f056f0e1d997678ff97dbb6bf9b","impliedFormat":1},{"version":"232f70c0cf2b432f3a6e56a8dc3417103eb162292a9fd376d51a3a9ea5fbbf6f","impliedFormat":1},{"version":"a47e6d954d22dd9ebb802e7e431b560ed7c581e79fb885e44dc92ed4f60d4c07","impliedFormat":1},{"version":"f019e57d2491c159d47a107fd90219a1734bdd2e25cd8d1db3c8fae5c6b414c4","impliedFormat":1},{"version":"8a0e762ceb20c7e72504feef83d709468a70af4abccb304f32d6b9bac1129b2c","impliedFormat":1},{"version":"d1c9bf292a54312888a77bb19dba5e2503ad803f5393beafd45d78d2f4fe9b48","impliedFormat":1},{"version":"9252d498a77517aab5d8d4b5eb9d71e4b225bbc7123df9713e08181de63180f6","impliedFormat":1},{"version":"cb8d8ef7b9ce8ed3e6f1c814fcbf3f90dab0cb8863079236784fc350746e27c4","impliedFormat":1},{"version":"35e6379c3f7cb27b111ad4c1aa69538fd8e788ab737b8ff7596a1b40e96f4f90","impliedFormat":1},{"version":"1fffe726740f9787f15b532e1dc870af3cd964dbe29e191e76121aa3dd8693f2","impliedFormat":1},{"version":"3be035da7bee86b4c3abf392e0edaa44fc6e45092995eefe36b39118c8a84068","affectsGlobalScope":true,"impliedFormat":1},{"version":"8f828825d077c2fa0ea606649faeb122749273a353daab23924fe674e98ba44c","impliedFormat":1},{"version":"2896c2e673a5d3bd9b4246811f79486a073cbb03950c3d252fba10003c57411a","impliedFormat":1},{"version":"616775f16134fa9d01fc677ad3f76e68c051a056c22ab552c64cc281a9686790","impliedFormat":1},{"version":"65c24a8baa2cca1de069a0ba9fba82a173690f52d7e2d0f1f7542d59d5eb4db0","impliedFormat":1},{"version":"f9fe6af238339a0e5f7563acee3178f51db37f32a2e7c09f85273098cee7ec49","impliedFormat":1},{"version":"407a06ba04eede4074eec470ecba2784cbb3bf4e7de56833b097dd90a2aa0651","impliedFormat":1},{"version":"77e71242e71ebf8528c5802993697878f0533db8f2299b4d36aa015bae08a79c","impliedFormat":1},{"version":"98a787be42bd92f8c2a37d7df5f13e5992da0d967fab794adbb7ee18370f9849","impliedFormat":1},{"version":"5c96bad5f78466785cdad664c056e9e2802d5482ca5f862ed19ba34ffbb7b3a4","impliedFormat":1},{"version":"81d8603ac527e75cfec72bb9391228b58f161c2b33514a9d814c7f3ebd3ef466","impliedFormat":1},{"version":"5f3dc10ae646f375776b4e028d2bed039a93eebbba105694d8b910feebbe8b9c","impliedFormat":1},{"version":"bb0cd7862b72f5eba39909c9889d566e198fcaddf7207c16737d0c2246112678","impliedFormat":1},{"version":"4545c1a1ceca170d5d83452dd7c4994644c35cf676a671412601689d9a62da35","impliedFormat":1},{"version":"320f4091e33548b554d2214ce5fc31c96631b513dffa806e2e3a60766c8c49d9","impliedFormat":1},{"version":"a2d648d333cf67b9aeac5d81a1a379d563a8ffa91ddd61c6179f68de724260ff","impliedFormat":1},{"version":"d90d5f524de38889d1e1dbc2aeef00060d779f8688c02766ddb9ca195e4a713d","impliedFormat":1},{"version":"a3f41ed1b4f2fc3049394b945a68ae4fdefd49fa1739c32f149d32c0545d67f5","impliedFormat":1},{"version":"bad68fd0401eb90fe7da408565c8aee9c7a7021c2577aec92fa1382e8876071a","impliedFormat":1},{"version":"47699512e6d8bebf7be488182427189f999affe3addc1c87c882d36b7f2d0b0e","impliedFormat":1},{"version":"fec01479923e169fb52bd4f668dbeef1d7a7ea6e6d491e15617b46f2cacfa37d","impliedFormat":1},{"version":"8a8fb3097ba52f0ae6530ec6ab34e43e316506eb1d9aa29420a4b1e92a81442d","impliedFormat":1},{"version":"44e09c831fefb6fe59b8e65ad8f68a7ecc0e708d152cfcbe7ba6d6080c31c61e","impliedFormat":1},{"version":"1c0a98de1323051010ce5b958ad47bc1c007f7921973123c999300e2b7b0ecc0","impliedFormat":1},{"version":"4655709c9cb3fd6db2b866cab7c418c40ed9533ce8ea4b66b5f17ec2feea46a9","impliedFormat":1},{"version":"87affad8e2243635d3a191fa72ef896842748d812e973b7510a55c6200b3c2a4","impliedFormat":1},{"version":"ad036a85efcd9e5b4f7dd5c1a7362c8478f9a3b6c3554654ca24a29aa850a9c5","impliedFormat":1},{"version":"fedebeae32c5cdd1a85b4e0504a01996e4a8adf3dfa72876920d3dd6e42978e7","impliedFormat":1},{"version":"3eecb25bb467a948c04874d70452b14ae7edb707660aac17dc053e42f2088b00","impliedFormat":1},{"version":"cdf21eee8007e339b1b9945abf4a7b44930b1d695cc528459e68a3adc39a622e","impliedFormat":1},{"version":"330896c1a2b9693edd617be24fbf9e5895d6e18c7955d6c08f028f272b37314d","impliedFormat":1},{"version":"1d9c0a9a6df4e8f29dc84c25c5aa0bb1da5456ebede7a03e03df08bb8b27bae6","impliedFormat":1},{"version":"84380af21da938a567c65ef95aefb5354f676368ee1a1cbb4cae81604a4c7d17","impliedFormat":1},{"version":"1af3e1f2a5d1332e136f8b0b95c0e6c0a02aaabd5092b36b64f3042a03debf28","impliedFormat":1},{"version":"30d8da250766efa99490fc02801047c2c6d72dd0da1bba6581c7e80d1d8842a4","impliedFormat":1},{"version":"03566202f5553bd2d9de22dfab0c61aa163cabb64f0223c08431fb3fc8f70280","impliedFormat":1},{"version":"5f0292a40df210ab94b9fb44c8b775c51e96777e14e073900e392b295ca1061b","impliedFormat":1},{"version":"bc9ee0192f056b3d5527bcd78dc3f9e527a9ba2bdc0a2c296fbc9027147df4b2","impliedFormat":1},{"version":"8627ad129bcf56e82adff0ab5951627c993937aa99f5949c33240d690088b803","impliedFormat":1},{"version":"1de80059b8078ea5749941c9f863aa970b4735bdbb003be4925c853a8b6b4450","impliedFormat":1},{"version":"1d079c37fa53e3c21ed3fa214a27507bda9991f2a41458705b19ed8c2b61173d","impliedFormat":1},{"version":"5bf5c7a44e779790d1eb54c234b668b15e34affa95e78eada73e5757f61ed76a","impliedFormat":1},{"version":"5835a6e0d7cd2738e56b671af0e561e7c1b4fb77751383672f4b009f4e161d70","impliedFormat":1},{"version":"5c634644d45a1b6bc7b05e71e05e52ec04f3d73d9ac85d5927f647a5f965181a","impliedFormat":1},{"version":"4b7f74b772140395e7af67c4841be1ab867c11b3b82a51b1aeb692822b76c872","impliedFormat":1},{"version":"27be6622e2922a1b412eb057faa854831b95db9db5035c3f6d4b677b902ab3b7","impliedFormat":1},{"version":"a68d4b3182e8d776cdede7ac9630c209a7bfbb59191f99a52479151816ef9f9e","impliedFormat":99},{"version":"39644b343e4e3d748344af8182111e3bbc594930fff0170256567e13bbdbebb0","impliedFormat":99},{"version":"ed7fd5160b47b0de3b1571c5c5578e8e7e3314e33ae0b8ea85a895774ee64749","impliedFormat":99},{"version":"63a7595a5015e65262557f883463f934904959da563b4f788306f699411e9bac","impliedFormat":1},{"version":"ecbaf0da125974be39c0aac869e403f72f033a4e7fd0d8cd821a8349b4159628","impliedFormat":1},{"version":"4ba137d6553965703b6b55fd2000b4e07ba365f8caeb0359162ad7247f9707a6","impliedFormat":1},{"version":"ceec3c81b2d81f5e3b855d9367c1d4c664ab5046dff8fd56552df015b7ccbe8f","affectsGlobalScope":true,"impliedFormat":1},{"version":"8fac4a15690b27612d8474fb2fc7cc00388df52d169791b78d1a3645d60b4c8b","affectsGlobalScope":true,"impliedFormat":1},{"version":"064ac1c2ac4b2867c2ceaa74bbdce0cb6a4c16e7c31a6497097159c18f74aa7c","impliedFormat":1},{"version":"3dc14e1ab45e497e5d5e4295271d54ff689aeae00b4277979fdd10fa563540ae","impliedFormat":1},{"version":"1d63055b690a582006435ddd3aa9c03aac16a696fac77ce2ed808f3e5a06efab","impliedFormat":1},{"version":"b789bf89eb19c777ed1e956dbad0925ca795701552d22e68fd130a032008b9f9","impliedFormat":1},"f4e8976c19fc926644d72610bf1058bd6bf52add97e46a02bc0b912a751625c0",{"version":"402e5c534fb2b85fa771170595db3ac0dd532112c8fa44fc23f233bc6967488b","impliedFormat":1},{"version":"52dcc257df5119fb66d864625112ce5033ac51a4c2afe376a0b299d2f7f76e4a","impliedFormat":1},{"version":"e5bab5f871ef708d52d47b3e5d0aa72a08ee7a152f33931d9a60809711a2a9a3","impliedFormat":1},{"version":"e16dc2a81595736024a206c7d5c8a39bfe2e6039208ef29981d0d95434ba8fcf","impliedFormat":1},{"version":"cc4a4903fb698ca1d961d4c10dce658aa3a479faf40509d526f122b044eaf6a4","impliedFormat":1},{"version":"19ee8416e6473ed6c7adb868fa796b5653cf0fa2a337658e677eaa0d134388c3","impliedFormat":1},{"version":"1328ab4e442614b28cdb3d4b414cf68325c0da0dca07287a338d0654b7a00261","impliedFormat":1},{"version":"a039dc21f045919f3cbee2ec13812cc6cc3eebc99dae4be00973230f468d19a6","impliedFormat":1},{"version":"3fbe57af01460e49dcd29df55d6931e1672bc6f1be0fb073d11410bc16f9037d","impliedFormat":1},{"version":"f760be449e8562ec5c09bb5187e8e1eabf3c113c0c58cddda53ef8c69f3e2131","impliedFormat":1},{"version":"44325ed13294fce6ab825b82947bbeed2611db7dad9d9135260192f375e5a189","impliedFormat":1},{"version":"e392e8fb5b514eafc585601c1d781485aa6dd6a320e75daf1064a4c6918a1b45","impliedFormat":1},{"version":"46e4a36e8ddbdfb4e7330e11c81c970dc8b218611df9183d39c41c5f8c653b55","impliedFormat":1},{"version":"370bde134aa8c2abc926d0e99d3a4d5d5dba65c6ee65459137e4f02670cbf841","impliedFormat":1},{"version":"6332f565867cf4a740a70e30f31cefba37ef7cebcf74f22eab8d744fde6d193e","impliedFormat":1},{"version":"2977b7884aedc895a1d0c9c210c7cf3272c29d6959a08a6fa3ff71e0aff08175","impliedFormat":1},{"version":"17f2922d41ddd032830a91371c948cd9ce903b35c95adca72271a54584f19b0b","impliedFormat":1},{"version":"3eed76ede2a1a14d7c9bb0a642041282dcc264811139d3dd275c9fe14efc9840","impliedFormat":1},{"version":"e3cf0611709328b449ec13f8c436712d62003620ce480139fae46ce001c2ee9f","impliedFormat":1},{"version":"8d369483f0c2b9ee388129cfdb6a43bc8112b377e86a41884bd06e19ce04f4c1","impliedFormat":99},{"version":"b558c9a18ea4e6e4157124465c3ef1063e64640da139e67be5edb22f534f2f08","impliedFormat":1},{"version":"01374379f82be05d25c08d2f30779fa4a4c41895a18b93b33f14aeef51768692","impliedFormat":1},{"version":"b0dee183d4e65cf938242efaf3d833c6b645afb35039d058496965014f158141","impliedFormat":1},{"version":"c0bbbf84d3fbd85dd60d040c81e8964cc00e38124a52e9c5dcdedf45fea3f213","impliedFormat":1},{"version":"c238ad6747e0dd9d82971eea43d234a8aba363a5086ccf052fa91b190ee6975a","signature":"d066da69f80c9038c4071aed7e423991449698d0c950eca084d7d145d80f249e"},{"version":"dcefdfd971ee8bab5ef024fef19913674cc15fefdac2378d97043bb26045ba6b","signature":"55f9a8b45c0cafc8265f7d580e56f593779e2c10ce6353d8c169025111e6df39"},{"version":"1a5c3f43b36fde54191651070ef954d42c9aee67c96316054ccdc7f7d429250a","signature":"573cce1021895000cca1bef4378d576cd8811c520a9d4a870712995611703067"},{"version":"d599f0bbc8c080a2f6ffcd5cb51f1117cfd0470254e0fde358123c1844e7bc17","signature":"d091922b7078ca783f1f089e2cad5185ba42fff52bbb5e2b04b9655a1ee25bce"},{"version":"7614933ec446577ffa2085020ce383096af08961d65f69184509b95a447a510f","signature":"65aa080ea7b44913f8f941fcf33f72f4b86c93920c26e60f19ddb69f3cce0dad"},{"version":"792271f1ce37d0036f987748cc3523cb1aaded5c37db02a0364f69abb35c08f9","signature":"ebf4be9ccd1cab1e770f94618ae076b3c1ee7b8749761279a14bc81b7d6603ba"},{"version":"eb2a0316ec79af0e959979210e8f63bfae5f369aae5455fc6bcbb9a3486a2c47","signature":"c0a41d52f497ec94914ec51077bb387798976f51b149b78e3fe5982b558312a3"},{"version":"907cc72520115f31aa47a92d8cf7a4227c7f3c453cafab1f7fffc2a0d5b83a03","signature":"0b62cee6104943ab1faf2f852323227d3a0401f307c770dfaba2592cc97f19cc"},{"version":"c1afcc4de1469cbe284289bb685e895a1307af76eea26aa4da3ab18a80183f93","signature":"b4d681b603f53dce8f1c0e3c6d9738db8f65c99603baf9cb28d0e64e9c7d74ad"},{"version":"50bba1979e98ddb4da793fe2e981e165bfa2ea6cead7482b88d2a9b7deff7a32","signature":"cc2c54d5bc88d27663e38ac99e64254623a7211b7a39a43b56afd11de9424f64"},{"version":"5c3070b6f7ea6e5c02f76426d6d500a8ebd84c7085160044e059ed752a91c16f","signature":"2ff3f25d58c28ee6c77fe435a47124369b8e42ad1c8db701a2e812a24f97f44c"},{"version":"a30841374b049721893783335a2f2e29c9767cee0051ccdacf627395b2bb9843","signature":"57032c52259fa84420760c60e43486596e02e96dd2da14ea24e4589b38f7b980"},{"version":"1badeb1f900ef85bd308119d39a641d330d40b9dd1b32f327eecaa383835e43e","signature":"e461223ec47b6cf3dcb30e41ff3e7b158f03ca6fe3090590903bfb9b84daaeae"},{"version":"432702e4f6bd6b2be3483e51724f84e1efb429e7aeb569043eebda3f0fd3253c","signature":"19b0f819f4b88745f99607e8b3502b0af1b43b6aef063b413854844a024ec115"},{"version":"ccfa37ce3f66ea9e1ba4e8f62a2119229a2f27d67a0606fbbb952d788f5d4f4a","signature":"bf212bd3ef1c7a1f70786be9c9a9882f314cac4eb0aca69e8b89e144adebbb92"},{"version":"1109d649c7d2c0452083154b020df061b80a02f7e78e17fc57dbdd9043f4dd9f","signature":"eaad0a1ff1a80bce0f16f866a0079cf723d510c709d929ba79e17faeb717d47e"},{"version":"c6b428fd24085554491ac97562a14c73dfb4410854ee8964f7aa6be735b226a3","signature":"713e180e90ee84f60fccae8b7526c705b0fc3ca81481f03a0c52a6dca15eecb4"},{"version":"0b391e85b08b37a3357cc3c4ce5589442a55708e00beab6622c2c19f59135521","signature":"9131540c29535d0c062f310d303b99caeb102d9a2a650fbb97eb8cff372fc8da"},{"version":"b880e5a145b250f1e7c29c16505dd6977f8fbab79ed086493a2eb0a78c85783e","signature":"4848487d09d804f54b8ee83aca7a48ec3c6f3258839071983ef8db43ee3105d9"},{"version":"b63c3f7cffe0d6afff0b20e132cc14aa049484ecf020ea5c013a61a7c11816d1","signature":"52ef00904b71330cd3e677b641fde9b51cfe1fdc9b5c5cbd7313b8a14e1d0dd8"},{"version":"a975181faf728c6b5bd24f6531776836769f1bc4bf6f9bd4ae72b14b4593f897","signature":"7ab31990272a5db1c06d02546edc3cb7fe120f0f84a506653a243d1a32e7a456"},{"version":"a3fb861197ea1196d414502c294307d3954343714a6056ec2ee146635673cbf3","signature":"8f3e9f7a6dd21772f3ce1188225dbca066efe5e0a2326798d82f911e150544cb"},{"version":"64ba4149b96df2e43193c9063e6b279b78dc1881acc469a79b77c4e4c4b6a088","signature":"63c4c395303b6b16bfd8d9da6fd77ed0caa1629308f57b070c1e7e21d430fe83"},{"version":"9e891167a65c034e359885563bc5289e83d1e71a8462b5d6ebe81f1374fada4c","signature":"9b37defc1cf2817877d82929745263a4741c10b95e7ad1ae1b2386ec1056dc7f"},{"version":"e855c9cea815967ecfc7045cd69d10345c7fd0e56ff51163e7d87e58364d393a","signature":"c81fe44646f6cb714c024efd34b88a1697310cfbf040e1bf5303ff9e1f520bc7"},{"version":"f102ba9c042ba4bb5f3d9a9a03e9358ceaaa17a90788ce8a1c9c903db851ce5f","signature":"c66ae10a4ce45010cccf830ee09a1fe674e9f3e52a8ec03f2cf2561280162fa5"},{"version":"2fa0470f7871560d69c1ab706513932289912f09895630ed9342bab3232bcbf7","signature":"0cf0bdc043e1a1dfa990a1bc608ff31a698a779ee8a1e9fc55cb62aec3704148"},{"version":"e274c5d39cbc5d61122443503da71a899c68dc6a169fc561085e14e956925f37","signature":"de354d3b23472dd83a2ae9ddaf34fc522d4d52e91fc196e6acb37efbb16c5415"},{"version":"120768ab7da66e5d490006973e77da6e9d0c4bdd13a8bdaca1016e093eeb81df","signature":"460e46f0db26f8e311aa9ea5ddadf7bbc9e631a2d790566f2912648e0dbb1bf8"},{"version":"2334b1d13eabd76e6d9072937ec184d831a640d335696fab265e7d9d151c45c0","signature":"b55304303ecdcbc66833c27400c21e67afefc819929164a4e793d4a2593f8cde"},{"version":"1df1a6e7f28ec191e2cb52d0e2b23c7e32fe40335022db5f97e828ea455c20d5","signature":"1a1ccbdca0434a8cde06d2df630ea1850a016aa7cc3ea05d9562d515de86f4ee"},{"version":"f1305c823dcec9b5c1801b82d678a45fbf44a321b5d5cf058403c19dcab75745","signature":"e03565deb249a0b2ab77cdd3c35167ff5ef6784891cc8d65a71538bc0da20a4a"},{"version":"f886ee0350bc6b251f8fc69edebe91358cd93ead7b66adb615af55a47c451529","signature":"57ee593399c37da19d2e3a9506548187a9267aaa1ce3d99d427f30855c3d4226"},{"version":"bcdf9a27d0c4989bae7bd60136a9dfcd0e5dc89119dd29555b9629ecaf265881","signature":"1b47d526d0c9e5ad1089df3eb0b39766c4832c14dac86ff1e3ad1617b0bef105"},{"version":"0e9b754677d306263b69da55a43de81e78d841c7fb903af1a292cbef2edcbaae","signature":"1784e8cf8c8a66fad5dbe358aca050b226cf84a0fcfe046f4f9276b8d164e031"},{"version":"a319def4af4d6ce7f78d71d6701f0a596933dc2a2ff99601db4994b44ef4a397","signature":"1274f0ff8c0fa713128ef15d02c75d630187b3334bf108f48ec9b688a04e0070"},{"version":"1ae88f0236baa842e5df42a1b12db0de3f8424eb19c0e126358ffe91d7005279","signature":"48dae49413d58e8db35d06d1c11e33b8c71f74bcea0a5dcff2562e948e8f365e"},{"version":"75ddf20b41eedf0ed24fd0d0bc1970196c22080c89ac577cc9502a6b46c5292e","signature":"907e21b00d8af148ed991499e23d0ead1b2511491db550423674f2bc09aa6ede"},{"version":"2bbfbc8f9b3fd9bc81ec4ed36d113c6dc5eb6b1404e0d03cfe70a6e52b8caf88","signature":"d37dc031929588e016890522565641aedbdd1a226188862ec9be0a02e178a075"},{"version":"6d7bf6d788c22d1b23900b67e794b88fb6c9ad171e1c95df82421eefae75fd93","signature":"4c083bb6fbeeaaefd4bf4b5d11ca65f66d198dcc54c1086d7ede865a71a3628e"},{"version":"861145f1b2bf0123af286fe08020dc0897977967e9cdf02df9094f61c9d5771a","signature":"6d0d70b16be6e19b69648bf6a70a21669354556fb04b5a8c24b1c04f3192e969"},{"version":"67aae4f77bd5f25a7f8643ecb216f0cbcacf4409bfc0edbf3d58342ad8da192a","signature":"3f3412156504c9713c6755cdfec72ed707600a589fdb3f26ef2cc502dcc8c635"},{"version":"1cd2ca739d138261c3eb9a7f625e9f96aa8035ed1c7f1f8233cc7bffaac2a36d","signature":"1685441caf26f08c74f259ba2fa13decf1c79837869c625a8c6acc23917cdce7"},{"version":"0131fbf080b0084145d1c4c68fd81002ed4062ec2e34105ef2bd06d68765ec3b","signature":"6c45675928d5db83b67f6f8d0c2238049d5290a975c3d1c99b7d2cdf8b415168"},{"version":"3cfc33ccdd63ec8cd01aa5c901ce8b94e9c28fc90b13d5f7160066487ddc7d2e","signature":"1a8425adc64e1d89dd05d4e08bb48f459f632f0c2699883466b4d9f2d92e2378"},{"version":"9eabdaff65cdbea98f1aeeccee6d91a01cda169d75a2390fcb2f86aa2e11b87c","signature":"a70456af830cb2543bf8f08f65580bcbaff43a6ebb7928937e66313cb043c511"},{"version":"6399356372d609e15f4bf7870abcdae59216d5500a877fde4b09c9721e9bbe17","signature":"45b373ad2e114de335dd3eaf62f9658266d71c2f34537489f88f3b4815fa72f8"},{"version":"bf43dc28aeab92051c1d757f956aea8d7fdef366d71624dfd64021fc298a8aec","signature":"7120a797c71970bc8b6f6a73fee60be717fac09d40429d6194235837f65b4923"},{"version":"dcd9aafe85e2b0cae141e2569d8d906d2eb026fc7c937c5f5a8b1571d7fff363","signature":"bb5c8befad584f367e53e3cad45b3d01e126d36d818e56bce56743dc053eba25"},{"version":"5dfbfdca51b72a5ab2480d8460875cf421dfaf6b6d8b13f9a0c29871ab2a5a7c","signature":"b83502a2f1f3c1aebc68abc5dca3d4a1723c4a0b1bf0526d4ae747d63967c52d"},"2552a31fad45a9ed1bde87e51b038dc0e786cd364b597162263abbf57018949b",{"version":"feb6045d0a3a72a2f76dc67748b263db4f59195491f00ff2f87e03bafd3a3c91","signature":"8e609bb71c20b858c77f0e9f90bb1319db8477b13f9f965f1a1e18524bf50881"},{"version":"30d2a757a8e99e8607410f6d4b8147c19a61ce68567ac74e8ff2a4ceab44b1dd","signature":"89b0f68f8f0b901f9dfff2b9e7255520283a783d6af7f2bc2953d771232317a2"},{"version":"e95b2ebb216c6943aba9135f04d888427dc2b5ae2d4bf0ab566b89ab9c173818","signature":"89b0f68f8f0b901f9dfff2b9e7255520283a783d6af7f2bc2953d771232317a2"},{"version":"7e079abaf577910dc1553216ac30bb4aa4fb91f489bbc3dd9aba815d52bd7e9a","signature":"89b0f68f8f0b901f9dfff2b9e7255520283a783d6af7f2bc2953d771232317a2"},{"version":"b78944b9e6a935a8033a73f361a7a9c2d5210b5d82f16bb056536aa0224bc53d","signature":"89b0f68f8f0b901f9dfff2b9e7255520283a783d6af7f2bc2953d771232317a2"},{"version":"ac535d3f0623b2ae25146fc6f3e46b6e7bd34c08f8d3204259893361e76794f2","signature":"89b0f68f8f0b901f9dfff2b9e7255520283a783d6af7f2bc2953d771232317a2"},{"version":"beb6130ff311a191b93e71a3c1749d767da190951df43f46f43e3731d9c61d2b","signature":"89b0f68f8f0b901f9dfff2b9e7255520283a783d6af7f2bc2953d771232317a2"},{"version":"49ccbfe1d6d9770e2b463e1d5b13604f3353c6f488820fcc331eb14ebe05f2ff","signature":"89b0f68f8f0b901f9dfff2b9e7255520283a783d6af7f2bc2953d771232317a2"},{"version":"ea409785f0eb892d36ed31f4e8c507629458f55358ee03fc3e8d6090d41e2cdf","signature":"89b0f68f8f0b901f9dfff2b9e7255520283a783d6af7f2bc2953d771232317a2"},{"version":"27986ed839e67c0b16bbc2d2be4960daa36fec4eb0603c5301ccc422d566031d","signature":"89b0f68f8f0b901f9dfff2b9e7255520283a783d6af7f2bc2953d771232317a2"}],"root":[83,497,[522,582]],"options":{"allowJs":true,"esModuleInterop":true,"jsx":1,"module":99,"skipLibCheck":true,"strict":true,"target":4},"referencedMap":[[576,1],[577,2],[578,3],[579,4],[580,5],[574,6],[581,7],[582,8],[575,9],[572,10],[83,11],[573,12],[551,13],[548,14],[552,15],[550,16],[553,13],[554,13],[556,17],[558,18],[559,19],[557,20],[560,21],[561,22],[562,23],[565,24],[566,25],[543,26],[568,27],[569,28],[545,29],[570,27],[546,30],[544,31],[563,32],[571,33],[541,34],[564,35],[542,36],[547,37],[549,38],[567,39],[555,40],[525,41],[524,42],[526,42],[527,42],[528,42],[529,43],[531,15],[532,22],[534,44],[536,45],[537,46],[533,46],[523,11],[530,11],[538,46],[539,46],[540,11],[535,11],[497,47],[244,11],[143,48],[144,48],[145,49],[100,50],[146,51],[147,52],[148,53],[95,11],[98,54],[96,11],[97,11],[149,55],[150,56],[151,57],[152,58],[153,59],[154,60],[155,60],[156,61],[157,62],[158,63],[159,64],[101,11],[99,11],[160,65],[161,66],[162,67],[194,68],[163,69],[164,70],[165,71],[166,72],[167,73],[168,74],[169,75],[170,76],[171,77],[172,78],[173,78],[174,79],[175,11],[176,80],[178,81],[177,82],[179,83],[180,84],[181,85],[182,86],[183,87],[184,88],[185,89],[186,90],[187,91],[188,92],[189,93],[190,94],[191,95],[102,11],[103,11],[104,11],[142,96],[192,97],[193,98],[198,99],[354,42],[199,100],[197,101],[356,102],[355,103],[195,104],[352,11],[196,105],[84,11],[86,106],[351,42],[262,42],[85,11],[93,107],[443,108],[448,10],[450,109],[220,110],[248,111],[426,112],[243,113],[231,11],[212,11],[218,11],[416,114],[279,115],[219,11],[385,116],[253,117],[254,118],[350,119],[413,120],[368,121],[420,122],[421,123],[419,124],[418,11],[417,125],[250,126],[221,127],[300,11],[301,128],[216,11],[232,129],[222,130],[284,129],[281,129],[205,129],[246,131],[245,11],[425,132],[435,11],[211,11],[326,133],[327,134],[321,42],[471,11],[329,11],[330,135],[322,136],[477,137],[475,138],[470,11],[412,139],[411,11],[469,140],[323,42],[364,141],[362,142],[472,11],[476,11],[474,143],[473,11],[363,144],[464,145],[467,146],[291,147],[290,148],[289,149],[480,42],[288,150],[273,11],[483,11],[486,11],[485,42],[487,151],[201,11],[422,152],[423,153],[424,154],[234,11],[210,155],[200,11],[342,42],[203,156],[341,157],[340,158],[331,11],[332,11],[339,11],[334,11],[337,159],[333,11],[335,160],[338,161],[336,160],[217,11],[208,11],[209,129],[263,162],[264,163],[261,164],[259,165],[260,166],[256,11],[348,135],[370,135],[442,167],[451,168],[455,169],[429,170],[428,11],[276,11],[488,171],[438,172],[324,173],[325,174],[316,175],[306,11],[347,176],[307,177],[349,178],[344,179],[343,11],[345,11],[361,180],[430,181],[431,182],[309,183],[313,184],[304,185],[408,186],[437,187],[283,188],[386,189],[206,190],[436,191],[202,113],[257,11],[265,192],[397,193],[255,11],[396,194],[94,11],[391,195],[233,11],[302,196],[387,11],[207,11],[266,11],[395,197],[215,11],[271,198],[312,199],[427,200],[311,11],[394,11],[258,11],[399,201],[400,202],[213,11],[402,203],[404,204],[403,205],[236,11],[393,190],[406,206],[392,207],[398,208],[224,11],[227,11],[225,11],[229,11],[226,11],[228,11],[230,209],[223,11],[378,210],[377,11],[383,211],[379,212],[382,213],[381,213],[384,211],[380,212],[270,214],[371,215],[434,216],[490,11],[459,217],[461,218],[308,11],[460,219],[432,181],[489,220],[328,181],[214,11],[310,221],[267,222],[268,223],[269,224],[299,225],[407,225],[285,225],[372,226],[286,226],[252,227],[251,11],[376,228],[375,229],[374,230],[373,231],[433,232],[320,233],[358,234],[319,235],[353,236],[357,237],[415,238],[414,239],[410,240],[367,241],[369,242],[366,243],[405,244],[360,11],[447,11],[359,245],[409,11],[272,246],[305,152],[303,247],[274,248],[277,249],[484,11],[275,250],[278,250],[445,11],[444,11],[446,11],[482,11],[280,251],[318,42],[92,11],[365,252],[249,11],[238,253],[314,11],[453,42],[463,254],[298,42],[457,135],[297,255],[440,256],[296,254],[204,11],[465,257],[294,42],[295,42],[287,11],[237,11],[293,258],[292,259],[235,260],[315,77],[282,77],[401,11],[389,261],[388,11],[449,11],[346,262],[317,42],[441,263],[87,42],[90,264],[91,265],[88,42],[89,11],[247,266],[242,267],[241,11],[240,268],[239,11],[439,269],[452,270],[454,271],[456,272],[458,273],[462,274],[496,275],[466,275],[495,276],[468,277],[478,278],[479,279],[481,280],[491,281],[494,155],[493,11],[492,282],[514,283],[512,284],[513,285],[501,286],[502,284],[509,287],[500,288],[505,289],[515,11],[506,290],[511,291],[517,292],[516,293],[499,294],[507,295],[508,296],[503,297],[510,283],[504,298],[390,299],[498,11],[520,300],[519,11],[518,11],[521,301],[81,11],[82,11],[13,11],[14,11],[16,11],[15,11],[2,11],[17,11],[18,11],[19,11],[20,11],[21,11],[22,11],[23,11],[24,11],[3,11],[25,11],[26,11],[4,11],[27,11],[31,11],[28,11],[29,11],[30,11],[32,11],[33,11],[34,11],[5,11],[35,11],[36,11],[37,11],[38,11],[6,11],[42,11],[39,11],[40,11],[41,11],[43,11],[7,11],[44,11],[49,11],[50,11],[45,11],[46,11],[47,11],[48,11],[8,11],[54,11],[51,11],[52,11],[53,11],[55,11],[9,11],[56,11],[57,11],[58,11],[60,11],[59,11],[61,11],[62,11],[10,11],[63,11],[64,11],[65,11],[11,11],[66,11],[67,11],[68,11],[69,11],[70,11],[1,11],[71,11],[72,11],[12,11],[76,11],[74,11],[79,11],[78,11],[73,11],[77,11],[75,11],[80,11],[120,302],[130,303],[119,302],[140,304],[111,305],[110,306],[139,282],[133,307],[138,308],[113,309],[127,310],[112,311],[136,312],[108,313],[107,282],[137,314],[109,315],[114,316],[115,11],[118,316],[105,11],[141,317],[131,318],[122,319],[123,320],[125,321],[121,322],[124,323],[134,282],[116,324],[117,325],[126,326],[106,327],[129,318],[128,316],[132,11],[135,328],[522,329]],"affectedFilesPendingEmit":[576,577,578,579,580,574,581,582,575,573,551,548,552,550,553,554,556,558,559,557,560,561,562,565,566,543,568,569,545,570,546,544,563,571,541,564,542,547,549,567,555,525,524,526,527,528,529,531,532,534,536,537,533,523,530,538,539,540,535,522],"version":"5.9.3"}
\ No newline at end of file
diff --git a/infrastructure/deploy/.env.production.example b/infrastructure/deploy/.env.production.example
new file mode 100644
index 0000000..8206b71
--- /dev/null
+++ b/infrastructure/deploy/.env.production.example
@@ -0,0 +1,79 @@
+# ============================================================
+# TorbatYar production env — copy to .env on app server
+# Domain: torbatyar.ir | App: 192.168.10.162 | Nginx: 192.168.10.156
+# ============================================================
+
+ENVIRONMENT=production
+DEBUG=false
+LOG_LEVEL=INFO
+SERVICE_NAME=core-service
+API_V1_PREFIX=/api/v1
+
+PLATFORM_NAME=Torbatyar
+PLATFORM_SUPPORT_EMAIL=support@torbatyar.ir
+PLATFORM_PRIMARY_COLOR=#0284c7
+PLATFORM_SECONDARY_COLOR=#0f172a
+PLATFORM_BASE_DOMAIN=torbatyar.ir
+
+POSTGRES_HOST=postgres
+POSTGRES_PORT=5432
+POSTGRES_USER=superapp
+POSTGRES_PASSWORD=superapp_password
+POSTGRES_DB=core_platform_db
+DATABASE_URL=postgresql+asyncpg://superapp:superapp_password@postgres:5432/core_platform_db
+DATABASE_URL_SYNC=postgresql+psycopg://superapp:superapp_password@postgres:5432/core_platform_db
+
+REDIS_HOST=redis
+REDIS_PORT=6379
+REDIS_DB=0
+REDIS_URL=redis://redis:6379/0
+FEATURE_ACCESS_CACHE_TTL=300
+TENANT_METADATA_CACHE_TTL=300
+
+CELERY_BROKER_URL=redis://redis:6379/1
+CELERY_RESULT_BACKEND=redis://redis:6379/2
+
+KEYCLOAK_ENABLED=true
+KEYCLOAK_SERVER_URL=http://keycloak:8080
+KEYCLOAK_PUBLIC_URL=https://auth.torbatyar.ir
+KEYCLOAK_REALM=superapp
+KEYCLOAK_CLIENT_ID=core-service
+KEYCLOAK_CLIENT_SECRET=change-me
+JWT_ALGORITHM=RS256
+JWT_AUDIENCE=account
+JWT_VERIFY_SIGNATURE=true
+AUTH_REQUIRED=true
+
+IDENTITY_SERVICE_URL=http://identity-access-service:8001
+IDENTITY_DATABASE_URL=postgresql+asyncpg://superapp:superapp_password@postgres:5432/identity_access_db
+IDENTITY_DATABASE_URL_SYNC=postgresql+psycopg://superapp:superapp_password@postgres:5432/identity_access_db
+KEYCLOAK_FRONTEND_CLIENT_ID=superapp-frontend
+KEYCLOAK_IDENTITY_CLIENT_SECRET=change-me-identity
+IDENTITY_KEYCLOAK_CLIENT_ID=identity-access-service
+FRONTEND_CALLBACK_URL=https://torbatyar.ir/auth/callback
+CORS_ORIGINS=https://torbatyar.ir,https://www.torbatyar.ir,http://torbatyar.ir,http://www.torbatyar.ir
+CORE_SERVICE_URL=http://core-service:8000
+
+PAYAMAK_USERNAME=9155105404
+PAYAMAK_FROM=9982004961
+PAYAMAK_FROM_NUMBER=9982004961
+PAYAMAK_API_KEY=967ceb37-eeef-429c-b6c5-b60a15306e4f
+PAYAMAK_APIKEY=967ceb37-eeef-429c-b6c5-b60a15306e4f
+PAYAMAK_PASSWORD=967ceb37-eeef-429c-b6c5-b60a15306e4f
+PAYAMAK_BODY_ID=245189
+JWT_SECRET=change-me-jwt-secret
+OTP_EXPIRE_SECONDS=120
+PLATFORM_ADMIN_MOBILES=09155105404
+
+FRONTEND_PORT=3000
+NEXT_PUBLIC_BACKEND_URL=https://api.torbatyar.ir
+NEXT_PUBLIC_API_BASE_URL=https://api.torbatyar.ir
+NEXT_PUBLIC_IDENTITY_API_URL=https://identity.torbatyar.ir
+NEXT_PUBLIC_KEYCLOAK_URL=https://auth.torbatyar.ir
+NEXT_PUBLIC_KEYCLOAK_REALM=superapp
+NEXT_PUBLIC_KEYCLOAK_CLIENT_ID=superapp-frontend
+
+INTERNAL_TOKEN_SECRET=change-me-internal-secret
+
+KEYCLOAK_ADMIN=admin
+KEYCLOAK_ADMIN_PASSWORD=admin
diff --git a/infrastructure/keycloak/realm/superapp-realm.json b/infrastructure/keycloak/realm/superapp-realm.json
new file mode 100644
index 0000000..51ee896
--- /dev/null
+++ b/infrastructure/keycloak/realm/superapp-realm.json
@@ -0,0 +1,100 @@
+{
+ "id": "superapp",
+ "realm": "superapp",
+ "enabled": true,
+ "displayName": "Torbatyar",
+ "displayNameHtml": "Torbatyar
",
+ "internationalizationEnabled": true,
+ "supportedLocales": ["fa", "en"],
+ "defaultLocale": "fa",
+ "loginTheme": "torbatyar",
+ "accountTheme": "torbatyar",
+ "emailTheme": "torbatyar",
+ "registrationAllowed": false,
+ "loginWithEmailAllowed": true,
+ "duplicateEmailsAllowed": false,
+ "resetPasswordAllowed": true,
+ "roles": {
+ "realm": [
+ { "name": "platform_admin", "description": "مدیر کل پلتفرم" },
+ { "name": "tenant_admin", "description": "مدیر tenant" },
+ { "name": "tenant_member", "description": "عضو tenant" },
+ { "name": "service_account", "description": "حساب سرویس داخلی" }
+ ]
+ },
+ "clients": [
+ {
+ "clientId": "superapp-frontend",
+ "name": "SuperApp Frontend",
+ "enabled": true,
+ "publicClient": true,
+ "standardFlowEnabled": true,
+ "directAccessGrantsEnabled": false,
+ "redirectUris": [
+ "http://localhost:3000/*",
+ "http://127.0.0.1:3000/*",
+ "http://torbatyar.xyz:3000/*",
+ "http://torbatyar.ir/*",
+ "https://torbatyar.ir/*",
+ "http://www.torbatyar.ir/*",
+ "https://www.torbatyar.ir/*",
+ "http://*.torbatyar.ir/*",
+ "https://*.torbatyar.ir/*"
+ ],
+ "webOrigins": [
+ "http://localhost:3000",
+ "http://127.0.0.1:3000",
+ "http://torbatyar.xyz:3000",
+ "http://torbatyar.ir",
+ "https://torbatyar.ir",
+ "http://www.torbatyar.ir",
+ "https://www.torbatyar.ir",
+ "+"
+ ],
+ "attributes": {
+ "pkce.code.challenge.method": "S256"
+ }
+ },
+ {
+ "clientId": "core-service",
+ "name": "Core Platform Service",
+ "enabled": true,
+ "publicClient": false,
+ "secret": "change-me",
+ "serviceAccountsEnabled": true,
+ "standardFlowEnabled": false,
+ "directAccessGrantsEnabled": false
+ },
+ {
+ "clientId": "identity-access-service",
+ "name": "Identity & Access Service",
+ "enabled": true,
+ "publicClient": false,
+ "secret": "change-me-identity",
+ "serviceAccountsEnabled": true,
+ "standardFlowEnabled": false,
+ "directAccessGrantsEnabled": true,
+ "attributes": {
+ "oauth2.token.exchange.grant.enabled": "true"
+ }
+ }
+ ],
+ "users": [
+ {
+ "username": "platform.admin",
+ "enabled": true,
+ "email": "admin@example.com",
+ "emailVerified": true,
+ "firstName": "Platform",
+ "lastName": "Admin",
+ "credentials": [
+ {
+ "type": "password",
+ "value": "admin123",
+ "temporary": false
+ }
+ ],
+ "realmRoles": ["platform_admin"]
+ }
+ ]
+}
diff --git a/infrastructure/keycloak/scripts/apply_realm_theme.py b/infrastructure/keycloak/scripts/apply_realm_theme.py
new file mode 100644
index 0000000..2ac7ffc
--- /dev/null
+++ b/infrastructure/keycloak/scripts/apply_realm_theme.py
@@ -0,0 +1,57 @@
+"""اعمال تم فارسی torbatyar و locale پیشفرض fa روی realm superapp."""
+from __future__ import annotations
+
+import os
+import sys
+
+import httpx
+
+KEYCLOAK_URL = os.getenv("KEYCLOAK_SERVER_URL", "http://localhost:8080")
+REALM = os.getenv("KEYCLOAK_REALM", "superapp")
+ADMIN_USER = os.getenv("KEYCLOAK_ADMIN", "admin")
+ADMIN_PASS = os.getenv("KEYCLOAK_ADMIN_PASSWORD", "admin")
+
+
+def main() -> int:
+ with httpx.Client(timeout=15.0) as client:
+ token_resp = client.post(
+ f"{KEYCLOAK_URL}/realms/master/protocol/openid-connect/token",
+ data={
+ "grant_type": "password",
+ "client_id": "admin-cli",
+ "username": ADMIN_USER,
+ "password": ADMIN_PASS,
+ },
+ )
+ token_resp.raise_for_status()
+ headers = {"Authorization": f"Bearer {token_resp.json()['access_token']}"}
+
+ realm_resp = client.get(f"{KEYCLOAK_URL}/admin/realms/{REALM}", headers=headers)
+ realm_resp.raise_for_status()
+ data = realm_resp.json()
+
+ data["displayName"] = "Torbatyar"
+ data["displayNameHtml"] = "Torbatyar
"
+ data["internationalizationEnabled"] = True
+ data["supportedLocales"] = ["fa", "en"]
+ data["defaultLocale"] = "fa"
+ data["loginTheme"] = "torbatyar"
+ data["accountTheme"] = "torbatyar"
+ data["emailTheme"] = "torbatyar"
+
+ update_resp = client.put(
+ f"{KEYCLOAK_URL}/admin/realms/{REALM}",
+ headers=headers,
+ json=data,
+ )
+ update_resp.raise_for_status()
+ print(f"Realm '{REALM}' updated: theme=torbatyar, locale=fa")
+ return 0
+
+
+if __name__ == "__main__":
+ try:
+ raise SystemExit(main())
+ except Exception as exc:
+ print(f"Error: {exc}", file=sys.stderr)
+ raise SystemExit(1) from exc
diff --git a/infrastructure/keycloak/scripts/patch_frontend_client.py b/infrastructure/keycloak/scripts/patch_frontend_client.py
new file mode 100644
index 0000000..1c5264e
--- /dev/null
+++ b/infrastructure/keycloak/scripts/patch_frontend_client.py
@@ -0,0 +1,9 @@
+"""Wrapper — اسکریپت اصلی در backend/services/identity-access/scripts/patch_keycloak_client.py"""
+import runpy
+import sys
+from pathlib import Path
+
+ROOT = Path(__file__).resolve().parents[3]
+SCRIPT = ROOT / "backend/services/identity-access/scripts/patch_keycloak_client.py"
+sys.path.insert(0, str(SCRIPT.parent))
+runpy.run_path(str(SCRIPT), run_name="__main__")
diff --git a/infrastructure/keycloak/themes/torbatyar/login/login.ftl b/infrastructure/keycloak/themes/torbatyar/login/login.ftl
new file mode 100644
index 0000000..4bb6475
--- /dev/null
+++ b/infrastructure/keycloak/themes/torbatyar/login/login.ftl
@@ -0,0 +1,130 @@
+<#import "template.ftl" as layout>
+<@layout.registrationLayout displayMessage=!messagesPerField.existsError('username','password') displayInfo=realm.password && realm.registrationAllowed && !registrationDisabled??; section>
+ <#if section = "header">
+ ${msg("loginAccountTitle")}
+ <#elseif section = "form">
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
${msg("tyOtpSent")}
+
+
+
+
+
+
+
+
+
+
+ <#if realm.password>
+
+ #if>
+
+
+ <#elseif section = "info">
+ <#-- ثبتنام از تب موبایل انجام میشود -->
+ <#elseif section = "socialProviders">
+ <#if realm.password && social.providers??>
+
+ #if>
+ #if>
+@layout.registrationLayout>
diff --git a/infrastructure/keycloak/themes/torbatyar/login/messages/messages_en.properties b/infrastructure/keycloak/themes/torbatyar/login/messages/messages_en.properties
new file mode 100644
index 0000000..e18bbe2
--- /dev/null
+++ b/infrastructure/keycloak/themes/torbatyar/login/messages/messages_en.properties
@@ -0,0 +1,27 @@
+doLogIn=Sign In
+doRegister=Register
+doForgotPassword=Forgot Password?
+loginAccountTitle=Sign in to your account
+username=Username
+usernameOrEmail=Username or email
+usernameOrEmailOrMobile=Username, email or mobile
+tyTabPassword=Password
+tyTabMobile=Mobile
+tyMobileHint=Sign in or register with SMS code
+tyMobileLabel=Mobile number
+tySendOtp=Send code
+tyNoAccount=No account? Register
+tySignupTitle=Account details
+tyDisplayName=Display name
+tyOtpSent=Code sent to
+tyChangeMobile=Change mobile number
+email=Email
+password=Password
+rememberMe=Remember me
+noAccount=Don\u2019t have an account?
+identityProviderLoginLabel=Or sign in with
+locale_fa=\u0641\u0627\u0631\u0633\u06cc
+locale_en=English
+mobileLoginDivider=or
+mobileLoginLink=Sign in with mobile number
+mobileLoginHint=Tenant admins can sign in with OTP
diff --git a/infrastructure/keycloak/themes/torbatyar/login/messages/messages_fa.properties b/infrastructure/keycloak/themes/torbatyar/login/messages/messages_fa.properties
new file mode 100644
index 0000000..09f6e40
--- /dev/null
+++ b/infrastructure/keycloak/themes/torbatyar/login/messages/messages_fa.properties
@@ -0,0 +1,85 @@
+doLogIn=ورود
+doRegister=ثبت\u200cنام
+doForgotPassword=فراموشی رمز عبور؟
+doClickHere=اینجا کلیک کنید
+doCancel=انصراف
+doSubmit=ارسال
+doYes=بله
+doNo=خیر
+doContinue=ادامه
+doIgnore=نادیده گرفتن
+doAccept=پذیرفتن
+doDecline=رد کردن
+doBack=بازگشت
+backToApplication=بازگشت به برنامه
+loginAccountTitle=ورود به حساب کاربری
+loginTitle=ورود به {0}
+loginTitleHtml=ورود به {0}
+locale_fa=\u0641\u0627\u0631\u0633\u06cc
+locale_en=English
+mobileLoginDivider=یا
+mobileLoginLink=\u0648\u0631\u0648\u062f / \u062b\u0628\u062a\u200c\u0646\u0627\u0645 \u0628\u0627 \u0634\u0645\u0627\u0631\u0647 \u0645\u0648\u0628\u0627\u06cc\u0644
+mobileLoginHint=\u0645\u062f\u06cc\u0631\u0627\u0646 tenant \u0645\u06cc\u200c\u062a\u0648\u0627\u0646\u0646\u062f \u0628\u0627 \u06a9\u062f \u06cc\u06a9\u0628\u0627\u0631\u0645\u0635\u0631\u0641 \u0648\u0627\u0631\u062f \u0634\u0648\u0646\u062f
+username=نام کاربری
+usernameOrEmail=نام کاربری یا ایمیل
+usernameOrEmailOrMobile=نام کاربری، ایمیل یا شماره موبایل
+tyTabPassword=رمز عبور
+tyTabMobile=موبایل
+tyTitleLogin=ورود
+tyTitleSignup=ثبت\u200cنام
+tySubtitlePhone=شماره موبایل خود را وارد کنید
+tyMobileHint=ورود یا ثبت\u200cنام با کد یکبارمصرف پیامکی
+tyMobileLabel=شماره موبایل
+tySendOtp=دریافت کد
+tyNoAccount=حساب کاربری ندارید؟ ثبت\u200cنام کنید
+tySignupTitle=اطلاعات حساب (ثبت\u200cنام)
+tyDisplayName=نام
+tyOtpSent=کد به
+tyChangeMobile=تغییر شماره موبایل
+email=ایمیل
+password=رمز عبور
+rememberMe=مرا به خاطر بسپار
+noAccount=حساب کاربری ندارید؟
+invalidUserMessage=نام کاربری یا رمز عبور اشتباه است.
+invalidUsernameMessage=نام کاربری نامعتبر است.
+invalidUsernameOrEmailMessage=نام کاربری یا ایمیل نامعتبر است.
+invalidPasswordMessage=رمز عبور نامعتبر است.
+invalidEmailMessage=ایمیل نامعتبر است.
+accountDisabledMessage=حساب کاربری غیرفعال است.
+accountTemporarilyDisabledMessage=حساب موقتاً غیرفعال است. لطفاً بعداً دوباره تلاش کنید.
+expiredCodeMessage=کد منقضی شده است. لطفاً دوباره وارد شوید.
+expiredActionMessage=عملیات منقضی شده است. لطفاً ادامه دهید.
+errorTitle=خطا
+errorTitleHtml=خطا
+missingUsernameMessage=لطفاً نام کاربری را وارد کنید.
+missingPasswordMessage=لطفاً رمز عبور را وارد کنید.
+missingEmailMessage=لطفاً ایمیل را وارد کنید.
+missingFirstNameMessage=لطفاً نام را وارد کنید.
+missingLastNameMessage=لطفاً نام خانوادگی را وارد کنید.
+invalidPasswordExistingMessage=رمز عبور فعلی اشتباه است.
+invalidPasswordConfirmMessage=تکرار رمز عبور مطابقت ندارد.
+invalidTotpMessage=کد تأیید نامعتبر است.
+emailVerifyTitle=تأیید ایمیل
+emailForgotTitle=بازیابی رمز عبور
+updatePasswordTitle=تغییر رمز عبور
+termsTitle=شرایط و قوانین
+termsText=شرایط و قوانین استفاده
+termsPlainText=شرایط و قوانین
+acceptTerms=پذیرش شرایط
+recaptchaFailed=اعتبارسنجی reCAPTCHA ناموفق بود.
+recaptchaNotConfigured=reCAPTCHA پیکربندی نشده است.
+oauthGrantTitle=دسترسی به حساب
+oauthGrantRequest=برنامه {0} درخواست دسترسی دارد
+oauthGrantInformation=اطلاعات زیر در دسترس قرار می\u200cگیرد
+oauthGrantReview=بررسی درخواست
+oauthGrantTos=شرایط استفاده
+oauthGrantTitleHtml=دسترسی به {0}
+identityProviderLoginLabel=یا ورود با
+loginTimeout=زمان ورود به پایان رسید. لطفاً دوباره تلاش کنید.
+pageExpiredTitle=صفحه منقضی شده
+pageExpiredMsg1=برای ادامه دوباره تلاش کنید
+pageExpiredMsg2=برای شروع مجدد اینجا کلیک کنید
+delegationCompleteHeader=ورود موفق
+delegationCompleteMessage=می\u200cتوانید این پنجره را ببندید.
+delegationFailedHeader=ورود ناموفق
+delegationFailedMessage=لطفاً دوباره تلاش کنید.
diff --git a/infrastructure/keycloak/themes/torbatyar/login/resources/css/fonts.css b/infrastructure/keycloak/themes/torbatyar/login/resources/css/fonts.css
new file mode 100644
index 0000000..43bcd61
--- /dev/null
+++ b/infrastructure/keycloak/themes/torbatyar/login/resources/css/fonts.css
@@ -0,0 +1,39 @@
+@font-face {
+ font-family: "Yekan Bakh FaNum";
+ font-style: normal;
+ font-weight: 300;
+ font-display: swap;
+ src: url("../fonts/YekanBakhFaNum-Light.woff") format("woff");
+}
+
+@font-face {
+ font-family: "Yekan Bakh FaNum";
+ font-style: normal;
+ font-weight: 400;
+ font-display: swap;
+ src: url("../fonts/YekanBakhFaNum-Regular.woff") format("woff");
+}
+
+@font-face {
+ font-family: "Yekan Bakh FaNum";
+ font-style: normal;
+ font-weight: 600;
+ font-display: swap;
+ src: url("../fonts/YekanBakhFaNum-SemiBold.woff") format("woff");
+}
+
+@font-face {
+ font-family: "Yekan Bakh FaNum";
+ font-style: normal;
+ font-weight: 700;
+ font-display: swap;
+ src: url("../fonts/YekanBakhFaNum-Bold.woff") format("woff");
+}
+
+@font-face {
+ font-family: "Yekan Bakh FaNum";
+ font-style: normal;
+ font-weight: 800;
+ font-display: swap;
+ src: url("../fonts/YekanBakhFaNum-ExtraBold.woff") format("woff");
+}
diff --git a/infrastructure/keycloak/themes/torbatyar/login/resources/css/login.css b/infrastructure/keycloak/themes/torbatyar/login/resources/css/login.css
new file mode 100644
index 0000000..a0279a1
--- /dev/null
+++ b/infrastructure/keycloak/themes/torbatyar/login/resources/css/login.css
@@ -0,0 +1,504 @@
+/* Torbatyar — تم ورود Keycloak (همسبک با frontend) */
+:root {
+ --ty-primary: #0284c7;
+ --ty-secondary: #0f172a;
+ --ty-primary-soft: rgba(2, 132, 199, 0.08);
+ --ty-primary-muted: rgba(2, 132, 199, 0.12);
+ --ty-radius: 0.75rem;
+ --ty-radius-lg: 1rem;
+}
+
+html,
+body,
+.login-pf,
+.login-pf body {
+ direction: rtl !important;
+ text-align: right !important;
+ font-family: "Yekan Bakh FaNum", Tahoma, sans-serif !important;
+}
+
+body {
+ background: linear-gradient(180deg, var(--ty-primary-soft) 0%, #ffffff 45%) !important;
+ min-height: 100vh;
+}
+
+.login-pf-page {
+ background: transparent !important;
+ display: flex !important;
+ flex-direction: column !important;
+ align-items: center !important;
+ justify-content: center !important;
+ min-height: 100vh !important;
+ padding: 1.5rem 1rem !important;
+}
+
+#kc-header {
+ margin-bottom: 1rem;
+ width: 100%;
+ max-width: 420px;
+}
+
+#kc-header-wrapper {
+ font-size: 0 !important;
+ text-align: center !important;
+ padding: 0 !important;
+}
+
+#kc-header-wrapper::before {
+ content: "Torbatyar";
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 3.5rem;
+ height: 3.5rem;
+ border-radius: var(--ty-radius-lg);
+ background: var(--ty-primary);
+ color: #fff;
+ font-size: 1.5rem;
+ font-weight: 800;
+ box-shadow: 0 8px 24px rgba(2, 132, 199, 0.25);
+ margin-bottom: 0.75rem;
+}
+
+#kc-header-wrapper::after {
+ content: "Torbatyar";
+ display: block;
+ font-size: 1.125rem;
+ font-weight: 700;
+ color: var(--ty-secondary);
+ line-height: 1.6;
+}
+
+/* ---- Auth panel header ---- */
+.ty-auth-header {
+ text-align: center;
+ margin-bottom: 1.25rem;
+}
+
+.ty-auth-title {
+ font-size: 1.375rem !important;
+ font-weight: 800 !important;
+ color: var(--ty-secondary) !important;
+ margin: 0 0 0.35rem !important;
+}
+
+.ty-auth-subtitle {
+ font-size: 0.875rem;
+ color: #6b7280;
+ margin: 0;
+ line-height: 1.5;
+}
+
+.ty-form-group {
+ margin-bottom: 0.85rem !important;
+}
+
+.ty-form-group label {
+ display: block;
+ margin-bottom: 0.35rem;
+}
+
+.ty-input-ltr {
+ direction: ltr !important;
+ text-align: left !important;
+}
+
+.card-pf {
+ background: #fff !important;
+ border: 1px solid #f3f4f6 !important;
+ border-radius: 1rem !important;
+ box-shadow: 0 10px 40px rgba(15, 23, 42, 0.06) !important;
+ padding: 1.75rem !important;
+ width: 100% !important;
+ max-width: 420px !important;
+ margin: 0 auto !important;
+ box-sizing: border-box !important;
+}
+
+#kc-form,
+#kc-form-wrapper,
+#kc-form-login,
+#kc-register-form,
+#kc-passwd-update-form,
+#kc-reset-password-form {
+ background: transparent !important;
+ border: none !important;
+ border-radius: 0 !important;
+ box-shadow: none !important;
+ padding: 0 !important;
+ margin: 0 !important;
+ width: 100% !important;
+ max-width: 100% !important;
+}
+
+#kc-page-title,
+.login-pf-header h1,
+#kc-form-wrapper h1 {
+ font-size: 1.25rem !important;
+ font-weight: 700 !important;
+ color: var(--ty-secondary) !important;
+ text-align: center !important;
+ margin-bottom: 1.25rem !important;
+}
+
+.pf-c-form__label,
+.login-pf-settings label,
+label {
+ font-weight: 600 !important;
+ color: var(--ty-secondary) !important;
+ font-size: 0.875rem !important;
+}
+
+.pf-c-form-control,
+input[type="text"],
+input[type="password"],
+input[type="email"] {
+ direction: rtl !important;
+ text-align: right !important;
+ border-radius: var(--ty-radius) !important;
+ border: 1px solid #e5e7eb !important;
+ padding: 0.65rem 0.85rem !important;
+ font-family: "Yekan Bakh FaNum", Tahoma, sans-serif !important;
+ font-size: 0.95rem !important;
+ box-shadow: none !important;
+ width: 100% !important;
+ box-sizing: border-box !important;
+}
+
+.pf-c-form-control:focus,
+input[type="text"]:focus,
+input[type="password"]:focus,
+input[type="email"]:focus {
+ border-color: var(--ty-primary) !important;
+ outline: none !important;
+ box-shadow: 0 0 0 3px var(--ty-primary-muted) !important;
+}
+
+#kc-login,
+input[type="submit"].pf-m-primary,
+#kc-form-buttons input[type="submit"] {
+ width: 100% !important;
+ max-width: 100% !important;
+ border-radius: var(--ty-radius-lg) !important;
+ background: var(--ty-primary) !important;
+ border: none !important;
+ color: #fff !important;
+ font-family: "Yekan Bakh FaNum", Tahoma, sans-serif !important;
+ font-weight: 700 !important;
+ font-size: 0.95rem !important;
+ padding: 0.75rem 1rem !important;
+ margin-top: 0.5rem !important;
+ transition: opacity 0.2s ease !important;
+ box-sizing: border-box !important;
+}
+
+#kc-login:hover,
+input[type="submit"].pf-m-primary:hover,
+#kc-form-buttons input[type="submit"]:hover {
+ opacity: 0.92 !important;
+ background: var(--ty-primary) !important;
+}
+
+#kc-form-options {
+ text-align: right !important;
+}
+
+#kc-form-options a,
+#kc-registration a {
+ color: var(--ty-primary) !important;
+ font-weight: 600 !important;
+}
+
+#kc-form-options a:hover,
+#kc-registration a:hover {
+ text-decoration: underline !important;
+}
+
+.ty-mobile-login {
+ margin-top: 1.25rem;
+ padding-top: 1.25rem;
+ border-top: 1px solid #f3f4f6;
+ text-align: center;
+}
+
+.ty-mobile-login-divider {
+ margin-bottom: 0.75rem;
+ color: #9ca3af;
+ font-size: 0.875rem;
+}
+
+.ty-mobile-login-link {
+ display: inline-flex !important;
+ align-items: center;
+ justify-content: center;
+ width: 100%;
+ max-width: 100%;
+ padding: 0.75rem 1rem;
+ border-radius: var(--ty-radius-lg);
+ border: 1px solid #e5e7eb !important;
+ background: #fff !important;
+ color: var(--ty-secondary) !important;
+ font-weight: 700 !important;
+ text-decoration: none !important;
+ box-sizing: border-box !important;
+}
+
+.ty-mobile-login-link:hover {
+ border-color: var(--ty-primary) !important;
+ color: var(--ty-primary) !important;
+}
+
+#input-error,
+.pf-c-alert,
+.alert-error,
+.kc-feedback-text {
+ border-radius: var(--ty-radius) !important;
+ direction: rtl !important;
+ text-align: right !important;
+ font-family: "Yekan Bakh FaNum", Tahoma, sans-serif !important;
+}
+
+#kc-info-wrapper {
+ background: var(--ty-primary-soft) !important;
+ border: none !important;
+ border-radius: var(--ty-radius) !important;
+ direction: rtl !important;
+}
+
+#kc-locale {
+ position: relative;
+ direction: ltr;
+ text-align: left;
+ margin-bottom: 0.75rem;
+ align-self: flex-start;
+ width: 100%;
+ max-width: 420px;
+}
+
+#kc-locale-dropdown,
+#kc-locale-wrapper {
+ position: relative;
+}
+
+#kc-locale .pf-c-dropdown__menu,
+#language-switch1 {
+ display: none !important;
+ position: absolute;
+ top: calc(100% + 4px);
+ left: 0;
+ right: auto;
+ z-index: 100;
+ min-width: 9rem;
+ margin: 0;
+ padding: 0.25rem 0;
+ list-style: none;
+ background: #fff;
+ border: 1px solid #e5e7eb;
+ border-radius: var(--ty-radius);
+ box-shadow: 0 8px 24px rgba(15, 23, 42, 0.1);
+}
+
+#kc-locale.ty-locale-open .pf-c-dropdown__menu,
+#kc-locale.ty-locale-open #language-switch1 {
+ display: block !important;
+}
+
+#kc-locale .pf-c-dropdown__menu-item {
+ display: block;
+ padding: 0.5rem 0.85rem;
+ color: var(--ty-secondary) !important;
+ text-decoration: none !important;
+ font-size: 0.875rem;
+ font-weight: 600;
+}
+
+#kc-locale .pf-c-dropdown__menu-item:hover {
+ background: var(--ty-primary-soft);
+ color: var(--ty-primary) !important;
+}
+
+#kc-current-locale-link {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.35rem;
+ padding: 0.4rem 0.75rem;
+ border: 1px solid #e5e7eb;
+ border-radius: var(--ty-radius);
+ background: #fff;
+ cursor: pointer;
+ font-family: "Yekan Bakh FaNum", Tahoma, sans-serif !important;
+ font-weight: 600 !important;
+ font-size: 0.875rem;
+ color: var(--ty-secondary);
+}
+
+#kc-current-locale-link::after {
+ content: "▾";
+ font-size: 0.75rem;
+ opacity: 0.7;
+}
+
+.login-pf-header {
+ width: 100%;
+ max-width: 420px;
+ display: flex;
+ flex-direction: column;
+ align-items: stretch;
+}
+
+.checkbox label,
+#rememberMe {
+ font-size: 0.875rem !important;
+}
+
+.login-pf-footer {
+ text-align: center !important;
+ color: #6b7280 !important;
+ font-size: 0.8rem !important;
+}
+
+#kc-content,
+#kc-content-wrapper {
+ width: 100%;
+ max-width: 420px;
+ margin: 0 auto;
+}
+
+@media (max-width: 767px) {
+ .card-pf {
+ margin: 0 !important;
+ }
+}
+
+/* ---- Unified auth tabs (password | mobile) ---- */
+.ty-auth-tabs {
+ display: flex;
+ gap: 0.25rem;
+ padding: 0.25rem;
+ background: #f9fafb;
+ border-radius: var(--ty-radius-lg);
+ margin-bottom: 1.25rem;
+}
+
+.ty-auth-tab {
+ flex: 1;
+ border: none;
+ background: transparent;
+ padding: 0.5rem 0.75rem;
+ border-radius: var(--ty-radius);
+ font-family: "Yekan Bakh FaNum", Tahoma, sans-serif !important;
+ font-weight: 700;
+ font-size: 0.875rem;
+ color: #6b7280;
+ cursor: pointer;
+}
+
+.ty-auth-tab--active {
+ background: #fff;
+ color: var(--ty-primary);
+ box-shadow: 0 1px 3px rgba(15, 23, 42, 0.08);
+}
+
+.ty-auth-panel {
+ display: none;
+}
+
+.ty-auth-panel.ty-auth-panel--active {
+ display: block !important;
+}
+
+.ty-auth-panel[hidden] {
+ display: none !important;
+}
+
+.ty-mobile-hint {
+ font-size: 0.8125rem;
+ color: #6b7280;
+ text-align: center;
+ margin-bottom: 1rem;
+}
+
+.ty-mobile-error {
+ background: #fef2f2;
+ color: #dc2626;
+ border-radius: var(--ty-radius);
+ padding: 0.5rem 0.75rem;
+ font-size: 0.875rem;
+ margin-bottom: 0.75rem;
+}
+
+.ty-signup-fields {
+ margin: 0.75rem 0 1rem;
+ padding: 1rem;
+ border: 1px solid #f3f4f6;
+ border-radius: var(--ty-radius-lg);
+ background: rgba(249, 250, 251, 0.85);
+ animation: ty-fade-in 0.25s ease;
+}
+
+@keyframes ty-fade-in {
+ from { opacity: 0; transform: translateY(-4px); }
+ to { opacity: 1; transform: translateY(0); }
+}
+
+.ty-signup-title {
+ font-size: 0.8125rem;
+ font-weight: 700;
+ color: var(--ty-secondary);
+ margin-bottom: 0.5rem;
+}
+
+.ty-btn-primary {
+ width: 100%;
+ margin-top: 0.5rem;
+ padding: 0.75rem 1rem;
+ border: none;
+ border-radius: var(--ty-radius-lg);
+ background: var(--ty-primary);
+ color: #fff;
+ font-family: "Yekan Bakh FaNum", Tahoma, sans-serif !important;
+ font-weight: 700;
+ cursor: pointer;
+}
+
+.ty-btn-primary:disabled {
+ opacity: 0.6;
+ cursor: not-allowed;
+}
+
+.ty-link-btn {
+ display: block;
+ width: 100%;
+ margin-top: 0.75rem;
+ border: none;
+ background: none;
+ color: var(--ty-primary);
+ font-family: "Yekan Bakh FaNum", Tahoma, sans-serif !important;
+ font-weight: 600;
+ font-size: 0.875rem;
+ cursor: pointer;
+ text-align: center;
+}
+
+.ty-otp-input {
+ text-align: center !important;
+ letter-spacing: 0.35em;
+ font-family: monospace !important;
+ font-size: 1.125rem !important;
+}
+
+.ty-otp-sent,
+.ty-countdown {
+ text-align: center;
+ font-size: 0.875rem;
+ color: #6b7280;
+}
+
+.ty-countdown {
+ color: var(--ty-primary);
+}
+
+input[type="tel"] {
+ direction: ltr !important;
+ text-align: left !important;
+}
diff --git a/infrastructure/keycloak/themes/torbatyar/login/resources/fonts/YekanBakhFaNum-Bold.woff b/infrastructure/keycloak/themes/torbatyar/login/resources/fonts/YekanBakhFaNum-Bold.woff
new file mode 100644
index 0000000..d1c239f
Binary files /dev/null and b/infrastructure/keycloak/themes/torbatyar/login/resources/fonts/YekanBakhFaNum-Bold.woff differ
diff --git a/infrastructure/keycloak/themes/torbatyar/login/resources/fonts/YekanBakhFaNum-ExtraBold.woff b/infrastructure/keycloak/themes/torbatyar/login/resources/fonts/YekanBakhFaNum-ExtraBold.woff
new file mode 100644
index 0000000..8a4f634
Binary files /dev/null and b/infrastructure/keycloak/themes/torbatyar/login/resources/fonts/YekanBakhFaNum-ExtraBold.woff differ
diff --git a/infrastructure/keycloak/themes/torbatyar/login/resources/fonts/YekanBakhFaNum-Light.woff b/infrastructure/keycloak/themes/torbatyar/login/resources/fonts/YekanBakhFaNum-Light.woff
new file mode 100644
index 0000000..4a1d8dd
Binary files /dev/null and b/infrastructure/keycloak/themes/torbatyar/login/resources/fonts/YekanBakhFaNum-Light.woff differ
diff --git a/infrastructure/keycloak/themes/torbatyar/login/resources/fonts/YekanBakhFaNum-Regular.woff b/infrastructure/keycloak/themes/torbatyar/login/resources/fonts/YekanBakhFaNum-Regular.woff
new file mode 100644
index 0000000..3eda900
Binary files /dev/null and b/infrastructure/keycloak/themes/torbatyar/login/resources/fonts/YekanBakhFaNum-Regular.woff differ
diff --git a/infrastructure/keycloak/themes/torbatyar/login/resources/fonts/YekanBakhFaNum-SemiBold.woff b/infrastructure/keycloak/themes/torbatyar/login/resources/fonts/YekanBakhFaNum-SemiBold.woff
new file mode 100644
index 0000000..aa69a78
Binary files /dev/null and b/infrastructure/keycloak/themes/torbatyar/login/resources/fonts/YekanBakhFaNum-SemiBold.woff differ
diff --git a/infrastructure/keycloak/themes/torbatyar/login/resources/js/locale-dropdown.js b/infrastructure/keycloak/themes/torbatyar/login/resources/js/locale-dropdown.js
new file mode 100644
index 0000000..f73af79
--- /dev/null
+++ b/infrastructure/keycloak/themes/torbatyar/login/resources/js/locale-dropdown.js
@@ -0,0 +1,40 @@
+(function () {
+ function initLocaleDropdown() {
+ var localeRoot = document.getElementById("kc-locale");
+ var toggleBtn = document.getElementById("kc-current-locale-link");
+ var menu = document.getElementById("language-switch1");
+ if (!localeRoot || !toggleBtn || !menu) return;
+
+ toggleBtn.setAttribute("type", "button");
+
+ function closeMenu() {
+ localeRoot.classList.remove("ty-locale-open");
+ toggleBtn.setAttribute("aria-expanded", "false");
+ }
+
+ toggleBtn.addEventListener("click", function (e) {
+ e.preventDefault();
+ e.stopPropagation();
+ var isOpen = localeRoot.classList.toggle("ty-locale-open");
+ toggleBtn.setAttribute("aria-expanded", isOpen ? "true" : "false");
+ });
+
+ document.addEventListener("click", function (e) {
+ if (!localeRoot.contains(e.target)) {
+ closeMenu();
+ }
+ });
+
+ document.addEventListener("keydown", function (e) {
+ if (e.key === "Escape") {
+ closeMenu();
+ }
+ });
+ }
+
+ if (document.readyState === "loading") {
+ document.addEventListener("DOMContentLoaded", initLocaleDropdown);
+ } else {
+ initLocaleDropdown();
+ }
+})();
diff --git a/infrastructure/keycloak/themes/torbatyar/login/resources/js/mobile-auth.js b/infrastructure/keycloak/themes/torbatyar/login/resources/js/mobile-auth.js
new file mode 100644
index 0000000..9e7fa33
--- /dev/null
+++ b/infrastructure/keycloak/themes/torbatyar/login/resources/js/mobile-auth.js
@@ -0,0 +1,368 @@
+(function () {
+ "use strict";
+
+ var MSG = {
+ titleLogin: "ورود",
+ titleSignup: "ثبت\u200cنام",
+ subtitlePhone: "شماره موبایل خود را وارد کنید",
+ subtitleCodeLogin: "کد ورود را وارد کنید",
+ subtitleCodeSignup: "کد تأیید و اطلاعات حساب را تکمیل کنید",
+ toggleSignup: "حساب کاربری ندارید؟ ثبت\u200cنام کنید",
+ toggleLogin: "حساب دارید؟ ورود",
+ btnLogin: "ورود",
+ btnSignup: "ثبت\u200cنام و ورود",
+ btnSendOtp: "دریافت کد",
+ };
+
+ function initMobileAuth() {
+ var root = document.getElementById("ty-auth-root");
+ if (!root) return;
+
+ var API = root.dataset.identityApi || "http://localhost:8001";
+ var intent = "login";
+ var mobile = "";
+ var countdownTimer = null;
+ var signupMode = false;
+ var step = "phone";
+
+ function $(id) {
+ return document.getElementById(id);
+ }
+
+ function showError(msg) {
+ var el = $("ty-mobile-error");
+ if (!el) return;
+ el.textContent = msg || "";
+ el.hidden = !msg;
+ }
+
+ function profileFromDom() {
+ return {
+ display_name: (($("ty-display-name") || {}).value || "").trim(),
+ email: (($("ty-email") || {}).value || "").trim(),
+ username: (($("ty-username") || {}).value || "").trim(),
+ password: (($("ty-password") || {}).value || ""),
+ };
+ }
+
+ function profileComplete(p) {
+ return (
+ p.display_name.length > 0 &&
+ p.email.length > 0 &&
+ p.username.length >= 3 &&
+ p.password.length >= 8
+ );
+ }
+
+ function showProfileFields(show) {
+ var fields = $("ty-signup-fields");
+ if (fields) fields.hidden = !show;
+ }
+
+ function updateHeader() {
+ var title = $("ty-auth-title");
+ var subtitle = $("ty-auth-subtitle");
+ if (title) {
+ title.textContent = signupMode ? MSG.titleSignup : MSG.titleLogin;
+ }
+ if (!subtitle) return;
+ if (step === "phone") {
+ subtitle.textContent = MSG.subtitlePhone;
+ } else if (intent === "login") {
+ subtitle.textContent = MSG.subtitleCodeLogin;
+ } else {
+ subtitle.textContent = MSG.subtitleCodeSignup;
+ }
+ }
+
+ function updateToggleSignup() {
+ var toggle = $("ty-toggle-signup");
+ if (!toggle) return;
+ toggle.textContent = signupMode ? MSG.toggleLogin : MSG.toggleSignup;
+ }
+
+ function showPanel(name) {
+ var passwordPanel = $("ty-panel-password");
+ var mobilePanel = $("ty-panel-mobile");
+ document.querySelectorAll(".ty-auth-tab").forEach(function (tab) {
+ var active = tab.getAttribute("data-ty-tab") === name;
+ tab.classList.toggle("ty-auth-tab--active", active);
+ tab.setAttribute("aria-selected", active ? "true" : "false");
+ });
+ if (passwordPanel) {
+ passwordPanel.classList.toggle("ty-auth-panel--active", name === "password");
+ passwordPanel.hidden = name !== "password";
+ }
+ if (mobilePanel) {
+ mobilePanel.classList.toggle("ty-auth-panel--active", name === "mobile");
+ mobilePanel.hidden = name !== "mobile";
+ if (name === "mobile") {
+ var input = step === "phone" ? $("ty-mobile-input") : $("ty-otp-code");
+ if (input) setTimeout(function () { input.focus(); }, 80);
+ }
+ }
+ showError("");
+ }
+
+ function normalizePhone(v) {
+ var d = String(v || "")
+ .replace(/[\u06F0-\u06F9\u0660-\u0669]/g, function (c) {
+ return String.fromCharCode(c.charCodeAt(0) & 15);
+ })
+ .replace(/\D/g, "");
+ if (d.indexOf("98") === 0 && d.length === 12) d = "0" + d.slice(2);
+ if (d.length === 10 && d.charAt(0) !== "0") d = "0" + d;
+ return d;
+ }
+
+ function normalizeCode(v) {
+ return String(v || "")
+ .replace(/[\u06F0-\u06F9\u0660-\u0669]/g, function (c) {
+ return String.fromCharCode(c.charCodeAt(0) & 15);
+ })
+ .replace(/\D/g, "")
+ .slice(0, 4);
+ }
+
+ function isValidMobile(m) {
+ return /^09\d{9}$/.test(m);
+ }
+
+ function apiPost(path, body) {
+ return fetch(API + path, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(body),
+ }).then(function (res) {
+ return res.json().catch(function () { return {}; }).then(function (data) {
+ if (!res.ok) {
+ var msg =
+ (data.error && data.error.message) ||
+ (typeof data.detail === "string" ? data.detail : null) ||
+ "خطا در ارتباط با سرور";
+ throw new Error(msg);
+ }
+ return data;
+ });
+ }).catch(function (err) {
+ if (err instanceof TypeError) {
+ throw new Error("اتصال به سرویس هویت برقرار نشد (" + API + ")");
+ }
+ throw err;
+ });
+ }
+
+ function setLoading(btn, loading) {
+ if (!btn) return;
+ btn.disabled = loading;
+ if (!btn.dataset.label) btn.dataset.label = btn.textContent;
+ btn.textContent = loading ? "..." : btn.dataset.label;
+ }
+
+ function startCountdown(sec) {
+ var el = $("ty-countdown");
+ if (!el) return;
+ if (countdownTimer) clearInterval(countdownTimer);
+ var left = sec;
+ el.hidden = false;
+ function tick() {
+ el.textContent = "ارسال مجدد تا " + left + " ثانیه";
+ if (left <= 0) {
+ el.hidden = true;
+ clearInterval(countdownTimer);
+ return;
+ }
+ left -= 1;
+ }
+ tick();
+ countdownTimer = setInterval(tick, 1000);
+ }
+
+ function goToCodeStep(res) {
+ intent = res.intent;
+ step = "code";
+ startCountdown(res.expires_in || 120);
+ var stepPhone = $("ty-mobile-step-phone");
+ var stepCode = $("ty-mobile-step-code");
+ if (stepPhone) stepPhone.hidden = true;
+ if (stepCode) stepCode.hidden = false;
+ var display = $("ty-mobile-display");
+ if (display) display.textContent = mobile;
+
+ var needProfile = res.intent === "register";
+ showProfileFields(signupMode || needProfile);
+
+ var verifyBtn = $("ty-verify-otp");
+ if (verifyBtn) {
+ verifyBtn.textContent = res.intent === "login" ? MSG.btnLogin : MSG.btnSignup;
+ }
+ updateHeader();
+ var otpInput = $("ty-otp-code");
+ if (otpInput) {
+ otpInput.value = "";
+ otpInput.focus();
+ }
+ }
+
+ function goToPhoneStep() {
+ step = "phone";
+ var stepCode = $("ty-mobile-step-code");
+ var stepPhone = $("ty-mobile-step-phone");
+ if (stepCode) stepCode.hidden = true;
+ if (stepPhone) stepPhone.hidden = false;
+ showProfileFields(signupMode);
+ showError("");
+ updateHeader();
+ if (countdownTimer) clearInterval(countdownTimer);
+ var otpInput = $("ty-otp-code");
+ if (otpInput) otpInput.value = "";
+ }
+
+ document.querySelectorAll(".ty-auth-tab").forEach(function (tab) {
+ tab.addEventListener("click", function (e) {
+ e.preventDefault();
+ showPanel(tab.getAttribute("data-ty-tab"));
+ });
+ });
+
+ var toggleSignup = $("ty-toggle-signup");
+ if (toggleSignup) {
+ toggleSignup.addEventListener("click", function () {
+ signupMode = !signupMode;
+ showProfileFields(signupMode);
+ updateToggleSignup();
+ updateHeader();
+ showError("");
+ });
+ }
+
+ var requestBtn = $("ty-request-otp");
+ if (requestBtn) {
+ requestBtn.addEventListener("click", function () {
+ showError("");
+ mobile = normalizePhone(($("ty-mobile-input") || {}).value);
+ if (!isValidMobile(mobile)) {
+ showError("شماره موبایل صحیح نیست (مثال: 09123456789)");
+ return;
+ }
+ var profile = profileFromDom();
+ if (signupMode && !profileComplete(profile)) {
+ showError("نام، ایمیل، نام کاربری (حداقل ۳ کاراکتر) و رمز (حداقل ۸) الزامی است");
+ return;
+ }
+ setLoading(requestBtn, true);
+ apiPost("/api/v1/auth/mobile/start", { mobile: mobile })
+ .then(function (res) {
+ if (res.intent === "login" && signupMode) {
+ signupMode = false;
+ showProfileFields(false);
+ updateToggleSignup();
+ showError("این شماره ثبت شده است — کد ورود ارسال شد");
+ }
+ goToCodeStep(res);
+ })
+ .catch(function (e) {
+ showError(e.message);
+ })
+ .finally(function () {
+ setLoading(requestBtn, false);
+ });
+ });
+ }
+
+ var backBtn = $("ty-back-phone");
+ if (backBtn) {
+ backBtn.addEventListener("click", goToPhoneStep);
+ }
+
+ function verifyOtp(codeOverride) {
+ showError("");
+ var code = normalizeCode(codeOverride || ($("ty-otp-code") || {}).value);
+ if (code.length !== 4) {
+ showError("کد ۴ رقمی را وارد کنید");
+ return;
+ }
+ var profile = profileFromDom();
+ var needProfile = intent === "register" || signupMode;
+ if (needProfile && !profileComplete(profile)) {
+ showError("نام، ایمیل، نام کاربری و رمز (۸+ کاراکتر) الزامی است");
+ return;
+ }
+ var payload = {
+ mobile: mobile,
+ code: code,
+ create_handoff: true,
+ display_name: profile.display_name || undefined,
+ };
+ if (profileComplete(profile)) {
+ payload.email = profile.email;
+ payload.username = profile.username;
+ payload.password = profile.password;
+ }
+ var verifyBtn = $("ty-verify-otp");
+ setLoading(verifyBtn, true);
+ apiPost("/api/v1/auth/mobile/complete", payload)
+ .then(function (res) {
+ if (res.redirect_url) {
+ window.location.href = res.redirect_url;
+ return;
+ }
+ if (res.access_token) {
+ window.location.href = root.dataset.callbackUrl || "/auth/callback";
+ return;
+ }
+ throw new Error("پاسخ ورود نامعتبر بود");
+ })
+ .catch(function (e) {
+ showError(e.message);
+ setLoading(verifyBtn, false);
+ });
+ }
+
+ var verifyBtn = $("ty-verify-otp");
+ if (verifyBtn) verifyBtn.addEventListener("click", function () { verifyOtp(); });
+
+ var otpInput = $("ty-otp-code");
+ if (otpInput) {
+ otpInput.addEventListener("input", function (e) {
+ var next = normalizeCode(e.target.value);
+ e.target.value = next;
+ if (next.length !== 4) return;
+ var profile = profileFromDom();
+ var canAuto = intent === "login" || profileComplete(profile);
+ if (canAuto) verifyOtp(next);
+ });
+ }
+
+ var mobileInput = $("ty-mobile-input");
+ if (mobileInput) {
+ mobileInput.addEventListener("input", function (e) {
+ e.target.value = normalizePhone(e.target.value);
+ });
+ mobileInput.addEventListener("keydown", function (e) {
+ if (e.key === "Enter") {
+ e.preventDefault();
+ if (requestBtn) requestBtn.click();
+ }
+ });
+ }
+
+ try {
+ if (sessionStorage.getItem("ty_signup_mode") === "1") {
+ signupMode = true;
+ sessionStorage.removeItem("ty_signup_mode");
+ showProfileFields(true);
+ }
+ } catch (ignore) {}
+
+ updateToggleSignup();
+ updateHeader();
+ showPanel("mobile");
+ }
+
+ if (document.readyState === "loading") {
+ document.addEventListener("DOMContentLoaded", initMobileAuth);
+ } else {
+ initMobileAuth();
+ }
+})();
diff --git a/infrastructure/keycloak/themes/torbatyar/login/theme.properties b/infrastructure/keycloak/themes/torbatyar/login/theme.properties
new file mode 100644
index 0000000..eaafa13
--- /dev/null
+++ b/infrastructure/keycloak/themes/torbatyar/login/theme.properties
@@ -0,0 +1,7 @@
+parent=keycloak
+import=common/keycloak
+styles=css/fonts.css css/login.css
+scripts=js/locale-dropdown.js js/mobile-auth.js
+locales=fa,en
+identityApiUrl=http://torbatyar.xyz:8001
+frontendCallbackUrl=http://torbatyar.xyz:3000/auth/callback
diff --git a/infrastructure/nginx/torbatyar.ir.conf b/infrastructure/nginx/torbatyar.ir.conf
new file mode 100644
index 0000000..1b3a125
--- /dev/null
+++ b/infrastructure/nginx/torbatyar.ir.conf
@@ -0,0 +1,150 @@
+# ============================================================
+# torbatyar.ir HTTPS — cert: /etc/letsencrypt/live/torbatyar.ir/
+# (apex + www + api + identity + auth on one cert)
+# Tenant wildcard *.torbatyar.ir still needs DNS-01 cert later.
+# ============================================================
+
+upstream torbatyar_frontend {
+ server 192.168.10.162:3000;
+ keepalive 16;
+}
+upstream torbatyar_core {
+ server 192.168.10.162:8000;
+ keepalive 16;
+}
+upstream torbatyar_identity {
+ server 192.168.10.162:8001;
+ keepalive 16;
+}
+upstream torbatyar_keycloak {
+ server 192.168.10.162:8080;
+ keepalive 8;
+}
+
+# Force HTTPS for known hosts
+server {
+ listen 192.168.10.156:80;
+ server_name torbatyar.ir www.torbatyar.ir api.torbatyar.ir identity.torbatyar.ir auth.torbatyar.ir;
+
+ location /.well-known/acme-challenge/ {
+ root /var/www/html;
+ }
+
+ location / {
+ return 301 https://$host$request_uri;
+ }
+}
+
+# Tenant subdomains stay on HTTP until wildcard cert exists
+server {
+ listen 192.168.10.156:80;
+ server_name *.torbatyar.ir;
+
+ location /.well-known/acme-challenge/ {
+ root /var/www/html;
+ }
+
+ location / {
+ proxy_pass http://torbatyar_frontend;
+ proxy_http_version 1.1;
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto $scheme;
+ proxy_set_header Upgrade $http_upgrade;
+ proxy_set_header Connection "upgrade";
+ proxy_read_timeout 600s;
+ proxy_send_timeout 600s;
+ client_max_body_size 50m;
+ }
+}
+
+server {
+ listen 192.168.10.156:443 ssl;
+ server_name torbatyar.ir www.torbatyar.ir;
+
+ ssl_certificate /etc/letsencrypt/live/torbatyar.ir/fullchain.pem;
+ ssl_certificate_key /etc/letsencrypt/live/torbatyar.ir/privkey.pem;
+ include /etc/letsencrypt/options-ssl-nginx.conf;
+ ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
+
+ location / {
+ proxy_pass http://torbatyar_frontend;
+ proxy_http_version 1.1;
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto $scheme;
+ proxy_set_header Upgrade $http_upgrade;
+ proxy_set_header Connection "upgrade";
+ proxy_read_timeout 600s;
+ proxy_send_timeout 600s;
+ client_max_body_size 50m;
+ }
+}
+
+server {
+ listen 192.168.10.156:443 ssl;
+ server_name api.torbatyar.ir;
+
+ ssl_certificate /etc/letsencrypt/live/torbatyar.ir/fullchain.pem;
+ ssl_certificate_key /etc/letsencrypt/live/torbatyar.ir/privkey.pem;
+ include /etc/letsencrypt/options-ssl-nginx.conf;
+ ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
+
+ location / {
+ proxy_pass http://torbatyar_core;
+ proxy_http_version 1.1;
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto $scheme;
+ proxy_read_timeout 600s;
+ client_max_body_size 50m;
+ }
+}
+
+server {
+ listen 192.168.10.156:443 ssl;
+ server_name identity.torbatyar.ir;
+
+ ssl_certificate /etc/letsencrypt/live/torbatyar.ir/fullchain.pem;
+ ssl_certificate_key /etc/letsencrypt/live/torbatyar.ir/privkey.pem;
+ include /etc/letsencrypt/options-ssl-nginx.conf;
+ ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
+
+ location / {
+ proxy_pass http://torbatyar_identity;
+ proxy_http_version 1.1;
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto $scheme;
+ proxy_read_timeout 600s;
+ client_max_body_size 20m;
+ }
+}
+
+server {
+ listen 192.168.10.156:443 ssl;
+ server_name auth.torbatyar.ir;
+
+ ssl_certificate /etc/letsencrypt/live/torbatyar.ir/fullchain.pem;
+ ssl_certificate_key /etc/letsencrypt/live/torbatyar.ir/privkey.pem;
+ include /etc/letsencrypt/options-ssl-nginx.conf;
+ ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
+
+ location / {
+ proxy_pass http://torbatyar_keycloak;
+ proxy_http_version 1.1;
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto $scheme;
+ proxy_buffer_size 128k;
+ proxy_buffers 4 256k;
+ proxy_busy_buffers_size 256k;
+ proxy_read_timeout 600s;
+ client_max_body_size 20m;
+ }
+}
diff --git a/infrastructure/nginx/torbatyar.ir.ssl.conf.example b/infrastructure/nginx/torbatyar.ir.ssl.conf.example
new file mode 100644
index 0000000..bf7ff10
--- /dev/null
+++ b/infrastructure/nginx/torbatyar.ir.ssl.conf.example
@@ -0,0 +1,130 @@
+# ============================================================
+# torbatyar.ir HTTPS — enable after certbot issues certificates
+# Certbot (HTTP-01) covers apex + service subdomains.
+# Wildcard *.torbatyar.ir needs DNS-01 (Cloudflare) — see deploy notes.
+# ============================================================
+
+# ---------- Apex + www → Frontend (HTTPS) ----------
+server {
+ listen 192.168.10.156:443 ssl;
+ server_name torbatyar.ir www.torbatyar.ir;
+
+ ssl_certificate /etc/letsencrypt/live/torbatyar.ir/fullchain.pem;
+ ssl_certificate_key /etc/letsencrypt/live/torbatyar.ir/privkey.pem;
+ include /etc/letsencrypt/options-ssl-nginx.conf;
+ ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
+
+ location /api/core/ {
+ proxy_pass http://torbatyar_core/;
+ proxy_http_version 1.1;
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto $scheme;
+ proxy_read_timeout 600s;
+ client_max_body_size 50m;
+ }
+
+ location / {
+ proxy_pass http://torbatyar_frontend;
+ proxy_http_version 1.1;
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto $scheme;
+ proxy_set_header Upgrade $http_upgrade;
+ proxy_set_header Connection "upgrade";
+ proxy_read_timeout 600s;
+ proxy_send_timeout 600s;
+ client_max_body_size 50m;
+ }
+}
+
+# ---------- Tenant wildcards (requires wildcard cert) ----------
+# Uncomment after: certbot certonly --dns-cloudflare -d '*.torbatyar.ir' -d torbatyar.ir
+# server {
+# listen 192.168.10.156:443 ssl;
+# server_name *.torbatyar.ir;
+# ssl_certificate /etc/letsencrypt/live/torbatyar.ir-wildcard/fullchain.pem;
+# ssl_certificate_key /etc/letsencrypt/live/torbatyar.ir-wildcard/privkey.pem;
+# include /etc/letsencrypt/options-ssl-nginx.conf;
+# ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
+# location / {
+# proxy_pass http://torbatyar_frontend;
+# proxy_http_version 1.1;
+# proxy_set_header Host $host;
+# proxy_set_header X-Real-IP $remote_addr;
+# proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+# proxy_set_header X-Forwarded-Proto $scheme;
+# proxy_set_header Upgrade $http_upgrade;
+# proxy_set_header Connection "upgrade";
+# proxy_read_timeout 600s;
+# client_max_body_size 50m;
+# }
+# }
+
+server {
+ listen 192.168.10.156:443 ssl;
+ server_name api.torbatyar.ir;
+
+ ssl_certificate /etc/letsencrypt/live/api.torbatyar.ir/fullchain.pem;
+ ssl_certificate_key /etc/letsencrypt/live/api.torbatyar.ir/privkey.pem;
+ include /etc/letsencrypt/options-ssl-nginx.conf;
+ ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
+
+ location / {
+ proxy_pass http://torbatyar_core;
+ proxy_http_version 1.1;
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto $scheme;
+ proxy_read_timeout 600s;
+ client_max_body_size 50m;
+ }
+}
+
+server {
+ listen 192.168.10.156:443 ssl;
+ server_name identity.torbatyar.ir;
+
+ ssl_certificate /etc/letsencrypt/live/identity.torbatyar.ir/fullchain.pem;
+ ssl_certificate_key /etc/letsencrypt/live/identity.torbatyar.ir/privkey.pem;
+ include /etc/letsencrypt/options-ssl-nginx.conf;
+ ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
+
+ location / {
+ proxy_pass http://torbatyar_identity;
+ proxy_http_version 1.1;
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto $scheme;
+ proxy_read_timeout 600s;
+ client_max_body_size 20m;
+ }
+}
+
+server {
+ listen 192.168.10.156:443 ssl;
+ server_name auth.torbatyar.ir;
+
+ ssl_certificate /etc/letsencrypt/live/auth.torbatyar.ir/fullchain.pem;
+ ssl_certificate_key /etc/letsencrypt/live/auth.torbatyar.ir/privkey.pem;
+ include /etc/letsencrypt/options-ssl-nginx.conf;
+ ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
+
+ location / {
+ proxy_pass http://torbatyar_keycloak;
+ proxy_http_version 1.1;
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto $scheme;
+ proxy_buffer_size 128k;
+ proxy_buffers 4 256k;
+ proxy_busy_buffers_size 256k;
+ proxy_read_timeout 600s;
+ client_max_body_size 20m;
+ }
+}
diff --git a/infrastructure/postgres/init-dbs.sql b/infrastructure/postgres/init-dbs.sql
new file mode 100644
index 0000000..cfb55d2
--- /dev/null
+++ b/infrastructure/postgres/init-dbs.sql
@@ -0,0 +1 @@
+CREATE DATABASE identity_access_db;
diff --git a/scripts/deploy_remote.py b/scripts/deploy_remote.py
new file mode 100644
index 0000000..4989847
--- /dev/null
+++ b/scripts/deploy_remote.py
@@ -0,0 +1,241 @@
+#!/usr/bin/env python3
+"""Package project, upload to app server, install docker, start stack, install nginx."""
+from __future__ import annotations
+
+import base64
+import io
+import os
+import sys
+import tarfile
+import time
+from pathlib import Path
+
+import paramiko
+
+ROOT = Path(__file__).resolve().parents[1]
+APP_HOST = "192.168.10.162"
+APP_USER = "morteza"
+APP_PASS = "Moli@5404"
+NGX_HOST = "192.168.10.156"
+NGX_USER = "torbatyaruser"
+NGX_PASS = "Moli@5404"
+REMOTE_DIR = "/home/morteza/torbatyar"
+
+
+EXCLUDE_DIRS = {
+ ".git",
+ "node_modules",
+ ".next",
+ ".venv",
+ "venv",
+ "__pycache__",
+ ".pytest_cache",
+ ".mypy_cache",
+ ".ruff_cache",
+ ".idea",
+ ".vscode",
+}
+EXCLUDE_FILES = {".DS_Store", "Thumbs.db"}
+
+
+def connect(host: str, user: str, password: str) -> paramiko.SSHClient:
+ c = paramiko.SSHClient()
+ c.set_missing_host_key_policy(paramiko.AutoAddPolicy())
+ c.connect(host, username=user, password=password, timeout=30, allow_agent=False, look_for_keys=False)
+ return c
+
+
+def run(client: paramiko.SSHClient, cmd: str, *, sudo: bool = False, password: str = "", timeout: int = 600) -> tuple[int, str]:
+ # Normalize newlines for remote bash (Windows source files may have CRLF).
+ cmd = cmd.replace("\r\n", "\n").replace("\r", "\n").strip() + "\n"
+ if sudo:
+ b64 = base64.b64encode(cmd.encode()).decode()
+ full = f"echo {password!r} | sudo -S -p '' bash -c 'echo {b64} | base64 -d | bash'"
+ else:
+ full = f"bash -lc {cmd!r}"
+ print(f"\n>>> {'[sudo] ' if sudo else ''}{cmd[:200].replace(chr(10), ' | ')}{'...' if len(cmd) > 200 else ''}")
+ stdin, stdout, stderr = client.exec_command(full, timeout=timeout, get_pty=True)
+ out = stdout.read().decode("utf-8", errors="replace")
+ err = stderr.read().decode("utf-8", errors="replace")
+ code = stdout.channel.recv_exit_status()
+ text = out + (("\n" + err) if err.strip() else "")
+ printable = text[-4000:] if len(text) > 4000 else text
+ try:
+ print(printable)
+ except UnicodeEncodeError:
+ print(printable.encode("ascii", "replace").decode("ascii"))
+ return code, text
+
+
+def should_exclude(path: Path) -> bool:
+ parts = set(path.parts)
+ if parts & EXCLUDE_DIRS:
+ return True
+ if path.name in EXCLUDE_FILES:
+ return True
+ if path.suffix in {".pyc", ".log"}:
+ return True
+ return False
+
+
+def make_tarball() -> Path:
+ out = ROOT / "scripts" / "_deploy_bundle.tar.gz"
+ print(f"Creating tarball {out} ...")
+ with tarfile.open(out, "w:gz") as tar:
+ for dirpath, dirnames, filenames in os.walk(ROOT):
+ p = Path(dirpath)
+ # prune excluded dirs in-place
+ dirnames[:] = [d for d in dirnames if d not in EXCLUDE_DIRS and not d.startswith(".cursor")]
+ rel_dir = p.relative_to(ROOT)
+ if should_exclude(rel_dir) and rel_dir != Path("."):
+ continue
+ for name in filenames:
+ fp = p / name
+ rel = fp.relative_to(ROOT)
+ if should_exclude(rel):
+ continue
+ if rel.as_posix().startswith("scripts/_deploy"):
+ continue
+ tar.add(fp, arcname=str(rel).replace("\\", "/"))
+ print(f"Tarball size: {out.stat().st_size / 1024 / 1024:.1f} MB")
+ return out
+
+
+def upload_file(client: paramiko.SSHClient, local: Path, remote: str) -> None:
+ sftp = client.open_sftp()
+ print(f"Uploading {local} -> {remote}")
+ sftp.put(str(local), remote)
+ sftp.close()
+
+
+def put_text_sudo(client: paramiko.SSHClient, password: str, remote: str, content: str) -> None:
+ b64 = base64.b64encode(content.encode()).decode()
+ cmd = f"mkdir -p $(dirname {remote!r}) && echo {b64} | base64 -d > {remote!r} && chmod 644 {remote!r}"
+ code, _ = run(client, cmd, sudo=True, password=password, timeout=60)
+ if code != 0:
+ raise RuntimeError(f"failed writing {remote}")
+
+
+def main() -> int:
+ tarball = make_tarball()
+
+ # --- App server ---
+ app = connect(APP_HOST, APP_USER, APP_PASS)
+ try:
+ run(app, f"mkdir -p {REMOTE_DIR}")
+ upload_file(app, tarball, f"/tmp/torbatyar_bundle.tar.gz")
+ run(app, f"mkdir -p {REMOTE_DIR} && tar -xzf /tmp/torbatyar_bundle.tar.gz -C {REMOTE_DIR} && rm -f /tmp/torbatyar_bundle.tar.gz && ls {REMOTE_DIR}")
+
+ # Install docker if missing
+ code, _ = run(app, "command -v docker >/dev/null && docker --version || echo NEED_DOCKER")
+ if "NEED_DOCKER" in _:
+ install = r"""
+set -e
+export DEBIAN_FRONTEND=noninteractive
+apt-get update -y
+apt-get install -y ca-certificates curl gnupg
+install -m 0755 -d /etc/apt/keyrings
+if [ ! -f /etc/apt/keyrings/docker.asc ]; then
+ curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc
+ chmod a+r /etc/apt/keyrings/docker.asc
+fi
+. /etc/os-release
+echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/ubuntu $VERSION_CODENAME stable" > /etc/apt/sources.list.d/docker.list
+apt-get update -y
+apt-get install -y docker-ce docker-ce-cli containerd.io docker-compose-plugin docker-buildx-plugin
+usermod -aG docker morteza
+systemctl enable --now docker
+docker --version
+docker compose version
+"""
+ code, out = run(app, install, sudo=True, password=APP_PASS, timeout=900)
+ if code != 0:
+ # fallback to ubuntu docker.io
+ fallback = r"""
+set -e
+export DEBIAN_FRONTEND=noninteractive
+apt-get update -y
+apt-get install -y docker.io docker-compose-v2
+usermod -aG docker morteza
+systemctl enable --now docker
+docker --version
+"""
+ code, out = run(app, fallback, sudo=True, password=APP_PASS, timeout=900)
+ if code != 0:
+ raise RuntimeError("docker install failed")
+
+ # Ensure morteza can use docker (new group may need sg)
+ run(app, "groups")
+
+ # Write production .env (HTTP-first until certs; CORS includes http)
+ env_src = (ROOT / "infrastructure" / "deploy" / ".env.production.example").read_text(encoding="utf-8")
+ # Start with HTTP until SSL certs exist; HTTPS URLs after certbot
+ env_http = (
+ env_src.replace("https://auth.torbatyar.ir", "http://auth.torbatyar.ir")
+ .replace("https://torbatyar.ir", "http://torbatyar.ir")
+ .replace("https://www.torbatyar.ir", "http://www.torbatyar.ir")
+ .replace("https://api.torbatyar.ir", "http://api.torbatyar.ir")
+ .replace("https://identity.torbatyar.ir", "http://identity.torbatyar.ir")
+ )
+ # upload env via sftp
+ sftp = app.open_sftp()
+ with sftp.file(f"{REMOTE_DIR}/.env", "w") as f:
+ f.write(env_http)
+ sftp.close()
+
+ # Bring up stack (use sg docker for group)
+ compose = f"""
+set -e
+cd {REMOTE_DIR}
+sg docker -c 'docker compose pull || true'
+sg docker -c 'docker compose up -d --build'
+sg docker -c 'docker compose ps'
+"""
+ code, out = run(app, compose, timeout=1800)
+ if code != 0:
+ # retry once with sudo docker
+ compose_sudo = f"""
+set -e
+cd {REMOTE_DIR}
+docker compose up -d --build
+docker compose ps
+"""
+ code, out = run(app, compose_sudo, sudo=True, password=APP_PASS, timeout=1800)
+ if code != 0:
+ raise RuntimeError("compose up failed")
+ finally:
+ app.close()
+
+ # --- Nginx server ---
+ ngx = connect(NGX_HOST, NGX_USER, NGX_PASS)
+ try:
+ conf = (ROOT / "infrastructure" / "nginx" / "torbatyar.ir.conf").read_text(encoding="utf-8")
+ put_text_sudo(ngx, NGX_PASS, "/etc/nginx/conf.d/torbatyar.ir.conf", conf)
+ code, out = run(ngx, "nginx -t && systemctl reload nginx && echo RELOADED", sudo=True, password=NGX_PASS)
+ if code != 0:
+ raise RuntimeError("nginx reload failed")
+
+ # Try certbot for apex + service hosts (wildcard needs DNS later)
+ certbot = r"""
+set -e
+mkdir -p /var/www/html
+certbot certonly --webroot -w /var/www/html \
+ -d torbatyar.ir -d www.torbatyar.ir \
+ -d api.torbatyar.ir -d identity.torbatyar.ir -d auth.torbatyar.ir \
+ --non-interactive --agree-tos --email support@torbatyar.ir --expand || true
+ls -la /etc/letsencrypt/live/torbatyar.ir 2>/dev/null || ls /etc/letsencrypt/live/ | head
+"""
+ run(ngx, certbot, sudo=True, password=NGX_PASS, timeout=300)
+ finally:
+ ngx.close()
+
+ print("\n=== DEPLOY SCRIPT FINISHED ===")
+ return 0
+
+
+if __name__ == "__main__":
+ try:
+ raise SystemExit(main())
+ except Exception as exc:
+ print(f"FATAL: {exc}", file=sys.stderr)
+ raise
diff --git a/scripts/remote_ssh.py b/scripts/remote_ssh.py
new file mode 100644
index 0000000..bc99fbc
--- /dev/null
+++ b/scripts/remote_ssh.py
@@ -0,0 +1,120 @@
+#!/usr/bin/env python3
+"""Simple SSH helper for deployment (paramiko)."""
+from __future__ import annotations
+
+import argparse
+import base64
+import sys
+from pathlib import Path
+
+import paramiko
+
+
+def connect(host: str, user: str, password: str) -> paramiko.SSHClient:
+ client = paramiko.SSHClient()
+ client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
+ client.connect(
+ host,
+ username=user,
+ password=password,
+ timeout=30,
+ allow_agent=False,
+ look_for_keys=False,
+ )
+ return client
+
+
+def _exec(client: paramiko.SSHClient, command: str, timeout: int = 120) -> tuple[int, str, str]:
+ stdin, stdout, stderr = client.exec_command(command, timeout=timeout, get_pty=True)
+ out = stdout.read().decode(errors="replace")
+ err = stderr.read().decode(errors="replace")
+ code = stdout.channel.recv_exit_status()
+ return code, out, err
+
+
+def run(
+ host: str,
+ user: str,
+ password: str,
+ command: str,
+ *,
+ sudo: bool = False,
+ timeout: int = 120,
+) -> int:
+ client = connect(host, user, password)
+ try:
+ if sudo:
+ b64 = base64.b64encode(command.encode()).decode()
+ full = (
+ f"echo {password!r} | sudo -S -p '' bash -c "
+ f"'echo {b64} | base64 -d | bash'"
+ )
+ else:
+ full = f"bash -lc {command!r}"
+ code, out, err = _exec(client, full, timeout=timeout)
+ sys.stdout.write(out)
+ if err.strip():
+ sys.stderr.write(err)
+ return code
+ finally:
+ client.close()
+
+
+def upload(
+ host: str,
+ user: str,
+ password: str,
+ local: str,
+ remote: str,
+ *,
+ sudo: bool = False,
+) -> int:
+ data = Path(local).read_bytes()
+ b64 = base64.b64encode(data).decode()
+ remote_q = remote.replace("'", "'\\''")
+ write_cmd = f"mkdir -p \"$(dirname '{remote_q}')\" && echo {b64} | base64 -d > '{remote_q}'"
+ return run(host, user, password, write_cmd, sudo=sudo, timeout=300)
+
+
+def main() -> int:
+ parser = argparse.ArgumentParser()
+ sub = parser.add_subparsers(dest="action", required=True)
+
+ p_run = sub.add_parser("run")
+ p_run.add_argument("--host", required=True)
+ p_run.add_argument("--user", required=True)
+ p_run.add_argument("--password", required=True)
+ p_run.add_argument("--sudo", action="store_true")
+ p_run.add_argument("--timeout", type=int, default=120)
+ p_run.add_argument("command")
+
+ p_up = sub.add_parser("upload")
+ p_up.add_argument("--host", required=True)
+ p_up.add_argument("--user", required=True)
+ p_up.add_argument("--password", required=True)
+ p_up.add_argument("--local", required=True)
+ p_up.add_argument("--remote", required=True)
+ p_up.add_argument("--sudo", action="store_true")
+
+ args = parser.parse_args()
+ if args.action == "run":
+ return run(
+ args.host,
+ args.user,
+ args.password,
+ args.command,
+ sudo=args.sudo,
+ timeout=args.timeout,
+ )
+ return upload(
+ args.host,
+ args.user,
+ args.password,
+ args.local,
+ args.remote,
+ sudo=args.sudo,
+ )
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/scripts/verify_endpoints.py b/scripts/verify_endpoints.py
new file mode 100644
index 0000000..0575ae1
--- /dev/null
+++ b/scripts/verify_endpoints.py
@@ -0,0 +1,30 @@
+#!/usr/bin/env python3
+import sys
+import paramiko
+
+sys.stdout.reconfigure(encoding="utf-8", errors="replace")
+
+
+def run(host, user, password, cmd):
+ c = paramiko.SSHClient()
+ c.set_missing_host_key_policy(paramiko.AutoAddPolicy())
+ c.connect(host, username=user, password=password, timeout=30, allow_agent=False, look_for_keys=False)
+ _, stdout, stderr = c.exec_command(cmd, timeout=60, get_pty=True)
+ text = (stdout.read() + stderr.read()).decode("utf-8", "replace")
+ print(text)
+ c.close()
+
+
+tests = [
+ "curl -sk -m 15 -o /dev/null -w apex=%{http_code} --resolve torbatyar.ir:443:192.168.10.156 https://torbatyar.ir/",
+ "curl -sk -m 10 -o /dev/null -w api=%{http_code} --resolve api.torbatyar.ir:443:192.168.10.156 https://api.torbatyar.ir/health",
+ "curl -sk -m 10 -o /dev/null -w identity=%{http_code} --resolve identity.torbatyar.ir:443:192.168.10.156 https://identity.torbatyar.ir/health",
+ "curl -sk -m 10 -o /dev/null -w auth=%{http_code} --resolve auth.torbatyar.ir:443:192.168.10.156 https://auth.torbatyar.ir/",
+ "curl -sS -m 10 -o /dev/null -w demo=%{http_code} -H 'Host: demo.torbatyar.ir' http://192.168.10.156/",
+ "curl -sS -m 10 -o /dev/null -w apex80=%{http_code} -H 'Host: torbatyar.ir' http://192.168.10.156/",
+]
+
+for t in tests:
+ print("CMD:", t)
+ run("192.168.10.156", "torbatyaruser", "Moli@5404", t)
+ print("---")