monacloud-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.
- package/LICENSE +21 -0
- package/README.md +176 -0
- package/STATUS.md +48 -0
- package/dist/auth.d.ts +39 -0
- package/dist/auth.js +211 -0
- package/dist/clients.d.ts +33 -0
- package/dist/clients.js +157 -0
- package/dist/config.d.ts +17 -0
- package/dist/config.js +25 -0
- package/dist/errors.d.ts +38 -0
- package/dist/errors.js +51 -0
- package/dist/http.d.ts +11 -0
- package/dist/http.js +76 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +40 -0
- package/dist/monapay.d.ts +16 -0
- package/dist/monapay.js +49 -0
- package/dist/server.d.ts +8 -0
- package/dist/server.js +312 -0
- package/dist/templates.d.ts +19 -0
- package/dist/templates.js +117 -0
- package/docs/ai-agent.md +159 -0
- package/package.json +36 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 The MONA Group
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
# monacloud-mcp
|
|
2
|
+
|
|
3
|
+
`monacloud-mcp` là MCP hợp nhất của MONA Cloud: cài một lần, đăng nhập một MONA ID và dùng chung ví VND để quản lý MONA Cloud, provision VibeCloud, tích hợp MONA Pay và đọc catalog MONA Agent ngay trong Claude Code, Codex hoặc Cursor.
|
|
4
|
+
|
|
5
|
+
Human chỉ cần đăng ký, nạp tiền và cung cấp OTP/KYC khi bắt buộc. Các bước tạo tài nguyên, đọc trạng thái, cấu hình webhook, test và deploy được thiết kế để AI agent làm qua MCP.
|
|
6
|
+
|
|
7
|
+
## Yêu cầu
|
|
8
|
+
|
|
9
|
+
- Node.js 20 trở lên.
|
|
10
|
+
- Một MONA ID có quyền dùng client `monacloud-mcp`.
|
|
11
|
+
- MONA ID, Billing, VibeCloud và MONA Pay đã được cấu hình theo môi trường triển khai.
|
|
12
|
+
|
|
13
|
+
## Cài và đăng nhập
|
|
14
|
+
|
|
15
|
+
Đăng nhập lần đầu bằng OAuth Device Authorization Grant:
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
npx -y monacloud-mcp login
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
Lệnh in URL tại `id.monacloud.vn` và mã thiết bị. Sau khi xác nhận, offline refresh token được lưu tại `~/.config/monacloud/token.json`; thư mục có mode `0700`, file có mode `0600`. Server tự refresh access token và không in token ra log.
|
|
22
|
+
|
|
23
|
+
Kiểm tra hoặc đăng xuất:
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
npx -y monacloud-mcp whoami
|
|
27
|
+
npx -y monacloud-mcp logout
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
### Claude Code
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
claude mcp add monacloud -- npx -y monacloud-mcp
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
### Codex (`~/.codex/config.toml`)
|
|
37
|
+
|
|
38
|
+
```toml
|
|
39
|
+
[mcp_servers.monacloud]
|
|
40
|
+
command = "npx"
|
|
41
|
+
args = ["-y", "monacloud-mcp"]
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
### Cursor (`.cursor/mcp.json`)
|
|
45
|
+
|
|
46
|
+
```json
|
|
47
|
+
{
|
|
48
|
+
"mcpServers": {
|
|
49
|
+
"monacloud": {
|
|
50
|
+
"command": "npx",
|
|
51
|
+
"args": ["-y", "monacloud-mcp"]
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
MCP client không cần giữ username/password sản phẩm. Có thể truyền PAT trực tiếp bằng `MONACLOUD_TOKEN` trong CI hoặc môi trường không dùng token store; không commit giá trị này.
|
|
58
|
+
|
|
59
|
+
## Tool
|
|
60
|
+
|
|
61
|
+
Tên tool dùng `snake_case` để giữ tương thích với prompt MONA Pay cũ.
|
|
62
|
+
|
|
63
|
+
### MONA Cloud
|
|
64
|
+
|
|
65
|
+
| Tool | Công dụng |
|
|
66
|
+
|---|---|
|
|
67
|
+
| `cloud_whoami` | Hồ sơ MONA ID hiện tại |
|
|
68
|
+
| `cloud_balance` | Số dư ví VND chung |
|
|
69
|
+
| `cloud_ledger` | Ledger nạp/trừ/hoàn tiền có cursor |
|
|
70
|
+
| `cloud_topup(amount)` | Tạo yêu cầu nạp, trả `qr_data_url` và hướng dẫn VietQR |
|
|
71
|
+
| `cloud_usage(period)` | Usage theo tháng, có thể lọc sản phẩm |
|
|
72
|
+
| `cloud_services` | Gom service VibeCloud, VA và webhook MONA Pay |
|
|
73
|
+
| `cloud_budget_set` / `cloud_budget_get` | Đặt và đọc budget theo product/project/token |
|
|
74
|
+
| `cloud_token_limit` | Đặt spend limit cho PAT/token |
|
|
75
|
+
| `cloud_open_console` | Trả URL console chung |
|
|
76
|
+
|
|
77
|
+
### MONA Pay
|
|
78
|
+
|
|
79
|
+
`monacloud-mcp` import `monapay-mcp` và re-export toàn bộ tool của package, không copy implementation. Dependency range là `^0.3.0`; bộ dependency offline tại lần verify này là 0.5.5 với 47 tool MONA Pay, gồm các nhóm:
|
|
80
|
+
|
|
81
|
+
- hồ sơ và nối ACB/VA bằng hai lần OTP;
|
|
82
|
+
- payment profile, checkout, VietQR, sandbox transaction và đối soát;
|
|
83
|
+
- webhook, log, thống kê và retry;
|
|
84
|
+
- email notification, verification, suppression và log;
|
|
85
|
+
- xoay key, kiểm chữ ký HMAC và sinh code mẫu webhook.
|
|
86
|
+
|
|
87
|
+
Các tên cũ như `monapay_create_qr`, `monapay_link_bank_start`, `monapay_create_webhook` được giữ nguyên. `monapay_link` là adapter chuyển tiếp: đổi MONA ID thành `client_id/client_secret` rồi cache kín ở `~/.config/monacloud/links.json`. Adapter này sẽ được bỏ khi MONA Pay nhận JWT MONA ID trực tiếp.
|
|
88
|
+
|
|
89
|
+
### VibeCloud
|
|
90
|
+
|
|
91
|
+
| Tool | Công dụng |
|
|
92
|
+
|---|---|
|
|
93
|
+
| `vibecloud_link` | Xác nhận direct MONA ID; chỉ đổi sang `vc_live_*` nếu upstream còn ở chế độ cũ |
|
|
94
|
+
| `vibecloud_create_vps` | Tạo LXC theo `package_slug` hoặc CPU/RAM/đĩa |
|
|
95
|
+
| `vibecloud_create_database` | Tạo MongoDB, PostgreSQL hoặc MySQL |
|
|
96
|
+
| `vibecloud_job_status` | Poll job tới trạng thái cuối, có timeout |
|
|
97
|
+
| `vibecloud_list_services` | Liệt kê VPS/database |
|
|
98
|
+
| `vibecloud_start` / `vibecloud_stop` / `vibecloud_rebuild` | Quản lý vòng đời service |
|
|
99
|
+
| `vibecloud_prices` / `vibecloud_packages` | Đơn giá và gói cấu hình |
|
|
100
|
+
| `vibecloud_agent_deploy` | Stub có cấu trúc cho runtime MONA Agent wave sau |
|
|
101
|
+
|
|
102
|
+
`vibecloud_stop` không bị chặn bởi số dư để người dùng luôn có thể hạn chế chi phí. Những lệnh tạo/start/rebuild đọc `GET /v1/balance` trước khi gọi VibeCloud.
|
|
103
|
+
|
|
104
|
+
### MONA Agent
|
|
105
|
+
|
|
106
|
+
- `agent_templates_list`: ưu tiên catalog local tại `~/monacloud/templates`, sau đó URL cấu hình, cuối cùng catalog wave 1 tích hợp.
|
|
107
|
+
- `agent_templates_get(template)`: trả các file `README.md`, `AGENTS.md`, `tools.json`, `deploy.md`, `CHECKLIST.md` và skill text.
|
|
108
|
+
- `agent_deploy(template)`: dùng cùng slot runtime với `vibecloud_agent_deploy`; hiện trả `agent_runtime_pending` theo yêu cầu stub của wave này.
|
|
109
|
+
|
|
110
|
+
## Resource và prompt
|
|
111
|
+
|
|
112
|
+
- `monacloud://llms`: mô tả máy đọc của toàn stack MONA Cloud.
|
|
113
|
+
- `monacloud://status`: health tổng hợp MONA ID, Billing, VibeCloud và MONA Pay.
|
|
114
|
+
- Prompt `dung-app-ban-hang-monacloud`: chuỗi VPS → database → VA/QR → webhook → deploy, chỉ dừng để hỏi nạp tiền hoặc OTP bắt buộc.
|
|
115
|
+
|
|
116
|
+
## Spend guard và lỗi cho AI
|
|
117
|
+
|
|
118
|
+
Trước lệnh VibeCloud có thể phát sinh tiền, MCP đọc ví chung. HTTP `402 insufficient_funds` và `402 budget_exceeded` được chuẩn hoá thành text JSON:
|
|
119
|
+
|
|
120
|
+
```json
|
|
121
|
+
{
|
|
122
|
+
"code": "budget_exceeded",
|
|
123
|
+
"message": "Ví thiếu 20.000 đ hoặc đã chạm giới hạn chi tiêu.",
|
|
124
|
+
"next_step": "Nạp ví hoặc tăng ngân sách tại https://monacloud.vn/console rồi gọi lại tool.",
|
|
125
|
+
"request_id": "req_..."
|
|
126
|
+
}
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
Token và linked secret không nằm trong payload lỗi hoặc log. `request_id` được giữ khi API upstream trả về để agent tự đối chiếu.
|
|
130
|
+
|
|
131
|
+
## Ví dụ một lượt: dựng app bán hàng
|
|
132
|
+
|
|
133
|
+
Người dùng nói:
|
|
134
|
+
|
|
135
|
+
> Dựng app bán hàng Node.js, có PostgreSQL và thu tiền VietQR, deploy lên MONA Cloud.
|
|
136
|
+
|
|
137
|
+
Agent thực hiện:
|
|
138
|
+
|
|
139
|
+
1. `cloud_whoami` → `cloud_balance` → `vibecloud_packages`.
|
|
140
|
+
2. Nếu ví thiếu: `cloud_topup(200000)`, đưa QR cho người dùng và chờ xác nhận.
|
|
141
|
+
3. `vibecloud_create_vps` + `vibecloud_create_database`, sau đó `vibecloud_job_status` cho từng job.
|
|
142
|
+
4. `monapay_link` nếu adapter chuyển tiếp chưa có credential; `monapay_whoami` để kiểm tra VA.
|
|
143
|
+
5. Nếu chưa có VA: bắt đầu nối ACB, hỏi người dùng OTP đúng hai điểm bắt buộc, không tự đoán.
|
|
144
|
+
6. Agent viết endpoint webhook HMAC/idempotent, gọi `monapay_create_webhook`, `monapay_test_webhook`, `monapay_webhook_logs`.
|
|
145
|
+
7. Agent cắm `monapay_create_checkout` hoặc `monapay_create_qr`, deploy code lên VPS và báo URL cuối.
|
|
146
|
+
|
|
147
|
+
Chi tiết dành riêng cho agent: [`docs/ai-agent.md`](docs/ai-agent.md).
|
|
148
|
+
|
|
149
|
+
## Biến môi trường
|
|
150
|
+
|
|
151
|
+
| Biến | Mặc định | Ý nghĩa |
|
|
152
|
+
|---|---|---|
|
|
153
|
+
| `MONACLOUD_ISSUER` | `https://id.monacloud.vn/realms/mona` | OIDC issuer |
|
|
154
|
+
| `MONACLOUD_BILLING_URL` | `https://billing.monacloud.vn` | Billing/ví API |
|
|
155
|
+
| `MONAPAY_API` | `https://api.monapay.vn` | MONA Pay API |
|
|
156
|
+
| `VIBECLOUD_API` | `https://api.vibecloud.vn` | VibeCloud API |
|
|
157
|
+
| `MONACLOUD_CONSOLE_URL` | `https://monacloud.vn/console` | URL trả cho bước human |
|
|
158
|
+
| `MONACLOUD_TOKEN` | — | PAT/access token ưu tiên token store |
|
|
159
|
+
| `MONACLOUD_CONFIG_DIR` | `~/.config/monacloud` | Token và link cache |
|
|
160
|
+
| `MONACLOUD_TEMPLATES_DIR` | `~/monacloud/templates` | Catalog agent local |
|
|
161
|
+
| `MONACLOUD_TEMPLATES_URL` | — | Catalog JSON từ xa, chỉ dùng khi local không có |
|
|
162
|
+
| `MONAPAY_LINK_PATH` | `/api/v1/client/oauth/mona-id/link` | Endpoint adapter chuyển tiếp |
|
|
163
|
+
| `VIBECLOUD_LINK_PATH` | `/api/auth/monaid/link` | Endpoint adapter cũ, chỉ gọi khi direct MONA ID bị từ chối |
|
|
164
|
+
|
|
165
|
+
`MONAPAY_CLIENT_ID` + `MONAPAY_CLIENT_SECRET` và `VIBECLOUD_API_TOKEN` chỉ là đường tương thích trong giai đoạn migrate. `logout` xoá cả token store lẫn linked credential cache; luồng đích là một MONA ID.
|
|
166
|
+
|
|
167
|
+
## Phát triển offline
|
|
168
|
+
|
|
169
|
+
Không chạy `npm install` trong workspace handoff; `node_modules` đã được chuẩn bị sẵn.
|
|
170
|
+
|
|
171
|
+
```bash
|
|
172
|
+
node_modules/.bin/tsc -p tsconfig.json
|
|
173
|
+
node --test
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
Test dùng Node built-in, MCP transport thật qua stdio, process con và mock HTTP local. Trong sandbox cấm bind socket, test tự dùng fetch fixture tương đương. Bộ test không gọi Internet.
|
package/STATUS.md
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# STATUS — monacloud-mcp 0.1.0
|
|
2
|
+
|
|
3
|
+
Updated: 2026-09-03 (Asia/Ho_Chi_Minh)
|
|
4
|
+
|
|
5
|
+
## Hoàn thành
|
|
6
|
+
|
|
7
|
+
- [x] Package Node 20 + TypeScript, ESM, stdio MCP; bin `monacloud-mcp`, version `0.1.0`.
|
|
8
|
+
- [x] OAuth device flow client `monacloud-mcp`, scope `offline_access`, token store private `0600`, refresh tự động; CLI `login`, `logout`, `whoami`.
|
|
9
|
+
- [x] 10 tool `cloud_*`: identity, balance, ledger, topup VietQR, usage, services aggregation, budget, token limit và console URL.
|
|
10
|
+
- [x] Import package `monapay-mcp` và re-export toàn bộ tool, không copy code. Dependency offline tại lần verify cuối là 0.5.5/47 tool (superset 24 tool yêu cầu); cộng `monapay_link` chuyển tiếp.
|
|
11
|
+
- [x] 11 tool `vibecloud_*`: link, VPS, database, job poll, list, start/stop/rebuild, prices, packages và agent-deploy stub.
|
|
12
|
+
- [x] 3 tool `agent_*`: list/get catalog local/remote/built-in và deploy qua cùng stub VibeCloud.
|
|
13
|
+
- [x] Spend guard đọc billing balance trước create/start/rebuild; chuẩn hoá `402 insufficient_funds|budget_exceeded` thành `{code,message,next_step,request_id?}`. Stop luôn được phép.
|
|
14
|
+
- [x] 2 resource đúng brief: `monacloud://llms`, `monacloud://status`.
|
|
15
|
+
- [x] 1 prompt `dung-app-ban-hang-monacloud`: VPS → DB → VA/QR → webhook → deploy.
|
|
16
|
+
- [x] Không log token; token và linked credential được lưu bằng atomic write, directory `0700`, file `0600`.
|
|
17
|
+
- [x] README có cài Claude Code/Codex/Cursor, device flow, env, tool, spend guard và ví dụ một lượt dựng app bán hàng.
|
|
18
|
+
- [x] `docs/ai-agent.md` mô tả zero-dashboard flow, OTP boundaries, idempotency, sandbox, deploy và definition of done.
|
|
19
|
+
- [x] Test offline: stdio process thật + `tools/list` + `cloud_whoami` token giả; spend guard; 402; MONA Pay re-export; catalog/path validation.
|
|
20
|
+
|
|
21
|
+
Tổng tool với dependency được chuẩn bị trong handoff tại lần verify cuối: **72**.
|
|
22
|
+
|
|
23
|
+
## Gate đã chạy
|
|
24
|
+
|
|
25
|
+
```text
|
|
26
|
+
$ node_modules/.bin/tsc -p tsconfig.json
|
|
27
|
+
exit 0, không có diagnostic
|
|
28
|
+
|
|
29
|
+
$ node --test
|
|
30
|
+
tests 7, pass 7, fail 0
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
Không chạy `npm install`; toàn bộ build/test dùng `node_modules` có sẵn và không gọi Internet.
|
|
34
|
+
|
|
35
|
+
## Boundary chuyển tiếp đã ghi rõ
|
|
36
|
+
|
|
37
|
+
- `monapay_link` mặc định gọi `/api/v1/client/oauth/mona-id/link`; đổi bằng `MONAPAY_LINK_PATH` nếu upstream chốt path khác. Adapter cache client credential và sẽ bị bỏ khi MONA Pay nhận JWT trực tiếp.
|
|
38
|
+
- `vibecloud_link` thử Bearer MONA ID trực tiếp trước; chỉ khi upstream trả 401/403 mới gọi adapter `/api/auth/monaid/link` (đổi bằng `VIBECLOUD_LINK_PATH`). Tool nghiệp vụ luôn giữ nguyên tên.
|
|
39
|
+
- `vibecloud_agent_deploy` và `agent_deploy` là stub được brief yêu cầu tới khi runtime MONA Agent được phát hành; response không báo thành công giả.
|
|
40
|
+
- Chưa chạy smoke production vì phiên này offline và không được cấp token/endpoint production. Unit/integration local đã phủ wire contract chính.
|
|
41
|
+
|
|
42
|
+
CODEX DONE
|
|
43
|
+
|
|
44
|
+
## QC Claude (03/09 tối) — ZERO-DASHBOARD TEST LOCAL: PASS
|
|
45
|
+
- Build tsc sạch · `node --test` 7/7 · stdio thật: **72 tool** (cloud 10 · monapay 48 · vibecloud 11 · agent 3), 2 resource, 1 prompt.
|
|
46
|
+
- E2E thật với Keycloak local (`MONACLOUD_ISSUER=http://127.0.0.1:8180/realms/mona`) + billing (`MONACLOUD_BILLING_URL=http://127.0.0.1:8191`): `monacloud-mcp login` → mã thiết bị → đăng nhập + màn "Cấp quyền" của MONA ID → `token.json` 0600 → `cloud_whoami` (userinfo thật) → `cloud_balance` 0đ (ví JIT) → `cloud_ledger` → `cloud_budget_get`. **Một token, không chạm dashboard.**
|
|
47
|
+
- Lưu ý test tự động: trang consent Keycloak có form action TƯƠNG ĐỐI (`/realms/mona/login-actions/consent?...`) → script phải ghép host; urllib Python rớt cookie ở hop 302 → dùng curl cookie jar.
|
|
48
|
+
- Chưa: publish npm (`npm publish` — Claude làm khi deploy), `monapay_link`/`vibecloud_link` chờ endpoint link phía sản phẩm (gói E monapay, brief VIBECLOUD-MONAID đã có `/api/auth/monaid/link`).
|
package/dist/auth.d.ts
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import type { Config } from './config.js';
|
|
2
|
+
export type StoredToken = {
|
|
3
|
+
access_token: string;
|
|
4
|
+
refresh_token?: string;
|
|
5
|
+
id_token?: string;
|
|
6
|
+
token_type?: string;
|
|
7
|
+
scope?: string;
|
|
8
|
+
expires_in?: number;
|
|
9
|
+
expires_at: number;
|
|
10
|
+
};
|
|
11
|
+
export declare class AuthManager {
|
|
12
|
+
readonly config: Config;
|
|
13
|
+
readonly env: NodeJS.ProcessEnv;
|
|
14
|
+
readonly fetchImpl: typeof fetch;
|
|
15
|
+
constructor(config: Config, env?: NodeJS.ProcessEnv, fetchImpl?: typeof fetch);
|
|
16
|
+
private discovery;
|
|
17
|
+
loadToken(): Promise<StoredToken | undefined>;
|
|
18
|
+
saveToken(token: StoredToken): Promise<void>;
|
|
19
|
+
logout(): Promise<boolean>;
|
|
20
|
+
refresh(token: StoredToken): Promise<StoredToken>;
|
|
21
|
+
accessToken(): Promise<string>;
|
|
22
|
+
userinfo(): Promise<Record<string, unknown>>;
|
|
23
|
+
login(output?: (line: string) => void): Promise<Record<string, unknown>>;
|
|
24
|
+
tokenId(token: string): string;
|
|
25
|
+
}
|
|
26
|
+
export type LinkedCredentials = {
|
|
27
|
+
monapay?: {
|
|
28
|
+
client_id: string;
|
|
29
|
+
client_secret: string;
|
|
30
|
+
linked_at: string;
|
|
31
|
+
};
|
|
32
|
+
vibecloud?: {
|
|
33
|
+
token: string;
|
|
34
|
+
expires_at?: number;
|
|
35
|
+
linked_at: string;
|
|
36
|
+
};
|
|
37
|
+
};
|
|
38
|
+
export declare function readLinks(config: Config): Promise<LinkedCredentials>;
|
|
39
|
+
export declare function writeLinks(config: Config, links: LinkedCredentials): Promise<void>;
|
package/dist/auth.js
ADDED
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
import { chmod, mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { randomUUID } from 'node:crypto';
|
|
3
|
+
import { CloudError } from './errors.js';
|
|
4
|
+
import { requestJson } from './http.js';
|
|
5
|
+
const sleep = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds));
|
|
6
|
+
const asObject = (value) => (value && typeof value === 'object' ? value : {});
|
|
7
|
+
async function readJsonFile(path) {
|
|
8
|
+
try {
|
|
9
|
+
return JSON.parse(await readFile(path, 'utf8'));
|
|
10
|
+
}
|
|
11
|
+
catch (error) {
|
|
12
|
+
if (error.code === 'ENOENT')
|
|
13
|
+
return undefined;
|
|
14
|
+
throw new CloudError('invalid_token_store', `Không đọc được token store: ${error instanceof Error ? error.message : String(error)}`, 'Chạy `monacloud-mcp logout`, sau đó đăng nhập lại.');
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
async function writePrivateJson(path, directory, value) {
|
|
18
|
+
await mkdir(directory, { recursive: true, mode: 0o700 });
|
|
19
|
+
await chmod(directory, 0o700);
|
|
20
|
+
const temporaryPath = `${path}.${process.pid}.${randomUUID()}.tmp`;
|
|
21
|
+
await writeFile(temporaryPath, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 });
|
|
22
|
+
await chmod(temporaryPath, 0o600);
|
|
23
|
+
await rename(temporaryPath, path);
|
|
24
|
+
await chmod(path, 0o600);
|
|
25
|
+
}
|
|
26
|
+
function tokenFromResponse(value, previousRefreshToken) {
|
|
27
|
+
const body = asObject(value);
|
|
28
|
+
if (typeof body.access_token !== 'string' || !body.access_token) {
|
|
29
|
+
throw new CloudError('invalid_token_response', 'MONA ID không trả access_token.', 'Kiểm tra cấu hình client `monacloud-mcp` trên MONA ID rồi đăng nhập lại.');
|
|
30
|
+
}
|
|
31
|
+
const expiresIn = typeof body.expires_in === 'number' ? body.expires_in : 300;
|
|
32
|
+
return {
|
|
33
|
+
access_token: body.access_token,
|
|
34
|
+
...(typeof body.refresh_token === 'string'
|
|
35
|
+
? { refresh_token: body.refresh_token }
|
|
36
|
+
: previousRefreshToken ? { refresh_token: previousRefreshToken } : {}),
|
|
37
|
+
...(typeof body.id_token === 'string' ? { id_token: body.id_token } : {}),
|
|
38
|
+
...(typeof body.token_type === 'string' ? { token_type: body.token_type } : {}),
|
|
39
|
+
...(typeof body.scope === 'string' ? { scope: body.scope } : {}),
|
|
40
|
+
expires_in: expiresIn,
|
|
41
|
+
expires_at: Date.now() + Math.max(0, expiresIn) * 1000,
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
async function postForm(url, form, fetchImpl) {
|
|
45
|
+
let response;
|
|
46
|
+
try {
|
|
47
|
+
response = await fetchImpl(url, {
|
|
48
|
+
method: 'POST',
|
|
49
|
+
headers: { Accept: 'application/json', 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
50
|
+
body: new URLSearchParams(form),
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
catch (error) {
|
|
54
|
+
throw new CloudError('network_error', `Không kết nối được MONA ID: ${error instanceof Error ? error.message : String(error)}`, 'Kiểm tra MONACLOUD_ISSUER và kết nối mạng rồi thử lại.');
|
|
55
|
+
}
|
|
56
|
+
const body = asObject(await response.json().catch(() => ({})));
|
|
57
|
+
return { response, body };
|
|
58
|
+
}
|
|
59
|
+
export class AuthManager {
|
|
60
|
+
config;
|
|
61
|
+
env;
|
|
62
|
+
fetchImpl;
|
|
63
|
+
constructor(config, env = process.env, fetchImpl = fetch) {
|
|
64
|
+
this.config = config;
|
|
65
|
+
this.env = env;
|
|
66
|
+
this.fetchImpl = fetchImpl;
|
|
67
|
+
}
|
|
68
|
+
async discovery() {
|
|
69
|
+
try {
|
|
70
|
+
return await requestJson(`${this.config.issuer}/.well-known/openid-configuration`, {
|
|
71
|
+
fetchImpl: this.fetchImpl,
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
catch {
|
|
75
|
+
return {};
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
async loadToken() {
|
|
79
|
+
return readJsonFile(this.config.tokenFile);
|
|
80
|
+
}
|
|
81
|
+
async saveToken(token) {
|
|
82
|
+
await writePrivateJson(this.config.tokenFile, this.config.configDir, token);
|
|
83
|
+
}
|
|
84
|
+
async logout() {
|
|
85
|
+
let removed = false;
|
|
86
|
+
for (const path of [this.config.tokenFile, this.config.linksFile]) {
|
|
87
|
+
try {
|
|
88
|
+
await rm(path);
|
|
89
|
+
removed = true;
|
|
90
|
+
}
|
|
91
|
+
catch (error) {
|
|
92
|
+
if (error.code !== 'ENOENT')
|
|
93
|
+
throw error;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
return removed;
|
|
97
|
+
}
|
|
98
|
+
async refresh(token) {
|
|
99
|
+
if (!token.refresh_token) {
|
|
100
|
+
throw new CloudError('login_required', 'Token đã hết hạn và không có offline refresh token.', 'Chạy `monacloud-mcp login` để đăng nhập lại.');
|
|
101
|
+
}
|
|
102
|
+
const discovery = await this.discovery();
|
|
103
|
+
const endpoint = discovery.token_endpoint
|
|
104
|
+
|| `${this.config.issuer}/protocol/openid-connect/token`;
|
|
105
|
+
const { response, body } = await postForm(endpoint, {
|
|
106
|
+
grant_type: 'refresh_token',
|
|
107
|
+
client_id: this.config.clientId,
|
|
108
|
+
refresh_token: token.refresh_token,
|
|
109
|
+
}, this.fetchImpl);
|
|
110
|
+
if (!response.ok) {
|
|
111
|
+
throw new CloudError('refresh_failed', typeof body.error_description === 'string' ? body.error_description : 'Không refresh được token MONA ID.', 'Chạy `monacloud-mcp login` để đăng nhập lại.', { status: response.status });
|
|
112
|
+
}
|
|
113
|
+
const refreshed = tokenFromResponse(body, token.refresh_token);
|
|
114
|
+
await this.saveToken(refreshed);
|
|
115
|
+
return refreshed;
|
|
116
|
+
}
|
|
117
|
+
async accessToken() {
|
|
118
|
+
if (this.env.MONACLOUD_TOKEN)
|
|
119
|
+
return this.env.MONACLOUD_TOKEN;
|
|
120
|
+
const stored = await this.loadToken();
|
|
121
|
+
if (!stored) {
|
|
122
|
+
throw new CloudError('login_required', 'Chưa có token MONA ID trên máy.', 'Chạy `monacloud-mcp login`, hoàn tất mã thiết bị rồi gọi lại tool.');
|
|
123
|
+
}
|
|
124
|
+
if (stored.expires_at > Date.now() + 30_000)
|
|
125
|
+
return stored.access_token;
|
|
126
|
+
return (await this.refresh(stored)).access_token;
|
|
127
|
+
}
|
|
128
|
+
async userinfo() {
|
|
129
|
+
const discovery = await this.discovery();
|
|
130
|
+
const endpoint = discovery.userinfo_endpoint
|
|
131
|
+
|| `${this.config.issuer}/protocol/openid-connect/userinfo`;
|
|
132
|
+
return requestJson(endpoint, {
|
|
133
|
+
token: await this.accessToken(),
|
|
134
|
+
fetchImpl: this.fetchImpl,
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
async login(output = console.log) {
|
|
138
|
+
const discovery = await this.discovery();
|
|
139
|
+
const deviceEndpoint = discovery.device_authorization_endpoint
|
|
140
|
+
|| `${this.config.issuer}/protocol/openid-connect/auth/device`;
|
|
141
|
+
const tokenEndpoint = discovery.token_endpoint
|
|
142
|
+
|| `${this.config.issuer}/protocol/openid-connect/token`;
|
|
143
|
+
const started = await postForm(deviceEndpoint, {
|
|
144
|
+
client_id: this.config.clientId,
|
|
145
|
+
scope: this.config.scope,
|
|
146
|
+
}, this.fetchImpl);
|
|
147
|
+
if (!started.response.ok) {
|
|
148
|
+
throw new CloudError('device_flow_unavailable', typeof started.body.error_description === 'string'
|
|
149
|
+
? started.body.error_description
|
|
150
|
+
: 'MONA ID không khởi tạo được device flow.', 'Kiểm tra client `monacloud-mcp` đã bật OAuth 2.0 Device Authorization Grant.', { status: started.response.status });
|
|
151
|
+
}
|
|
152
|
+
const device = started.body;
|
|
153
|
+
if (!device.device_code || !device.user_code || !device.verification_uri) {
|
|
154
|
+
throw new CloudError('invalid_device_response', 'MONA ID trả device-flow response không hợp lệ.', 'Kiểm tra cấu hình MONA ID rồi thử lại.');
|
|
155
|
+
}
|
|
156
|
+
output('Đăng nhập MONA Cloud');
|
|
157
|
+
output(`Mở: ${device.verification_uri_complete || device.verification_uri}`);
|
|
158
|
+
output(`Mã: ${device.user_code}`);
|
|
159
|
+
output('Đang chờ xác nhận…');
|
|
160
|
+
let intervalSeconds = Math.max(1, device.interval || 5);
|
|
161
|
+
const deadline = Date.now() + Math.max(1, device.expires_in) * 1000;
|
|
162
|
+
while (Date.now() < deadline) {
|
|
163
|
+
await sleep(intervalSeconds * 1000);
|
|
164
|
+
const polled = await postForm(tokenEndpoint, {
|
|
165
|
+
grant_type: 'urn:ietf:params:oauth:grant-type:device_code',
|
|
166
|
+
device_code: device.device_code,
|
|
167
|
+
client_id: this.config.clientId,
|
|
168
|
+
}, this.fetchImpl);
|
|
169
|
+
if (polled.response.ok) {
|
|
170
|
+
const token = tokenFromResponse(polled.body);
|
|
171
|
+
await this.saveToken(token);
|
|
172
|
+
const identity = await this.userinfo().catch(() => ({}));
|
|
173
|
+
output(`Đăng nhập xong${identity.email ? `: ${String(identity.email)}` : '.'}`);
|
|
174
|
+
return identity;
|
|
175
|
+
}
|
|
176
|
+
const error = polled.body.error;
|
|
177
|
+
if (error === 'authorization_pending')
|
|
178
|
+
continue;
|
|
179
|
+
if (error === 'slow_down') {
|
|
180
|
+
intervalSeconds += 5;
|
|
181
|
+
continue;
|
|
182
|
+
}
|
|
183
|
+
if (error === 'access_denied') {
|
|
184
|
+
throw new CloudError('access_denied', 'Người dùng đã từ chối đăng nhập.', 'Chạy lại `monacloud-mcp login` khi sẵn sàng.');
|
|
185
|
+
}
|
|
186
|
+
if (error === 'expired_token')
|
|
187
|
+
break;
|
|
188
|
+
throw new CloudError(typeof error === 'string' ? error : 'login_failed', typeof polled.body.error_description === 'string' ? polled.body.error_description : 'Đăng nhập thất bại.', 'Chạy lại `monacloud-mcp login`.', { status: polled.response.status });
|
|
189
|
+
}
|
|
190
|
+
throw new CloudError('device_code_expired', 'Mã đăng nhập đã hết hạn.', 'Chạy lại `monacloud-mcp login` để lấy mã mới.');
|
|
191
|
+
}
|
|
192
|
+
tokenId(token) {
|
|
193
|
+
try {
|
|
194
|
+
const payload = JSON.parse(Buffer.from(token.split('.')[1] || '', 'base64url').toString('utf8'));
|
|
195
|
+
if (typeof payload.jti === 'string' && payload.jti)
|
|
196
|
+
return payload.jti;
|
|
197
|
+
if (typeof payload.sub === 'string' && payload.sub)
|
|
198
|
+
return payload.sub;
|
|
199
|
+
}
|
|
200
|
+
catch {
|
|
201
|
+
// Opaque PATs intentionally fall through to the stable client identifier.
|
|
202
|
+
}
|
|
203
|
+
return this.config.clientId;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
export async function readLinks(config) {
|
|
207
|
+
return (await readJsonFile(config.linksFile)) || {};
|
|
208
|
+
}
|
|
209
|
+
export async function writeLinks(config, links) {
|
|
210
|
+
await writePrivateJson(config.linksFile, config.configDir, links);
|
|
211
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { Config } from './config.js';
|
|
2
|
+
import { AuthManager } from './auth.js';
|
|
3
|
+
type JsonObject = Record<string, unknown>;
|
|
4
|
+
export declare class CloudClients {
|
|
5
|
+
readonly config: Config;
|
|
6
|
+
readonly auth: AuthManager;
|
|
7
|
+
readonly fetchImpl: typeof fetch;
|
|
8
|
+
constructor(config: Config, auth: AuthManager, fetchImpl?: typeof fetch);
|
|
9
|
+
private billing;
|
|
10
|
+
balance(): Promise<unknown>;
|
|
11
|
+
ledger(cursor?: string, limit?: number): Promise<unknown>;
|
|
12
|
+
usage(period: string, product?: string): Promise<unknown>;
|
|
13
|
+
topup(amount: number, idempotencyKey?: string): Promise<unknown>;
|
|
14
|
+
budgetSet(body: {
|
|
15
|
+
scope: string;
|
|
16
|
+
scope_id: string;
|
|
17
|
+
limit_vnd: number;
|
|
18
|
+
period: string;
|
|
19
|
+
}): Promise<unknown>;
|
|
20
|
+
budgetGet(): Promise<unknown>;
|
|
21
|
+
tokenLimit(spendLimitVnd: number, period: string, tokenId?: string): Promise<unknown>;
|
|
22
|
+
spendGuard(): Promise<JsonObject>;
|
|
23
|
+
private vibecloudToken;
|
|
24
|
+
vibecloud<T = unknown>(path: string, options?: {
|
|
25
|
+
method?: string;
|
|
26
|
+
body?: unknown;
|
|
27
|
+
}): Promise<T>;
|
|
28
|
+
vibecloudLink(): Promise<JsonObject>;
|
|
29
|
+
monapayLink(): Promise<JsonObject>;
|
|
30
|
+
prices(): Promise<unknown>;
|
|
31
|
+
packages(): Promise<unknown>;
|
|
32
|
+
}
|
|
33
|
+
export {};
|