@datviet/nbp-sdk 0.0.317
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/README.md +121 -0
- package/auth.d.ts +54 -0
- package/auth.js +213 -0
- package/client.d.ts +7 -0
- package/client.js +25 -0
- package/gen/client/client.gen.d.ts +2 -0
- package/gen/client/client.gen.js +165 -0
- package/gen/client/index.d.ts +7 -0
- package/gen/client/index.js +5 -0
- package/gen/client/types.gen.d.ts +127 -0
- package/gen/client/types.gen.js +2 -0
- package/gen/client/utils.gen.d.ts +38 -0
- package/gen/client/utils.gen.js +226 -0
- package/gen/client.gen.d.ts +12 -0
- package/gen/client.gen.js +5 -0
- package/gen/core/auth.gen.d.ts +18 -0
- package/gen/core/auth.gen.js +14 -0
- package/gen/core/bodySerializer.gen.d.ts +17 -0
- package/gen/core/bodySerializer.gen.js +57 -0
- package/gen/core/params.gen.d.ts +33 -0
- package/gen/core/params.gen.js +89 -0
- package/gen/core/pathSerializer.gen.d.ts +33 -0
- package/gen/core/pathSerializer.gen.js +106 -0
- package/gen/core/serverSentEvents.gen.d.ts +59 -0
- package/gen/core/serverSentEvents.gen.js +117 -0
- package/gen/core/types.gen.d.ts +78 -0
- package/gen/core/types.gen.js +2 -0
- package/gen/core/utils.gen.d.ts +14 -0
- package/gen/core/utils.gen.js +69 -0
- package/gen/sdk.gen.d.ts +403 -0
- package/gen/sdk.gen.js +881 -0
- package/gen/types.gen.d.ts +3372 -0
- package/gen/types.gen.js +2 -0
- package/index.d.ts +10 -0
- package/index.js +16 -0
- package/package.json +47 -0
- package/server.d.ts +23 -0
- package/server.js +94 -0
- package/v2/client.d.ts +7 -0
- package/v2/client.js +27 -0
- package/v2/gen/client/client.gen.d.ts +2 -0
- package/v2/gen/client/client.gen.js +225 -0
- package/v2/gen/client/index.d.ts +8 -0
- package/v2/gen/client/index.js +6 -0
- package/v2/gen/client/types.gen.d.ts +117 -0
- package/v2/gen/client/types.gen.js +2 -0
- package/v2/gen/client/utils.gen.d.ts +33 -0
- package/v2/gen/client/utils.gen.js +226 -0
- package/v2/gen/client.gen.d.ts +12 -0
- package/v2/gen/client.gen.js +3 -0
- package/v2/gen/core/auth.gen.d.ts +18 -0
- package/v2/gen/core/auth.gen.js +14 -0
- package/v2/gen/core/bodySerializer.gen.d.ts +25 -0
- package/v2/gen/core/bodySerializer.gen.js +57 -0
- package/v2/gen/core/params.gen.d.ts +43 -0
- package/v2/gen/core/params.gen.js +102 -0
- package/v2/gen/core/pathSerializer.gen.d.ts +33 -0
- package/v2/gen/core/pathSerializer.gen.js +106 -0
- package/v2/gen/core/queryKeySerializer.gen.d.ts +18 -0
- package/v2/gen/core/queryKeySerializer.gen.js +93 -0
- package/v2/gen/core/serverSentEvents.gen.d.ts +71 -0
- package/v2/gen/core/serverSentEvents.gen.js +131 -0
- package/v2/gen/core/types.gen.d.ts +78 -0
- package/v2/gen/core/types.gen.js +2 -0
- package/v2/gen/core/utils.gen.d.ts +19 -0
- package/v2/gen/core/utils.gen.js +87 -0
- package/v2/gen/sdk.gen.d.ts +2959 -0
- package/v2/gen/sdk.gen.js +4537 -0
- package/v2/gen/types.gen.d.ts +9316 -0
- package/v2/gen/types.gen.js +2 -0
- package/v2/index.d.ts +10 -0
- package/v2/index.js +16 -0
- package/v2/server.d.ts +23 -0
- package/v2/server.js +94 -0
package/README.md
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
# @datviet/nbp-sdk
|
|
2
|
+
|
|
3
|
+
TypeScript SDK cho NBP API (`https://nbp.datviet.ai`) — listings, clients, contacts, tasks, tags, agent sessions.
|
|
4
|
+
|
|
5
|
+
Types sinh trực tiếp từ OpenAPI của server nên autocomplete luôn khớp API thật. Chạy được trên Bun, Node 22+, và trong trình duyệt.
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm i @datviet/nbp-sdk
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Xác thực
|
|
12
|
+
|
|
13
|
+
Hai đường, chọn theo việc UI của bạn có nhiều người dùng riêng biệt hay không:
|
|
14
|
+
|
|
15
|
+
| Đường | Khi nào | Audit / RBAC |
|
|
16
|
+
| -------------------------------- | ---------------------------------------------------------------- | ----------------- |
|
|
17
|
+
| **API key** (`sk-…`) | prototype, script, dashboard nội bộ, hoặc backend của bạn gọi hộ | chung một account |
|
|
18
|
+
| **Đăng nhập người dùng** (OAuth) | app nhiều môi giới, mỗi người quyền khác nhau | theo từng người |
|
|
19
|
+
|
|
20
|
+
### API key
|
|
21
|
+
|
|
22
|
+
Settings → Khoá API → Tạo khoá. Chuỗi khoá chỉ hiện một lần.
|
|
23
|
+
|
|
24
|
+
Khoá gắn chết vào một workspace — không cần gửi `X-Workspace-ID`, và không đổi được phạm vi từ phía client.
|
|
25
|
+
|
|
26
|
+
```ts
|
|
27
|
+
import { createOpencodeClient } from "@datviet/nbp-sdk/v2/client"
|
|
28
|
+
|
|
29
|
+
const nbp = createOpencodeClient({
|
|
30
|
+
baseUrl: "https://nbp.datviet.ai",
|
|
31
|
+
headers: { Authorization: `Bearer ${process.env.NBP_API_KEY}` },
|
|
32
|
+
throwOnError: true,
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
const listings = await nbp.listings.list({ limit: 20 })
|
|
36
|
+
const clients = await nbp.clients.list({ limit: 20 })
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
Tham số đi **phẳng** — không bọc `query` / `body`. SDK tự xếp cái nào vào query, cái nào vào body.
|
|
40
|
+
|
|
41
|
+
Hai mức quyền, chọn lúc tạo khoá:
|
|
42
|
+
|
|
43
|
+
| Mức | Làm được | Dùng khi |
|
|
44
|
+
| --------- | --------------------------- | ---------------------------------------------- |
|
|
45
|
+
| Chỉ đọc | đọc listings / clients | khoá nằm trong app của đối tác, hoặc dashboard |
|
|
46
|
+
| Đọc & ghi | thêm/sửa listings / clients | hệ thống bạn tự kiểm soát (backend của bạn) |
|
|
47
|
+
|
|
48
|
+
Khoá **không** mở được `/map/*` (dữ liệu quy hoạch bán theo lượt) — đường đó dùng Partner API.
|
|
49
|
+
|
|
50
|
+
### Đăng nhập người dùng (OAuth 2.0 + PKCE)
|
|
51
|
+
|
|
52
|
+
`@datviet/nbp-sdk/auth` lo PKCE, đổi code, refresh, và retry-on-401. Không tự viết lại — access token sống 1 giờ nên UI thiếu refresh chạy tốt suốt buổi demo rồi mới chết lúc khách dùng thật.
|
|
53
|
+
|
|
54
|
+
```ts
|
|
55
|
+
import { createNbpAuth } from "@datviet/nbp-sdk/auth"
|
|
56
|
+
import { createOpencodeClient } from "@datviet/nbp-sdk/v2/client"
|
|
57
|
+
|
|
58
|
+
const auth = createNbpAuth({
|
|
59
|
+
clientID: "congty-a", // Datviet cấp — backend dùng chuỗi này để phân quyền
|
|
60
|
+
redirectURI: `${location.origin}/auth/callback`,
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
const nbp = createOpencodeClient({ baseUrl: "https://nbp.datviet.ai", fetch: auth.fetch })
|
|
64
|
+
|
|
65
|
+
// Nút đăng nhập
|
|
66
|
+
await auth.login()
|
|
67
|
+
|
|
68
|
+
// Route /auth/callback
|
|
69
|
+
const params = new URLSearchParams(location.search)
|
|
70
|
+
const ok = await auth.handleCallback(params.get("code")!, params.get("state") ?? undefined)
|
|
71
|
+
|
|
72
|
+
// Chọn workspace rồi mọi request sau đó tự mang X-Workspace-ID
|
|
73
|
+
const ws = await nbp.workspaces.list()
|
|
74
|
+
auth.setWorkspace(ws.data!.data[0]!.id)
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
Ba điều bắt buộc biết:
|
|
78
|
+
|
|
79
|
+
1. **`redirectURI` bị khoá theo domain**: `localhost` / `127.0.0.1` (http được), `*.datviet.ai`, `*.ankapong.com` (https), và deep link `nbp://auth/callback`. Vibe-code ở local chạy ngay; deploy lên domain khác thì **auth từ chối ở bước authorize** — không phải lỗi SDK. Cách rẻ nhất: xin một subdomain `<tên-bạn>.datviet.ai`.
|
|
80
|
+
2. **Người dùng phải là member của workspace**, không thì mọi request trả 403 `Not a member of this workspace`. Mời họ qua Settings → Thành viên bằng đúng email họ đăng nhập — hệ thống tự nối lúc họ login lần đầu.
|
|
81
|
+
3. **`clientID` phải do Datviet cấp.** Backend dùng nó để phân quyền bề mặt tính tiền; chuỗi tự đặt sẽ bị chặn 403 `aud_not_allowed` ở các endpoint đó.
|
|
82
|
+
|
|
83
|
+
## Chạy trong trình duyệt
|
|
84
|
+
|
|
85
|
+
CORS đã mở cho `*.datviet.ai`, `*.ankapong.com` và mọi `http://localhost:<port>` — vibe-code ở local là chạy được ngay. Deploy lên domain riêng thì gửi domain đó để thêm vào whitelist.
|
|
86
|
+
|
|
87
|
+
⚠️ Khoá `sk-…` đặt trong frontend là **coi như đã lộ** — ai mở devtools cũng đọc được. Với web app thật:
|
|
88
|
+
|
|
89
|
+
- khoá read-only, hoặc
|
|
90
|
+
- proxy qua backend của bạn (khoá nằm ở server), hoặc
|
|
91
|
+
- dùng JWT của người dùng qua `auth.datviet.ai`.
|
|
92
|
+
|
|
93
|
+
## Ví dụ hay dùng
|
|
94
|
+
|
|
95
|
+
```ts
|
|
96
|
+
// Tin mới nhất, có ảnh, nhà phố dưới 10 tỷ ở Quận 2
|
|
97
|
+
const res = await nbp.listings.list({
|
|
98
|
+
limit: 12,
|
|
99
|
+
sortBy: "time_created",
|
|
100
|
+
sortDirection: "desc",
|
|
101
|
+
hasPhotos: "true",
|
|
102
|
+
property_type: "house",
|
|
103
|
+
maxPrice: 10_000_000_000,
|
|
104
|
+
district: "Quận 2",
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
// Tạo tin (khoá phải là "Đọc & ghi")
|
|
108
|
+
await nbp.listings.create({
|
|
109
|
+
price: 8_500_000_000,
|
|
110
|
+
tags: { category: "sale", property_type: "house", district: "Quận 2", area: 80 },
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
// Quản lý khoá API — chỉ JWT của owner/admin gọi được, khoá `sk-` bị chặn 403
|
|
114
|
+
await nbp.workspaces.apiKeys.list({ id: workspaceID })
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
Tag lưu trong DB bằng **key tiếng Anh** (`"house"`), UI hiện nhãn Việt (`"Nhà phố"`) — nhớ gửi key.
|
|
118
|
+
|
|
119
|
+
## Bản `v2` vs bản gốc
|
|
120
|
+
|
|
121
|
+
`@datviet/nbp-sdk/v2/client` là bản đang dùng (client theo class, `nbp.listings.list()`). Export ở root (`@datviet/nbp-sdk`) là bản cũ kế thừa từ opencode, giữ cho tương thích — code mới dùng `/v2/client`.
|
package/auth.d.ts
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Đăng nhập người dùng cho UI dựng bằng SDK — OAuth 2.0 Authorization Code + PKCE với
|
|
3
|
+
* `auth.datviet.ai`.
|
|
4
|
+
*
|
|
5
|
+
* Vì sao nằm trong SDK: không có nó thì mỗi tích hợp phải tự viết PKCE + đổi code + refresh +
|
|
6
|
+
* retry-on-401. Ba phần đầu sai thì lộ ra ngay, phần cuối thì KHÔNG — access token sống 1 giờ, nên
|
|
7
|
+
* một UI thiếu refresh chạy tốt suốt buổi demo rồi mới chết lúc khách dùng thật.
|
|
8
|
+
*
|
|
9
|
+
* Zero dependency (WebCrypto + fetch, có sẵn trên browser/Bun/Node 22+): SDK này khách cài vào app
|
|
10
|
+
* của họ, thêm dep vào đây là thêm ràng buộc version cho họ.
|
|
11
|
+
*
|
|
12
|
+
* ⚠️ `redirect_uri` bị auth server khoá theo domain: `localhost`/`127.0.0.1`, `*.datviet.ai`,
|
|
13
|
+
* `*.ankapong.com`, và deep link `nbp://auth/callback`. Domain khác ⇒ auth trả lỗi ngay ở bước
|
|
14
|
+
* authorize, KHÔNG phải lỗi của SDK. Cần domain riêng thì phải thêm vào allowlist của auth service.
|
|
15
|
+
*/
|
|
16
|
+
/** Nơi giữ token giữa các lần load trang. Mặc định `localStorage`, không có thì giữ trong RAM. */
|
|
17
|
+
export interface AuthStorage {
|
|
18
|
+
getItem(key: string): string | null | Promise<string | null>;
|
|
19
|
+
setItem(key: string, value: string): void | Promise<void>;
|
|
20
|
+
removeItem(key: string): void | Promise<void>;
|
|
21
|
+
}
|
|
22
|
+
export interface AuthConfig {
|
|
23
|
+
/**
|
|
24
|
+
* clientID đăng ký với Datviet (auth server không đòi đăng ký, nhưng backend NBP dùng chuỗi này
|
|
25
|
+
* làm `aud` để phân quyền — dùng chuỗi lạ là bị chặn ở các bề mặt tính tiền).
|
|
26
|
+
*/
|
|
27
|
+
clientID: string;
|
|
28
|
+
/** Phải khớp URL callback thật của app và nằm trong allowlist domain ở trên. */
|
|
29
|
+
redirectURI: string;
|
|
30
|
+
issuer?: string;
|
|
31
|
+
storage?: AuthStorage;
|
|
32
|
+
/** Workspace mặc định gắn vào mọi request (`X-Workspace-ID`). Đổi được sau bằng `setWorkspace()`. */
|
|
33
|
+
workspaceID?: string;
|
|
34
|
+
/** Gọi mỗi khi token đổi (login, refresh, logout) — để app đồng bộ UI. */
|
|
35
|
+
onTokens?: (tokens: Tokens | null) => void;
|
|
36
|
+
}
|
|
37
|
+
export interface Tokens {
|
|
38
|
+
access: string;
|
|
39
|
+
refresh: string;
|
|
40
|
+
}
|
|
41
|
+
export declare function createNbpAuth(config: AuthConfig): {
|
|
42
|
+
login: () => Promise<string>;
|
|
43
|
+
authorizeUrl: () => Promise<string>;
|
|
44
|
+
handleCallback: (code: string, returnedState?: string) => Promise<boolean>;
|
|
45
|
+
refresh: () => Promise<Tokens | null>;
|
|
46
|
+
logout: () => Promise<void>;
|
|
47
|
+
fetch: typeof fetch;
|
|
48
|
+
/** Token hiện tại (đã load từ storage) — dùng để hiện trạng thái đăng nhập. */
|
|
49
|
+
tokens: () => Promise<Tokens | null>;
|
|
50
|
+
isAuthenticated: () => Promise<boolean>;
|
|
51
|
+
/** Workspace gắn vào request sau đó. Lấy id từ `GET /workspaces`. */
|
|
52
|
+
setWorkspace: (id: string | undefined) => void;
|
|
53
|
+
workspaceID: () => string | undefined;
|
|
54
|
+
};
|
package/auth.js
ADDED
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Đăng nhập người dùng cho UI dựng bằng SDK — OAuth 2.0 Authorization Code + PKCE với
|
|
3
|
+
* `auth.datviet.ai`.
|
|
4
|
+
*
|
|
5
|
+
* Vì sao nằm trong SDK: không có nó thì mỗi tích hợp phải tự viết PKCE + đổi code + refresh +
|
|
6
|
+
* retry-on-401. Ba phần đầu sai thì lộ ra ngay, phần cuối thì KHÔNG — access token sống 1 giờ, nên
|
|
7
|
+
* một UI thiếu refresh chạy tốt suốt buổi demo rồi mới chết lúc khách dùng thật.
|
|
8
|
+
*
|
|
9
|
+
* Zero dependency (WebCrypto + fetch, có sẵn trên browser/Bun/Node 22+): SDK này khách cài vào app
|
|
10
|
+
* của họ, thêm dep vào đây là thêm ràng buộc version cho họ.
|
|
11
|
+
*
|
|
12
|
+
* ⚠️ `redirect_uri` bị auth server khoá theo domain: `localhost`/`127.0.0.1`, `*.datviet.ai`,
|
|
13
|
+
* `*.ankapong.com`, và deep link `nbp://auth/callback`. Domain khác ⇒ auth trả lỗi ngay ở bước
|
|
14
|
+
* authorize, KHÔNG phải lỗi của SDK. Cần domain riêng thì phải thêm vào allowlist của auth service.
|
|
15
|
+
*/
|
|
16
|
+
const DEFAULT_ISSUER = "https://auth.datviet.ai";
|
|
17
|
+
const TOKENS_KEY = "nbp_auth_tokens";
|
|
18
|
+
const PKCE_KEY = "nbp_pkce_challenge";
|
|
19
|
+
function memoryStorage() {
|
|
20
|
+
const map = new Map();
|
|
21
|
+
return {
|
|
22
|
+
getItem: (k) => map.get(k) ?? null,
|
|
23
|
+
setItem: (k, v) => void map.set(k, v),
|
|
24
|
+
removeItem: (k) => void map.delete(k),
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
function defaultStorage() {
|
|
28
|
+
if (typeof localStorage === "undefined")
|
|
29
|
+
return memoryStorage();
|
|
30
|
+
return {
|
|
31
|
+
getItem: (k) => localStorage.getItem(k),
|
|
32
|
+
setItem: (k, v) => localStorage.setItem(k, v),
|
|
33
|
+
removeItem: (k) => localStorage.removeItem(k),
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
function base64url(bytes) {
|
|
37
|
+
const arr = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes);
|
|
38
|
+
let str = "";
|
|
39
|
+
for (const b of arr)
|
|
40
|
+
str += String.fromCharCode(b);
|
|
41
|
+
return btoa(str).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
42
|
+
}
|
|
43
|
+
function randomString(bytes = 32) {
|
|
44
|
+
return base64url(crypto.getRandomValues(new Uint8Array(bytes)));
|
|
45
|
+
}
|
|
46
|
+
async function challenge(verifier) {
|
|
47
|
+
const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier));
|
|
48
|
+
return base64url(digest);
|
|
49
|
+
}
|
|
50
|
+
export function createNbpAuth(config) {
|
|
51
|
+
const issuer = (config.issuer ?? DEFAULT_ISSUER).replace(/\/$/, "");
|
|
52
|
+
const storage = config.storage ?? defaultStorage();
|
|
53
|
+
const state = {
|
|
54
|
+
tokens: null,
|
|
55
|
+
loaded: false,
|
|
56
|
+
workspaceID: config.workspaceID,
|
|
57
|
+
/** Refresh đang bay — nhiều request 401 cùng lúc phải dùng CHUNG một lần refresh, không thì
|
|
58
|
+
* refresh token bị rotate song song và các request sau cầm token đã chết. */
|
|
59
|
+
refreshing: null,
|
|
60
|
+
};
|
|
61
|
+
async function load() {
|
|
62
|
+
if (state.loaded)
|
|
63
|
+
return state.tokens;
|
|
64
|
+
const raw = await storage.getItem(TOKENS_KEY);
|
|
65
|
+
state.loaded = true;
|
|
66
|
+
if (!raw)
|
|
67
|
+
return null;
|
|
68
|
+
state.tokens = JSON.parse(raw);
|
|
69
|
+
return state.tokens;
|
|
70
|
+
}
|
|
71
|
+
async function save(tokens) {
|
|
72
|
+
state.tokens = tokens;
|
|
73
|
+
state.loaded = true;
|
|
74
|
+
if (!tokens)
|
|
75
|
+
await storage.removeItem(TOKENS_KEY);
|
|
76
|
+
if (tokens)
|
|
77
|
+
await storage.setItem(TOKENS_KEY, JSON.stringify(tokens));
|
|
78
|
+
config.onTokens?.(tokens);
|
|
79
|
+
}
|
|
80
|
+
/** URL để đưa người dùng tới trang đăng nhập. Đã lưu verifier + state vào storage. */
|
|
81
|
+
async function authorizeUrl() {
|
|
82
|
+
const verifier = randomString();
|
|
83
|
+
const csrf = randomString(16);
|
|
84
|
+
await storage.setItem(PKCE_KEY, JSON.stringify({ verifier, state: csrf }));
|
|
85
|
+
const params = new URLSearchParams({
|
|
86
|
+
client_id: config.clientID,
|
|
87
|
+
redirect_uri: config.redirectURI,
|
|
88
|
+
response_type: "code",
|
|
89
|
+
code_challenge: await challenge(verifier),
|
|
90
|
+
code_challenge_method: "S256",
|
|
91
|
+
state: csrf,
|
|
92
|
+
});
|
|
93
|
+
return `${issuer}/authorize?${params}`;
|
|
94
|
+
}
|
|
95
|
+
/** Trong browser: chuyển trang sang trang đăng nhập. Ngoài browser: trả URL để tự mở. */
|
|
96
|
+
async function login() {
|
|
97
|
+
const url = await authorizeUrl();
|
|
98
|
+
if (typeof window !== "undefined")
|
|
99
|
+
window.location.href = url;
|
|
100
|
+
return url;
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* Gọi ở route callback với `code` (và `state` nếu có) lấy từ query string.
|
|
104
|
+
* Trả `false` khi state không khớp hoặc đổi code thất bại — KHÔNG throw, để UI hiện lỗi tử tế.
|
|
105
|
+
*/
|
|
106
|
+
async function handleCallback(code, returnedState) {
|
|
107
|
+
const raw = await storage.getItem(PKCE_KEY);
|
|
108
|
+
if (!raw)
|
|
109
|
+
return false;
|
|
110
|
+
const pkce = JSON.parse(raw);
|
|
111
|
+
await storage.removeItem(PKCE_KEY);
|
|
112
|
+
// State khớp là thứ chặn CSRF; thiếu verifier thì code cũng vô dụng.
|
|
113
|
+
if (returnedState !== undefined && returnedState !== pkce.state)
|
|
114
|
+
return false;
|
|
115
|
+
const res = await fetch(`${issuer}/token`, {
|
|
116
|
+
method: "POST",
|
|
117
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
118
|
+
body: new URLSearchParams({
|
|
119
|
+
grant_type: "authorization_code",
|
|
120
|
+
client_id: config.clientID,
|
|
121
|
+
redirect_uri: config.redirectURI,
|
|
122
|
+
code,
|
|
123
|
+
code_verifier: pkce.verifier,
|
|
124
|
+
}),
|
|
125
|
+
}).catch(() => null);
|
|
126
|
+
if (!res?.ok)
|
|
127
|
+
return false;
|
|
128
|
+
const json = (await res.json().catch(() => null));
|
|
129
|
+
if (!json?.access_token || !json?.refresh_token)
|
|
130
|
+
return false;
|
|
131
|
+
await save({ access: json.access_token, refresh: json.refresh_token });
|
|
132
|
+
return true;
|
|
133
|
+
}
|
|
134
|
+
async function refresh() {
|
|
135
|
+
if (state.refreshing)
|
|
136
|
+
return state.refreshing;
|
|
137
|
+
state.refreshing = (async () => {
|
|
138
|
+
const current = await load();
|
|
139
|
+
if (!current)
|
|
140
|
+
return null;
|
|
141
|
+
const res = await fetch(`${issuer}/token`, {
|
|
142
|
+
method: "POST",
|
|
143
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
144
|
+
body: new URLSearchParams({
|
|
145
|
+
grant_type: "refresh_token",
|
|
146
|
+
client_id: config.clientID,
|
|
147
|
+
refresh_token: current.refresh,
|
|
148
|
+
}),
|
|
149
|
+
}).catch(() => null);
|
|
150
|
+
const json = res?.ok
|
|
151
|
+
? (await res.json().catch(() => null))
|
|
152
|
+
: null;
|
|
153
|
+
if (!json?.access_token) {
|
|
154
|
+
// Refresh chết (hết 30 ngày, bị thu hồi) ⇒ xoá sạch. Giữ lại chỉ khiến mọi request sau đó
|
|
155
|
+
// 401 im lặng mà UI vẫn tưởng đang đăng nhập.
|
|
156
|
+
await save(null);
|
|
157
|
+
return null;
|
|
158
|
+
}
|
|
159
|
+
const next = { access: json.access_token, refresh: json.refresh_token ?? current.refresh };
|
|
160
|
+
await save(next);
|
|
161
|
+
return next;
|
|
162
|
+
})().finally(() => {
|
|
163
|
+
state.refreshing = null;
|
|
164
|
+
});
|
|
165
|
+
return state.refreshing;
|
|
166
|
+
}
|
|
167
|
+
async function logout() {
|
|
168
|
+
await save(null);
|
|
169
|
+
await storage.removeItem(PKCE_KEY);
|
|
170
|
+
}
|
|
171
|
+
/**
|
|
172
|
+
* `fetch` để truyền vào `createOpencodeClient({ fetch })`: tự gắn Bearer + `X-Workspace-ID`, và
|
|
173
|
+
* 401 thì refresh RỒI THỬ LẠI ĐÚNG MỘT LẦN (thử lại vòng vo sẽ biến token chết thành bão request).
|
|
174
|
+
*/
|
|
175
|
+
const authFetch = async (input, init) => {
|
|
176
|
+
const send = async (token) => {
|
|
177
|
+
const source = input instanceof Request ? input.headers : init?.headers;
|
|
178
|
+
const headers = new Headers(source);
|
|
179
|
+
if (input instanceof Request && init?.headers) {
|
|
180
|
+
new Headers(init.headers).forEach((v, k) => headers.set(k, v));
|
|
181
|
+
}
|
|
182
|
+
if (token)
|
|
183
|
+
headers.set("Authorization", `Bearer ${token}`);
|
|
184
|
+
if (state.workspaceID)
|
|
185
|
+
headers.set("X-Workspace-ID", state.workspaceID);
|
|
186
|
+
return fetch(input, { ...init, headers });
|
|
187
|
+
};
|
|
188
|
+
const current = await load();
|
|
189
|
+
const res = await send(current?.access ?? null);
|
|
190
|
+
if (res.status !== 401 || !current)
|
|
191
|
+
return res;
|
|
192
|
+
const renewed = await refresh();
|
|
193
|
+
if (!renewed)
|
|
194
|
+
return res;
|
|
195
|
+
return send(renewed.access);
|
|
196
|
+
};
|
|
197
|
+
return {
|
|
198
|
+
login,
|
|
199
|
+
authorizeUrl,
|
|
200
|
+
handleCallback,
|
|
201
|
+
refresh,
|
|
202
|
+
logout,
|
|
203
|
+
fetch: authFetch,
|
|
204
|
+
/** Token hiện tại (đã load từ storage) — dùng để hiện trạng thái đăng nhập. */
|
|
205
|
+
tokens: () => load(),
|
|
206
|
+
isAuthenticated: async () => !!(await load()),
|
|
207
|
+
/** Workspace gắn vào request sau đó. Lấy id từ `GET /workspaces`. */
|
|
208
|
+
setWorkspace: (id) => {
|
|
209
|
+
state.workspaceID = id;
|
|
210
|
+
},
|
|
211
|
+
workspaceID: () => state.workspaceID,
|
|
212
|
+
};
|
|
213
|
+
}
|
package/client.d.ts
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export * from "./gen/types.gen.js";
|
|
2
|
+
import { type Config } from "./gen/client/types.gen.js";
|
|
3
|
+
import { OpencodeClient } from "./gen/sdk.gen.js";
|
|
4
|
+
export { type Config as OpencodeClientConfig, OpencodeClient };
|
|
5
|
+
export declare function createOpencodeClient(config?: Config & {
|
|
6
|
+
directory?: string;
|
|
7
|
+
}): OpencodeClient;
|
package/client.js
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
export * from "./gen/types.gen.js";
|
|
2
|
+
import { createClient } from "./gen/client/client.gen.js";
|
|
3
|
+
import { OpencodeClient } from "./gen/sdk.gen.js";
|
|
4
|
+
export { OpencodeClient };
|
|
5
|
+
export function createOpencodeClient(config) {
|
|
6
|
+
if (!config?.fetch) {
|
|
7
|
+
const customFetch = (req) => {
|
|
8
|
+
// @ts-ignore
|
|
9
|
+
req.timeout = false;
|
|
10
|
+
return fetch(req);
|
|
11
|
+
};
|
|
12
|
+
config = {
|
|
13
|
+
...config,
|
|
14
|
+
fetch: customFetch,
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
if (config?.directory) {
|
|
18
|
+
config.headers = {
|
|
19
|
+
...config.headers,
|
|
20
|
+
"x-opencode-directory": config.directory,
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
const client = createClient(config);
|
|
24
|
+
return new OpencodeClient({ client });
|
|
25
|
+
}
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
// This file is auto-generated by @hey-api/openapi-ts
|
|
2
|
+
import { createSseClient } from "../core/serverSentEvents.gen.js";
|
|
3
|
+
import { buildUrl, createConfig, createInterceptors, getParseAs, mergeConfigs, mergeHeaders, setAuthParams, } from "./utils.gen.js";
|
|
4
|
+
export const createClient = (config = {}) => {
|
|
5
|
+
let _config = mergeConfigs(createConfig(), config);
|
|
6
|
+
const getConfig = () => ({ ..._config });
|
|
7
|
+
const setConfig = (config) => {
|
|
8
|
+
_config = mergeConfigs(_config, config);
|
|
9
|
+
return getConfig();
|
|
10
|
+
};
|
|
11
|
+
const interceptors = createInterceptors();
|
|
12
|
+
const beforeRequest = async (options) => {
|
|
13
|
+
const opts = {
|
|
14
|
+
..._config,
|
|
15
|
+
...options,
|
|
16
|
+
fetch: options.fetch ?? _config.fetch ?? globalThis.fetch,
|
|
17
|
+
headers: mergeHeaders(_config.headers, options.headers),
|
|
18
|
+
serializedBody: undefined,
|
|
19
|
+
};
|
|
20
|
+
if (opts.security) {
|
|
21
|
+
await setAuthParams({
|
|
22
|
+
...opts,
|
|
23
|
+
security: opts.security,
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
if (opts.requestValidator) {
|
|
27
|
+
await opts.requestValidator(opts);
|
|
28
|
+
}
|
|
29
|
+
if (opts.body && opts.bodySerializer) {
|
|
30
|
+
opts.serializedBody = opts.bodySerializer(opts.body);
|
|
31
|
+
}
|
|
32
|
+
// remove Content-Type header if body is empty to avoid sending invalid requests
|
|
33
|
+
if (opts.serializedBody === undefined || opts.serializedBody === "") {
|
|
34
|
+
opts.headers.delete("Content-Type");
|
|
35
|
+
}
|
|
36
|
+
const url = buildUrl(opts);
|
|
37
|
+
return { opts, url };
|
|
38
|
+
};
|
|
39
|
+
const request = async (options) => {
|
|
40
|
+
// @ts-expect-error
|
|
41
|
+
const { opts, url } = await beforeRequest(options);
|
|
42
|
+
const requestInit = {
|
|
43
|
+
redirect: "follow",
|
|
44
|
+
...opts,
|
|
45
|
+
body: opts.serializedBody,
|
|
46
|
+
};
|
|
47
|
+
let request = new Request(url, requestInit);
|
|
48
|
+
for (const fn of interceptors.request._fns) {
|
|
49
|
+
if (fn) {
|
|
50
|
+
request = await fn(request, opts);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
// fetch must be assigned here, otherwise it would throw the error:
|
|
54
|
+
// TypeError: Failed to execute 'fetch' on 'Window': Illegal invocation
|
|
55
|
+
const _fetch = opts.fetch;
|
|
56
|
+
let response = await _fetch(request);
|
|
57
|
+
for (const fn of interceptors.response._fns) {
|
|
58
|
+
if (fn) {
|
|
59
|
+
response = await fn(response, request, opts);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
const result = {
|
|
63
|
+
request,
|
|
64
|
+
response,
|
|
65
|
+
};
|
|
66
|
+
if (response.ok) {
|
|
67
|
+
if (response.status === 204 || response.headers.get("Content-Length") === "0") {
|
|
68
|
+
return opts.responseStyle === "data"
|
|
69
|
+
? {}
|
|
70
|
+
: {
|
|
71
|
+
data: {},
|
|
72
|
+
...result,
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
const parseAs = (opts.parseAs === "auto" ? getParseAs(response.headers.get("Content-Type")) : opts.parseAs) ?? "json";
|
|
76
|
+
let data;
|
|
77
|
+
switch (parseAs) {
|
|
78
|
+
case "arrayBuffer":
|
|
79
|
+
case "blob":
|
|
80
|
+
case "formData":
|
|
81
|
+
case "json":
|
|
82
|
+
case "text":
|
|
83
|
+
data = await response[parseAs]();
|
|
84
|
+
break;
|
|
85
|
+
case "stream":
|
|
86
|
+
return opts.responseStyle === "data"
|
|
87
|
+
? response.body
|
|
88
|
+
: {
|
|
89
|
+
data: response.body,
|
|
90
|
+
...result,
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
if (parseAs === "json") {
|
|
94
|
+
if (opts.responseValidator) {
|
|
95
|
+
await opts.responseValidator(data);
|
|
96
|
+
}
|
|
97
|
+
if (opts.responseTransformer) {
|
|
98
|
+
data = await opts.responseTransformer(data);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
return opts.responseStyle === "data"
|
|
102
|
+
? data
|
|
103
|
+
: {
|
|
104
|
+
data,
|
|
105
|
+
...result,
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
const textError = await response.text();
|
|
109
|
+
let jsonError;
|
|
110
|
+
try {
|
|
111
|
+
jsonError = JSON.parse(textError);
|
|
112
|
+
}
|
|
113
|
+
catch {
|
|
114
|
+
// noop
|
|
115
|
+
}
|
|
116
|
+
const error = jsonError ?? textError;
|
|
117
|
+
let finalError = error;
|
|
118
|
+
for (const fn of interceptors.error._fns) {
|
|
119
|
+
if (fn) {
|
|
120
|
+
finalError = (await fn(error, response, request, opts));
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
finalError = finalError || {};
|
|
124
|
+
if (opts.throwOnError) {
|
|
125
|
+
throw finalError;
|
|
126
|
+
}
|
|
127
|
+
// TODO: we probably want to return error and improve types
|
|
128
|
+
return opts.responseStyle === "data"
|
|
129
|
+
? undefined
|
|
130
|
+
: {
|
|
131
|
+
error: finalError,
|
|
132
|
+
...result,
|
|
133
|
+
};
|
|
134
|
+
};
|
|
135
|
+
const makeMethod = (method) => {
|
|
136
|
+
const fn = (options) => request({ ...options, method });
|
|
137
|
+
fn.sse = async (options) => {
|
|
138
|
+
const { opts, url } = await beforeRequest(options);
|
|
139
|
+
return createSseClient({
|
|
140
|
+
...opts,
|
|
141
|
+
body: opts.body,
|
|
142
|
+
headers: opts.headers,
|
|
143
|
+
method,
|
|
144
|
+
url,
|
|
145
|
+
});
|
|
146
|
+
};
|
|
147
|
+
return fn;
|
|
148
|
+
};
|
|
149
|
+
return {
|
|
150
|
+
buildUrl,
|
|
151
|
+
connect: makeMethod("CONNECT"),
|
|
152
|
+
delete: makeMethod("DELETE"),
|
|
153
|
+
get: makeMethod("GET"),
|
|
154
|
+
getConfig,
|
|
155
|
+
head: makeMethod("HEAD"),
|
|
156
|
+
interceptors,
|
|
157
|
+
options: makeMethod("OPTIONS"),
|
|
158
|
+
patch: makeMethod("PATCH"),
|
|
159
|
+
post: makeMethod("POST"),
|
|
160
|
+
put: makeMethod("PUT"),
|
|
161
|
+
request,
|
|
162
|
+
setConfig,
|
|
163
|
+
trace: makeMethod("TRACE"),
|
|
164
|
+
};
|
|
165
|
+
};
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export type { Auth } from "../core/auth.gen.js";
|
|
2
|
+
export type { QuerySerializerOptions } from "../core/bodySerializer.gen.js";
|
|
3
|
+
export { formDataBodySerializer, jsonBodySerializer, urlSearchParamsBodySerializer, } from "../core/bodySerializer.gen.js";
|
|
4
|
+
export { buildClientParams } from "../core/params.gen.js";
|
|
5
|
+
export { createClient } from "./client.gen.js";
|
|
6
|
+
export type { Client, ClientOptions, Config, CreateClientConfig, Options, OptionsLegacyParser, RequestOptions, RequestResult, ResolvedRequestOptions, ResponseStyle, TDataShape, } from "./types.gen.js";
|
|
7
|
+
export { createConfig, mergeHeaders } from "./utils.gen.js";
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
// This file is auto-generated by @hey-api/openapi-ts
|
|
2
|
+
export { formDataBodySerializer, jsonBodySerializer, urlSearchParamsBodySerializer, } from "../core/bodySerializer.gen.js";
|
|
3
|
+
export { buildClientParams } from "../core/params.gen.js";
|
|
4
|
+
export { createClient } from "./client.gen.js";
|
|
5
|
+
export { createConfig, mergeHeaders } from "./utils.gen.js";
|