- users.is_platform_owner → users.platform_role (owner|manager|user) با مهاجرت d4e5f6a7b8c9 و backfill. ایندکس یکتای جزئی uq_single_platform_owner تضمین میکند دقیقاً یک مالک وجود داشته باشد — در سطح دیتابیس، نه فقط در کد. - ثبتنام عمومی حذف شد (POST /auth/signup). حسابها فقط با POST /admin/users (فقط مالک، با رمز موقت یکبارمصرف) یا با پذیرش لینک دعوت ساخته میشوند. در مسیر دعوت، اگر حساب وجود نداشته باشد خودِ دعوتشده رمز تعیین میکند؛ اگر وجود داشته باشد لینک هرگز جای reset رمز را نمیگیرد. - ساخت سازمان فقط برای مالک و مدیران؛ کاربر عادی با دعوت عضو میشود. - تنزل یا حذف مدیر، سازمانهایش را به مالک سامانه منتقل میکند (مالکیت + عضویت، با audit). حساب مالک از API تغییرناپذیر است؛ جابهجایی صندلی فقط با POST /admin/transfer-ownership و در یک تراکنش انجام میشود. - افزودن POST /auth/change-password برای تبدیل رمز موقت به رمز خصوصی. - فرانت: صفحهٔ /admin/users، صفحهٔ /account، بازنویسی /accept-invite، حذف /signup، گارد /onboarding برای کاربر عادی، و نشان نقش پلتفرم در پوستهٔ اپ. - PRAGMA foreign_keys=ON برای SQLite تا ON DELETE CASCADE در dev و prod یکسان رفتار کند (پیشتر حذف کاربر، عضویتهایش را یتیم میگذاشت). - تست: ۳۰۰ تست بکاند (شامل tests/test_platform_hierarchy.py با ۲۲ تست) و ۶۴ تست فرانتاند؛ ruff، mypy، tsc، eslint و build تمیز.
347 lines
20 KiB
Markdown
347 lines
20 KiB
Markdown
# نمای کلی معماری — نسخه وب مستقل Smart Organization
|
||
|
||
> این سند تصویر فنی پروژهٔ `web/` است: چه چیزی کجاست، چرا اینطور چیده شده، و
|
||
> مرزهای اعتماد کداماند. برای چکلیست امنیتی به
|
||
> [`../security-checklist.md`](../security-checklist.md) مراجعه کنید.
|
||
|
||
---
|
||
|
||
## ۱. هدف و قابلیتهای محصول
|
||
|
||
یک **SaaS چندمستأجری مستقل** بهنام «AI Workforce Platform». مشتری وارد میشود،
|
||
سازمان میسازد، عضو دعوت میکند، پروژه تعریف میکند و به کارمندان هوش مصنوعی کار
|
||
میسپارد. مشتری هرگز نمیبیند که پشت صحنه چه مدلی، چه providerی یا چه کلیدی است.
|
||
|
||
**آنچه مشتری میبیند:** سازمان، اعضا، پروژه، مخزن Git، دامنهٔ preview، محیط
|
||
اجرای ایزوله، کارمند AI، گفتگو با مدیر، task و run، مصرف، صورتحساب، audit log.
|
||
|
||
**آنچه فقط مالک سامانه میبیند:** provider، مدل، سیاست مسیریابی، rate card،
|
||
کلیدهای رمزنگاریشده، همهٔ سازمانها.
|
||
|
||
**مستقل از تلگرام:** نسخهٔ وب هیچ import یا وابستگی runtime به `bot.py`,
|
||
`web_panel.py`, `caddy_manager.py`, `git_manager.py` یا `gitea_api.py` ندارد. منطق
|
||
Git و domain routing فقط بهعنوان مرجع خوانده و در `web/` بهصورت adapter مستقل
|
||
بازنویسی شده است.
|
||
|
||
---
|
||
|
||
## ۲. استک
|
||
|
||
| لایه | فناوری | چرا |
|
||
| --- | --- | --- |
|
||
| UI | Next.js 15.1.6 (App Router) + React 19 + TypeScript | RSC، مسیریابی فایلمحور، یک پکیج مشترک برای کل محصول |
|
||
| استایل | Tailwind CSS v3 | RTL و dark-first بدون فایل CSS پراکنده |
|
||
| State/Data | کلاینت `fetch` نازک + `localStorage` برای توکن | بدون وابستگی اضافه؛ TanStack Query لازم نشد |
|
||
| API | FastAPI + Pydantic v2 | قرارداد تایپدار، OpenAPI خودکار |
|
||
| ORM | SQLAlchemy 2.x (async) | همراستا با مدل async FastAPI |
|
||
| Migration | Alembic | تنها مسیر تغییر schema در production |
|
||
| DB | PostgreSQL (توسعه: SQLite حافظهای برای تست) | تستها بدون سرویس بیرونی اجرا میشوند |
|
||
| Cache/Queue | Redis | آماده برای workerهای background |
|
||
| امنیت | argon2 (رمز)، PyJWT (نشست)، cryptography/Fernet (رازها) | انتخابهای استاندارد و مقاوم |
|
||
| تست | pytest + pytest-asyncio (backend)، Vitest + Testing Library (frontend) | — |
|
||
|
||
---
|
||
|
||
## ۳. چیدمان مخزن
|
||
|
||
```
|
||
web/
|
||
├── apps/
|
||
│ ├── api/ ← بکاند FastAPI (Clean/Hexagonal)
|
||
│ └── web/ ← فرانتاند Next.js
|
||
├── packages/
|
||
│ ├── contracts/ ← تایپهای مشترک request/response
|
||
│ └── ui/ ← کامپوننتهای پایه (RTL، dark-first)
|
||
├── services/ ← workerهای مستقل (ai-runtime, billing-worker, sandbox-gateway)
|
||
├── infra/docker/ ← docker-compose برای postgres/redis
|
||
├── docs/ ← همین اسناد
|
||
├── PLAN.md ← سند مرجع محصول و معماری
|
||
└── TODO.md ← فهرست اجرای مرحلهبهمرحله
|
||
```
|
||
|
||
---
|
||
|
||
## ۴. معماری بکاند — Clean / Hexagonal
|
||
|
||
وابستگیها **همیشه به سمت داخل** است. لایهٔ domain به هیچ فریمورک، ORM، Redis یا
|
||
HTTP وابسته نیست (این با تست `test_domain_layer_has_no_framework_dependencies`
|
||
تضمین شده است).
|
||
|
||
```
|
||
┌───────────────────────────────────────────────────────────────────┐
|
||
│ interfaces/http routers + deps + error handlers │
|
||
│ فقط route، schema ورودی/خروجی و تبدیل خطا │
|
||
├───────────────────────────────────────────────────────────────────┤
|
||
│ application services (use case) + ports (driven iface) │
|
||
│ sandbox_service, workforce_service, project_service, ... │
|
||
├───────────────────────────────────────────────────────────────────┤
|
||
│ domain قواعد خالص، بدون I/O │
|
||
│ identity, projects, workforce, sandbox, billing, audit, │
|
||
│ knowledge, ai_control, platform/rules │
|
||
├───────────────────────────────────────────────────────────────────┤
|
||
│ infrastructure adapters + repositories + db/models │
|
||
│ mock, gitea, caddy, openai_compatible, sandbox_remote, stripe │
|
||
└───────────────────────────────────────────────────────────────────┘
|
||
↑ همهٔ پیکانها به سمت domain
|
||
```
|
||
|
||
### ۴.۱ لایهٔ domain (بدون I/O)
|
||
|
||
هر bounded context یک پکیج است با `models.py` و قواعد مربوط به خود:
|
||
|
||
| Context | مسئولیت |
|
||
| --- | --- |
|
||
| `identity` | کاربر، سازمان، عضویت، نقش، دعوت |
|
||
| `projects` | پروژه، وضعیت مخزن، دامنهٔ preview |
|
||
| `workforce` | قالب کارمند، کارمند، thread، message، task، run، artifact، مجوز ابزار (`tools.py`) |
|
||
| `sandbox` | workspace، session، snapshot، `NetworkPolicy`، `SandboxResourceLimit` |
|
||
| `billing` | پلن، rate card، usage ledger |
|
||
| `audit` | audit event، trace، span، error، quality metric |
|
||
| `knowledge` | منبع دانش، chunk |
|
||
| `ai_control` | provider، مدل، سیاست مسیریابی |
|
||
| `platform/rules.py` | **قواعد امنیتی سکو:** اعتبارسنجی برچسب دامنه، ضدتزریق host، ضدSSRF، پورتهای رزرو/محافظتشده |
|
||
|
||
`platform/rules.py` عمداً در domain است، نه در infrastructure: این قواعد باید
|
||
بدون DB و بدون شبکه قابل تست باشند، چون بیشترین بار امنیتی را دارند.
|
||
|
||
### ۴.۲ لایهٔ application
|
||
|
||
**سرویسها (use case):**
|
||
|
||
- `project_service` — ساخت پروژه، اطمینان از مخزن، دامنهٔ preview
|
||
- `workforce_service` — کارمند، prompt، مجوز ابزار، thread، task، اجرای run
|
||
- `sandbox_service` — workspace، session، اجرای فرمان، clamp منابع، ثبت مصرف
|
||
- `billing_service` — خلاصهٔ مصرف، صورتحساب
|
||
- `knowledge_service` — منابع دانش و شمارش chunk
|
||
- `audit_service` — audit log، trace
|
||
|
||
**پورتها (interfaceهای driven):** در `app/application/ports/`
|
||
|
||
`ModelProviderPort`, `PaymentProviderPort`, `SandboxProviderPort`,
|
||
`GitProviderPort`, `DomainRouterPort`, `StoragePort`, `VectorStorePort`,
|
||
`NotificationPort`, `UsageLedgerPort`, `AuditPort`.
|
||
|
||
هر پورت حداقل یک **adapter mock** دارد، بنابراین کل استک بدون هیچ credential
|
||
بیرونی بالا میآید.
|
||
|
||
### ۴.۳ لایهٔ infrastructure
|
||
|
||
**انتخاب adapter — یک نقطهٔ تصمیم:** `infrastructure/adapters/registry.py`
|
||
تنها جایی است که تصمیم میگیرد کدام adapter پشت هر پورت بنشیند، فقط بر اساس
|
||
سوئیچهای env:
|
||
|
||
| env | مقادیر مجاز | adapter واقعی |
|
||
| --- | --- | --- |
|
||
| `AI_DEFAULT_PROVIDER` | `mock` \| `openai_compatible` | `openai_compatible.py` |
|
||
| `GIT_PROVIDER` | `mock` \| `gitea` | `gitea.py` |
|
||
| `DOMAIN_ROUTER` | `mock` \| `caddy` | `caddy.py` |
|
||
| `SANDBOX_PROVIDER` | `mock` \| `daytona` \| `e2b` | `sandbox_remote.py` |
|
||
| `PAYMENT_PROVIDER` | `mock` \| `stripe` | `stripe_payments.py` |
|
||
|
||
مقدار ناشناخته به `ProviderUnavailableError` منجر میشود — **fail-closed**، نه
|
||
fallback بیصدا به mock.
|
||
|
||
**Repositoryها:** `infrastructure/repositories/` — `identity`, `projects`,
|
||
`workforce`, `sandbox`, `billing`, `knowledge`, `ai_control`, `audit`.
|
||
هر متد لیست، `limit`/`offset` میگیرد و سقف `min(limit, 500)` را اعمال میکند.
|
||
|
||
**مدلهای DB:** `infrastructure/db/models.py` — ۴۸ جدول با indexهای ترکیبی روی
|
||
`(organization_id, created_at)` برای مسیرهای داغ داشبورد.
|
||
|
||
### ۴.۴ لایهٔ interfaces
|
||
|
||
`interfaces/http/` شامل `routers/` (۹ روتر: `health`, `auth`, `organizations`,
|
||
`projects`, `workforce`, `billing`, `knowledge`, `sandbox`, `admin`)،
|
||
`deps.py` (auth، org context، capability) و `errors.py` (تبدیل استثناها به
|
||
پاسخهای یکدست). `main.py` تنها composition root است: روترها را به هم میبندد و
|
||
CORS را تنظیم میکند.
|
||
|
||
---
|
||
|
||
## ۵. معماری فرانتاند
|
||
|
||
Next.js App Router با دو ناحیهٔ مسیر:
|
||
|
||
**ناحیهٔ عمومی (بدون احراز هویت):** `/login`, `/accept-invite`, `/onboarding`
|
||
|
||
> `/signup` وجود ندارد: ثبتنام عمومی بسته است و حسابها فقط توسط مالک سامانه یا
|
||
> با پذیرش لینک دعوت ساخته میشوند.
|
||
|
||
**ناحیهٔ اپلیکیشن (`(app)` — نیازمند نشست):**
|
||
`/dashboard`, `/workforce`, `/workforce/[id]`, `/manager`, `/tasks`,
|
||
`/tasks/[id]`, `/projects`, `/projects/[id]`, `/knowledge`, `/billing`,
|
||
`/settings`, `/traces`
|
||
|
||
**ناحیهٔ مالک سامانه:** `/admin`, `/admin/users`, `/admin/agy-accounts`,
|
||
`/admin/models`, `/admin/routing`, `/admin/rate-cards`, `/admin/organizations`,
|
||
`/admin/usage`, `/admin/errors`
|
||
|
||
**دو نقطهٔ اتصال مرکزی:**
|
||
|
||
- `src/lib/api.ts` — تنها جایی که base URL، bearer token و پوشش خطا را میشناسد.
|
||
base URL از `NEXT_PUBLIC_API_URL` میآید (یک URL عمومی، نه راز).
|
||
- `src/lib/session.tsx` — Context نشست: کاربر، سازمانها، سازمان فعال، ورود/خروج.
|
||
|
||
**پکیج مشترک UI:** `packages/ui/src/index.tsx` کامپوننتهای پایه را دارد —
|
||
`Button`, `Card`, `Input`, `Badge`, `Modal`, `Drawer`, `Table` (با حالت
|
||
empty/loading/error داخلی), `Tabs`, `ToastStack`, `ArtifactViewer`. RTL و
|
||
dark-first بهصورت پیشفرض.
|
||
|
||
---
|
||
|
||
## ۶. مسیر یک درخواست (مثال: اجرای یک task)
|
||
|
||
```
|
||
مشتری: دکمهٔ «اجرا» در /tasks/[id]
|
||
│
|
||
├─ POST /api/organizations/{org}/tasks/{task}/runs (Bearer token)
|
||
│
|
||
├─ deps.get_current_user → توکن را decode میکند، کاربر را میخواند (۴۰۱ اگر نامعتبر)
|
||
├─ deps.get_org_context → عضویت فعال را میخواند (۴۰۴ اگر نباشد — نه ۴۰۳)
|
||
├─ deps.require_capability(...) → نقش کافی است؟ (۴۰۳ اگر نه)
|
||
│
|
||
├─ workforce_service.start_run(...)
|
||
│ ├─ انتخاب سیاست مسیریابی بر اساس mode (بدون افشای مدل به مشتری)
|
||
│ ├─ اجرای گامها → در صورت نیاز، sandbox_service.run_command(...)
|
||
│ │ ├─ SandboxProviderPort (mock یا remote)
|
||
│ │ ├─ clamp timeout/output از policy (نه از caller)
|
||
│ │ └─ ثبت usage در UsageLedger
|
||
│ └─ ثبت گامهای timeline (kind="output" — بدون افشای «model»)
|
||
│
|
||
└─ پاسخ: run با timeline، وضعیت، و مصرف ثبتشده
|
||
```
|
||
|
||
---
|
||
|
||
## ۷. مرزهای اعتماد
|
||
|
||
1. **مرز مستأجر:** هر endpoint چندسازمانی `organization_id` را enforce میکند.
|
||
نبود عضویت → **۴۰۴**. هیچ مسیری برای دیدن دادهٔ سازمان دیگر وجود ندارد.
|
||
2. **مرز مشتری/سکو:** provider، مدل و کلید هرگز از API به مشتری نمیرسند. مشتری
|
||
فقط یک *نام* سیاست مسیریابی میبیند.
|
||
3. **مرز اجرای کد:** هیچ مسیری برای اجرا روی host وجود ندارد. همهٔ کد کاربر فقط
|
||
از `SandboxProviderPort` عبور میکند، با egress پیشفرض بسته (`DENY_ALL`).
|
||
4. **مرز راز:** رازها در حالت سکون با Fernet رمزنگاری میشوند؛ در پاسخها فقط
|
||
نسخهٔ masked برمیگردد. frontend هیچ رازی ندارد.
|
||
|
||
---
|
||
|
||
## ۸. جریان داده و ثبت مصرف
|
||
|
||
**usage ledger منبع حقیقت صورتحساب است** — providerهای پرداخت فقط پول جابهجا
|
||
میکنند و در محاسبه دخالتی ندارند. هر مصرف (model call، زمان sandbox،
|
||
storage/artifact، tool call) یک رکورد در ledger میگذارد با `organization_id`,
|
||
`project_id`, `user_id`, `employee_id`, `run_id`, `unit`, `quantity`, `unit_price`.
|
||
|
||
هر عملیات حساس یک `AuditEvent` هم مینویسد (ساخت سازمان، دعوت عضو، ساخت repo،
|
||
ساخت دامنه، اجرای فرمان، حذف).
|
||
|
||
---
|
||
|
||
## ۹. تست
|
||
|
||
**بکاند (`apps/api/tests/`)** — ۳۰۰ تست:
|
||
|
||
| فایل | چه چیزی را میسنجد |
|
||
| --- | --- |
|
||
| `test_auth.py` | ورود، نشست، تغییر رمز، بستهبودن ثبتنام، نشتنداشتن hash |
|
||
| `test_platform_hierarchy.py` | یکتایی مالک، گارد ساخت سازمان، ساخت مدیر توسط مالک، انتقال سازمان هنگام تنزل/حذف مدیر، انتقال مالکیت |
|
||
| `test_invites.py` | دعوت، پیشنمایش ناشناس، ساخت حساب از لینک، رد reset رمز با لینک |
|
||
| `test_tenant_isolation.py` | جداسازی مستأجر، ۴۰۴ بهجای ۴۰۳، دعوت |
|
||
| `test_admin_control_plane.py` | دسترسی مالک سامانه، mask شدن کلید |
|
||
| `test_platform_rules.py` | برچسب دامنه، ضدتزریق host، ضدSSRF، پورتها |
|
||
| `test_project_repo.py` | مخزن خصوصی، نشتنداشتن توکن |
|
||
| `test_workforce.py` | کارمند، مجوز ابزار (deny-by-default)، run، نشتنداشتن مدل |
|
||
| `test_config_guard.py` | اعتبارسنجی رازها در production |
|
||
| `test_pagination.py` | سقف صفحهبندی، رد offset نامعتبر |
|
||
| `test_domain_isolation.py` | نبود وابستگی فریمورک در domain |
|
||
| `test_e2e_smoke.py` | کل مسیر محصول + نامرئیبودن برای مستأجر دیگر |
|
||
| `test_workers.py` | اجرای task بدون کلیک، idempotency صورتحساب، reaping سندباکس |
|
||
| `test_seed.py` | قابلورود بودن حسابهای نمونه، idempotency seed |
|
||
| `test_redact.py` | پاکسازی secret در ۴۲ حالت (ساختاری، الگویی، مقداری) |
|
||
| `test_error_log.py` | مسیر کامل خطا: middleware → handler → recorder → دیتابیس |
|
||
| `test_error_console.py` | دسترسی مالک سامانه، گروهبندی، بستن/بازگشایی، audit |
|
||
|
||
**فرانتاند (`apps/web/src/test/`)** — ۲۵ تست: قرارداد API، کامپوننتهای پایه،
|
||
design system، و همبستگی `request_id` در کلاینت.
|
||
|
||
---
|
||
|
||
## ۹.۱ خط لولهٔ خطا
|
||
|
||
یک استثنا از محل وقوع تا کنسول چهار مرحله را طی میکند:
|
||
|
||
```
|
||
route raises
|
||
│
|
||
├─ RequestContextMiddleware → request_id + method/path/ip/user_agent روی request.state
|
||
├─ deps.get_current_user → user_id (پس از تأیید توکن)
|
||
├─ deps.get_org_context → organization_id (پس از تأیید عضویت)
|
||
│
|
||
├─ exception handler → انتخاب اینکه ثبت شود یا نه + ساخت پاسخ استاندارد
|
||
│ (errors.py) (۵xx همیشه، ۴xx فقط با ERROR_LOG_CLIENT_ERRORS)
|
||
│
|
||
├─ error_log.record_error → redact → truncate → fingerprint
|
||
│ (infrastructure) سشن مستقل خودش؛ هرگز استثنا پرتاب نمیکند
|
||
│
|
||
└─ ObservabilityRepository → INSERT در error_events
|
||
```
|
||
|
||
سه نکته که طراحی را تعیین کردهاند:
|
||
|
||
1. **recorder سشن خودش را باز میکند.** سشن درخواست در لحظهٔ فراخوانی معمولاً
|
||
غیرقابلاستفاده است: یک `IntegrityError` آن را در وضعیت
|
||
`PendingRollbackError` رها میکند. اگر از همان سشن استفاده میشد، سختترین
|
||
خطاها برای ثبت — خطاهای دیتابیس — هرگز ثبت نمیشدند.
|
||
2. **recorder هرگز پرتاب نمیکند.** یک خطای ثبتی نباید یک ۵۰۰ را به hang یا به
|
||
یک استثنای مدیریتنشده تبدیل کند.
|
||
3. **`X-Request-ID` در خود هندلر ست میشود، نه فقط در middleware.**
|
||
`ServerErrorMiddleware` استارلت بیرون از همهٔ middlewareهای کاربر است، پس
|
||
middleware هرگز پاسخی که برای یک استثنای مدیریتنشده ساخته میشود را
|
||
نمیبیند. این باگ واقعاً رخ داد و با
|
||
`test_error_responses_carry_the_header_for_every_handler` پوشش داده شد.
|
||
|
||
---
|
||
|
||
## ۱۰. اجرای پروژه
|
||
|
||
```bash
|
||
# ۱. زیرساخت
|
||
docker compose -f infra/docker/docker-compose.yml up -d postgres redis
|
||
|
||
# ۲. بکاند
|
||
cd apps/api
|
||
python3 -m venv .venv && .venv/bin/python -m pip install -e '.[dev]'
|
||
cp ../../.env.example ../../.env # مقادیر mock کافی است
|
||
.venv/bin/python -m alembic upgrade head # schema
|
||
.venv/bin/python -m app.seed # دادهٔ نمونه (provider/مدل/rate card/قالب)
|
||
|
||
# ۳. فرانتاند
|
||
cd ../.. && corepack pnpm install
|
||
corepack pnpm dev # web روی ۳۰۰۰، api روی ۸۰۰۰
|
||
```
|
||
|
||
**دستورهای کیفیت:**
|
||
|
||
```bash
|
||
pnpm lint # ESLint (web) + ruff (api)
|
||
pnpm typecheck # tsc --noEmit (web) + mypy app (api)
|
||
pnpm test # vitest (web) + pytest (api)
|
||
pnpm build # next build
|
||
```
|
||
|
||
---
|
||
|
||
## ۱۱. تصمیمهای معماری کلیدی
|
||
|
||
| تصمیم | دلیل |
|
||
| --- | --- |
|
||
| Modular monolith، نه microservice از روز اول | مرزهای domain از ابتدا روشناند؛ شکستن به سرویس بعداً ارزان است، اما پیچیدگی توزیع از روز اول گران است |
|
||
| یک adapter mock برای هر پورت | کل محصول بدون credential قابل اجرا و تست است؛ §۲۴ TODO را برآورده میکند |
|
||
| ۴۰۴ بهجای ۴۰۳ برای نبود عضویت | `403` وجود منبع را تأیید میکند و اجازهٔ شمارش میدهد |
|
||
| قواعد امنیتی در domain، نه infrastructure | بدون DB/شبکه قابل تستاند و از لایهٔ I/O جدا میمانند |
|
||
| clamp منابع دولایه (service + adapter) | defense in depth؛ خطای پیکربندی policy سقف مطلق را نمیشکند |
|
||
| usage ledger بهعنوان منبع حقیقت | محاسبهٔ صورتحساب از provider پرداخت جدا میشود |
|
||
| انتخاب adapter فقط با env، fail-closed | پیکربندی اشتباه باید بلند شکست بخورد، نه بیصدا به mock برگردد |
|
||
| `services/` فعلاً اسکلت است | `ai-runtime` و `billing-worker` فعلاً داخل فرآیند API اجرا میشوند؛ مرز پورتها آمادهٔ استخراج است |
|