entity-server-client 0.3.2 → 1.0.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/dist/EntityServerClient.d.ts +432 -0
- package/dist/client/base.d.ts +84 -0
- package/dist/client/hmac.d.ts +8 -0
- package/dist/client/packet.d.ts +24 -0
- package/dist/client/request.d.ts +16 -0
- package/dist/client/utils.d.ts +8 -0
- package/dist/hooks/useEntityServer.d.ts +63 -0
- package/{src/index.ts → dist/index.d.ts} +1 -3
- package/dist/index.js +2 -0
- package/dist/index.js.map +7 -0
- package/dist/mixins/alimtalk.d.ts +56 -0
- package/dist/mixins/auth.d.ts +100 -0
- package/dist/mixins/email.d.ts +51 -0
- package/dist/mixins/entity.d.ts +126 -0
- package/dist/mixins/file.d.ts +85 -0
- package/dist/mixins/identity.d.ts +52 -0
- package/dist/mixins/llm.d.ts +94 -0
- package/dist/mixins/pg.d.ts +63 -0
- package/dist/mixins/push.d.ts +101 -0
- package/dist/mixins/sms.d.ts +55 -0
- package/dist/mixins/smtp.d.ts +51 -0
- package/dist/mixins/utils.d.ts +88 -0
- package/dist/packet.d.ts +11 -0
- package/dist/packet.js +2 -0
- package/dist/packet.js.map +7 -0
- package/dist/react.js +2 -0
- package/dist/react.js.map +7 -0
- package/{src/types.ts → dist/types.d.ts} +2 -42
- package/package.json +9 -36
- package/LICENSE +0 -21
- package/README.md +0 -128
- package/build.mjs +0 -36
- package/docs/api/alimtalk.md +0 -62
- package/docs/api/auth.md +0 -256
- package/docs/api/email.md +0 -37
- package/docs/api/entity.md +0 -273
- package/docs/api/file.md +0 -80
- package/docs/api/health.md +0 -47
- package/docs/api/identity.md +0 -32
- package/docs/api/import.md +0 -45
- package/docs/api/packet.md +0 -90
- package/docs/api/pg.md +0 -90
- package/docs/api/push.md +0 -107
- package/docs/api/react.md +0 -141
- package/docs/api/request.md +0 -118
- package/docs/api/setup.md +0 -43
- package/docs/api/sms.md +0 -45
- package/docs/api/smtp.md +0 -33
- package/docs/api/transaction.md +0 -50
- package/docs/api/utils.md +0 -52
- package/docs/apis.md +0 -26
- package/docs/react.md +0 -137
- package/src/EntityServerClient.ts +0 -28
- package/src/client/base.ts +0 -348
- package/src/client/hmac.ts +0 -41
- package/src/client/packet.ts +0 -77
- package/src/client/request.ts +0 -139
- package/src/client/utils.ts +0 -33
- package/src/hooks/useEntityServer.ts +0 -154
- package/src/mixins/auth.ts +0 -143
- package/src/mixins/entity.ts +0 -205
- package/src/mixins/file.ts +0 -99
- package/src/mixins/push.ts +0 -109
- package/src/mixins/smtp.ts +0 -20
- package/src/mixins/utils.ts +0 -106
- package/src/packet.ts +0 -84
- package/tests/packet.test.mjs +0 -50
- package/tsconfig.json +0 -14
- /package/{src/react.ts → dist/react.d.ts} +0 -0
package/src/client/base.ts
DELETED
|
@@ -1,348 +0,0 @@
|
|
|
1
|
-
import type { EntityServerClientOptions } from "../types";
|
|
2
|
-
import { readEnv } from "./utils";
|
|
3
|
-
import { derivePacketKey, parseRequestBody } from "./packet";
|
|
4
|
-
import { entityRequest, type RequestOptions } from "./request";
|
|
5
|
-
|
|
6
|
-
// mixin 헬퍼 타입
|
|
7
|
-
export type GConstructor<T = object> = new (...args: any[]) => T;
|
|
8
|
-
|
|
9
|
-
export class EntityServerClientBase {
|
|
10
|
-
baseUrl: string;
|
|
11
|
-
token: string;
|
|
12
|
-
anonymousPacketToken: string;
|
|
13
|
-
apiKey: string;
|
|
14
|
-
hmacSecret: string;
|
|
15
|
-
encryptRequests: boolean;
|
|
16
|
-
activeTxId: string | null = null;
|
|
17
|
-
|
|
18
|
-
// 세션 유지 관련
|
|
19
|
-
keepSession: boolean;
|
|
20
|
-
refreshBuffer: number;
|
|
21
|
-
onTokenRefreshed?: (accessToken: string, expiresIn: number) => void;
|
|
22
|
-
onSessionExpired?: (error: Error) => void;
|
|
23
|
-
_sessionRefreshToken: string | null = null;
|
|
24
|
-
_refreshTimer: ReturnType<typeof setTimeout> | null = null;
|
|
25
|
-
|
|
26
|
-
// ─── 초기화 & 설정 ────────────────────────────────────────────────────────
|
|
27
|
-
|
|
28
|
-
/**
|
|
29
|
-
* EntityServerClient 인스턴스를 생성합니다.
|
|
30
|
-
*
|
|
31
|
-
* 기본값:
|
|
32
|
-
* - `baseUrl`: `VITE_ENTITY_SERVER_URL` 또는 상대 경로(`""`)
|
|
33
|
-
*/
|
|
34
|
-
constructor(options: EntityServerClientOptions = {}) {
|
|
35
|
-
const envBaseUrl = readEnv("VITE_ENTITY_SERVER_URL");
|
|
36
|
-
|
|
37
|
-
this.baseUrl = (options.baseUrl ?? envBaseUrl ?? "").replace(/\/$/, "");
|
|
38
|
-
this.token = options.token ?? "";
|
|
39
|
-
this.anonymousPacketToken = options.anonymousPacketToken ?? "";
|
|
40
|
-
this.apiKey = options.apiKey ?? "";
|
|
41
|
-
this.hmacSecret = options.hmacSecret ?? "";
|
|
42
|
-
this.encryptRequests = options.encryptRequests ?? false;
|
|
43
|
-
this.keepSession = options.keepSession ?? false;
|
|
44
|
-
this.refreshBuffer = options.refreshBuffer ?? 60;
|
|
45
|
-
this.onTokenRefreshed = options.onTokenRefreshed;
|
|
46
|
-
this.onSessionExpired = options.onSessionExpired;
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
/** baseUrl, token, encryptRequests 값을 런타임에 갱신합니다. */
|
|
50
|
-
configure(options: Partial<EntityServerClientOptions>): void {
|
|
51
|
-
if (typeof options.baseUrl === "string") {
|
|
52
|
-
this.baseUrl = options.baseUrl.replace(/\/$/, "");
|
|
53
|
-
}
|
|
54
|
-
if (typeof options.token === "string") this.token = options.token;
|
|
55
|
-
if (typeof options.anonymousPacketToken === "string") {
|
|
56
|
-
this.anonymousPacketToken = options.anonymousPacketToken;
|
|
57
|
-
}
|
|
58
|
-
if (typeof options.encryptRequests === "boolean")
|
|
59
|
-
this.encryptRequests = options.encryptRequests;
|
|
60
|
-
if (typeof options.apiKey === "string") this.apiKey = options.apiKey;
|
|
61
|
-
if (typeof options.hmacSecret === "string")
|
|
62
|
-
this.hmacSecret = options.hmacSecret;
|
|
63
|
-
if (typeof options.keepSession === "boolean")
|
|
64
|
-
this.keepSession = options.keepSession;
|
|
65
|
-
if (typeof options.refreshBuffer === "number")
|
|
66
|
-
this.refreshBuffer = options.refreshBuffer;
|
|
67
|
-
if (options.onTokenRefreshed)
|
|
68
|
-
this.onTokenRefreshed = options.onTokenRefreshed;
|
|
69
|
-
if (options.onSessionExpired)
|
|
70
|
-
this.onSessionExpired = options.onSessionExpired;
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
/** 인증 요청에 사용할 JWT Access Token을 설정합니다. */
|
|
74
|
-
setToken(token: string): void {
|
|
75
|
-
this.token = token;
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
/** 익명 패킷 암호화용 토큰을 설정합니다. */
|
|
79
|
-
setAnonymousPacketToken(token: string): void {
|
|
80
|
-
this.anonymousPacketToken = token;
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
/** HMAC 인증용 API Key를 설정합니다. */
|
|
84
|
-
setApiKey(apiKey: string): void {
|
|
85
|
-
this.apiKey = apiKey;
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
/** HMAC 인증용 시크릿을 설정합니다. */
|
|
89
|
-
setHmacSecret(secret: string): void {
|
|
90
|
-
this.hmacSecret = secret;
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
/** 암호화 요청 활성화 여부를 설정합니다. */
|
|
94
|
-
setEncryptRequests(value: boolean): void {
|
|
95
|
-
this.encryptRequests = value;
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
// ─── 세션 유지 ────────────────────────────────────────────────────────────
|
|
99
|
-
|
|
100
|
-
/** @internal 자동 토큰 갱신 타이머를 시작합니다. */
|
|
101
|
-
_scheduleKeepSession(
|
|
102
|
-
refreshToken: string,
|
|
103
|
-
expiresIn: number,
|
|
104
|
-
refreshFn: (
|
|
105
|
-
rt: string,
|
|
106
|
-
) => Promise<{ access_token: string; expires_in: number }>,
|
|
107
|
-
): void {
|
|
108
|
-
this._clearRefreshTimer();
|
|
109
|
-
this._sessionRefreshToken = refreshToken;
|
|
110
|
-
const delayMs = Math.max((expiresIn - this.refreshBuffer) * 1000, 0);
|
|
111
|
-
this._refreshTimer = setTimeout(async () => {
|
|
112
|
-
if (!this._sessionRefreshToken) return;
|
|
113
|
-
try {
|
|
114
|
-
const result = await refreshFn(this._sessionRefreshToken);
|
|
115
|
-
this.onTokenRefreshed?.(result.access_token, result.expires_in);
|
|
116
|
-
this._scheduleKeepSession(
|
|
117
|
-
this._sessionRefreshToken,
|
|
118
|
-
result.expires_in,
|
|
119
|
-
refreshFn,
|
|
120
|
-
);
|
|
121
|
-
} catch (err) {
|
|
122
|
-
this._clearRefreshTimer();
|
|
123
|
-
this.onSessionExpired?.(
|
|
124
|
-
err instanceof Error ? err : new Error(String(err)),
|
|
125
|
-
);
|
|
126
|
-
}
|
|
127
|
-
}, delayMs);
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
/** @internal 자동 갱신 타이머를 정리합니다. */
|
|
131
|
-
_clearRefreshTimer(): void {
|
|
132
|
-
if (this._refreshTimer !== null) {
|
|
133
|
-
clearTimeout(this._refreshTimer);
|
|
134
|
-
this._refreshTimer = null;
|
|
135
|
-
}
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
/**
|
|
139
|
-
* 세션 유지 타이머를 중지합니다.
|
|
140
|
-
* `logout()` 호출 시 자동으로 중지되며, 직접 호출이 필요한 경우는 드뭅니다.
|
|
141
|
-
*/
|
|
142
|
-
stopKeepSession(): void {
|
|
143
|
-
this._clearRefreshTimer();
|
|
144
|
-
this._sessionRefreshToken = null;
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
// ─── 요청 본문 파싱 ───────────────────────────────────────────────────────
|
|
148
|
-
|
|
149
|
-
/**
|
|
150
|
-
* 요청 바디를 파싱합니다.
|
|
151
|
-
* `application/octet-stream`이면 XChaCha20-Poly1305 복호화, 그 외는 JSON 파싱합니다.
|
|
152
|
-
*
|
|
153
|
-
* @param requireEncrypted `true`이면 암호화된 요청만 허용합니다.
|
|
154
|
-
*/
|
|
155
|
-
readRequestBody<T = Record<string, unknown>>(
|
|
156
|
-
body: ArrayBuffer | Uint8Array | string | T | null | undefined,
|
|
157
|
-
contentType = "application/json",
|
|
158
|
-
requireEncrypted = false,
|
|
159
|
-
): T {
|
|
160
|
-
const key = derivePacketKey(
|
|
161
|
-
this.hmacSecret,
|
|
162
|
-
this.token || this.anonymousPacketToken,
|
|
163
|
-
);
|
|
164
|
-
return parseRequestBody<T>(body, contentType, requireEncrypted, key);
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
// ─── 내부 헬퍼 ───────────────────────────────────────────────────────────
|
|
168
|
-
|
|
169
|
-
get _reqOpts(): RequestOptions {
|
|
170
|
-
return {
|
|
171
|
-
baseUrl: this.baseUrl,
|
|
172
|
-
token: this.token,
|
|
173
|
-
anonymousPacketToken: this.anonymousPacketToken,
|
|
174
|
-
apiKey: this.apiKey,
|
|
175
|
-
hmacSecret: this.hmacSecret,
|
|
176
|
-
encryptRequests: this.encryptRequests,
|
|
177
|
-
};
|
|
178
|
-
}
|
|
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
|
-
|
|
241
|
-
_request<T>(
|
|
242
|
-
method: string,
|
|
243
|
-
path: string,
|
|
244
|
-
body?: unknown,
|
|
245
|
-
withAuth = true,
|
|
246
|
-
extraHeaders?: Record<string, string>,
|
|
247
|
-
): Promise<T> {
|
|
248
|
-
return entityRequest<T>(
|
|
249
|
-
this._reqOpts,
|
|
250
|
-
method,
|
|
251
|
-
path,
|
|
252
|
-
body,
|
|
253
|
-
withAuth,
|
|
254
|
-
extraHeaders,
|
|
255
|
-
true,
|
|
256
|
-
);
|
|
257
|
-
}
|
|
258
|
-
|
|
259
|
-
/** PNG/바이너리 응답을 ArrayBuffer로 반환합니다. (QR, 바코드 등) */
|
|
260
|
-
async _requestBinary(
|
|
261
|
-
method: string,
|
|
262
|
-
path: string,
|
|
263
|
-
body?: unknown,
|
|
264
|
-
withAuth = true,
|
|
265
|
-
): Promise<ArrayBuffer> {
|
|
266
|
-
const headers: Record<string, string> = {
|
|
267
|
-
"Content-Type": "application/json",
|
|
268
|
-
};
|
|
269
|
-
if (withAuth && this.token)
|
|
270
|
-
headers["Authorization"] = `Bearer ${this.token}`;
|
|
271
|
-
if (this.apiKey) headers["X-API-Key"] = this.apiKey;
|
|
272
|
-
|
|
273
|
-
const res = await fetch(this.baseUrl + path, {
|
|
274
|
-
method,
|
|
275
|
-
headers,
|
|
276
|
-
...(body != null ? { body: JSON.stringify(body) } : {}),
|
|
277
|
-
credentials: "include",
|
|
278
|
-
});
|
|
279
|
-
|
|
280
|
-
if (!res.ok) {
|
|
281
|
-
const text = await res.text();
|
|
282
|
-
const err = new Error(`HTTP ${res.status}: ${text}`);
|
|
283
|
-
(err as { status?: number }).status = res.status;
|
|
284
|
-
throw err;
|
|
285
|
-
}
|
|
286
|
-
|
|
287
|
-
return res.arrayBuffer();
|
|
288
|
-
}
|
|
289
|
-
|
|
290
|
-
/** multipart/form-data 요청을 보냅니다. (파일 업로드 등) */
|
|
291
|
-
async _requestForm<T>(
|
|
292
|
-
method: string,
|
|
293
|
-
path: string,
|
|
294
|
-
form: FormData,
|
|
295
|
-
withAuth = true,
|
|
296
|
-
): Promise<T> {
|
|
297
|
-
const headers: Record<string, string> = {};
|
|
298
|
-
if (withAuth && this.token)
|
|
299
|
-
headers["Authorization"] = `Bearer ${this.token}`;
|
|
300
|
-
if (this.apiKey) headers["X-API-Key"] = this.apiKey;
|
|
301
|
-
|
|
302
|
-
const res = await fetch(this.baseUrl + path, {
|
|
303
|
-
method,
|
|
304
|
-
headers,
|
|
305
|
-
body: form,
|
|
306
|
-
credentials: "include",
|
|
307
|
-
});
|
|
308
|
-
|
|
309
|
-
const data = (await res.json()) as { ok?: boolean; message?: string };
|
|
310
|
-
if (!data.ok) {
|
|
311
|
-
const err = new Error(
|
|
312
|
-
data.message ?? `EntityServer error (HTTP ${res.status})`,
|
|
313
|
-
);
|
|
314
|
-
(err as { status?: number }).status = res.status;
|
|
315
|
-
throw err;
|
|
316
|
-
}
|
|
317
|
-
return data as T;
|
|
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
|
-
}
|
|
348
|
-
}
|
package/src/client/hmac.ts
DELETED
|
@@ -1,41 +0,0 @@
|
|
|
1
|
-
// @ts-ignore
|
|
2
|
-
import { sha256 } from "@noble/hashes/sha2";
|
|
3
|
-
// @ts-ignore
|
|
4
|
-
import { hmac } from "@noble/hashes/hmac";
|
|
5
|
-
|
|
6
|
-
/**
|
|
7
|
-
* HMAC-SHA256 서명 헤더를 생성합니다.
|
|
8
|
-
*
|
|
9
|
-
* 서명 대상: `METHOD|PATH|TIMESTAMP|NONCE|BODY`
|
|
10
|
-
*
|
|
11
|
-
* @returns `X-API-Key`, `X-Timestamp`, `X-Nonce`, `X-Signature` 헤더 객체
|
|
12
|
-
*/
|
|
13
|
-
export function buildHmacHeaders(
|
|
14
|
-
method: string,
|
|
15
|
-
path: string,
|
|
16
|
-
bodyBytes: Uint8Array,
|
|
17
|
-
apiKey: string,
|
|
18
|
-
hmacSecret: string,
|
|
19
|
-
): Record<string, string> {
|
|
20
|
-
const timestamp = String(Math.floor(Date.now() / 1000));
|
|
21
|
-
const nonce = crypto.randomUUID();
|
|
22
|
-
|
|
23
|
-
const prefix = new TextEncoder().encode(
|
|
24
|
-
`${method}|${path}|${timestamp}|${nonce}|`,
|
|
25
|
-
);
|
|
26
|
-
const payload = new Uint8Array(prefix.length + bodyBytes.length);
|
|
27
|
-
payload.set(prefix, 0);
|
|
28
|
-
payload.set(bodyBytes, prefix.length);
|
|
29
|
-
|
|
30
|
-
const sig = hmac(sha256, new TextEncoder().encode(hmacSecret), payload);
|
|
31
|
-
const signature = [...sig]
|
|
32
|
-
.map((b) => b.toString(16).padStart(2, "0"))
|
|
33
|
-
.join("");
|
|
34
|
-
|
|
35
|
-
return {
|
|
36
|
-
"X-API-Key": apiKey,
|
|
37
|
-
"X-Timestamp": timestamp,
|
|
38
|
-
"X-Nonce": nonce,
|
|
39
|
-
"X-Signature": signature,
|
|
40
|
-
};
|
|
41
|
-
}
|
package/src/client/packet.ts
DELETED
|
@@ -1,77 +0,0 @@
|
|
|
1
|
-
import {
|
|
2
|
-
derivePacketKey as derivePacketKeyCore,
|
|
3
|
-
encryptPacket as encryptPacketCore,
|
|
4
|
-
decryptPacket as decryptPacketCore,
|
|
5
|
-
} from "../packet";
|
|
6
|
-
|
|
7
|
-
/**
|
|
8
|
-
* 패킷 암호화 키를 유도합니다.
|
|
9
|
-
* - HMAC 모드 (`hmacSecret` 유효 시): HKDF-SHA256(hmac_secret, "entity-server:packet-encryption")
|
|
10
|
-
* - JWT 모드: HKDF-SHA256(jwt_token, "entity-server:packet-encryption")
|
|
11
|
-
*/
|
|
12
|
-
export function derivePacketKey(hmacSecret: string, token: string): Uint8Array {
|
|
13
|
-
return derivePacketKeyCore(hmacSecret || token);
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
/**
|
|
17
|
-
* 평문 바이트를 XChaCha20-Poly1305로 암호화합니다.
|
|
18
|
-
* 포맷: [random_magic:K][random_nonce:24][ciphertext+tag]
|
|
19
|
-
* K = 2 + key[31] % 14 (패킷 키에서 자동 파생)
|
|
20
|
-
*/
|
|
21
|
-
export function encryptPacket(
|
|
22
|
-
plaintext: Uint8Array,
|
|
23
|
-
key: Uint8Array,
|
|
24
|
-
): Uint8Array {
|
|
25
|
-
return encryptPacketCore(plaintext, key);
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
/**
|
|
29
|
-
* XChaCha20-Poly1305 패킷을 복호화해 JSON 객체로 변환합니다.
|
|
30
|
-
* 포맷: [magic:K][nonce:24][ciphertext+tag]
|
|
31
|
-
* K = 2 + key[31] % 14 (패킷 키에서 자동 파생)
|
|
32
|
-
*/
|
|
33
|
-
export function decryptPacket<T>(buffer: ArrayBuffer, key: Uint8Array): T {
|
|
34
|
-
const plaintext = decryptPacketCore(buffer, key);
|
|
35
|
-
return JSON.parse(new TextDecoder().decode(plaintext)) as T;
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
/**
|
|
39
|
-
* 요청 바디를 파싱합니다. `application/octet-stream`이면 복호화, 그 외는 JSON 파싱합니다.
|
|
40
|
-
*
|
|
41
|
-
* @param requireEncrypted `true`이면 암호화된 요청만 허용합니다.
|
|
42
|
-
*/
|
|
43
|
-
export function parseRequestBody<T>(
|
|
44
|
-
body: ArrayBuffer | Uint8Array | string | T | null | undefined,
|
|
45
|
-
contentType: string,
|
|
46
|
-
requireEncrypted: boolean,
|
|
47
|
-
key: Uint8Array,
|
|
48
|
-
): T {
|
|
49
|
-
const isEncrypted = contentType
|
|
50
|
-
.toLowerCase()
|
|
51
|
-
.includes("application/octet-stream");
|
|
52
|
-
|
|
53
|
-
if (requireEncrypted && !isEncrypted) {
|
|
54
|
-
throw new Error(
|
|
55
|
-
"Encrypted request required: Content-Type must be application/octet-stream",
|
|
56
|
-
);
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
if (isEncrypted) {
|
|
60
|
-
if (body == null) throw new Error("Encrypted request body is empty");
|
|
61
|
-
if (body instanceof ArrayBuffer) return decryptPacket<T>(body, key);
|
|
62
|
-
if (body instanceof Uint8Array) {
|
|
63
|
-
const sliced = body.buffer.slice(
|
|
64
|
-
body.byteOffset,
|
|
65
|
-
body.byteOffset + body.byteLength,
|
|
66
|
-
);
|
|
67
|
-
return decryptPacket<T>(sliced as ArrayBuffer, key);
|
|
68
|
-
}
|
|
69
|
-
throw new Error(
|
|
70
|
-
"Encrypted request body must be ArrayBuffer or Uint8Array",
|
|
71
|
-
);
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
if (body == null || body === "") return {} as T;
|
|
75
|
-
if (typeof body === "string") return JSON.parse(body) as T;
|
|
76
|
-
return body as T;
|
|
77
|
-
}
|
package/src/client/request.ts
DELETED
|
@@ -1,139 +0,0 @@
|
|
|
1
|
-
import { derivePacketKey, encryptPacket, decryptPacket } from "./packet";
|
|
2
|
-
import { buildHmacHeaders } from "./hmac";
|
|
3
|
-
|
|
4
|
-
export interface RequestOptions {
|
|
5
|
-
baseUrl: string;
|
|
6
|
-
token: string;
|
|
7
|
-
anonymousPacketToken: string;
|
|
8
|
-
apiKey: string;
|
|
9
|
-
hmacSecret: string;
|
|
10
|
-
encryptRequests: boolean;
|
|
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
|
-
|
|
32
|
-
/**
|
|
33
|
-
* Entity Server에 HTTP 요청을 보냅니다.
|
|
34
|
-
*
|
|
35
|
-
* - `encryptRequests` 활성화 시 인증된 POST 바디를 자동 암호화합니다.
|
|
36
|
-
* - 응답이 `application/octet-stream`이면 자동 복호화합니다.
|
|
37
|
-
* - JSON 응답의 `ok`가 false이면 에러를 던집니다.
|
|
38
|
-
*/
|
|
39
|
-
export async function entityRequest<T>(
|
|
40
|
-
opts: RequestOptions,
|
|
41
|
-
method: string,
|
|
42
|
-
path: string,
|
|
43
|
-
body?: unknown,
|
|
44
|
-
withAuth = true,
|
|
45
|
-
extraHeaders: Record<string, string> = {},
|
|
46
|
-
requireOkShape = true,
|
|
47
|
-
): Promise<T> {
|
|
48
|
-
const {
|
|
49
|
-
baseUrl,
|
|
50
|
-
token,
|
|
51
|
-
apiKey,
|
|
52
|
-
hmacSecret,
|
|
53
|
-
encryptRequests,
|
|
54
|
-
anonymousPacketToken,
|
|
55
|
-
} = opts;
|
|
56
|
-
const isHmacMode = withAuth && !!(apiKey && hmacSecret);
|
|
57
|
-
const packetSource = resolvePacketSource(opts);
|
|
58
|
-
|
|
59
|
-
const headers: Record<string, string> = {
|
|
60
|
-
"Content-Type": "application/json",
|
|
61
|
-
...extraHeaders,
|
|
62
|
-
};
|
|
63
|
-
if (!isHmacMode && withAuth && token) {
|
|
64
|
-
headers.Authorization = `Bearer ${token}`;
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
let fetchBody: string | Uint8Array | null = null;
|
|
68
|
-
if (body != null) {
|
|
69
|
-
const shouldEncrypt =
|
|
70
|
-
encryptRequests &&
|
|
71
|
-
!!packetSource &&
|
|
72
|
-
method !== "GET" &&
|
|
73
|
-
method !== "HEAD";
|
|
74
|
-
|
|
75
|
-
if (shouldEncrypt) {
|
|
76
|
-
const key = derivePacketKey(
|
|
77
|
-
hmacSecret,
|
|
78
|
-
token || anonymousPacketToken,
|
|
79
|
-
);
|
|
80
|
-
fetchBody = encryptPacket(
|
|
81
|
-
new TextEncoder().encode(JSON.stringify(body)),
|
|
82
|
-
key,
|
|
83
|
-
);
|
|
84
|
-
headers["Content-Type"] = "application/octet-stream";
|
|
85
|
-
if (!token && !isHmacMode && anonymousPacketToken) {
|
|
86
|
-
headers["X-Packet-Token"] = anonymousPacketToken;
|
|
87
|
-
}
|
|
88
|
-
} else {
|
|
89
|
-
fetchBody = JSON.stringify(body);
|
|
90
|
-
}
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
if (isHmacMode) {
|
|
94
|
-
const bodyBytes =
|
|
95
|
-
fetchBody instanceof Uint8Array
|
|
96
|
-
? fetchBody
|
|
97
|
-
: typeof fetchBody === "string"
|
|
98
|
-
? new TextEncoder().encode(fetchBody)
|
|
99
|
-
: new Uint8Array(0);
|
|
100
|
-
Object.assign(
|
|
101
|
-
headers,
|
|
102
|
-
buildHmacHeaders(method, path, bodyBytes, apiKey, hmacSecret),
|
|
103
|
-
);
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
const res = await fetch(baseUrl + path, {
|
|
107
|
-
method,
|
|
108
|
-
headers,
|
|
109
|
-
...(fetchBody != null ? { body: fetchBody as RequestInit["body"] } : {}),
|
|
110
|
-
credentials: "include",
|
|
111
|
-
});
|
|
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
|
-
|
|
119
|
-
const contentType = res.headers.get("Content-Type") ?? "";
|
|
120
|
-
if (contentType.includes("application/octet-stream")) {
|
|
121
|
-
const key = derivePacketKey(hmacSecret, token || anonymousPacketToken);
|
|
122
|
-
return decryptPacket<T>(await res.arrayBuffer(), key);
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
if (!contentType.includes("application/json")) {
|
|
126
|
-
return (await res.text()) as T;
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
const data = (await res.json()) as { ok?: boolean; message?: string };
|
|
130
|
-
if (requireOkShape && !data.ok) {
|
|
131
|
-
const err = new Error(
|
|
132
|
-
data.message ?? `EntityServer error (HTTP ${res.status})`,
|
|
133
|
-
);
|
|
134
|
-
(err as { status?: number }).status = res.status;
|
|
135
|
-
throw err;
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
return data as T;
|
|
139
|
-
}
|
package/src/client/utils.ts
DELETED
|
@@ -1,33 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* 환경변수를 읽습니다.
|
|
3
|
-
* - 브라우저/Vite: `import.meta.env`
|
|
4
|
-
* - Node.js: `process.env`
|
|
5
|
-
*/
|
|
6
|
-
export function readEnv(name: string): string | undefined {
|
|
7
|
-
// Vite / 기타 번들러 (import.meta.env)
|
|
8
|
-
const meta = import.meta as unknown as {
|
|
9
|
-
env?: Record<string, string | undefined>;
|
|
10
|
-
};
|
|
11
|
-
if (meta?.env?.[name] != null) return meta.env[name];
|
|
12
|
-
|
|
13
|
-
// Node.js (process.env)
|
|
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];
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
return undefined;
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
/** 쿼리 파라미터 객체를 URL 쿼리 문자열로 변환합니다. `orderBy` 키는 `order_by`로 변환됩니다. */
|
|
25
|
-
export function buildQuery(params: Record<string, unknown>): string {
|
|
26
|
-
return Object.entries(params)
|
|
27
|
-
.filter(([, value]) => value != null)
|
|
28
|
-
.map(
|
|
29
|
-
([key, value]) =>
|
|
30
|
-
`${encodeURIComponent(key === "orderBy" ? "order_by" : key)}=${encodeURIComponent(String(value))}`,
|
|
31
|
-
)
|
|
32
|
-
.join("&");
|
|
33
|
-
}
|