entity-server-client 0.2.6 → 0.3.1
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 +32 -0
- package/build.mjs +7 -0
- package/docs/api/alimtalk.md +62 -0
- package/docs/api/auth.md +256 -0
- package/docs/api/email.md +37 -0
- package/docs/api/entity.md +273 -0
- package/docs/api/file.md +80 -0
- package/docs/api/health.md +47 -0
- package/docs/api/identity.md +32 -0
- package/docs/api/import.md +45 -0
- package/docs/api/packet.md +90 -0
- package/docs/api/pg.md +90 -0
- package/docs/api/push.md +107 -0
- package/docs/api/react.md +141 -0
- package/docs/api/request.md +118 -0
- package/docs/api/setup.md +43 -0
- package/docs/api/sms.md +45 -0
- package/docs/api/smtp.md +33 -0
- package/docs/api/transaction.md +50 -0
- package/docs/api/utils.md +52 -0
- package/docs/apis.md +22 -779
- package/docs/react.md +58 -0
- package/package.json +6 -1
- package/src/EntityServerClient.ts +5 -31
- package/src/client/base.ts +114 -12
- package/src/client/packet.ts +8 -31
- package/src/client/request.ts +52 -6
- package/src/client/utils.ts +5 -6
- package/src/mixins/auth.ts +14 -158
- package/src/mixins/push.ts +0 -23
- package/src/mixins/utils.ts +32 -1
- package/src/packet.ts +84 -0
- package/src/types.ts +15 -125
- package/tests/packet.test.mjs +50 -0
- package/dist/EntityServerClient.d.ts +0 -709
- package/dist/client/base.d.ts +0 -59
- package/dist/client/hmac.d.ts +0 -8
- package/dist/client/packet.d.ts +0 -24
- package/dist/client/request.d.ts +0 -15
- package/dist/client/utils.d.ts +0 -8
- package/dist/hooks/useEntityServer.d.ts +0 -63
- package/dist/index.d.ts +0 -4
- package/dist/index.js +0 -2
- package/dist/index.js.map +0 -7
- package/dist/mixins/alimtalk.d.ts +0 -56
- package/dist/mixins/auth.d.ts +0 -167
- package/dist/mixins/email.d.ts +0 -51
- package/dist/mixins/entity.d.ts +0 -119
- package/dist/mixins/file.d.ts +0 -78
- package/dist/mixins/identity.d.ts +0 -52
- package/dist/mixins/pg.d.ts +0 -63
- package/dist/mixins/push.d.ts +0 -110
- package/dist/mixins/sms.d.ts +0 -55
- package/dist/mixins/smtp.d.ts +0 -44
- package/dist/mixins/utils.d.ts +0 -70
- package/dist/react.d.ts +0 -1
- package/dist/react.js +0 -2
- package/dist/react.js.map +0 -7
- package/dist/types.d.ts +0 -329
- package/src/mixins/alimtalk.ts +0 -35
- package/src/mixins/email.ts +0 -46
- package/src/mixins/identity.ts +0 -35
- package/src/mixins/pg.ts +0 -58
- package/src/mixins/sms.ts +0 -46
package/docs/react.md
CHANGED
|
@@ -26,6 +26,64 @@ export function AccountPage() {
|
|
|
26
26
|
}
|
|
27
27
|
```
|
|
28
28
|
|
|
29
|
+
## `client`로 사용 가능한 모든 API
|
|
30
|
+
|
|
31
|
+
`client`는 `EntityServerClient` 인스턴스 그대로이므로 엔티티 CRUD 외에도 **모든 API**를 호출할 수 있습니다.
|
|
32
|
+
`submit` / `del` / `query` 래퍼는 `isPending` / `error` 상태를 자동 관리해주는 편의 메서드일 뿐입니다.
|
|
33
|
+
|
|
34
|
+
```tsx
|
|
35
|
+
const { client, submit, del } = useEntityServer({
|
|
36
|
+
tokenResolver: () => localStorage.getItem("auth_access_token"),
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
// 인증
|
|
40
|
+
await client.login("hong@example.com", "pw");
|
|
41
|
+
await client.logout();
|
|
42
|
+
|
|
43
|
+
// 트랜잭션
|
|
44
|
+
await client.transStart();
|
|
45
|
+
await client.submit("order", { product_seq: 1, qty: 2 });
|
|
46
|
+
await client.transCommit();
|
|
47
|
+
|
|
48
|
+
// 파일 업로드
|
|
49
|
+
const res = await client.fileUpload("product", file, { refSeq: 10 });
|
|
50
|
+
|
|
51
|
+
// SMS 인증
|
|
52
|
+
await client.smsVerificationSend("01012345678");
|
|
53
|
+
await client.smsVerificationVerify("01012345678", "123456");
|
|
54
|
+
|
|
55
|
+
// 알림톡 발송
|
|
56
|
+
await client.alimtalkSend({
|
|
57
|
+
to: "01012345678",
|
|
58
|
+
templateCode: "ORDER_CONFIRM",
|
|
59
|
+
variables: {},
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
// PG 결제
|
|
63
|
+
const order = await client.pgCreateOrder({
|
|
64
|
+
orderId: "ord-001",
|
|
65
|
+
amount: 15000,
|
|
66
|
+
orderName: "상품 A",
|
|
67
|
+
customerName: "홍길동",
|
|
68
|
+
customerEmail: "hong@example.com",
|
|
69
|
+
});
|
|
70
|
+
await client.pgConfirmPayment({
|
|
71
|
+
paymentKey: "key",
|
|
72
|
+
orderId: "ord-001",
|
|
73
|
+
amount: 15000,
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
// 본인인증
|
|
77
|
+
const req = await client.identityRequest({
|
|
78
|
+
redirect_url: "https://example.com/callback",
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
// QR코드
|
|
82
|
+
const buf = await client.qrcode("https://example.com", { size: 300 });
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
카테고리별 상세 파라미터는 [api/](api/) 폴더의 각 문서를 참고하세요.
|
|
86
|
+
|
|
29
87
|
## 옵션
|
|
30
88
|
|
|
31
89
|
### `singleton` (기본: `true`)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "entity-server-client",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.1",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"publishConfig": {
|
|
@@ -11,6 +11,10 @@
|
|
|
11
11
|
"types": "./dist/index.d.ts",
|
|
12
12
|
"default": "./dist/index.js"
|
|
13
13
|
},
|
|
14
|
+
"./packet": {
|
|
15
|
+
"types": "./dist/packet.d.ts",
|
|
16
|
+
"default": "./dist/packet.js"
|
|
17
|
+
},
|
|
14
18
|
"./react": {
|
|
15
19
|
"types": "./dist/react.d.ts",
|
|
16
20
|
"default": "./dist/react.js"
|
|
@@ -20,6 +24,7 @@
|
|
|
20
24
|
"types": "./dist/index.d.ts",
|
|
21
25
|
"scripts": {
|
|
22
26
|
"build": "tsc --declaration --emitDeclarationOnly && node build.mjs",
|
|
27
|
+
"test": "npm run build && node --test tests/**/*.test.mjs",
|
|
23
28
|
"prepublishOnly": "npm run build"
|
|
24
29
|
},
|
|
25
30
|
"peerDependencies": {
|
|
@@ -4,51 +4,25 @@
|
|
|
4
4
|
*
|
|
5
5
|
* 절(section)별 구현:
|
|
6
6
|
* src/client/base.ts — 상태·생성자·공통 헬퍼
|
|
7
|
-
* src/mixins/auth.ts — 인증 (로그인/로그아웃/
|
|
7
|
+
* src/mixins/auth.ts — 인증 (로그인/로그아웃/me/트랜잭션 등)
|
|
8
8
|
* src/mixins/entity.ts — 트랜잭션 & 엔티티 CRUD
|
|
9
|
-
* src/mixins/push.ts — 푸시 디바이스 관리
|
|
10
|
-
* src/mixins/email.ts — 이메일 인증/변경
|
|
11
|
-
* src/mixins/sms.ts — SMS 발송/인증
|
|
9
|
+
* src/mixins/push.ts — 푸시 디바이스 관리
|
|
12
10
|
* src/mixins/smtp.ts — SMTP 메일 발송
|
|
13
|
-
* src/mixins/alimtalk.ts — 알림톡/친구톡
|
|
14
|
-
* src/mixins/pg.ts — PG 결제 게이트웨이
|
|
15
11
|
* src/mixins/file.ts — 파일 스토리지
|
|
16
|
-
* src/mixins/
|
|
17
|
-
* src/mixins/utils.ts — QR코드/바코드
|
|
12
|
+
* src/mixins/utils.ts — QR코드/바코드/PDF변환
|
|
18
13
|
*/
|
|
19
14
|
import { EntityServerClientBase } from "./client/base";
|
|
20
15
|
import { AuthMixin } from "./mixins/auth";
|
|
21
16
|
import { EntityMixin } from "./mixins/entity";
|
|
22
17
|
import { PushMixin } from "./mixins/push";
|
|
23
|
-
import { EmailMixin } from "./mixins/email";
|
|
24
|
-
import { SmsMixin } from "./mixins/sms";
|
|
25
18
|
import { SmtpMixin } from "./mixins/smtp";
|
|
26
|
-
import { AlimtalkMixin } from "./mixins/alimtalk";
|
|
27
|
-
import { PgMixin } from "./mixins/pg";
|
|
28
19
|
import { FileMixin } from "./mixins/file";
|
|
29
|
-
import { IdentityMixin } from "./mixins/identity";
|
|
30
20
|
import { UtilsMixin } from "./mixins/utils";
|
|
31
21
|
|
|
32
22
|
// ─── Composed class ───────────────────────────────────────────────────────────
|
|
33
23
|
|
|
34
24
|
export class EntityServerClient extends UtilsMixin(
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
PgMixin(
|
|
38
|
-
AlimtalkMixin(
|
|
39
|
-
SmtpMixin(
|
|
40
|
-
SmsMixin(
|
|
41
|
-
EmailMixin(
|
|
42
|
-
PushMixin(
|
|
43
|
-
EntityMixin(
|
|
44
|
-
AuthMixin(EntityServerClientBase),
|
|
45
|
-
),
|
|
46
|
-
),
|
|
47
|
-
),
|
|
48
|
-
),
|
|
49
|
-
),
|
|
50
|
-
),
|
|
51
|
-
),
|
|
52
|
-
),
|
|
25
|
+
FileMixin(
|
|
26
|
+
SmtpMixin(PushMixin(EntityMixin(AuthMixin(EntityServerClientBase)))),
|
|
53
27
|
),
|
|
54
28
|
) {}
|
package/src/client/base.ts
CHANGED
|
@@ -9,6 +9,7 @@ export type GConstructor<T = object> = new (...args: any[]) => T;
|
|
|
9
9
|
export class EntityServerClientBase {
|
|
10
10
|
baseUrl: string;
|
|
11
11
|
token: string;
|
|
12
|
+
anonymousPacketToken: string;
|
|
12
13
|
apiKey: string;
|
|
13
14
|
hmacSecret: string;
|
|
14
15
|
encryptRequests: boolean;
|
|
@@ -17,10 +18,7 @@ export class EntityServerClientBase {
|
|
|
17
18
|
// 세션 유지 관련
|
|
18
19
|
keepSession: boolean;
|
|
19
20
|
refreshBuffer: number;
|
|
20
|
-
onTokenRefreshed?: (
|
|
21
|
-
accessToken: string,
|
|
22
|
-
expiresIn: number,
|
|
23
|
-
) => void;
|
|
21
|
+
onTokenRefreshed?: (accessToken: string, expiresIn: number) => void;
|
|
24
22
|
onSessionExpired?: (error: Error) => void;
|
|
25
23
|
_sessionRefreshToken: string | null = null;
|
|
26
24
|
_refreshTimer: ReturnType<typeof setTimeout> | null = null;
|
|
@@ -31,17 +29,14 @@ export class EntityServerClientBase {
|
|
|
31
29
|
* EntityServerClient 인스턴스를 생성합니다.
|
|
32
30
|
*
|
|
33
31
|
* 기본값:
|
|
34
|
-
* - `baseUrl`: `VITE_ENTITY_SERVER_URL` 또는 `
|
|
32
|
+
* - `baseUrl`: `VITE_ENTITY_SERVER_URL` 또는 상대 경로(`""`)
|
|
35
33
|
*/
|
|
36
34
|
constructor(options: EntityServerClientOptions = {}) {
|
|
37
35
|
const envBaseUrl = readEnv("VITE_ENTITY_SERVER_URL");
|
|
38
36
|
|
|
39
|
-
this.baseUrl = (
|
|
40
|
-
options.baseUrl ??
|
|
41
|
-
envBaseUrl ??
|
|
42
|
-
"http://localhost:47200"
|
|
43
|
-
).replace(/\/$/, "");
|
|
37
|
+
this.baseUrl = (options.baseUrl ?? envBaseUrl ?? "").replace(/\/$/, "");
|
|
44
38
|
this.token = options.token ?? "";
|
|
39
|
+
this.anonymousPacketToken = options.anonymousPacketToken ?? "";
|
|
45
40
|
this.apiKey = options.apiKey ?? "";
|
|
46
41
|
this.hmacSecret = options.hmacSecret ?? "";
|
|
47
42
|
this.encryptRequests = options.encryptRequests ?? false;
|
|
@@ -53,8 +48,13 @@ export class EntityServerClientBase {
|
|
|
53
48
|
|
|
54
49
|
/** baseUrl, token, encryptRequests 값을 런타임에 갱신합니다. */
|
|
55
50
|
configure(options: Partial<EntityServerClientOptions>): void {
|
|
56
|
-
if (options.baseUrl
|
|
51
|
+
if (typeof options.baseUrl === "string") {
|
|
52
|
+
this.baseUrl = options.baseUrl.replace(/\/$/, "");
|
|
53
|
+
}
|
|
57
54
|
if (typeof options.token === "string") this.token = options.token;
|
|
55
|
+
if (typeof options.anonymousPacketToken === "string") {
|
|
56
|
+
this.anonymousPacketToken = options.anonymousPacketToken;
|
|
57
|
+
}
|
|
58
58
|
if (typeof options.encryptRequests === "boolean")
|
|
59
59
|
this.encryptRequests = options.encryptRequests;
|
|
60
60
|
if (typeof options.apiKey === "string") this.apiKey = options.apiKey;
|
|
@@ -75,6 +75,11 @@ export class EntityServerClientBase {
|
|
|
75
75
|
this.token = token;
|
|
76
76
|
}
|
|
77
77
|
|
|
78
|
+
/** 익명 패킷 암호화용 토큰을 설정합니다. */
|
|
79
|
+
setAnonymousPacketToken(token: string): void {
|
|
80
|
+
this.anonymousPacketToken = token;
|
|
81
|
+
}
|
|
82
|
+
|
|
78
83
|
/** HMAC 인증용 API Key를 설정합니다. */
|
|
79
84
|
setApiKey(apiKey: string): void {
|
|
80
85
|
this.apiKey = apiKey;
|
|
@@ -152,7 +157,10 @@ export class EntityServerClientBase {
|
|
|
152
157
|
contentType = "application/json",
|
|
153
158
|
requireEncrypted = false,
|
|
154
159
|
): T {
|
|
155
|
-
const key = derivePacketKey(
|
|
160
|
+
const key = derivePacketKey(
|
|
161
|
+
this.hmacSecret,
|
|
162
|
+
this.token || this.anonymousPacketToken,
|
|
163
|
+
);
|
|
156
164
|
return parseRequestBody<T>(body, contentType, requireEncrypted, key);
|
|
157
165
|
}
|
|
158
166
|
|
|
@@ -162,12 +170,74 @@ export class EntityServerClientBase {
|
|
|
162
170
|
return {
|
|
163
171
|
baseUrl: this.baseUrl,
|
|
164
172
|
token: this.token,
|
|
173
|
+
anonymousPacketToken: this.anonymousPacketToken,
|
|
165
174
|
apiKey: this.apiKey,
|
|
166
175
|
hmacSecret: this.hmacSecret,
|
|
167
176
|
encryptRequests: this.encryptRequests,
|
|
168
177
|
};
|
|
169
178
|
}
|
|
170
179
|
|
|
180
|
+
/**
|
|
181
|
+
* 임의 경로에 JSON 요청을 보냅니다. 응답이 JSON이면 파싱, octet-stream이면 자동 복호화합니다.
|
|
182
|
+
* `ok` 필드를 강제하지 않아 go서버/앱서버 신규 라우트 등 자유 응답 포맷에 사용합니다.
|
|
183
|
+
* `encryptRequests: true`이면 요청 바디도 자동 암호화됩니다.
|
|
184
|
+
*/
|
|
185
|
+
requestJson<T>(
|
|
186
|
+
method: string,
|
|
187
|
+
path: string,
|
|
188
|
+
body?: unknown,
|
|
189
|
+
withAuth = true,
|
|
190
|
+
extraHeaders?: Record<string, string>,
|
|
191
|
+
): Promise<T> {
|
|
192
|
+
return entityRequest<T>(
|
|
193
|
+
this._reqOpts,
|
|
194
|
+
method,
|
|
195
|
+
path,
|
|
196
|
+
body,
|
|
197
|
+
withAuth,
|
|
198
|
+
extraHeaders,
|
|
199
|
+
false,
|
|
200
|
+
);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* 임의 경로에 요청을 보내고 바이너리(ArrayBuffer)를 반환합니다.
|
|
205
|
+
* 이미지, PDF, 압축 파일 등 바이너리 응답이 오는 엔드포인트에 사용합니다.
|
|
206
|
+
*/
|
|
207
|
+
requestBinary(
|
|
208
|
+
method: string,
|
|
209
|
+
path: string,
|
|
210
|
+
body?: unknown,
|
|
211
|
+
withAuth = true,
|
|
212
|
+
): Promise<ArrayBuffer> {
|
|
213
|
+
return this._requestBinary(method, path, body, withAuth);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* multipart/form-data 요청을 보냅니다. 파일 업로드 등에 사용합니다.
|
|
218
|
+
* 응답은 JSON으로 파싱하여 반환합니다.
|
|
219
|
+
*/
|
|
220
|
+
requestForm<T>(
|
|
221
|
+
method: string,
|
|
222
|
+
path: string,
|
|
223
|
+
form: FormData,
|
|
224
|
+
withAuth = true,
|
|
225
|
+
): Promise<T> {
|
|
226
|
+
return this._requestForm<T>(method, path, form, withAuth);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* multipart/form-data 요청을 보내고 바이너리(ArrayBuffer)를 반환합니다.
|
|
231
|
+
*/
|
|
232
|
+
requestFormBinary(
|
|
233
|
+
method: string,
|
|
234
|
+
path: string,
|
|
235
|
+
form: FormData,
|
|
236
|
+
withAuth = true,
|
|
237
|
+
): Promise<ArrayBuffer> {
|
|
238
|
+
return this._requestFormBinary(method, path, form, withAuth);
|
|
239
|
+
}
|
|
240
|
+
|
|
171
241
|
_request<T>(
|
|
172
242
|
method: string,
|
|
173
243
|
path: string,
|
|
@@ -182,6 +252,7 @@ export class EntityServerClientBase {
|
|
|
182
252
|
body,
|
|
183
253
|
withAuth,
|
|
184
254
|
extraHeaders,
|
|
255
|
+
true,
|
|
185
256
|
);
|
|
186
257
|
}
|
|
187
258
|
|
|
@@ -203,6 +274,7 @@ export class EntityServerClientBase {
|
|
|
203
274
|
method,
|
|
204
275
|
headers,
|
|
205
276
|
...(body != null ? { body: JSON.stringify(body) } : {}),
|
|
277
|
+
credentials: "include",
|
|
206
278
|
});
|
|
207
279
|
|
|
208
280
|
if (!res.ok) {
|
|
@@ -231,6 +303,7 @@ export class EntityServerClientBase {
|
|
|
231
303
|
method,
|
|
232
304
|
headers,
|
|
233
305
|
body: form,
|
|
306
|
+
credentials: "include",
|
|
234
307
|
});
|
|
235
308
|
|
|
236
309
|
const data = await res.json();
|
|
@@ -243,4 +316,33 @@ export class EntityServerClientBase {
|
|
|
243
316
|
}
|
|
244
317
|
return data as T;
|
|
245
318
|
}
|
|
319
|
+
|
|
320
|
+
/** multipart/form-data 요청을 보내고 바이너리(ArrayBuffer)를 반환합니다. */
|
|
321
|
+
async _requestFormBinary(
|
|
322
|
+
method: string,
|
|
323
|
+
path: string,
|
|
324
|
+
form: FormData,
|
|
325
|
+
withAuth = true,
|
|
326
|
+
): Promise<ArrayBuffer> {
|
|
327
|
+
const headers: Record<string, string> = {};
|
|
328
|
+
if (withAuth && this.token)
|
|
329
|
+
headers["Authorization"] = `Bearer ${this.token}`;
|
|
330
|
+
if (this.apiKey) headers["X-API-Key"] = this.apiKey;
|
|
331
|
+
|
|
332
|
+
const res = await fetch(this.baseUrl + path, {
|
|
333
|
+
method,
|
|
334
|
+
headers,
|
|
335
|
+
body: form,
|
|
336
|
+
credentials: "include",
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
if (!res.ok) {
|
|
340
|
+
const text = await res.text();
|
|
341
|
+
const err = new Error(`HTTP ${res.status}: ${text}`);
|
|
342
|
+
(err as { status?: number }).status = res.status;
|
|
343
|
+
throw err;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
return res.arrayBuffer();
|
|
347
|
+
}
|
|
246
348
|
}
|
package/src/client/packet.ts
CHANGED
|
@@ -1,9 +1,8 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
import { hkdf } from "@noble/hashes/hkdf";
|
|
1
|
+
import {
|
|
2
|
+
derivePacketKey as derivePacketKeyCore,
|
|
3
|
+
encryptPacket as encryptPacketCore,
|
|
4
|
+
decryptPacket as decryptPacketCore,
|
|
5
|
+
} from "../packet";
|
|
7
6
|
|
|
8
7
|
/**
|
|
9
8
|
* 패킷 암호화 키를 유도합니다.
|
|
@@ -11,10 +10,7 @@ import { hkdf } from "@noble/hashes/hkdf";
|
|
|
11
10
|
* - JWT 모드: HKDF-SHA256(jwt_token, "entity-server:packet-encryption")
|
|
12
11
|
*/
|
|
13
12
|
export function derivePacketKey(hmacSecret: string, token: string): Uint8Array {
|
|
14
|
-
|
|
15
|
-
const salt = new TextEncoder().encode("entity-server:hkdf:v1");
|
|
16
|
-
const info = new TextEncoder().encode("entity-server:packet-encryption");
|
|
17
|
-
return hkdf(sha256, new TextEncoder().encode(ikm), salt, info, 32);
|
|
13
|
+
return derivePacketKeyCore(hmacSecret || token);
|
|
18
14
|
}
|
|
19
15
|
|
|
20
16
|
/**
|
|
@@ -26,18 +22,7 @@ export function encryptPacket(
|
|
|
26
22
|
plaintext: Uint8Array,
|
|
27
23
|
key: Uint8Array,
|
|
28
24
|
): Uint8Array {
|
|
29
|
-
|
|
30
|
-
const magic = new Uint8Array(magicLen);
|
|
31
|
-
const nonce = new Uint8Array(24);
|
|
32
|
-
crypto.getRandomValues(magic);
|
|
33
|
-
crypto.getRandomValues(nonce);
|
|
34
|
-
const cipher = xchacha20poly1305(key, nonce);
|
|
35
|
-
const ciphertext = cipher.encrypt(plaintext);
|
|
36
|
-
const result = new Uint8Array(magicLen + 24 + ciphertext.length);
|
|
37
|
-
result.set(magic, 0);
|
|
38
|
-
result.set(nonce, magicLen);
|
|
39
|
-
result.set(ciphertext, magicLen + 24);
|
|
40
|
-
return result;
|
|
25
|
+
return encryptPacketCore(plaintext, key);
|
|
41
26
|
}
|
|
42
27
|
|
|
43
28
|
/**
|
|
@@ -46,15 +31,7 @@ export function encryptPacket(
|
|
|
46
31
|
* K = 2 + key[31] % 14 (패킷 키에서 자동 파생)
|
|
47
32
|
*/
|
|
48
33
|
export function decryptPacket<T>(buffer: ArrayBuffer, key: Uint8Array): T {
|
|
49
|
-
const
|
|
50
|
-
const data = new Uint8Array(buffer);
|
|
51
|
-
if (data.length < magicLen + 24 + 16) {
|
|
52
|
-
throw new Error("Encrypted packet too short");
|
|
53
|
-
}
|
|
54
|
-
const nonce = data.slice(magicLen, magicLen + 24);
|
|
55
|
-
const ciphertext = data.slice(magicLen + 24);
|
|
56
|
-
const cipher = xchacha20poly1305(key, nonce);
|
|
57
|
-
const plaintext = cipher.decrypt(ciphertext);
|
|
34
|
+
const plaintext = decryptPacketCore(buffer, key);
|
|
58
35
|
return JSON.parse(new TextDecoder().decode(plaintext)) as T;
|
|
59
36
|
}
|
|
60
37
|
|
package/src/client/request.ts
CHANGED
|
@@ -4,11 +4,31 @@ import { buildHmacHeaders } from "./hmac";
|
|
|
4
4
|
export interface RequestOptions {
|
|
5
5
|
baseUrl: string;
|
|
6
6
|
token: string;
|
|
7
|
+
anonymousPacketToken: string;
|
|
7
8
|
apiKey: string;
|
|
8
9
|
hmacSecret: string;
|
|
9
10
|
encryptRequests: boolean;
|
|
10
11
|
}
|
|
11
12
|
|
|
13
|
+
function resolvePacketSource(opts: RequestOptions): string {
|
|
14
|
+
return opts.hmacSecret || opts.token || opts.anonymousPacketToken;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
async function readErrorMessage(res: Response): Promise<string> {
|
|
18
|
+
const contentType = res.headers.get("Content-Type") ?? "";
|
|
19
|
+
if (contentType.includes("application/json")) {
|
|
20
|
+
const data = (await res.json().catch(() => null)) as {
|
|
21
|
+
error?: string;
|
|
22
|
+
message?: string;
|
|
23
|
+
} | null;
|
|
24
|
+
if (data?.error) return data.error;
|
|
25
|
+
if (data?.message) return data.message;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const text = await res.text().catch(() => "");
|
|
29
|
+
return text || `HTTP ${res.status}`;
|
|
30
|
+
}
|
|
31
|
+
|
|
12
32
|
/**
|
|
13
33
|
* Entity Server에 HTTP 요청을 보냅니다.
|
|
14
34
|
*
|
|
@@ -23,9 +43,18 @@ export async function entityRequest<T>(
|
|
|
23
43
|
body?: unknown,
|
|
24
44
|
withAuth = true,
|
|
25
45
|
extraHeaders: Record<string, string> = {},
|
|
46
|
+
requireOkShape = true,
|
|
26
47
|
): Promise<T> {
|
|
27
|
-
const {
|
|
48
|
+
const {
|
|
49
|
+
baseUrl,
|
|
50
|
+
token,
|
|
51
|
+
apiKey,
|
|
52
|
+
hmacSecret,
|
|
53
|
+
encryptRequests,
|
|
54
|
+
anonymousPacketToken,
|
|
55
|
+
} = opts;
|
|
28
56
|
const isHmacMode = withAuth && !!(apiKey && hmacSecret);
|
|
57
|
+
const packetSource = resolvePacketSource(opts);
|
|
29
58
|
|
|
30
59
|
const headers: Record<string, string> = {
|
|
31
60
|
"Content-Type": "application/json",
|
|
@@ -39,18 +68,23 @@ export async function entityRequest<T>(
|
|
|
39
68
|
if (body != null) {
|
|
40
69
|
const shouldEncrypt =
|
|
41
70
|
encryptRequests &&
|
|
42
|
-
|
|
43
|
-
(token || isHmacMode) &&
|
|
71
|
+
!!packetSource &&
|
|
44
72
|
method !== "GET" &&
|
|
45
73
|
method !== "HEAD";
|
|
46
74
|
|
|
47
75
|
if (shouldEncrypt) {
|
|
48
|
-
const key = derivePacketKey(
|
|
76
|
+
const key = derivePacketKey(
|
|
77
|
+
hmacSecret,
|
|
78
|
+
token || anonymousPacketToken,
|
|
79
|
+
);
|
|
49
80
|
fetchBody = encryptPacket(
|
|
50
81
|
new TextEncoder().encode(JSON.stringify(body)),
|
|
51
82
|
key,
|
|
52
83
|
);
|
|
53
84
|
headers["Content-Type"] = "application/octet-stream";
|
|
85
|
+
if (!token && !isHmacMode && anonymousPacketToken) {
|
|
86
|
+
headers["X-Packet-Token"] = anonymousPacketToken;
|
|
87
|
+
}
|
|
54
88
|
} else {
|
|
55
89
|
fetchBody = JSON.stringify(body);
|
|
56
90
|
}
|
|
@@ -73,21 +107,33 @@ export async function entityRequest<T>(
|
|
|
73
107
|
method,
|
|
74
108
|
headers,
|
|
75
109
|
...(fetchBody != null ? { body: fetchBody as BodyInit } : {}),
|
|
110
|
+
credentials: "include",
|
|
76
111
|
});
|
|
77
112
|
|
|
113
|
+
if (!res.ok) {
|
|
114
|
+
const err = new Error(await readErrorMessage(res));
|
|
115
|
+
(err as { status?: number }).status = res.status;
|
|
116
|
+
throw err;
|
|
117
|
+
}
|
|
118
|
+
|
|
78
119
|
const contentType = res.headers.get("Content-Type") ?? "";
|
|
79
120
|
if (contentType.includes("application/octet-stream")) {
|
|
80
|
-
const key = derivePacketKey(hmacSecret, token);
|
|
121
|
+
const key = derivePacketKey(hmacSecret, token || anonymousPacketToken);
|
|
81
122
|
return decryptPacket<T>(await res.arrayBuffer(), key);
|
|
82
123
|
}
|
|
83
124
|
|
|
125
|
+
if (!contentType.includes("application/json")) {
|
|
126
|
+
return (await res.text()) as T;
|
|
127
|
+
}
|
|
128
|
+
|
|
84
129
|
const data = await res.json();
|
|
85
|
-
if (!data.ok) {
|
|
130
|
+
if (requireOkShape && !data.ok) {
|
|
86
131
|
const err = new Error(
|
|
87
132
|
data.message ?? `EntityServer error (HTTP ${res.status})`,
|
|
88
133
|
);
|
|
89
134
|
(err as { status?: number }).status = res.status;
|
|
90
135
|
throw err;
|
|
91
136
|
}
|
|
137
|
+
|
|
92
138
|
return data as T;
|
|
93
139
|
}
|
package/src/client/utils.ts
CHANGED
|
@@ -11,12 +11,11 @@ export function readEnv(name: string): string | undefined {
|
|
|
11
11
|
if (meta?.env?.[name] != null) return meta.env[name];
|
|
12
12
|
|
|
13
13
|
// Node.js (process.env)
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
return process.env[name];
|
|
14
|
+
const _proc = (
|
|
15
|
+
globalThis as { process?: { env?: Record<string, string | undefined> } }
|
|
16
|
+
).process;
|
|
17
|
+
if (_proc?.env?.[name] != null) {
|
|
18
|
+
return _proc.env[name];
|
|
20
19
|
}
|
|
21
20
|
|
|
22
21
|
return undefined;
|