# راهنمای اتصال به API میزگرد

نشانی پایه: **`https://mizgerd.com/api/v1`**

فایل‌های همراه این راهنما:
- مشخصات OpenAPI 3.1: [`docs/openapi.yaml`](openapi.yaml) و نسخه زنده `https://mizgerd.com/api/openapi.json`
- صفحه راهنما در خود سامانه: `https://mizgerd.com/developers`
- استقرار: [`docs/DEPLOY.md`](DEPLOY.md)

نشانی‌های `/api/v1` از همان توابع و همان قواعد دسترسی برنامه میزگرد استفاده می‌کنند. چیزی که در برنامه ممنوع است، از API هم ممکن نیست.

## ۱. معماری امن اتصال

```
مرورگر کاربر  ->  سایت یا سرور شما  ->  https://mizgerd.com/api/v1
                   (کلید API اینجا می‌ماند)
```

- کلید API فقط روی **سرور شما** نگهداری می‌شود. مرورگر، افزونه سمت کاربر، برنامه موبایل و مخزن عمومی جای کلید نیستند.
- سایت شما درخواست کاربر را می‌گیرد، خودش بررسی می‌کند که آن کاربر اجازه این کار را دارد، و بعد با کلید خودش از میزگرد کار می‌خواهد.
- کلید با اختیار **مدیری که آن را ساخته** کار می‌کند، فقط در حد دسترسی‌هایی که هنگام ساخت انتخاب شده. هر دسترسی جدا حساب می‌شود و `write` شامل `read` نیست.
- اگر مدیر سازنده غیرفعال شود یا نقش مدیر را از دست بدهد، کلیدهایش همان لحظه از کار می‌افتند.

## ۲. شروع سریع

1. مدیر سامانه در `https://mizgerd.com/admin/api-keys` کلیدی با کمترین دسترسی لازم می‌سازد. مقدار کلید فقط یک بار نمایش داده می‌شود.
2. کلید را در متغیر محیطی سرور بگذارید (نه در کد): `MIZGERD_API_KEY=mzk_your_key`.
3. با یک درخواست ساده بررسی کنید:

```bash
curl https://mizgerd.com/api/v1/rooms?limit=1 \
  -H "Authorization: Bearer $MIZGERD_API_KEY"
```

پاسخ موفق `200` است و بدنه `{ "data": [...], "page": { "next": null, "limit": 1 }, "requestId": "req_..." }` دارد. پاسخ `401` یعنی کلید نادرست، باطل‌شده یا سازنده‌اش غیرفعال است.

## ۳. احراز هویت

هر درخواست باید این هدر را داشته باشد:

```
Authorization: Bearer mzk_your_key
```

- قالب کلید: `mzk_<۸ نویسه هگز>_<رمز>`. بخش اول (پیشوند) در فهرست کلیدها دیده می‌شود، رمز نه.
- در پایگاه داده فقط چکیده SHA-256 رمز نگهداری می‌شود.
- کوکی و توکن CSRF روی `/api/v1` نه لازم است و نه پذیرفته می‌شود.
- باطل کردن کلید در همان صفحه `/admin/api-keys` فوری اثر می‌کند.

## ۴. امنیت

- کلید را در جاوااسکریپت مرورگر، منبع قالب وردپرس، فایل‌های عمومی، برنامه موبایل یا مخزن عمومی نگذارید. هر چه به دست کاربر برسد لو رفته است.
- برای هر سرویس یک کلید جدا بسازید و فقط دسترسی‌های لازم را بدهید (جدول بخش ۵). سایتی که فقط اتاق می‌سازد به `users:write` یا `recordings:write` نیازی ندارد.
- کلید به‌اندازه مدیر سازنده‌اش قدرت دارد. بهتر است سازنده یک حساب مدیر اختصاصی باشد، نه حساب شخصی.
- کلید را دوره‌ای عوض کنید: کلید تازه بسازید، سرویس را روی آن ببرید، کلید قدیمی را باطل کنید.
- کلید را در لاگ، پیام خطا و ابزار پایش چاپ نکنید. هدر `Authorization` را از لاگ پراکسی حذف کنید.
- `API_CORS_ORIGINS` را فقط وقتی تنظیم کنید که واقعا یک صفحه مرورگر باید مستقیم با API حرف بزند. پیش‌فرض خالی است و یعنی هیچ مبدا مرورگری مجاز نیست. اگر مقدار می‌دهید، مبداها را دقیق و جدا با ویرگول بنویسید (مثلا `https://admin.example.com`). CORS جای نگه داشتن کلید در سرور را نمی‌گیرد.
- هر کلید تنها در دقیقه ۳۰۰ درخواست دارد؛ بیشتر از آن `429` می‌گیرد.

## ۵. دسترسی‌ها (scopes)

هر عملیات دقیقا یک دسترسی لازم دارد. نبود دسترسی `403` با کد `insufficient_scope` است. بدون جایگزین و بدون سلسله‌مراتب.

<!-- scopes:start -->
| Scope | امکان |
|---|---|
| `rooms:read` | فهرست و جزئیات اتاق‌ها، وضعیت زنده اتاق (صف انتظار، حاضران، چیدمان، ضبط جاری) |
| `rooms:write` | ساخت اتاق، تغییر قفل، اتاق انتظار، گفتگو و ظرفیت، تعیین چیدمان و کیفیت دوربین‌ها، دریافت و تعویض و لغو لینک دعوت |
| `participants:read` | فهرست و خواندن شرکت‌کنندگان یک اتاق، با مجوزهای موثر هر کدام |
| `participants:write` | پذیرش، رد و حذف مهمان، دادن و گرفتن میکروفن، دوربین و اجازه نقاشی |
| `messages:read` | خواندن گفتگوی عمومی و پیام‌های خصوصی فرستاده‌شده به یک شرکت‌کننده |
| `messages:write` | نوشتن و حذف پیام عمومی، فرستادن و پس گرفتن پیام خصوصی |
| `content:read` | خواندن اطلاعات و فایل PDF، حاشیه‌نویسی‌ها، صفحه‌های تخته و عملیات آن‌ها |
| `content:write` | بارگذاری، تعویض و توقف PDF، تغییر صفحه، حاشیه‌نویسی، مدیریت صفحه‌های تخته و نقاشی روی آن |
| `recordings:read` | فهرست و وضعیت ضبط‌ها و پخش فایل MP4 |
| `recordings:write` | شروع و پایان ضبط |
| `users:read` | فهرست و خواندن حساب‌های مدیر و مدرس |
| `users:write` | ساخت و ویرایش حساب، فعال و غیرفعال کردن، تعیین گذرواژه، خروج اجباری از همه نشست‌ها |
<!-- scopes:end -->

دریافت لینک دعوت (`GET /rooms/{roomId}/invite`) دسترسی `rooms:write` می‌خواهد، چون هر کس لینک را داشته باشد می‌تواند درخواست ورود بدهد.

## ۶. قالب درخواست و پاسخ

**هدرهای درخواست**
- `Authorization: Bearer mzk_your_key` (همیشه)
- `Content-Type: application/json; charset=utf-8` برای بدنه JSON. فایل PDF با `multipart/form-data` فرستاده می‌شود.

**هدرهای پاسخ**
- `X-Request-Id`: شناسه درخواست، مثل `req_f7c8e20c965aa35e84`. هنگام پیگیری مشکل همین را بدهید.
- `Cache-Control: no-store` (فایل‌ها: `private, no-store`).
- `Retry-After` فقط روی `429`.

**پاسخ موفق**

```json
{ "data": { "id": "cmv...", "title": "ریاضی دهم" }, "requestId": "req_f7c8e20c965aa35e84" }
```

ساخت چیز تازه `201` می‌دهد، بقیه `200`. فایل‌ها بدنه خام دارند و از درخواست بازه‌ای (`Range`) پشتیبانی می‌کنند (`206` و `416`).

**فهرست‌ها و صفحه‌بندی**

فهرست‌ها کران‌دارند: `limit` از ۱ تا ۱۰۰ (پیش‌فرض ۵۰ و برای `/users` ۲۵). پاسخ این شکل را دارد:

```json
{ "data": [ ... ], "page": { "next": "cmv...", "limit": 50 }, "requestId": "req_..." }
```

برای صفحه بعد مقدار `page.next` را به‌عنوان `cursor` بفرستید. `next` برابر `null` یعنی صفحه آخر. مقدار `cursor` را معنا نکنید، فقط پس بفرستید. فهرست ضبط‌ها، پیام‌های عمومی و عملیات تخته صفحه‌بندی `cursor` ندارند و تعداد ثابتی برمی‌گردانند (به توضیح هر عملیات در OpenAPI نگاه کنید).

**خطا**

```json
{ "error": { "code": "insufficient_scope", "message": "This endpoint requires the \"rooms:write\" scope." }, "requestId": "req_..." }
```

`code` برای برنامه‌نویسی پایدار است. `message` انگلیسی است و برای انسان نوشته شده؛ روی متنش شرط نگذارید.

| وضعیت | معنا |
|---|---|
| 400 | بدنه یا پارامتر نادرست (`invalid_request`)، یا قاعده دامنه رد شد (مثل `weak_password`، `bad_message`) |
| 401 | `invalid_api_key`: کلید نیست، قالبش درست نیست، ناشناخته یا باطل است، یا سازنده‌اش دیگر مدیر فعال نیست |
| 403 | `insufficient_scope`، یا `forbidden` |
| 404 | نشانی یا منبع نیست (منبع اتاق دیگر هم همین را می‌گیرد) |
| 405 | `method_not_allowed` |
| 409 | تعارض وضعیت، مثل `email_taken`، `last_admin`، `self_change` یا ضبط باز در همان اتاق |
| 413 | بدنه JSON بیش از ۱ مگابایت (`payload_too_large`) یا PDF بیش از ۲۵ مگابایت (`pdf_too_large`) |
| 429 | `rate_limited`: بیش از ۳۰۰ درخواست در دقیقه برای هر کلید. `Retry-After` را رعایت کنید |
| 500 / 503 | خطای سرور، یا سرور رسانه تغییر را تایید نکرد. دوباره تلاش کنید و در تکرار، `requestId` را گزارش دهید |

**متن، تاریخ و فایل**
- همه بدنه‌ها و پاسخ‌های JSON با UTF-8 هستند. متن فارسی را بدون escape خاصی بفرستید؛ `curl --data` با فایل UTF-8 یا `JSON.stringify` همین کار را می‌کند.
- تاریخ‌ها رشته ISO 8601 به وقت UTC هستند، مثل `2026-10-29T14:59:22.556Z`. زمان برگزاری اتاق (`scheduledAt`) را هم همین‌طور بفرستید، با پسوند `Z` (offset مثل `+03:30` پذیرفته نمی‌شود).
- بارگذاری PDF: فیلدهای `file` (حداکثر ۲۵ مگابایت، با بایت‌های ابتدایی `%PDF-`) و `pageCount` (عدد صحیح ۱ تا ۲۰۰۰). سرور تعداد صفحه را خودش نمی‌شمارد و همان عدد شما را می‌پذیرد، پس درست بفرستید. هر اتاق یک PDF دارد؛ بارگذاری تازه فایل قبلی و حاشیه‌نویسی‌هایش را جایگزین می‌کند.

## ۷. دستورهای کار

در همه نمونه‌ها `B` و `H` این‌ها هستند و کلید فقط در `MIZGERD_API_KEY` است:

```bash
B=https://mizgerd.com/api/v1
H="Authorization: Bearer $MIZGERD_API_KEY"
```

### ۷.۱ ساخت کلید API
1. با حساب مدیر وارد `https://mizgerd.com` شوید و از بالای صفحه «کلیدهای API» را باز کنید.
2. نام سرویس را بنویسید و دسترسی‌های لازم را تیک بزنید.
3. «ساخت کلید» را بزنید و مقدار نمایش‌داده‌شده را همان لحظه در مدیریت اسرار سرور خود ذخیره کنید. بعدا فقط پیشوند دیده می‌شود.

### ۷.۲ فهرست و ساخت کاربر (حساب مدیر یا مدرس)
دسترسی: `users:read` و `users:write`. دانش‌آموزان و مهمان‌ها حساب نیستند و در این فهرست نمی‌آیند.

```bash
curl "$B/users?q=sara&status=active&limit=10" -H "$H"

curl -X POST $B/users -H "$H" -H "Content-Type: application/json" \
  -d '{"name":"سارا محمدی","email":"sara@example.com","role":"TEACHER","password":"Strong-pass-123"}'
```

- نقش‌های قابل تعیین: `ADMIN` و `TEACHER`.
- ایمیل کوچک‌سازی و یکتا می‌شود (`409 email_taken`).
- گذرواژه ۱۰ تا ۱۲۸ نویسه با دست کم یک حرف و یک رقم و غیر از ایمیل یا نام (`400 weak_password`).
- ویرایش: `PATCH /users/{userId}` فقط `name`، `email`، `role` را می‌پذیرد. غیرفعال کردن: `PUT /users/{userId}/status` با `{"active":false}` که همان لحظه همه نشست‌هایش را می‌بندد. حذف کامل وجود ندارد.
- کلید نمی‌تواند سازنده خودش را غیرفعال یا تنزل دهد (`409 self_change`) و هیچ تغییری نمی‌تواند آخرین مدیر فعال را بردارد (`409 last_admin`).
- پاسخ‌ها هرگز چکیده گذرواژه یا شناسه نشست ندارند.

### ۷.۳ ساخت اتاق
دسترسی: `rooms:write`.

```bash
curl -X POST $B/rooms -H "$H" -H "Content-Type: application/json" \
  -d '{"title":"ریاضی دهم","capacity":150,"scheduledAt":"2026-11-01T16:30:00Z"}'
```

پاسخ `201` شامل `id`، `title`، `capacity`، `locked`، `waitingRoom`، `chatEnabled`، `layout`، `stage`، `scheduledAt` و `createdAt` است. لینک دعوت در همین پاسخ نیست.

### ۷.۴ لینک دعوت
دسترسی: `rooms:write`.

```bash
curl $B/rooms/ROOM_ID/invite -H "$H"
# { "data": { "url": "https://mizgerd.com/join/...", "revoked": false }, ... }

curl -X POST $B/rooms/ROOM_ID/invite/rotate -H "$H"   # لینک تازه؛ لینک قبلی کار نمی‌کند
curl -X POST $B/rooms/ROOM_ID/invite/revoke -H "$H"   # لغو لینک
```

### ۷.۵ شرکت‌کنندگان و مجوزها
دسترسی: `participants:read` برای خواندن و `participants:write` برای مدیریت. مهمان با لینک دعوت وارد می‌شود و در اتاق انتظار می‌ماند.

```bash
curl "$B/rooms/ROOM_ID/participants?limit=50" -H "$H"
curl $B/rooms/ROOM_ID/participants/PARTICIPANT_ID -H "$H"

curl -X POST $B/rooms/ROOM_ID/participants/PARTICIPANT_ID/admit -H "$H"
curl -X PATCH $B/rooms/ROOM_ID/participants/PARTICIPANT_ID/permissions -H "$H" -H "Content-Type: application/json"   -d '{"microphone":true,"camera":true,"draw":false}'
curl -X POST $B/rooms/ROOM_ID/participants/PARTICIPANT_ID/remove -H "$H"
```

هر ردیف شرکت‌کننده این‌ها را دارد: `id`، `name`، `status` (`WAITING`، `ADMITTED`، `DENIED`، `REMOVED`)، `staff`، `micGranted`، `cameraGranted`، `drawGranted`، `handRaisedAt`، `joinedAt` و شیء **`permissions`**. پاسخ `admit` و `PATCH .../permissions` هم همین شکل را برمی‌گردانند.

**مجوزهای موثر (`permissions`)** را سرور از وضعیت ذخیره‌شده حساب می‌کند و همان‌ها در توکن ورود، در رابط برنامه و در API به کار می‌روند. همه مقدارها `true` یا `false` هستند:

<!-- permissions:start -->
| کلید | معنا | چطور تعیین می‌شود |
|---|---|---|
| `subscribe` | دیدن و شنیدن دیگران | حاضر پذیرفته‌شده |
| `microphone` | روشن کردن میکروفن | مهمان: مجوز میکروفن. مدرس: همیشه |
| `camera` | روشن کردن دوربین | مهمان: مجوز دوربین. مدرس: همیشه |
| `screenShare` | اشتراک صفحه | فقط مدرس یا مدیری که اتاق را مدیریت می‌کند. مهمان هرگز |
| `draw` | نقاشی روی تخته و PDF | مهمان: مجوز نقاشی. مدرس: همیشه |
| `publicChat` | نوشتن در گفتگوی عمومی | پذیرفته‌شده، گفتگوی اتاق باز است و مدرس ارسال پیام را برایش نبسته است |
| `privateMessageReceive` | دریافت پیام خصوصی یک‌طرفه | مهمان پذیرفته‌شده؛ مدرس نه |
<!-- permissions:end -->

شرکت‌کننده‌ای که منتظر، ردشده یا حذف‌شده است همه کلیدها را `false` دارد، حتی اگر مجوزی قبلا داشته. فقط `microphone`، `camera` و `draw` قابل تعیین‌اند. بقیه مشتق‌اند: فرستادن `screenShare`، `subscribe`، `publicChat` یا `privateMessageReceive` در `PATCH` خطای `400 invalid_request` می‌دهد و چیزی تغییر نمی‌کند. مدرس را نمی‌توان با این مسیر محدود کرد (`400 staff_always_publish`). بدنه باید دست‌کم یک کلید داشته باشد و مقدارها `true` یا `false` واقعی باشند.

**لغو فوری.** وقتی میکروفن یا دوربین برداشته می‌شود، سرور همان لحظه مجوز را روی شرکت‌کننده‌ای که وصل است عوض می‌کند و سرور رسانه تراک در حال پخش او را قطع می‌کند. اگر سرور رسانه در دسترس نباشد، پاسخ `503 live_enforcement_unconfirmed` است؛ لغو در پایگاه داده ثبت می‌ماند (توکن‌های بعدی بدون آن مجوز صادر می‌شوند) و باید دوباره بفرستید. اگر **دادن** مجوز تایید نشود، مجوز برگردانده می‌شود تا پایگاه داده چیزی ادعا نکند که اجرا نشده. توکن ورود ۱۲۰ ثانیه عمر دارد و فقط مجوزهای همان لحظه را دارد.

مسیرهای قدیمی `PUT .../mic` و `PUT .../draw` همچنان کار می‌کنند و از همان سرویس عبور می‌کنند. `deny` مهمان منتظر را رد می‌کند و `remove` قطعی است و او را از سرور تصویر هم بیرون می‌اندازد.

### ۷.۶ پیام خصوصی
دسترسی: `messages:write`. پیام یک‌طرفه است: از مدیر یا مدرس به یک مهمان پذیرفته‌شده. گیرنده نمی‌تواند پاسخ بدهد و فقط خودش و مدیر اتاق پیام را می‌بینند.

```bash
curl -X POST $B/rooms/ROOM_ID/participants/PARTICIPANT_ID/private-messages \
  -H "$H" -H "Content-Type: application/json" -d '{"body":"لطفا میکروفن را ببندید"}'

curl $B/rooms/ROOM_ID/participants/PARTICIPANT_ID/private-messages -H "$H"        # messages:read
curl -X DELETE $B/rooms/ROOM_ID/private-messages/MESSAGE_ID -H "$H"              # پس گرفتن
```

حداکثر ۱۰۰۰ نویسه متن ساده. بیش از ۲۰ پیام در دقیقه برای هر اتاق و فرستنده `dm_rate_limited` می‌گیرد. گیرنده باید `ADMITTED` باشد (`409 not_admitted`).

### ۷.۷ چیدمان
دسترسی: `rooms:write`. مقدار: `FOCUS` (پیش‌فرض)، `SPLIT` یا `GALLERY`. همه حاضران و ضبط جاری از همین مقدار پیروی می‌کنند. اگر منبع تصویر کافی نباشد، نمایش خودکار به `FOCUS` برمی‌گردد.

```bash
curl -X PUT $B/rooms/ROOM_ID/layout -H "$H" -H "Content-Type: application/json" -d '{"layout":"SPLIT"}'
```

### ۷.۷.۱ کیفیت دوربین‌ها
دسترسی: `rooms:write`. سیاست اتاق سقف کیفیتی است که هر دوربین منتشرشده در اتاق دارد؛ دریافت تصویر را اجبار نمی‌کند و هر نفر کلیدهای کم‌مصرف و «بدون تصویر دوربین» خودش را نگه می‌دارد.

```bash
curl -X PATCH $B/rooms/ROOM_ID -H "$H" -H "Content-Type: application/json" -d '{"cameraQuality":"HIGH"}'
```

<!-- quality:start -->
| مقدار | برچسب | ثبت تصویر | سقف بیت‌ریت لایه اصلی |
|---|---|---|---|
| `LOW` | کم مصرف | 640x360، ۱۵ فریم در ثانیه | حدود 250 kbps (یک لایه) |
| `AUTO` (پیش‌فرض) | خودکار | 1280x720، ۲۴ فریم در ثانیه | حدود 800 kbps، لایه‌های کوچک‌تر 180p و 360p |
| `HIGH` | باکیفیت | 1280x720، ۳۰ فریم در ثانیه | حدود 1200 kbps، لایه‌های کوچک‌تر 180p و 360p |
<!-- quality:end -->

مقدار در `cameraQuality` پاسخ اتاق هست. با تغییر آن، همه حاضران از طریق همگام‌سازی وضعیت خبردار می‌شوند و هر کس دوربینش روشن است، آن را با قیدهای تازه دوباره راه می‌اندازد. رزولوشن 1080p وجود ندارد. **ضبط همیشه 720p است**؛ کیفیت منبع دوربین می‌تواند پایین‌تر باشد (مثلا `LOW`)، و ضبط از آن بیشتر نمی‌شود.

### ۷.۸ فایل و تخته
دسترسی: `content:read` و `content:write`.

```bash
# بارگذاری PDF (جایگزین فایل قبلی می‌شود و از صفحه ۱ شروع می‌کند)
curl -X POST $B/rooms/ROOM_ID/presentation -H "$H" -F file=@slides.pdf -F pageCount=12

curl -X PUT $B/rooms/ROOM_ID/presentation/page -H "$H" -H "Content-Type: application/json" -d '{"page":2}'
curl $B/rooms/ROOM_ID/presentation -H "$H"          # اطلاعات فایل؛ بدون مسیر ذخیره
curl -X DELETE $B/rooms/ROOM_ID/presentation -H "$H"   # توقف ارائه؛ فایل و حاشیه‌نویسی‌ها پاک می‌شوند
```

حاشیه‌نویسی روی PDF و تخته با `.../presentation/annotations` و `.../board/pages/{pageId}/ops` انجام می‌شود. شکل عملیات (`op`) در OpenAPI شرح داده شده؛ مختصات بین ۰ و ۱ هستند و سرور شناسه و ترتیب را خودش تعیین می‌کند. خواندن با `since` فقط تغییرات بعد از آن شماره را می‌دهد (حداکثر ۵۰۰ مورد).

### ۷.۹ ضبط
دسترسی: `recordings:write` برای شروع و پایان، `recordings:read` برای وضعیت و فایل. در هر اتاق فقط یک ضبط باز وجود دارد و ضبط را سرور رسانه (Egress) انجام می‌دهد.

```bash
curl -X POST $B/rooms/ROOM_ID/recordings -H "$H"          # 201، وضعیت STARTING
curl $B/rooms/ROOM_ID/recordings/RECORDING_ID -H "$H"     # وضعیت و playbackPath
curl -X POST $B/rooms/ROOM_ID/recordings/stop -H "$H"
curl -o class.mp4 $B/rooms/ROOM_ID/recordings/RECORDING_ID/file -H "$H"   # فقط وقتی READY است
```

وضعیت‌ها: `STARTING`، `ACTIVE`، `STOPPING`، `PROCESSING`، `READY`، `FAILED`. وضعیت فقط از خود سرور رسانه می‌آید؛ بعد از `stop` تا `READY` شدن چند ثانیه صبر کنید و وضعیت را بخوانید. پاسخ دارای `id`، `status`، `name`، `createdAt`، `startedAt`، `endedAt`، `size`، `durationMs` و `error` است. توقف ضبط اگر به سرور رسانه نرسد `503` می‌دهد و ضبط همان‌طور که بود می‌ماند.

## ۸. نمونه کد سمت سرور

همه نمونه‌ها کلید را از محیط می‌خوانند. `mzk_your_key` فقط جانگهدار است. این بخش از `src/server/api/examples.ts` ساخته می‌شود و همان متن صفحه `/developers` است.

<!-- examples:start -->
### curl

```bash
export MIZGERD_API_KEY="mzk_your_key"
curl -sS https://mizgerd.com/api/v1/rooms \
  -H "Authorization: Bearer $MIZGERD_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"title":"ریاضی دهم","capacity":150}'
```

### Node.js (۱۸ به بالا، fetch داخلی)

```js
const BASE = "https://mizgerd.com/api/v1";
const KEY = process.env.MIZGERD_API_KEY; // مثلا mzk_your_key؛ فقط در سرور

async function mizgerd(method, path, body) {
  const res = await fetch(BASE + path, {
    method,
    headers: { Authorization: `Bearer ${KEY}`, "Content-Type": "application/json" },
    body: body === undefined ? undefined : JSON.stringify(body),
  });
  const json = await res.json();
  if (!res.ok) {
    const err = new Error(`${json.error.code} (${json.requestId})`);
    err.status = res.status;
    throw err;
  }
  return json.data;
}

const room = await mizgerd("POST", "/rooms", { title: "ریاضی دهم", capacity: 150 });
const invite = await mizgerd("GET", `/rooms/${room.id}/invite`);
console.log(room.id, invite.url);
```

### PHP (cURL)

```php
<?php
function mizgerd(string $method, string $path, ?array $body = null): array {
    $ch = curl_init("https://mizgerd.com/api/v1" . $path);
    curl_setopt_array($ch, [
        CURLOPT_CUSTOMREQUEST => $method,
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_TIMEOUT => 20,
        CURLOPT_HTTPHEADER => [
            "Authorization: Bearer " . getenv("MIZGERD_API_KEY"), // مثلا mzk_your_key
            "Content-Type: application/json",
        ],
    ]);
    if ($body !== null) {
        curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($body, JSON_UNESCAPED_UNICODE));
    }
    $raw = curl_exec($ch);
    $status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
    curl_close($ch);
    $json = json_decode((string) $raw, true) ?? [];
    if ($status >= 400) {
        throw new RuntimeException(($json["error"]["code"] ?? "error") . " " . ($json["requestId"] ?? ""));
    }
    return $json["data"] ?? [];
}

$room = mizgerd("POST", "/rooms", ["title" => "ریاضی دهم", "capacity" => 150]);
$invite = mizgerd("GET", "/rooms/" . $room["id"] . "/invite");
echo $invite["url"];
```

### وردپرس: تعریف کلید در wp-config.php

کلید را اینجا بگذارید، نه در پایگاه داده قالب و نه در جاوااسکریپت.

```php
<?php
define('MIZGERD_API_KEY', 'mzk_your_key');
```

### وردپرس: افزونه سمت سرور (mizgerd-rooms.php)

این کد فقط روی سرور وردپرس اجرا می‌شود و مرورگر کلید را نمی‌بیند. دسترسی کاربر و nonce را خود وردپرس بررسی می‌کند.

```php
<?php
/* Plugin Name: Mizgerd Rooms */

function mizgerd_request(string $method, string $path, ?array $body = null) {
    $args = [
        'method'  => $method,
        'timeout' => 20,
        'headers' => [
            'Authorization' => 'Bearer ' . MIZGERD_API_KEY,
            'Content-Type'  => 'application/json; charset=utf-8',
        ],
    ];
    if ($body !== null) {
        $args['body'] = wp_json_encode($body, JSON_UNESCAPED_UNICODE);
    }
    $res = wp_remote_request('https://mizgerd.com/api/v1' . $path, $args);
    if (is_wp_error($res)) {
        return $res;
    }
    $json = json_decode(wp_remote_retrieve_body($res), true);
    if (wp_remote_retrieve_response_code($res) >= 400) {
        return new WP_Error($json['error']['code'] ?? 'mizgerd_error', $json['requestId'] ?? '');
    }
    return $json['data'] ?? [];
}

add_action('admin_post_mizgerd_create_room', function () {
    if (!current_user_can('manage_options')) {
        wp_die('forbidden', '', ['response' => 403]);
    }
    check_admin_referer('mizgerd_create_room');
    $room = mizgerd_request('POST', '/rooms', ['title' => sanitize_text_field($_POST['title'] ?? ''), 'capacity' => 150]);
    if (is_wp_error($room)) {
        wp_die(esc_html($room->get_error_message()));
    }
    $invite = mizgerd_request('GET', '/rooms/' . rawurlencode($room['id']) . '/invite');
    if (is_wp_error($invite)) {
        wp_die(esc_html($invite->get_error_message()));
    }
    wp_safe_redirect(add_query_arg('invite', rawurlencode($invite['url']), admin_url('admin.php?page=mizgerd')));
    exit;
});
```
<!-- examples:end -->

## ۹. فهرست نقاط پایانی

این جدول از همان جدول مسیرهای سرور ساخته می‌شود (`npm run openapi`) و با `docs/openapi.yaml` یکی است. شرح انگلیسی همان متن OpenAPI است. همه مسیرها زیر `https://mizgerd.com` هستند.

<!-- endpoints:start -->
#### اتاق‌ها (Rooms)

| Method | Path | Scope | Summary |
|---|---|---|---|
| GET | `/api/v1/rooms` | `rooms:read` | List rooms (paginated) |
| POST | `/api/v1/rooms` | `rooms:write` | Create a room |
| GET | `/api/v1/rooms/{roomId}` | `rooms:read` | Get a room |
| PATCH | `/api/v1/rooms/{roomId}` | `rooms:write` | Update lock, waiting room, public chat switch, capacity or camera quality (LOW, AUTO, HIGH) |
| PUT | `/api/v1/rooms/{roomId}/layout` | `rooms:write` | Set the global layout (FOCUS, SPLIT or GALLERY); every client and recordings follow it |
| GET | `/api/v1/rooms/{roomId}/state` | `rooms:read` | Room state: waiting list, admitted participants, hand queue, layout, board/presentation summary, current recording |

#### لینک دعوت (Invitations)

| Method | Path | Scope | Summary |
|---|---|---|---|
| GET | `/api/v1/rooms/{roomId}/invite` | `rooms:write` | Get the guest invitation URL (a capability: needs rooms:write) |
| POST | `/api/v1/rooms/{roomId}/invite/rotate` | `rooms:write` | Replace the invitation link (the old one stops working) |
| POST | `/api/v1/rooms/{roomId}/invite/revoke` | `rooms:write` | Revoke the invitation link |

#### شرکت‌کنندگان (Participants)

| Method | Path | Scope | Summary |
|---|---|---|---|
| GET | `/api/v1/rooms/{roomId}/participants` | `participants:read` | List participants (paginated) |
| GET | `/api/v1/rooms/{roomId}/participants/{participantId}` | `participants:read` | Get one participant with their effective permissions |
| POST | `/api/v1/rooms/{roomId}/participants/{participantId}/admit` | `participants:write` | Admit a waiting guest; returns the participant with their effective permissions |
| PATCH | `/api/v1/rooms/{roomId}/participants/{participantId}/permissions` | `participants:write` | Grant or revoke microphone, camera and drawing for an admitted guest in one atomic change; derived permissions (screenShare, subscribe, publicChat, privateMessageReceive) are rejected |
| POST | `/api/v1/rooms/{roomId}/participants/{participantId}/deny` | `participants:write` | Deny a waiting guest |
| POST | `/api/v1/rooms/{roomId}/participants/{participantId}/remove` | `participants:write` | Remove a guest (final) and kick them from the media server |
| PUT | `/api/v1/rooms/{roomId}/participants/{participantId}/mic` | `participants:write` | Grant or revoke the microphone |
| PUT | `/api/v1/rooms/{roomId}/participants/{participantId}/draw` | `participants:write` | Grant or revoke whiteboard / PDF-annotation drawing |

#### گفتگوی عمومی (Messages)

| Method | Path | Scope | Summary |
|---|---|---|---|
| GET | `/api/v1/rooms/{roomId}/messages` | `messages:read` | Public chat (newest 100, or the changes after `since`) |
| POST | `/api/v1/rooms/{roomId}/messages` | `messages:write` | Post to the public chat as the key's user |
| DELETE | `/api/v1/rooms/{roomId}/messages/{messageId}` | `messages:write` | Delete (tombstone) a public message |

#### پیام خصوصی (Private messages)

| Method | Path | Scope | Summary |
|---|---|---|---|
| GET | `/api/v1/rooms/{roomId}/participants/{participantId}/private-messages` | `messages:read` | Private messages sent to one participant |
| POST | `/api/v1/rooms/{roomId}/participants/{participantId}/private-messages` | `messages:write` | Send a one-way private message to one admitted participant (max 1000 characters) |
| DELETE | `/api/v1/rooms/{roomId}/private-messages/{messageId}` | `messages:write` | Retract a private message |

#### فایل PDF (Presentation)

| Method | Path | Scope | Summary |
|---|---|---|---|
| GET | `/api/v1/rooms/{roomId}/presentation` | `content:read` | Current PDF metadata (no storage path) |
| POST | `/api/v1/rooms/{roomId}/presentation` | `content:write` | Upload or replace the PDF (multipart: file, pageCount; max 25 MiB; starts on page 1) |
| PUT | `/api/v1/rooms/{roomId}/presentation/page` | `content:write` | Go to a PDF page |
| DELETE | `/api/v1/rooms/{roomId}/presentation` | `content:write` | Stop presenting (deletes the file and its annotations) |
| GET | `/api/v1/rooms/{roomId}/presentation/file` | `content:read` | Download the PDF (byte ranges supported) |

#### حاشیه‌نویسی روی PDF (Annotations)

| Method | Path | Scope | Summary |
|---|---|---|---|
| GET | `/api/v1/rooms/{roomId}/presentation/annotations` | `content:read` | Annotation operations of one PDF page (delta since a sequence number, max 500) |
| POST | `/api/v1/rooms/{roomId}/presentation/annotations` | `content:write` | Add an annotation on a PDF page |
| POST | `/api/v1/rooms/{roomId}/presentation/annotations/undo` | `content:write` | Undo the newest annotation on a PDF page |
| POST | `/api/v1/rooms/{roomId}/presentation/annotations/clear` | `content:write` | Clear all annotations on a PDF page |

#### تخته (Whiteboard)

| Method | Path | Scope | Summary |
|---|---|---|---|
| GET | `/api/v1/rooms/{roomId}/board` | `content:read` | Whiteboard pages, current page and what is on stage |
| PUT | `/api/v1/rooms/{roomId}/board/visibility` | `content:write` | Show the whiteboard on stage, or return to the PDF |
| POST | `/api/v1/rooms/{roomId}/board/pages` | `content:write` | Add a whiteboard page (max 20) |
| PUT | `/api/v1/rooms/{roomId}/board/current` | `content:write` | Switch the current whiteboard page |
| DELETE | `/api/v1/rooms/{roomId}/board/pages/{pageId}` | `content:write` | Delete a whiteboard page (never the last) |
| GET | `/api/v1/rooms/{roomId}/board/pages/{pageId}/ops` | `content:read` | Operations of one whiteboard page (delta since a sequence number, max 500) |
| POST | `/api/v1/rooms/{roomId}/board/pages/{pageId}/ops` | `content:write` | Draw on a whiteboard page |
| POST | `/api/v1/rooms/{roomId}/board/pages/{pageId}/undo` | `content:write` | Undo the newest drawing on a page |
| POST | `/api/v1/rooms/{roomId}/board/pages/{pageId}/clear` | `content:write` | Clear a whiteboard page |

#### کاربران (حساب‌های مدیر و مدرس) (Users)

| Method | Path | Scope | Summary |
|---|---|---|---|
| GET | `/api/v1/users` | `users:read` | List staff accounts (paginated; search, role and status filters) |
| POST | `/api/v1/users` | `users:write` | Create a staff account (role ADMIN or TEACHER; password policy applies) |
| GET | `/api/v1/users/{userId}` | `users:read` | Get a staff account |
| PATCH | `/api/v1/users/{userId}` | `users:write` | Edit name, email or role (an admin cannot change their own role; the last active admin cannot be demoted) |
| PUT | `/api/v1/users/{userId}/status` | `users:write` | Enable or disable an account; disabling signs it out at once and keeps its rooms and history |
| PUT | `/api/v1/users/{userId}/password` | `users:write` | Set a new password and sign the account out everywhere |
| DELETE | `/api/v1/users/{userId}/sessions` | `users:write` | Revoke every session of an account (force sign-out) |

#### ضبط (Recordings)

| Method | Path | Scope | Summary |
|---|---|---|---|
| GET | `/api/v1/rooms/{roomId}/recordings` | `recordings:read` | List recordings (newest 50) with status |
| POST | `/api/v1/rooms/{roomId}/recordings` | `recordings:write` | Start recording (one open recording per room) |
| POST | `/api/v1/rooms/{roomId}/recordings/stop` | `recordings:write` | Stop the open recording |
| GET | `/api/v1/rooms/{roomId}/recordings/{recordingId}` | `recordings:read` | Recording status and playback metadata |
| GET | `/api/v1/rooms/{roomId}/recordings/{recordingId}/file` | `recordings:read` | Stream the MP4 (READY only; byte ranges supported) |
<!-- endpoints:end -->

## ۱۰. چک‌لیست استقرار برای mizgerd.com

جزئیات در [`docs/DEPLOY.md`](DEPLOY.md) است. برای اتصال API این‌ها را بررسی کنید:

- [ ] TLS معتبر روی `https://mizgerd.com` و آدرس رسانه `wss://...`
- [ ] `NEXT_PUBLIC_APP_URL=https://mizgerd.com` (لینک‌های دعوت و کوکی `Secure` از آن ساخته می‌شوند)
- [ ] `ALLOWED_ORIGINS=https://mizgerd.com` (برای رابط مرورگری خود برنامه، نه برای `/api/v1`)
- [ ] `API_CORS_ORIGINS` خالی است، مگر مبدا مرورگری مشخصی واقعا لازم باشد
- [ ] پراکسی `Host` و `X-Forwarded-Proto` را پاس می‌دهد و بدنه تا ۲۶ مگابایت را می‌پذیرد
- [ ] هدر `Authorization` و بدنه درخواست‌ها در لاگ پراکسی و برنامه ثبت نمی‌شود
- [ ] بررسی سلامت: `curl -I https://mizgerd.com/` پاسخ `200` بدهد و `curl https://mizgerd.com/api/openapi.json` مشخصات را برگرداند
- [ ] یک کلید آزمایشی بسازید، `GET /api/v1/rooms?limit=1` را بزنید، سپس کلید را باطل کنید و `401` را ببینید

## ۱۱. توسعه محلی

فقط برای کار روی خود میزگرد. نشانی پایه `http://localhost:3100/api/v1` است و فهرست کلیدها و سرورها همان است که در `.env` تنظیم شده. جایگزین‌کردن دامنه در نمونه‌های بالا کافی است:

```bash
curl http://localhost:3100/api/v1/rooms?limit=1 -H "Authorization: Bearer mzk_your_key"
```

## ۱۲. هنوز نیست

OAuth و توکن به‌ازای هر کاربر، وب‌هوک عمومی خروجی، SDK تولیدشده، انقضای کلید. سقف ۳۰۰ درخواست در دقیقه در حافظه هر پردازش شمرده می‌شود؛ اگر چند نمونه از برنامه اجرا شود، سقف موثر چند برابر می‌شود.
