hamravesh-mcp 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,264 @@
1
+ # Hamravesh — بدنه‌ی درخواست write ها
2
+
3
+ > استخراج‌شده از کد پنل کنسول هم‌روش (۱۴۰۵/۰۳/۲۳ — 2026-06-13).
4
+ > ستون «Body» شکل بدنه‌ی JSON است که فرانت می‌فرستد. متغیرهای تک‌حرفی (مثل `t`,`i`,`e`) یعنی
5
+ > یک آبجکت/مقدار که از ورودی کاربر می‌آید. `{paas}`=`darkube`.
6
+
7
+ > 🔐 احراز هویت: `Authorization: Api-Key <key>` + `X-Organization: <org>` (یا `Bearer <jwt>`).
8
+
9
+ ---
10
+
11
+ ## ⭐ ساخت اپ داکر-ایمیج — بدنه‌ی تأییدشده با تست زنده (2026-06-13)
12
+
13
+ این بدنه با ضبط از پنل + اجرای واقعی (CREATE→201، RESTART→200، DELETE→204) تأیید شده است:
14
+
15
+ ```
16
+ POST /api/v1/darkube/apps/
17
+ {
18
+ "image_repo": "traefik/whoami",
19
+ "image_tag": "latest",
20
+ "builder": "dockerfile",
21
+ "creation_method": "docker_image",
22
+ "name": "<نام یکتا، بعداً غیرقابل‌تغییر>",
23
+ "svc": {"type":"ClusterIP","ports":{"main":{"protocol":"TCP","servicePort":80,"containerPort":80}}},
24
+ "command": "",
25
+ "args": "",
26
+ "readiness_probe_path": "",
27
+ "custom_config": {},
28
+ "plan": "<uuid پلن منابع>",
29
+ "replicas": 1,
30
+ "backup_config": null,
31
+ "namespace": <id عددی namespace>,
32
+ "deploy_context": null,
33
+ "ssl_challenge_type": "dns01",
34
+ "organization": <id عددی سازمان>
35
+ }
36
+ → 201 { "id": "<uuid اپ>", ... }
37
+ ```
38
+
39
+ - مقادیر **wgcup**: `organization=24772`، `namespace=165693`، پلن کوچک `plan=73904144-6592-40ce-a96f-3789f611b5e4` (۵۰۰MB/0.25core).
40
+ - یک pre-step هم هست: `POST /api/v1/darkube/apps/schedule_info/` (پنل قبل از create صدا می‌زند؛ برای create لازم نیست).
41
+ - **چرخه‌ی عمر تأییدشده:** `POST .../apps/{id}/restart/` با `{}` → 200 ؛ `DELETE .../apps/{id}/` (بدون بدنه) → 204.
42
+ - این دقیقاً همان کاری است که ابزار `hamravesh_create_app` در MCP انجام می‌دهد.
43
+
44
+ ## Auth & API Keys
45
+
46
+ | Method | Path | Body |
47
+ |---|---|---|
48
+ | POST | `/api/v1/2fa/disable/` | `e` |
49
+ | POST | `/api/v1/2fa/enable/` | `e` |
50
+ | POST | `/api/v1/account/cluster/cluster_ingress_host_name/` | `e` |
51
+ | POST | `/api/v1/apikeys/` | `e` |
52
+ | DELETE | `/api/v1/apikeys/{id}/` | `e` |
53
+ | POST | `/api/v1/oauth/auth/` | `t` |
54
+ | POST | `/api/v1/token/` | `a` |
55
+ | POST | `/api/v1/token/refresh/` | `{refresh:a}` |
56
+ | POST | `/api/v1/users/change_password/` | `e` |
57
+ | POST | `/api/v1/users/forget_password/` | `e` |
58
+ | POST | `/api/v1/users/logout` | `{token:a,organization_id:t}` |
59
+ | PUT | `/api/v1/users/profile` | `e` |
60
+ | POST | `/api/v1/users/reset_password/` | `e` |
61
+ | PUT | `/api/v2/users/profile` | `e` |
62
+ | POST | `/api/v2/users/register_user/` | `e` |
63
+ | POST | `/api/v2/users/resend_verification_email/` | `e` |
64
+ | POST | `/api/v2/users/send_verification_sms/` | `e` |
65
+ | POST | `/api/v2/users/update_email/` | `e` |
66
+ | POST | `/api/v2/users/verify_email/` | `e` |
67
+ | POST | `/api/v2/users/verify_mobile/` | `e` |
68
+ | POST | `/api/v2/users/verify_mobile_national_id/` | `e` |
69
+
70
+ ## PaaS / Darkube apps
71
+
72
+ | Method | Path | Body |
73
+ |---|---|---|
74
+ | POST | `/api/v1/darkube/apps/{id}/backup/download_information/` | `{backup_id:t}` |
75
+ | POST | `/api/v1/darkube/apps/{id}/backup/trigger_backup/` | `{backup_id:t}` |
76
+ | POST | `/api/v1/darkube/apps/{id}/backup/trigger_restore/` | `{snapshot_id:t}` |
77
+ | POST | `/api/v1/darkube/apps/{id}/compatible_plan/` | `{backupConfig:t}` |
78
+ | PUT | `/api/v1/darkube/database/user/{id}` | `{username:t,password:r}` |
79
+ | POST | `/api/v1/darkube/postgres_standby/check` | `{src_host:s,src_username:r,src_password:a,src_port:t,dst_tag:o}` |
80
+ | PUT | `/api/v1/darkube/promote-to-master/{id}` | `—` |
81
+ | POST | `/api/v1/darkube/pvcs/defrag/` | `e` |
82
+ | POST | `/api/v1/{paas}/apps/` | `a` |
83
+ | POST | `/api/v1/{paas}/apps/check_dns_records/` | `{hosts:a}` |
84
+ | POST | `/api/v1/{paas}/apps/check_subdomain/` | `{subdomain:a}` |
85
+ | POST | `/api/v1/{paas}/apps/docker_compose/` | `e` |
86
+ | POST | `/api/v1/{paas}/apps/schedule_info/` | `e` |
87
+ | DELETE | `/api/v1/{paas}/apps/{id}/` | `{git_repo_url:i}` |
88
+ | PATCH | `/api/v1/{paas}/apps/{id}/` | `{fields:["is_enabled"],is_enabled:i}` |
89
+ | PUT | `/api/v1/{paas}/apps/{id}/` | `i` |
90
+ | POST | `/api/v1/{paas}/apps/{id}/app_files/` | `a` |
91
+ | POST | `/api/v1/{paas}/apps/{id}/check_repo/` | `{git_repo_url:i}` |
92
+ | POST | `/api/v1/{paas}/apps/{id}/migrate/` | `i` |
93
+ | DELETE | `/api/v1/{paas}/apps/{id}/plugins/` | `—` |
94
+ | POST | `/api/v1/{paas}/apps/{id}/plugins/` | `{plugin_type:i}` |
95
+ | POST | `/api/v1/{paas}/apps/{id}/read_vault_secret/` | `{path:t}` |
96
+ | POST | `/api/v1/{paas}/apps/{id}/read_vault_secret_env/` | `{name:t}` |
97
+ | POST | `/api/v1/{paas}/apps/{id}/reinstall/` | `{}` |
98
+ | POST | `/api/v1/{paas}/apps/{id}/restart/` | `{}` |
99
+ | POST | `/api/v1/{paas}/apps/{id}/ssl_certificate_records/` | `{external_hosts:i}` |
100
+ | POST | `/api/v1/{paas}/apps/{id}/suggestions/` | `{}` |
101
+ | POST | `/api/v1/{paas}/apps/{id}/upgrade_version/` | `t` |
102
+ | POST | `/api/v1/{paas}/build/{id}/stop/` | `{}` |
103
+ | POST | `/api/v1/{paas}/deploy_contexts/` | `e` |
104
+ | DELETE | `/api/v1/{paas}/deploy_contexts/{id}/` | `a` |
105
+ | PUT | `/api/v1/{paas}/deploy_contexts/{id}/` | `i` |
106
+ | POST | `/api/v1/{paas}/github/branches/` | `{full_name:a}` |
107
+ | POST | `/api/v1/{paas}/gitlab/branches/` | `{id:a}` |
108
+ | POST | `/api/v1/{paas}/license/confluence/` | `a` |
109
+ | POST | `/api/v1/{paas}/license/jira/` | `a` |
110
+ | POST | `/api/v1/{paas}/license/jirasm/` | `a` |
111
+ | POST | `/api/v1/{paas}/license/plugin/` | `a` |
112
+ | POST | `/api/v1/{paas}/logs/loki/` | `{app_id:a,start:i,end:r,stream_name:s}` |
113
+ | POST | `/api/v1/{paas}/namespaces/` | `{name:a,cluster_id:i}` |
114
+ | PUT | `/api/v1/{paas}/permissions/app/{id}/` | `i` |
115
+ | PUT | `/api/v1/{paas}/permissions/user/{id}/` | `i` |
116
+ | POST | `/api/v1/{paas}/plans/calculator/` | `a` |
117
+ | POST | `/api/v1/{paas}/stateless_apps/sync_apps_data/` | `{}` |
118
+ | DELETE | `/api/v1/{paas}/template/` | `s` |
119
+
120
+ ## Databases (DBaaS)
121
+
122
+ | Method | Path | Body |
123
+ |---|---|---|
124
+ | POST | `/dbaas/api/v1/app/backups/{app_id}/download_info/` | `—` |
125
+ | POST | `/dbaas/api/v1/app/backups/{app_id}/trigger_backup/` | `—` |
126
+ | POST | `/dbaas/api/v1/app/database/` | `{...a,cpu:(0,r.yr)(a.cpu),ram:(0,r.ft)(a.ram),disk:(0,r.ft)(a.disk)}` |
127
+ | POST | `/dbaas/api/v1/app/database/compatibility_check/` | `{src_host:e,src_username:l,src_password:o,src_port:i,dst_tag:r,engine:t}` |
128
+ | PUT | `/dbaas/api/v1/app/database/config/{app_id}/` | `{config:a.config}` |
129
+ | POST | `/dbaas/api/v1/app/database/readonly-cluster/` | `{...a,cpu:(0,r.yr)(a.cpu),ram:(0,r.ft)(a.ram),disk:(0,r.ft)(a.disk)}` |
130
+ | DELETE | `/dbaas/api/v1/app/database/{app_id}/` | `—` |
131
+ | PUT | `/dbaas/api/v1/app/database/{app_id}/` | `{plan:a.plan,disk:(0,r.ft)(a.disk),cpu:(0,r.yr)(a.cpu),ram:(0,r.ft)(a.ram),nodes:a.nodes}` |
132
+ | POST | `/dbaas/api/v1/app/database/{app_id}/external_access/` | `—` |
133
+ | POST | `/dbaas/api/v1/app/database/{app_id}/internal_access/` | `—` |
134
+ | POST | `/dbaas/api/v1/app/database/{app_id}/shutdown/` | `—` |
135
+ | POST | `/dbaas/api/v1/app/database/{app_id}/start/` | `—` |
136
+ | POST | `/dbaas/api/v1/app/database/{id}/promote/` | `—` |
137
+ | POST | `/dbaas/api/v1/app/db/database/{app_id}/` | `{db_name:a.database}` |
138
+ | DELETE | `/dbaas/api/v1/app/db/database/{app_id}/{id}/` | `{db_name:a.database}` |
139
+ | PUT | `/dbaas/api/v1/app/permissions/database/{app_id}/` | `a.permissions` |
140
+ | DELETE | `/dbaas/api/v1/app/pghero/{app_id}` | `—` |
141
+ | POST | `/dbaas/api/v1/app/pghero/{app_id}` | `—` |
142
+ | POST | `/dbaas/api/v1/app/pools/database/{app_id}/` | `{pool_name:a.poolName,pool_mode:a.poolMode,db_name:a.database,user_name:a.username,pool_size:a.poolSize}` |
143
+ | DELETE | `/dbaas/api/v1/app/pools/database/{app_id}/{id}/` | `{pool_name:a.poolName,pool_mode:a.poolMode,db_name:a.database,user_name:a.username,pool_size:a.poolSize}` |
144
+ | PUT | `/dbaas/api/v1/app/pools/database/{app_id}/{id}/` | `{pool_mode:a.poolMode,db_name:a.database,user_name:a.username,pool_size:a.poolSize}` |
145
+ | POST | `/dbaas/api/v1/app/query-analysis/{app_id}/activate/` | `i` |
146
+ | POST | `/dbaas/api/v1/app/query-analysis/{id}/metrics/` | `i` |
147
+ | POST | `/dbaas/api/v1/app/query-analysis/{id}/report/` | `i` |
148
+ | POST | `/dbaas/api/v1/app/user/database/{app_id}/` | `{username:a.username}` |
149
+ | DELETE | `/dbaas/api/v1/app/user/database/{app_id}/{id}/` | `—` |
150
+ | PUT | `/dbaas/api/v1/app/user/database/{app_id}/{id}/change_password/` | `{username:a.username}` |
151
+
152
+ ## Container Registry
153
+
154
+ | Method | Path | Body |
155
+ |---|---|---|
156
+ | POST | `/api/v1/registry-gc-strategies/` | `—` |
157
+ | DELETE | `/api/v1/registry-gc-strategies/{id}/` | `—` |
158
+ | PUT | `/api/v1/registry-gc-strategies/{id}/` | `i` |
159
+ | POST | `/api/v1/registry/` | `a` |
160
+ | DELETE | `/api/v1/registry/{id}/` | `{secret_fields:["vault_password"]}` |
161
+ | POST | `/api/v1/registry/{id}/change_password/` | `i` |
162
+ | POST | `/api/v1/registry/{id}/delete_digests/` | `{repository_name:i,digests_sha:r}` |
163
+ | POST | `/api/v1/registry/{id}/delete_repository/` | `{repository_name:i}` |
164
+ | POST | `/api/v1/registry/{id}/delete_tags/` | `{repository_name:i,tags:r,digest_sha:s}` |
165
+ | POST | `/api/v1/registry/{id}/list_repository_digests/` | `{repository_name:i}` |
166
+ | POST | `/api/v1/registry/{id}/read_vault_secret/` | `{secret_fields:["vault_password"]}` |
167
+
168
+ ## Monitoring & Logging
169
+
170
+ | Method | Path | Body |
171
+ |---|---|---|
172
+ | POST | `/api/v1/datasources/` | `{name:s,plan:a,datasource_type:r,zone_id:i}` |
173
+ | DELETE | `/api/v1/datasources/zones/` | `—` |
174
+ | DELETE | `/api/v1/datasources/{id}` | `{username:s,role:a}` |
175
+ | POST | `/api/v1/datasources/{id}/users/` | `{username:s,role:a}` |
176
+ | DELETE | `/api/v1/datasources/{id}/users/{id}/` | `—` |
177
+ | POST | `/api/v1/dazzle/metric-datasources/` | `{}` |
178
+ | DELETE | `/api/v1/dazzle/metric-datasources/{id}/` | `{secret_fields:["vault_password"]}` |
179
+ | POST | `/api/v1/dazzle/metric-datasources/{id}/read_vault_secret/` | `{secret_fields:["vault_password"]}` |
180
+ | POST | `/api/v1/hamartia/datasources/` | `{}` |
181
+ | DELETE | `/api/v1/hamartia/datasources/{id}/` | `i` |
182
+ | POST | `/api/v1/hamartia/datasources/{id}/pipelines/` | `i` |
183
+ | DELETE | `/api/v1/hamartia/datasources/{id}/pipelines/?pipeline_id={id}` | `{secret_fields:["vault_password"]}` |
184
+ | POST | `/api/v1/hamartia/datasources/{id}/read_vault_secret/` | `{secret_fields:["vault_password"]}` |
185
+ | POST | `/api/v1/logstorage/loki/` | `{name:s,controller_loki_service:a,users:l}` |
186
+ | DELETE | `/api/v1/logstorage/loki/{id}/` | `—` |
187
+ | PUT | `/api/v1/logstorage/loki/{id}/` | `{users:l}` |
188
+ | DELETE | `/api/v1/logstorage/loki/{id}/users/{id}/` | `{data_store_name:a,` |
189
+ | POST | `/api/v1/loki/usages/` | `{data_store_name:a,data_store_id:t}` |
190
+
191
+ ## Marketplace / SaaS
192
+
193
+ | Method | Path | Body |
194
+ |---|---|---|
195
+ | POST | `/api/v1/app/license/{id}/` | `o` |
196
+ | POST | `/api/v1/app/saas/` | `e` |
197
+ | POST | `/api/v1/app/saas/set_ssl_challenge_dns01/` | `{app_id:e.serviceId,domain:e.domain}` |
198
+ | DELETE | `/api/v1/app/saas/{id}/` | `e` |
199
+ | PUT | `/api/v1/app/saas/{id}/` | `o` |
200
+ | POST | `/api/v1/app/saas/{id}/backup/download_info/` | `{backup_id:o}` |
201
+ | POST | `/api/v1/app/saas/{id}/backup/trigger_backup/` | `{backup_id:o}` |
202
+ | POST | `/api/v1/app/saas/{id}/backup/trigger_restore/` | `{snapshot_id:o}` |
203
+ | POST | `/api/v1/app/saas/{id}/check_upgrade_compatibility/` | `o` |
204
+ | POST | `/api/v1/app/saas/{id}/disable_filebrowser/` | `—` |
205
+ | POST | `/api/v1/app/saas/{id}/enable_filebrowser/` | `o` |
206
+ | POST | `/api/v1/app/saas/{id}/restart/` | `o` |
207
+ | POST | `/api/v1/app/saas/{id}/set_ssl_challenge_type/` | `{ssl_challenge_type:e.ssl_challenge_type}` |
208
+ | POST | `/api/v1/app/saas/{id}/shown_field/` | `o` |
209
+ | POST | `/api/v1/app/saas/{id}/ssl_certificate_records/` | `{external_hosts:e.external_hosts}` |
210
+ | PUT | `/api/v1/app/saas/{id}/version_upgrade/` | `o` |
211
+
212
+ ## Organizations & Members
213
+
214
+ | Method | Path | Body |
215
+ |---|---|---|
216
+ | POST | `/api/v1/organizations/join_by_token` | `{token:a,organization_id:t}` |
217
+ | POST | `/api/v1/organizations/{id}/accept-transfer` | `null` |
218
+ | POST | `/api/v1/organizations/{id}/cancel-transfer` | `null` |
219
+ | PUT | `/api/v1/organizations/{id}/edit` | `i` |
220
+ | DELETE | `/api/v1/organizations/{id}/enforce-2fa/` | `e` |
221
+ | POST | `/api/v1/organizations/{id}/enforce-2fa/` | `—` |
222
+ | POST | `/api/v1/organizations/{id}/initiate-transfer` | `{target_user_email:i}` |
223
+ | POST | `/api/v1/organizations/{id}/leave` | `null` |
224
+ | PUT | `/api/v1/organizations/{id}/members` | `i` |
225
+ | POST | `/api/v1/organizations/{id}/reject-transfer` | `null` |
226
+
227
+ ## Billing
228
+
229
+ | Method | Path | Body |
230
+ |---|---|---|
231
+ | POST | `/api/v1/billing/billbillak/history/` | `{app_name:t,cluster:i,namespace:a,resource:r,owner_kind:n,start_time:l,end_time:s}` |
232
+ | POST | `/api/v1/billing/invoices/export_report/` | `{start_date:t,end_date:i}` |
233
+ | POST | `/api/v1/billing/invoices/invoice_settings/` | `e` |
234
+
235
+ ## Sentry
236
+
237
+ | Method | Path | Body |
238
+ |---|---|---|
239
+ | POST | `/api/v1/sentry/sentrys/` | `e` |
240
+ | DELETE | `/api/v1/sentry/sentrys/{id}/` | `t` |
241
+ | PATCH | `/api/v1/sentry/sentrys/{id}/` | `t` |
242
+
243
+ ## Notifications & Alerts
244
+
245
+ | Method | Path | Body |
246
+ |---|---|---|
247
+ | POST | `/api/v1/viper/alert_routes/` | `a` |
248
+ | DELETE | `/api/v1/viper/alert_routes/{id}/` | `—` |
249
+ | POST | `/notifications/alerts/api/organization-id-alert-routers/` | `e` |
250
+
251
+ ## Backup
252
+
253
+ | Method | Path | Body |
254
+ |---|---|---|
255
+ | POST | `/api/v1/backups/storage_destinations/{id}/read_vault_secret/` | `{secret_fields:["vault_password"]}` |
256
+
257
+ ## Other
258
+
259
+ | Method | Path | Body |
260
+ |---|---|---|
261
+ | PUT | `/api/v1/{id}/permissions/user/{id}/` | `o` |
262
+ | POST | `/backup/` | `{...s,worker_type:"rclone",sync_mode:"backup"}` |
263
+ | DELETE | `/backup/{id}/` | `{user_email:e.user_email,st` |
264
+ | DELETE | `/backup/{id}/progress` | `{user_email:e.user_email,storages_access:e.storages_access}` |
package/package.json ADDED
@@ -0,0 +1,58 @@
1
+ {
2
+ "name": "hamravesh-mcp",
3
+ "version": "0.1.0",
4
+ "description": "MCP server for the Hamravesh / Darkube console API — manage apps, deploys, databases, registries and billing from any MCP client (Claude, etc.).",
5
+ "type": "module",
6
+ "bin": {
7
+ "hamravesh-mcp": "src/index.js"
8
+ },
9
+ "main": "src/index.js",
10
+ "files": [
11
+ "src",
12
+ "README.md",
13
+ "LICENSE",
14
+ "CHANGELOG.md",
15
+ ".env.example",
16
+ "ENDPOINTS.md",
17
+ "WRITE-ENDPOINTS.md",
18
+ "LIVE-STATUS.md",
19
+ "ENDPOINTS-RAW.txt"
20
+ ],
21
+ "scripts": {
22
+ "start": "node src/index.js",
23
+ "test": "node test/ci.js",
24
+ "smoke": "node test/smoke.js",
25
+ "smoke:write": "node test/write-smoke.js"
26
+ },
27
+ "engines": {
28
+ "node": ">=18"
29
+ },
30
+ "keywords": [
31
+ "mcp",
32
+ "model-context-protocol",
33
+ "modelcontextprotocol",
34
+ "hamravesh",
35
+ "darkube",
36
+ "kubernetes",
37
+ "paas",
38
+ "devops",
39
+ "claude",
40
+ "ai"
41
+ ],
42
+ "license": "MIT",
43
+ "author": "Mohammad Bakhtari <skyborn91@gmail.com>",
44
+ "homepage": "https://github.com/bakhtarimohammad/hamravesh-mcp#readme",
45
+ "repository": {
46
+ "type": "git",
47
+ "url": "git+https://github.com/bakhtarimohammad/hamravesh-mcp.git"
48
+ },
49
+ "bugs": {
50
+ "url": "https://github.com/bakhtarimohammad/hamravesh-mcp/issues"
51
+ },
52
+ "publishConfig": {
53
+ "access": "public"
54
+ },
55
+ "dependencies": {
56
+ "@modelcontextprotocol/sdk": "^1.18.0"
57
+ }
58
+ }
package/src/client.js ADDED
@@ -0,0 +1,181 @@
1
+ // @ts-check
2
+ /**
3
+ * client.js — کلاینت HTTP برای API داخلی کنسول هم‌روش (api.hamravesh.com).
4
+ *
5
+ * دو روش احراز هویت:
6
+ * 1) API Key (توصیه‌شده): هدر Authorization: Api-Key <key>
7
+ * 2) ایمیل/رمز: لاگین JWT + تمدید خودکار توکن
8
+ *
9
+ * این کلاینت از fetch داخلی Node (>=18) استفاده می‌کند؛ هیچ وابستگی شبکه‌ای ندارد.
10
+ */
11
+
12
+ const DEFAULT_BASE = "https://api.hamravesh.com";
13
+
14
+ /** @param {string} token */
15
+ function jwtExp(token) {
16
+ try {
17
+ const payload = token.split(".")[1];
18
+ const json = Buffer.from(payload.replace(/-/g, "+").replace(/_/g, "/"), "base64").toString("utf8");
19
+ return (JSON.parse(json).exp || 0) * 1000; // ms
20
+ } catch {
21
+ return 0;
22
+ }
23
+ }
24
+
25
+ export class HamraveshError extends Error {
26
+ /** @param {number} status @param {any} body */
27
+ constructor(status, body) {
28
+ const msg = typeof body === "string" ? body : JSON.stringify(body);
29
+ super(`HTTP ${status}: ${msg}`);
30
+ this.name = "HamraveshError";
31
+ this.status = status;
32
+ this.body = body;
33
+ }
34
+ }
35
+
36
+ export class HamraveshClient {
37
+ /**
38
+ * @param {object} cfg
39
+ * @param {string} [cfg.apiKey]
40
+ * @param {string} [cfg.email]
41
+ * @param {string} [cfg.password]
42
+ * @param {string} [cfg.otp]
43
+ * @param {string} [cfg.org] سازمان پیش‌فرض (X-Organization)
44
+ * @param {string} [cfg.base] base URL
45
+ */
46
+ constructor(cfg = {}) {
47
+ this.apiKey = cfg.apiKey || "";
48
+ this.email = cfg.email || "";
49
+ this.password = cfg.password || "";
50
+ this.otp = cfg.otp || "";
51
+ this.org = cfg.org || "";
52
+ this.base = (cfg.base || DEFAULT_BASE).replace(/\/$/, "");
53
+ /** @type {{access?:string, refresh?:string}} */
54
+ this.tokens = {};
55
+ if (!this.apiKey && (!this.email || !this.password)) {
56
+ throw new Error(
57
+ "احراز هویت تنظیم نشده: یا HAMRAVESH_API_KEY بده، یا HAMRAVESH_EMAIL + HAMRAVESH_PASSWORD."
58
+ );
59
+ }
60
+ }
61
+
62
+ get authMode() {
63
+ return this.apiKey ? "api-key" : "password";
64
+ }
65
+
66
+ async _login() {
67
+ const r = await fetch(this.base + "/api/v1/token/", {
68
+ method: "POST",
69
+ headers: { "content-type": "application/json", accept: "application/json" },
70
+ body: JSON.stringify({
71
+ email: this.email,
72
+ password: this.password,
73
+ captcha: "",
74
+ client_time: Date.now(),
75
+ ...(this.otp ? { otp: this.otp } : {}),
76
+ }),
77
+ });
78
+ // متن را اول بگیر تا اگر JSON نبود، خطای واقعی گم نشود
79
+ const text = await r.text();
80
+ let body;
81
+ try {
82
+ body = text ? JSON.parse(text) : {};
83
+ } catch {
84
+ body = text;
85
+ }
86
+ if (!r.ok) throw new HamraveshError(r.status, body);
87
+ this.tokens = { access: body.access, refresh: body.refresh };
88
+ }
89
+
90
+ async _refresh() {
91
+ const rt = this.tokens.refresh;
92
+ if (!rt || jwtExp(rt) < Date.now() + 30_000) return this._login();
93
+ const r = await fetch(this.base + "/api/v1/token/refresh/", {
94
+ method: "POST",
95
+ headers: { "content-type": "application/json", accept: "application/json" },
96
+ body: JSON.stringify({ refresh: rt }),
97
+ });
98
+ if (!r.ok) return this._login();
99
+ const body = await r.json().catch(() => ({}));
100
+ this.tokens.access = body.access;
101
+ if (body.refresh) this.tokens.refresh = body.refresh;
102
+ }
103
+
104
+ async _authHeader() {
105
+ if (this.apiKey) return "Api-Key " + this.apiKey;
106
+ const exp = this.tokens.access ? jwtExp(this.tokens.access) : 0;
107
+ if (!this.tokens.access || exp <= 0 || exp < Date.now() + 60_000) {
108
+ await this._refresh();
109
+ }
110
+ return "Bearer " + this.tokens.access;
111
+ }
112
+
113
+ /**
114
+ * یک درخواست خام به API هم‌روش.
115
+ * @param {string} method
116
+ * @param {string} path مثل /api/v1/darkube/apps/ یا URL کامل
117
+ * @param {object} [opts]
118
+ * @param {string} [opts.org]
119
+ * @param {Record<string,any>} [opts.params]
120
+ * @param {any} [opts.body]
121
+ * @returns {Promise<{status:number, body:any}>}
122
+ */
123
+ async request(method, path, opts = {}) {
124
+ const org = opts.org || this.org;
125
+ // قفلِ میزبان: فقط با base host خودِ هم‌روش حرف بزن (جلوگیری از SSRF)
126
+ let url;
127
+ if (path.startsWith("http")) {
128
+ const u = new URL(path);
129
+ const baseHost = new URL(this.base).host;
130
+ if (u.host !== baseHost) {
131
+ throw new Error(`میزبان غیرمجاز: ${u.host} — فقط ${baseHost} مجاز است.`);
132
+ }
133
+ url = path;
134
+ } else {
135
+ if (!path.startsWith("/")) throw new Error("مسیر باید با / شروع شود.");
136
+ url = this.base + path;
137
+ }
138
+ if (opts.params && typeof opts.params === "object" && !Array.isArray(opts.params) && Object.keys(opts.params).length) {
139
+ const qs = new URLSearchParams();
140
+ for (const [k, v] of Object.entries(opts.params)) {
141
+ if (v === undefined || v === null) continue;
142
+ if (Array.isArray(v)) v.forEach((x) => qs.append(k, String(x)));
143
+ else qs.append(k, String(v));
144
+ }
145
+ url += (url.includes("?") ? "&" : "?") + qs.toString();
146
+ }
147
+ const doFetch = async () => {
148
+ const headers = {
149
+ accept: "application/json, text/plain, */*",
150
+ "accept-language": "fa",
151
+ authorization: await this._authHeader(),
152
+ };
153
+ if (org) headers["x-organization"] = org;
154
+ /** @type {RequestInit} */
155
+ const init = { method: method.toUpperCase(), headers };
156
+ if (opts.body !== undefined && method.toUpperCase() !== "GET") {
157
+ headers["content-type"] = "application/json";
158
+ init.body = JSON.stringify(opts.body);
159
+ }
160
+ return fetch(url, init);
161
+ };
162
+ let r = await doFetch();
163
+ if (r.status === 401 && this.authMode === "password") {
164
+ await this._login();
165
+ r = await doFetch();
166
+ }
167
+ let body;
168
+ const text = await r.text();
169
+ try {
170
+ body = text ? JSON.parse(text) : "";
171
+ } catch {
172
+ body = text;
173
+ }
174
+ if (!r.ok) throw new HamraveshError(r.status, body);
175
+ return { status: r.status, body };
176
+ }
177
+
178
+ get(path, opts) {
179
+ return this.request("GET", path, opts);
180
+ }
181
+ }