entity-server-client 0.2.4 → 0.2.5
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 +203 -0
- package/dist/client/hmac.d.ts +8 -0
- package/dist/client/packet.d.ts +22 -0
- package/dist/client/request.d.ts +16 -0
- package/dist/client/utils.d.ts +4 -0
- package/dist/index.d.ts +3 -393
- package/dist/index.js +1 -1
- package/dist/index.js.map +4 -4
- package/dist/react.js +1 -1
- package/dist/react.js.map +4 -4
- package/dist/types.d.ts +165 -0
- package/package.json +1 -1
- package/src/EntityServerClient.ts +546 -0
- package/src/client/hmac.ts +41 -0
- package/src/client/packet.ts +113 -0
- package/src/client/request.ts +102 -0
- package/src/client/utils.ts +18 -0
- package/src/index.ts +3 -917
- package/src/types.ts +186 -0
package/src/index.ts
CHANGED
|
@@ -1,920 +1,6 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
// @ts-ignore
|
|
4
|
-
import { sha256 } from "@noble/hashes/sha2";
|
|
5
|
-
// @ts-ignore
|
|
6
|
-
import { hkdf } from "@noble/hashes/hkdf";
|
|
7
|
-
// @ts-ignore
|
|
8
|
-
import { hmac } from "@noble/hashes/hmac";
|
|
1
|
+
export * from "./types";
|
|
2
|
+
export * from "./EntityServerClient";
|
|
9
3
|
|
|
10
|
-
|
|
11
|
-
* 엔티티 목록 조회 파라미터입니다.
|
|
12
|
-
*
|
|
13
|
-
* ```ts
|
|
14
|
-
* client.list("post", {
|
|
15
|
-
* page: 1, limit: 10,
|
|
16
|
-
* orderBy: "created_time", orderDir: "DESC",
|
|
17
|
-
* fields: ["seq", "title", "created_time"],
|
|
18
|
-
* conditions: { status: "active" },
|
|
19
|
-
* });
|
|
20
|
-
* ```
|
|
21
|
-
*/
|
|
22
|
-
export interface EntityListParams {
|
|
23
|
-
/** 조회 페이지 번호. 기본값: `1` */
|
|
24
|
-
page?: number;
|
|
25
|
-
/** 페이지당 레코드 수. 기본값: `20` */
|
|
26
|
-
limit?: number;
|
|
27
|
-
/** 정렬 기준 필드명 */
|
|
28
|
-
orderBy?: string;
|
|
29
|
-
/** 정렬 방향. 기본값: `"ASC"` */
|
|
30
|
-
orderDir?: "ASC" | "DESC";
|
|
31
|
-
/**
|
|
32
|
-
* 반환할 필드 목록.
|
|
33
|
-
*
|
|
34
|
-
* - **미지정 (기본값)**: 엔티티의 인덱스 필드만 반환합니다.
|
|
35
|
-
* 복호화를 건너뛰기 때문에 **가장 빠릅니다**.
|
|
36
|
-
* - `["*"]`: 전체 필드 반환 (복호화 수행).
|
|
37
|
-
* - 필드명 목록: 해당 필드만 반환합니다.
|
|
38
|
-
* 엔티티 설정에 `index`로 선언된 필드만 지정 가능합니다.
|
|
39
|
-
* 존재하지 않는 필드명을 지정하면 서버 에러가 발생합니다.
|
|
40
|
-
* - `seq`, `created_time`, `updated_time`, `license_seq`는 필드에 관계없이 항상 포함됩니다.
|
|
41
|
-
*
|
|
42
|
-
* ```ts
|
|
43
|
-
* // 기본값 (인덱스 필드만, 가장 빠름)
|
|
44
|
-
* client.list("account")
|
|
45
|
-
* // 전체 필드
|
|
46
|
-
* client.list("account", { fields: ["*"] })
|
|
47
|
-
* // seq, name, email만
|
|
48
|
-
* client.list("account", { fields: ["seq", "name", "email"] })
|
|
49
|
-
* ```
|
|
50
|
-
*/
|
|
51
|
-
fields?: string[];
|
|
52
|
-
/** 필터 조건. POST body로 전달됩니다. (예: `{ status: "active" }`) */
|
|
53
|
-
conditions?: Record<string, unknown>;
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
/**
|
|
57
|
-
* `query()` 메서드에 전달하는 SQL 쿼리 요청입니다.
|
|
58
|
-
*
|
|
59
|
-
* - `sql`: SELECT 전용 SQL. 인덱스 테이블만 조회 가능하며 JOIN 지원.
|
|
60
|
-
* - `params`: SQL 바인딩 파라미터 (`?` 플레이스홀더 대응).
|
|
61
|
-
* - `limit`: 최대 반환 건수 (최대 1000. 미지정 시 서버 기본값 적용).
|
|
62
|
-
*
|
|
63
|
-
* ```ts
|
|
64
|
-
* client.query("order", {
|
|
65
|
-
* sql: `SELECT o.seq, o.status, u.name
|
|
66
|
-
* FROM order o
|
|
67
|
-
* JOIN account u ON u.data_seq = o.account_seq
|
|
68
|
-
* WHERE o.status = ?`,
|
|
69
|
-
* params: ["pending"],
|
|
70
|
-
* limit: 100,
|
|
71
|
-
* });
|
|
72
|
-
* ```
|
|
73
|
-
*/
|
|
74
|
-
export interface EntityQueryRequest {
|
|
75
|
-
sql: string;
|
|
76
|
-
params?: unknown[];
|
|
77
|
-
limit?: number;
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
export interface RegisterPushDeviceOptions {
|
|
81
|
-
platform?: string;
|
|
82
|
-
deviceType?: string;
|
|
83
|
-
browser?: string;
|
|
84
|
-
browserVersion?: string;
|
|
85
|
-
pushEnabled?: boolean;
|
|
86
|
-
transactionId?: string;
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
/** EntityServerClient 생성/설정 옵션입니다. */
|
|
90
|
-
export interface EntityServerClientOptions {
|
|
91
|
-
baseUrl?: string;
|
|
92
|
-
token?: string;
|
|
93
|
-
packetMagicLen?: number;
|
|
94
|
-
/**
|
|
95
|
-
* `true`이면 인증된 POST/PUT 요청 바디를 XChaCha20-Poly1305로 암호화합니다.
|
|
96
|
-
*
|
|
97
|
-
* 서버의 `EnablePacketEncryption`이 활성화된 경우 필수로 설정해야 합니다.
|
|
98
|
-
* 로그인(`login()`)·토큰 갱신(`refreshToken()`)은 인증 전 요청이므로 자동으로 건너뜁니다.
|
|
99
|
-
*
|
|
100
|
-
* 기본값: `false`
|
|
101
|
-
*/
|
|
102
|
-
encryptRequests?: boolean;
|
|
103
|
-
/**
|
|
104
|
-
* `true`이면 `login()` 성공 후 Access Token 만료 전에 자동으로 갱신(silent refresh)합니다.
|
|
105
|
-
* 갱신 시점은 `expires_in - refreshBuffer` 초입니다.
|
|
106
|
-
*
|
|
107
|
-
* 갱신 성공 시 `onTokenRefreshed`, 실패 시 `onSessionExpired` 콜백이 호출됩니다.
|
|
108
|
-
*
|
|
109
|
-
* 기본값: `false`
|
|
110
|
-
*/
|
|
111
|
-
keepSession?: boolean;
|
|
112
|
-
/**
|
|
113
|
-
* 만료 몇 초 전에 자동 갱신을 시도할지 설정합니다.
|
|
114
|
-
*
|
|
115
|
-
* 예: `expires_in = 3600`, `refreshBuffer = 60` → 3540초 후 갱신
|
|
116
|
-
*
|
|
117
|
-
* 기본값: `60`
|
|
118
|
-
*/
|
|
119
|
-
refreshBuffer?: number;
|
|
120
|
-
/**
|
|
121
|
-
* 자동 갱신 성공 시 호출되는 콜백입니다.
|
|
122
|
-
* 새 `access_token`과 `expires_in`이 전달됩니다.
|
|
123
|
-
* 앱은 이 콜백에서 localStorage 등에 토큰을 저장해야 합니다.
|
|
124
|
-
*/
|
|
125
|
-
onTokenRefreshed?: (accessToken: string, expiresIn: number) => void;
|
|
126
|
-
/**
|
|
127
|
-
* 세션 유지 갱신 실패 시 호출되는 콜백입니다.
|
|
128
|
-
* refresh_token 만료 등으로 재발급이 불가능한 경우입니다.
|
|
129
|
-
* 앱은 이 콜백에서 로그인 페이지로 이동하는 등의 처리를 해야 합니다.
|
|
130
|
-
*/
|
|
131
|
-
onSessionExpired?: (error: Error) => void;
|
|
132
|
-
/**
|
|
133
|
-
* HMAC 인증용 API Key (`X-API-Key` 헤더).
|
|
134
|
-
* `hmacSecret`과 함께 설정하면 HMAC 인증 모드로 동작합니다.
|
|
135
|
-
* **서버 사이드(Node.js 등) 전용. 브라우저에서는 사용하지 마세요.**
|
|
136
|
-
*/
|
|
137
|
-
apiKey?: string;
|
|
138
|
-
/**
|
|
139
|
-
* HMAC 인증 시크릿. `apiKey`와 함께 설정하면 HMAC 인증 모드로 동작합니다.
|
|
140
|
-
*
|
|
141
|
-
* 패킷 암호화 키도 이 값에서 HKDF-SHA256으로 유도합니다:
|
|
142
|
-
* `key = HKDF-SHA256(hmac_secret, info="entity-server:packet-encryption", salt="entity-server:hkdf:v1")`
|
|
143
|
-
*
|
|
144
|
-
* **서버 사이드(Node.js 등) 전용. 브라우저에서는 사용하지 마세요.**
|
|
145
|
-
*/
|
|
146
|
-
hmacSecret?: string;
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
/**
|
|
150
|
-
* `list()`, `history()` 응답의 `data` 필드 구조입니다.
|
|
151
|
-
*
|
|
152
|
-
* 서버는 항상 이 구조로 반환합니다:
|
|
153
|
-
* ```json
|
|
154
|
-
* { "ok": true, "data": { "items": [...], "total": 100, "page": 1, "limit": 20 } }
|
|
155
|
-
* ```
|
|
156
|
-
*/
|
|
157
|
-
export interface EntityListResult<T = unknown> {
|
|
158
|
-
items: T[];
|
|
159
|
-
/** 전체 레코드 수 */
|
|
160
|
-
total: number;
|
|
161
|
-
/** 현재 페이지 번호 */
|
|
162
|
-
page: number;
|
|
163
|
-
/** 페이지당 레코드 수 */
|
|
164
|
-
limit: number;
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
/**
|
|
168
|
-
* `history()` 응답의 개별 이력 레코드 구조입니다.
|
|
169
|
-
*
|
|
170
|
-
* - `action`: `"INSERT"` | `"UPDATE"` | `"DELETE_SOFT"` | `"DELETE_HARD"` | `"ROLLBACK"`
|
|
171
|
-
* - `data_snapshot`: 변경 당시 엔티티 데이터 스냅샷
|
|
172
|
-
*/
|
|
173
|
-
export interface EntityHistoryRecord<T = unknown> {
|
|
174
|
-
seq: number;
|
|
175
|
-
action:
|
|
176
|
-
| "INSERT"
|
|
177
|
-
| "UPDATE"
|
|
178
|
-
| "DELETE_SOFT"
|
|
179
|
-
| "DELETE_HARD"
|
|
180
|
-
| "ROLLBACK"
|
|
181
|
-
| string;
|
|
182
|
-
data_snapshot: T | null;
|
|
183
|
-
changed_by: number | null;
|
|
184
|
-
changed_time: string;
|
|
185
|
-
}
|
|
186
|
-
|
|
187
|
-
/** Vite 환경변수(`import.meta.env`)에서 값을 읽습니다. */
|
|
188
|
-
function readEnv(name: string): string | undefined {
|
|
189
|
-
const meta = import.meta as unknown as {
|
|
190
|
-
env?: Record<string, string | undefined>;
|
|
191
|
-
};
|
|
192
|
-
return meta?.env?.[name];
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
export class EntityServerClient {
|
|
196
|
-
private baseUrl: string;
|
|
197
|
-
private token: string;
|
|
198
|
-
private apiKey: string;
|
|
199
|
-
private hmacSecret: string;
|
|
200
|
-
private packetMagicLen: number;
|
|
201
|
-
private encryptRequests: boolean;
|
|
202
|
-
private activeTxId: string | null = null;
|
|
203
|
-
|
|
204
|
-
// 세션 유지 관련
|
|
205
|
-
private keepSession: boolean;
|
|
206
|
-
private refreshBuffer: number;
|
|
207
|
-
private onTokenRefreshed?: (accessToken: string, expiresIn: number) => void;
|
|
208
|
-
private onSessionExpired?: (error: Error) => void;
|
|
209
|
-
private _sessionRefreshToken: string | null = null;
|
|
210
|
-
private _refreshTimer: ReturnType<typeof setTimeout> | null = null;
|
|
211
|
-
|
|
212
|
-
/**
|
|
213
|
-
* EntityServerClient 인스턴스를 생성합니다.
|
|
214
|
-
*
|
|
215
|
-
* 기본값:
|
|
216
|
-
* - `baseUrl`: `VITE_ENTITY_SERVER_URL` 또는 `http://localhost:47200`
|
|
217
|
-
* - `packetMagicLen`: `VITE_ENTITY_SERVER_PACKET_MAGIC_LEN` 또는 `4`
|
|
218
|
-
*/
|
|
219
|
-
constructor(options: EntityServerClientOptions = {}) {
|
|
220
|
-
const envBaseUrl = readEnv("VITE_ENTITY_SERVER_URL");
|
|
221
|
-
const envMagicLen = readEnv("VITE_ENTITY_SERVER_PACKET_MAGIC_LEN");
|
|
222
|
-
|
|
223
|
-
this.baseUrl = (
|
|
224
|
-
options.baseUrl ??
|
|
225
|
-
envBaseUrl ??
|
|
226
|
-
"http://localhost:47200"
|
|
227
|
-
).replace(/\/$/, "");
|
|
228
|
-
|
|
229
|
-
this.token = options.token ?? "";
|
|
230
|
-
this.apiKey = options.apiKey ?? "";
|
|
231
|
-
this.hmacSecret = options.hmacSecret ?? "";
|
|
232
|
-
this.packetMagicLen =
|
|
233
|
-
options.packetMagicLen ?? (envMagicLen ? Number(envMagicLen) : 4);
|
|
234
|
-
this.encryptRequests = options.encryptRequests ?? false;
|
|
235
|
-
this.keepSession = options.keepSession ?? false;
|
|
236
|
-
this.refreshBuffer = options.refreshBuffer ?? 60;
|
|
237
|
-
this.onTokenRefreshed = options.onTokenRefreshed;
|
|
238
|
-
this.onSessionExpired = options.onSessionExpired;
|
|
239
|
-
}
|
|
240
|
-
|
|
241
|
-
/** baseUrl, token, packetMagicLen, encryptRequests 값을 런타임에 갱신합니다. */
|
|
242
|
-
configure(options: Partial<EntityServerClientOptions>): void {
|
|
243
|
-
if (options.baseUrl) {
|
|
244
|
-
this.baseUrl = options.baseUrl.replace(/\/$/, "");
|
|
245
|
-
}
|
|
246
|
-
if (typeof options.token === "string") {
|
|
247
|
-
this.token = options.token;
|
|
248
|
-
}
|
|
249
|
-
if (typeof options.packetMagicLen === "number") {
|
|
250
|
-
this.packetMagicLen = options.packetMagicLen;
|
|
251
|
-
}
|
|
252
|
-
if (typeof options.encryptRequests === "boolean") {
|
|
253
|
-
this.encryptRequests = options.encryptRequests;
|
|
254
|
-
}
|
|
255
|
-
if (typeof options.apiKey === "string") {
|
|
256
|
-
this.apiKey = options.apiKey;
|
|
257
|
-
}
|
|
258
|
-
if (typeof options.hmacSecret === "string") {
|
|
259
|
-
this.hmacSecret = options.hmacSecret;
|
|
260
|
-
}
|
|
261
|
-
if (typeof options.keepSession === "boolean") {
|
|
262
|
-
this.keepSession = options.keepSession;
|
|
263
|
-
}
|
|
264
|
-
if (typeof options.refreshBuffer === "number") {
|
|
265
|
-
this.refreshBuffer = options.refreshBuffer;
|
|
266
|
-
}
|
|
267
|
-
if (options.onTokenRefreshed) {
|
|
268
|
-
this.onTokenRefreshed = options.onTokenRefreshed;
|
|
269
|
-
}
|
|
270
|
-
if (options.onSessionExpired) {
|
|
271
|
-
this.onSessionExpired = options.onSessionExpired;
|
|
272
|
-
}
|
|
273
|
-
}
|
|
274
|
-
|
|
275
|
-
/** 인증 요청에 사용할 JWT Access Token을 설정합니다. */
|
|
276
|
-
setToken(token: string): void {
|
|
277
|
-
this.token = token;
|
|
278
|
-
}
|
|
279
|
-
|
|
280
|
-
/** HMAC 인증용 API Key를 설정합니다. */
|
|
281
|
-
setApiKey(apiKey: string): void {
|
|
282
|
-
this.apiKey = apiKey;
|
|
283
|
-
}
|
|
284
|
-
|
|
285
|
-
/** HMAC 인증용 시크릿을 설정합니다. */
|
|
286
|
-
setHmacSecret(secret: string): void {
|
|
287
|
-
this.hmacSecret = secret;
|
|
288
|
-
}
|
|
289
|
-
|
|
290
|
-
/** 암호화 패킷 magic 길이(`packet_magic_len`)를 설정합니다. */
|
|
291
|
-
setPacketMagicLen(length: number): void {
|
|
292
|
-
this.packetMagicLen = length;
|
|
293
|
-
}
|
|
294
|
-
|
|
295
|
-
/** 현재 암호화 패킷 magic 길이를 반환합니다. */
|
|
296
|
-
getPacketMagicLen(): number {
|
|
297
|
-
return this.packetMagicLen;
|
|
298
|
-
}
|
|
299
|
-
|
|
300
|
-
/**
|
|
301
|
-
* 자동 토큰 갱신 타이머를 시작합니다.
|
|
302
|
-
* @param refreshToken 갱신에 사용할 Refresh Token
|
|
303
|
-
* @param expiresIn Access Token의 유효 기간 (초)
|
|
304
|
-
*/
|
|
305
|
-
private _scheduleKeepSession(
|
|
306
|
-
refreshToken: string,
|
|
307
|
-
expiresIn: number,
|
|
308
|
-
): void {
|
|
309
|
-
this._clearRefreshTimer();
|
|
310
|
-
this._sessionRefreshToken = refreshToken;
|
|
311
|
-
|
|
312
|
-
const delayMs = Math.max((expiresIn - this.refreshBuffer) * 1000, 0);
|
|
313
|
-
this._refreshTimer = setTimeout(async () => {
|
|
314
|
-
if (!this._sessionRefreshToken) return;
|
|
315
|
-
try {
|
|
316
|
-
const result = await this.refreshToken(
|
|
317
|
-
this._sessionRefreshToken,
|
|
318
|
-
);
|
|
319
|
-
this.onTokenRefreshed?.(result.access_token, result.expires_in);
|
|
320
|
-
// 갱신 성공 시 다음 만료 전 타이머 재예약
|
|
321
|
-
this._scheduleKeepSession(
|
|
322
|
-
this._sessionRefreshToken,
|
|
323
|
-
result.expires_in,
|
|
324
|
-
);
|
|
325
|
-
} catch (err) {
|
|
326
|
-
this._clearRefreshTimer();
|
|
327
|
-
this.onSessionExpired?.(
|
|
328
|
-
err instanceof Error ? err : new Error(String(err)),
|
|
329
|
-
);
|
|
330
|
-
}
|
|
331
|
-
}, delayMs);
|
|
332
|
-
}
|
|
333
|
-
|
|
334
|
-
/** 자동 갱신 타이머를 정리합니다. */
|
|
335
|
-
private _clearRefreshTimer(): void {
|
|
336
|
-
if (this._refreshTimer !== null) {
|
|
337
|
-
clearTimeout(this._refreshTimer);
|
|
338
|
-
this._refreshTimer = null;
|
|
339
|
-
}
|
|
340
|
-
}
|
|
341
|
-
|
|
342
|
-
/**
|
|
343
|
-
* 세션 유지 타이머를 중지합니다.
|
|
344
|
-
* `logout()` 호출 시 자동으로 중지되므로 직접 호출이 필요한 경우는 드뭅니다.
|
|
345
|
-
*/
|
|
346
|
-
stopKeepSession(): void {
|
|
347
|
-
this._clearRefreshTimer();
|
|
348
|
-
this._sessionRefreshToken = null;
|
|
349
|
-
}
|
|
350
|
-
|
|
351
|
-
/**
|
|
352
|
-
* 서버 헬스 체크를 수행하고 패킷 암호화 활성 여부를 자동으로 감지합니다.
|
|
353
|
-
*
|
|
354
|
-
* 서버가 `packet_encryption: true`를 응답하면 이후 모든 요청에 암호화가 자동 적용됩니다.
|
|
355
|
-
* 초기화 직후 또는 로그인 전에 호출하면 암호화 설정을 자동으로 구성할 수 있습니다.
|
|
356
|
-
*
|
|
357
|
-
* ```ts
|
|
358
|
-
* await client.checkHealth();
|
|
359
|
-
* await client.login(email, password); // 이후 요청은 암호화 자동 적용
|
|
360
|
-
* ```
|
|
361
|
-
*
|
|
362
|
-
* @returns `{ ok: true }` 또는 `{ ok: true, packet_encryption: true }`
|
|
363
|
-
*/
|
|
364
|
-
async checkHealth(): Promise<{ ok: boolean; packet_encryption?: boolean }> {
|
|
365
|
-
const res = await fetch(`${this.baseUrl}/v1/health`, {
|
|
366
|
-
signal: AbortSignal.timeout(3000),
|
|
367
|
-
});
|
|
368
|
-
const data = (await res.json()) as {
|
|
369
|
-
ok: boolean;
|
|
370
|
-
packet_encryption?: boolean;
|
|
371
|
-
};
|
|
372
|
-
if (data.packet_encryption) {
|
|
373
|
-
this.encryptRequests = true;
|
|
374
|
-
}
|
|
375
|
-
return data;
|
|
376
|
-
}
|
|
377
|
-
|
|
378
|
-
/** 로그인 후 `access_token`을 내부 상태에 저장합니다. */
|
|
379
|
-
async login(
|
|
380
|
-
email: string,
|
|
381
|
-
password: string,
|
|
382
|
-
): Promise<{
|
|
383
|
-
access_token: string;
|
|
384
|
-
refresh_token: string;
|
|
385
|
-
expires_in: number;
|
|
386
|
-
}> {
|
|
387
|
-
const data = await this.request<{
|
|
388
|
-
data: {
|
|
389
|
-
access_token: string;
|
|
390
|
-
refresh_token: string;
|
|
391
|
-
expires_in: number;
|
|
392
|
-
};
|
|
393
|
-
}>("POST", "/v1/auth/login", { email, passwd: password }, false);
|
|
394
|
-
this.token = data.data.access_token;
|
|
395
|
-
if (this.keepSession) {
|
|
396
|
-
this._scheduleKeepSession(
|
|
397
|
-
data.data.refresh_token,
|
|
398
|
-
data.data.expires_in,
|
|
399
|
-
);
|
|
400
|
-
}
|
|
401
|
-
return data.data;
|
|
402
|
-
}
|
|
403
|
-
|
|
404
|
-
/** Refresh Token으로 Access Token을 재발급받아 내부 토큰을 교체합니다. */
|
|
405
|
-
async refreshToken(
|
|
406
|
-
refreshToken: string,
|
|
407
|
-
): Promise<{ access_token: string; expires_in: number }> {
|
|
408
|
-
const data = await this.request<{
|
|
409
|
-
data: { access_token: string; expires_in: number };
|
|
410
|
-
}>("POST", "/v1/auth/refresh", { refresh_token: refreshToken }, false);
|
|
411
|
-
this.token = data.data.access_token;
|
|
412
|
-
if (this.keepSession) {
|
|
413
|
-
this._scheduleKeepSession(refreshToken, data.data.expires_in);
|
|
414
|
-
}
|
|
415
|
-
return data.data;
|
|
416
|
-
}
|
|
417
|
-
|
|
418
|
-
/**
|
|
419
|
-
* 서버에 로그아웃을 요청하고 내부 토큰을 초기화합니다.
|
|
420
|
-
* refresh_token을 서버에 전달해 무효화합니다.
|
|
421
|
-
*/
|
|
422
|
-
async logout(refreshToken: string): Promise<{ ok: boolean }> {
|
|
423
|
-
this.stopKeepSession();
|
|
424
|
-
const data = await this.request<{ ok: boolean }>(
|
|
425
|
-
"POST",
|
|
426
|
-
"/v1/auth/logout",
|
|
427
|
-
{ refresh_token: refreshToken },
|
|
428
|
-
false,
|
|
429
|
-
);
|
|
430
|
-
this.token = "";
|
|
431
|
-
return data;
|
|
432
|
-
}
|
|
433
|
-
|
|
434
|
-
/** 트랜잭션을 시작하고 활성 트랜잭션 ID를 저장합니다. */
|
|
435
|
-
async transStart(): Promise<string> {
|
|
436
|
-
const res = await this.request<{ ok: boolean; transaction_id: string }>(
|
|
437
|
-
"POST",
|
|
438
|
-
"/v1/transaction/start",
|
|
439
|
-
undefined,
|
|
440
|
-
false,
|
|
441
|
-
);
|
|
442
|
-
this.activeTxId = res.transaction_id;
|
|
443
|
-
return this.activeTxId;
|
|
444
|
-
}
|
|
445
|
-
|
|
446
|
-
/** 활성 트랜잭션(또는 전달된 transactionId)을 롤백합니다. */
|
|
447
|
-
transRollback(transactionId?: string): Promise<{ ok: boolean }> {
|
|
448
|
-
const txId = transactionId ?? this.activeTxId;
|
|
449
|
-
if (!txId) {
|
|
450
|
-
return Promise.reject(
|
|
451
|
-
new Error("No active transaction. Call transStart() first."),
|
|
452
|
-
);
|
|
453
|
-
}
|
|
454
|
-
this.activeTxId = null;
|
|
455
|
-
return this.request("POST", `/v1/transaction/rollback/${txId}`);
|
|
456
|
-
}
|
|
457
|
-
|
|
458
|
-
/** 활성 트랜잭션(또는 전달된 transactionId)을 커밋합니다.
|
|
459
|
-
*
|
|
460
|
-
* @returns `results` 배열: commit된 각 작업의 `entity`, `action`, `seq`
|
|
461
|
-
*/
|
|
462
|
-
transCommit(transactionId?: string): Promise<{
|
|
463
|
-
ok: boolean;
|
|
464
|
-
results: Array<{ entity: string; action: string; seq: number }>;
|
|
465
|
-
}> {
|
|
466
|
-
const txId = transactionId ?? this.activeTxId;
|
|
467
|
-
if (!txId) {
|
|
468
|
-
return Promise.reject(
|
|
469
|
-
new Error("No active transaction. Call transStart() first."),
|
|
470
|
-
);
|
|
471
|
-
}
|
|
472
|
-
this.activeTxId = null;
|
|
473
|
-
return this.request("POST", `/v1/transaction/commit/${txId}`);
|
|
474
|
-
}
|
|
475
|
-
|
|
476
|
-
/** 시퀀스 ID로 엔티티 단건을 조회합니다. */
|
|
477
|
-
get<T = unknown>(
|
|
478
|
-
entity: string,
|
|
479
|
-
seq: number,
|
|
480
|
-
opts: { skipHooks?: boolean } = {},
|
|
481
|
-
): Promise<{ ok: boolean; data: T }> {
|
|
482
|
-
const q = opts.skipHooks ? "?skipHooks=true" : "";
|
|
483
|
-
return this.request("GET", `/v1/entity/${entity}/${seq}${q}`);
|
|
484
|
-
}
|
|
485
|
-
|
|
486
|
-
/** 조건으로 엔티티 단건을 조회합니다. data 컬럼을 완전히 복호화하여 반환합니다. */
|
|
487
|
-
find<T = unknown>(
|
|
488
|
-
entity: string,
|
|
489
|
-
conditions?: Record<string, unknown>,
|
|
490
|
-
opts: { skipHooks?: boolean } = {},
|
|
491
|
-
): Promise<{ ok: boolean; data: T }> {
|
|
492
|
-
const q = opts.skipHooks ? "?skipHooks=true" : "";
|
|
493
|
-
return this.request(
|
|
494
|
-
"POST",
|
|
495
|
-
`/v1/entity/${entity}/find${q}`,
|
|
496
|
-
conditions ?? {},
|
|
497
|
-
);
|
|
498
|
-
}
|
|
499
|
-
|
|
500
|
-
/** 페이지네이션/정렬/필터 조건으로 엔티티 목록을 조회합니다. */
|
|
501
|
-
list<T = unknown>(
|
|
502
|
-
entity: string,
|
|
503
|
-
params: EntityListParams = {},
|
|
504
|
-
): Promise<{ ok: boolean; data: EntityListResult<T> }> {
|
|
505
|
-
const { conditions, fields, orderDir, orderBy, ...rest } = params;
|
|
506
|
-
|
|
507
|
-
const queryObj: Record<string, unknown> = {
|
|
508
|
-
page: 1,
|
|
509
|
-
limit: 20,
|
|
510
|
-
...rest,
|
|
511
|
-
};
|
|
512
|
-
if (orderBy) {
|
|
513
|
-
queryObj.orderBy = orderDir === "DESC" ? `-${orderBy}` : orderBy;
|
|
514
|
-
}
|
|
515
|
-
if (fields?.length) {
|
|
516
|
-
queryObj.fields = fields.join(",");
|
|
517
|
-
}
|
|
518
|
-
|
|
519
|
-
const q = buildQuery(queryObj);
|
|
520
|
-
return this.request(
|
|
521
|
-
"POST",
|
|
522
|
-
`/v1/entity/${entity}/list?${q}`,
|
|
523
|
-
conditions ?? {},
|
|
524
|
-
);
|
|
525
|
-
}
|
|
526
|
-
|
|
527
|
-
/**
|
|
528
|
-
* 엔티티 총 건수를 조회합니다.
|
|
529
|
-
*
|
|
530
|
-
* @param conditions 필터 조건 (예: `{ status: "active" }`)
|
|
531
|
-
*/
|
|
532
|
-
count(
|
|
533
|
-
entity: string,
|
|
534
|
-
conditions?: Record<string, unknown>,
|
|
535
|
-
): Promise<{ ok: boolean; count: number }> {
|
|
536
|
-
return this.request(
|
|
537
|
-
"POST",
|
|
538
|
-
`/v1/entity/${entity}/count`,
|
|
539
|
-
conditions ?? {},
|
|
540
|
-
);
|
|
541
|
-
}
|
|
542
|
-
|
|
543
|
-
/**
|
|
544
|
-
* 커스텀 SQL로 엔티티를 조회합니다.
|
|
545
|
-
*
|
|
546
|
-
* SELECT 전용이며 인덱스 테이블만 조회 가능합니다.
|
|
547
|
-
* JOIN을 사용해 여러 엔티티를 조합할 수 있습니다.
|
|
548
|
-
* `entity`는 SQL에 포함된 기본 엔티티명(라우트 경로용)입니다.
|
|
549
|
-
*/
|
|
550
|
-
query<T = unknown>(
|
|
551
|
-
entity: string,
|
|
552
|
-
req: EntityQueryRequest,
|
|
553
|
-
): Promise<{ ok: boolean; data: { items: T[]; count: number } }> {
|
|
554
|
-
return this.request("POST", `/v1/entity/${entity}/query`, req);
|
|
555
|
-
}
|
|
556
|
-
|
|
557
|
-
/** 엔티티 데이터를 생성/수정(Submit)합니다. `seq`가 없으면 INSERT, 있으면 UPDATE입니다. */
|
|
558
|
-
submit(
|
|
559
|
-
entity: string,
|
|
560
|
-
data: Record<string, unknown>,
|
|
561
|
-
opts: { transactionId?: string; skipHooks?: boolean } = {},
|
|
562
|
-
): Promise<{ ok: boolean; seq: number }> {
|
|
563
|
-
const txId = opts.transactionId ?? this.activeTxId;
|
|
564
|
-
const extraHeaders = txId ? { "X-Transaction-ID": txId } : undefined;
|
|
565
|
-
const q = opts.skipHooks ? "?skipHooks=true" : "";
|
|
566
|
-
|
|
567
|
-
return this.request(
|
|
568
|
-
"POST",
|
|
569
|
-
`/v1/entity/${entity}/submit${q}`,
|
|
570
|
-
data,
|
|
571
|
-
true,
|
|
572
|
-
extraHeaders,
|
|
573
|
-
);
|
|
574
|
-
}
|
|
575
|
-
|
|
576
|
-
/** 시퀀스 ID로 엔티티를 삭제합니다(`hard=true`면 하드 삭제, 기본은 소프트 삭제). */
|
|
577
|
-
delete(
|
|
578
|
-
entity: string,
|
|
579
|
-
seq: number,
|
|
580
|
-
opts: {
|
|
581
|
-
transactionId?: string;
|
|
582
|
-
hard?: boolean;
|
|
583
|
-
skipHooks?: boolean;
|
|
584
|
-
} = {},
|
|
585
|
-
): Promise<{ ok: boolean; deleted: number }> {
|
|
586
|
-
const params = new URLSearchParams();
|
|
587
|
-
if (opts.hard) params.set("hard", "true");
|
|
588
|
-
if (opts.skipHooks) params.set("skipHooks", "true");
|
|
589
|
-
const q = params.size ? `?${params}` : "";
|
|
590
|
-
const txId = opts.transactionId ?? this.activeTxId;
|
|
591
|
-
const extraHeaders = txId ? { "X-Transaction-ID": txId } : undefined;
|
|
592
|
-
|
|
593
|
-
return this.request(
|
|
594
|
-
"POST",
|
|
595
|
-
`/v1/entity/${entity}/delete/${seq}${q}`,
|
|
596
|
-
undefined,
|
|
597
|
-
true,
|
|
598
|
-
extraHeaders,
|
|
599
|
-
);
|
|
600
|
-
}
|
|
601
|
-
|
|
602
|
-
/** 엔티티 단건의 변경 이력을 조회합니다. 이력 항목당 `action`, `data_snapshot`, `changed_by`, `changed_time`을 포함합니다. */
|
|
603
|
-
history<T = unknown>(
|
|
604
|
-
entity: string,
|
|
605
|
-
seq: number,
|
|
606
|
-
params: Pick<EntityListParams, "page" | "limit"> = {},
|
|
607
|
-
): Promise<{
|
|
608
|
-
ok: boolean;
|
|
609
|
-
data: EntityListResult<EntityHistoryRecord<T>>;
|
|
610
|
-
}> {
|
|
611
|
-
const q = buildQuery({ page: 1, limit: 50, ...params });
|
|
612
|
-
return this.request("GET", `/v1/entity/${entity}/history/${seq}?${q}`);
|
|
613
|
-
}
|
|
614
|
-
|
|
615
|
-
/** 특정 이력 시점으로 엔티티를 롤백합니다. */
|
|
616
|
-
rollback(entity: string, historySeq: number): Promise<{ ok: boolean }> {
|
|
617
|
-
return this.request(
|
|
618
|
-
"POST",
|
|
619
|
-
`/v1/entity/${entity}/rollback/${historySeq}`,
|
|
620
|
-
);
|
|
621
|
-
}
|
|
622
|
-
|
|
623
|
-
/** 푸시 관련 엔티티로 payload를 전송(Submit)합니다. */
|
|
624
|
-
push(
|
|
625
|
-
pushEntity: string,
|
|
626
|
-
payload: Record<string, unknown>,
|
|
627
|
-
opts: { transactionId?: string } = {},
|
|
628
|
-
): Promise<{ ok: boolean; seq: number }> {
|
|
629
|
-
return this.submit(pushEntity, payload, opts);
|
|
630
|
-
}
|
|
631
|
-
|
|
632
|
-
/** 푸시 로그 엔티티 목록을 조회합니다. */
|
|
633
|
-
pushLogList<T = unknown>(
|
|
634
|
-
params: EntityListParams = {},
|
|
635
|
-
): Promise<{ ok: boolean; data: EntityListResult<T> }> {
|
|
636
|
-
return this.list<T>("push_log", params);
|
|
637
|
-
}
|
|
638
|
-
|
|
639
|
-
/** 계정의 푸시 디바이스를 등록합니다. */
|
|
640
|
-
registerPushDevice(
|
|
641
|
-
accountSeq: number,
|
|
642
|
-
deviceId: string,
|
|
643
|
-
pushToken: string,
|
|
644
|
-
opts: RegisterPushDeviceOptions = {},
|
|
645
|
-
): Promise<{ ok: boolean; seq: number }> {
|
|
646
|
-
const {
|
|
647
|
-
platform,
|
|
648
|
-
deviceType,
|
|
649
|
-
browser,
|
|
650
|
-
browserVersion,
|
|
651
|
-
pushEnabled = true,
|
|
652
|
-
transactionId,
|
|
653
|
-
} = opts;
|
|
654
|
-
|
|
655
|
-
return this.submit(
|
|
656
|
-
"account_device",
|
|
657
|
-
{
|
|
658
|
-
id: deviceId,
|
|
659
|
-
account_seq: accountSeq,
|
|
660
|
-
push_token: pushToken,
|
|
661
|
-
push_enabled: pushEnabled,
|
|
662
|
-
...(platform ? { platform } : {}),
|
|
663
|
-
...(deviceType ? { device_type: deviceType } : {}),
|
|
664
|
-
...(browser ? { browser } : {}),
|
|
665
|
-
...(browserVersion ? { browser_version: browserVersion } : {}),
|
|
666
|
-
},
|
|
667
|
-
{ transactionId },
|
|
668
|
-
);
|
|
669
|
-
}
|
|
670
|
-
|
|
671
|
-
/** 디바이스 레코드의 푸시 토큰을 갱신합니다. */
|
|
672
|
-
updatePushDeviceToken(
|
|
673
|
-
deviceSeq: number,
|
|
674
|
-
pushToken: string,
|
|
675
|
-
opts: { pushEnabled?: boolean; transactionId?: string } = {},
|
|
676
|
-
): Promise<{ ok: boolean; seq: number }> {
|
|
677
|
-
const { pushEnabled = true, transactionId } = opts;
|
|
678
|
-
return this.submit(
|
|
679
|
-
"account_device",
|
|
680
|
-
{
|
|
681
|
-
seq: deviceSeq,
|
|
682
|
-
push_token: pushToken,
|
|
683
|
-
push_enabled: pushEnabled,
|
|
684
|
-
},
|
|
685
|
-
{ transactionId },
|
|
686
|
-
);
|
|
687
|
-
}
|
|
688
|
-
|
|
689
|
-
/** 디바이스의 푸시 수신을 비활성화합니다. */
|
|
690
|
-
disablePushDevice(
|
|
691
|
-
deviceSeq: number,
|
|
692
|
-
opts: { transactionId?: string } = {},
|
|
693
|
-
): Promise<{ ok: boolean; seq: number }> {
|
|
694
|
-
return this.submit(
|
|
695
|
-
"account_device",
|
|
696
|
-
{
|
|
697
|
-
seq: deviceSeq,
|
|
698
|
-
push_enabled: false,
|
|
699
|
-
},
|
|
700
|
-
{ transactionId: opts.transactionId },
|
|
701
|
-
);
|
|
702
|
-
}
|
|
703
|
-
|
|
704
|
-
/**
|
|
705
|
-
* 요청 바디를 파싱하고 `application/octet-stream`인 경우 복호화합니다.
|
|
706
|
-
*
|
|
707
|
-
* 원시 암호화 payload를 직접 다루는 클라이언트에서 사용합니다.
|
|
708
|
-
*/
|
|
709
|
-
readRequestBody<T = Record<string, unknown>>(
|
|
710
|
-
body: ArrayBuffer | Uint8Array | string | T | null | undefined,
|
|
711
|
-
contentType = "application/json",
|
|
712
|
-
requireEncrypted = false,
|
|
713
|
-
): T {
|
|
714
|
-
const lowered = contentType.toLowerCase();
|
|
715
|
-
const isEncrypted = lowered.includes("application/octet-stream");
|
|
716
|
-
|
|
717
|
-
if (requireEncrypted && !isEncrypted) {
|
|
718
|
-
throw new Error(
|
|
719
|
-
"Encrypted request required: Content-Type must be application/octet-stream",
|
|
720
|
-
);
|
|
721
|
-
}
|
|
722
|
-
|
|
723
|
-
if (isEncrypted) {
|
|
724
|
-
if (body == null) {
|
|
725
|
-
throw new Error("Encrypted request body is empty");
|
|
726
|
-
}
|
|
727
|
-
if (body instanceof ArrayBuffer) {
|
|
728
|
-
return this.decryptPacket<T>(body);
|
|
729
|
-
}
|
|
730
|
-
if (body instanceof Uint8Array) {
|
|
731
|
-
const sliced = body.buffer.slice(
|
|
732
|
-
body.byteOffset,
|
|
733
|
-
body.byteOffset + body.byteLength,
|
|
734
|
-
);
|
|
735
|
-
return this.decryptPacket<T>(sliced as ArrayBuffer);
|
|
736
|
-
}
|
|
737
|
-
throw new Error(
|
|
738
|
-
"Encrypted request body must be ArrayBuffer or Uint8Array",
|
|
739
|
-
);
|
|
740
|
-
}
|
|
741
|
-
|
|
742
|
-
if (body == null || body === "") return {} as T;
|
|
743
|
-
if (typeof body === "string") return JSON.parse(body) as T;
|
|
744
|
-
return body as T;
|
|
745
|
-
}
|
|
746
|
-
|
|
747
|
-
/**
|
|
748
|
-
* 공통 HTTP 요청 함수입니다.
|
|
749
|
-
*
|
|
750
|
-
* - `encryptRequests`가 활성화된 인증 요청의 POST 바디를 자동 암호화합니다.
|
|
751
|
-
* - 응답이 `application/octet-stream`이면 자동 복호화합니다.
|
|
752
|
-
* - JSON 응답의 `ok`가 false이면 에러를 던집니다.
|
|
753
|
-
*/
|
|
754
|
-
private async request<T>(
|
|
755
|
-
method: string,
|
|
756
|
-
path: string,
|
|
757
|
-
body?: unknown,
|
|
758
|
-
withAuth = true,
|
|
759
|
-
extraHeaders: Record<string, string> = {},
|
|
760
|
-
): Promise<T> {
|
|
761
|
-
const isHmacMode = withAuth && !!(this.apiKey && this.hmacSecret);
|
|
762
|
-
const headers: Record<string, string> = {
|
|
763
|
-
"Content-Type": "application/json",
|
|
764
|
-
...extraHeaders,
|
|
765
|
-
};
|
|
766
|
-
if (!isHmacMode && withAuth && this.token) {
|
|
767
|
-
headers.Authorization = `Bearer ${this.token}`;
|
|
768
|
-
}
|
|
769
|
-
|
|
770
|
-
// 요청 바디 결정: encryptRequests 활성화 시 POST 바디를 암호화합니다.
|
|
771
|
-
// - 로그인/토큰 갱신(withAuth=false)은 암호화하지 않습니다.
|
|
772
|
-
// - GET 은 바디가 없으므로 건너뜁니다.
|
|
773
|
-
// - HMAC 모드는 token 없이도 hmacSecret 이 있으면 암호화합니다.
|
|
774
|
-
let fetchBody: string | Uint8Array | null = null;
|
|
775
|
-
if (body != null) {
|
|
776
|
-
const shouldEncrypt =
|
|
777
|
-
this.encryptRequests &&
|
|
778
|
-
withAuth &&
|
|
779
|
-
(this.token || isHmacMode) &&
|
|
780
|
-
method !== "GET" &&
|
|
781
|
-
method !== "HEAD";
|
|
782
|
-
|
|
783
|
-
if (shouldEncrypt) {
|
|
784
|
-
const plaintext = new TextEncoder().encode(
|
|
785
|
-
JSON.stringify(body),
|
|
786
|
-
);
|
|
787
|
-
const encrypted = this.encryptPacket(plaintext);
|
|
788
|
-
headers["Content-Type"] = "application/octet-stream";
|
|
789
|
-
fetchBody = encrypted;
|
|
790
|
-
} else {
|
|
791
|
-
fetchBody = JSON.stringify(body);
|
|
792
|
-
}
|
|
793
|
-
}
|
|
794
|
-
|
|
795
|
-
// HMAC 모드: X-API-Key / X-Timestamp / X-Nonce / X-Signature 헤더 추가
|
|
796
|
-
if (isHmacMode) {
|
|
797
|
-
const timestamp = String(Math.floor(Date.now() / 1000));
|
|
798
|
-
const nonce = crypto.randomUUID();
|
|
799
|
-
const bodyBytes =
|
|
800
|
-
fetchBody instanceof Uint8Array
|
|
801
|
-
? fetchBody
|
|
802
|
-
: typeof fetchBody === "string"
|
|
803
|
-
? new TextEncoder().encode(fetchBody)
|
|
804
|
-
: new Uint8Array(0);
|
|
805
|
-
const prefix = new TextEncoder().encode(
|
|
806
|
-
`${method}|${path}|${timestamp}|${nonce}|`,
|
|
807
|
-
);
|
|
808
|
-
const payload = new Uint8Array(prefix.length + bodyBytes.length);
|
|
809
|
-
payload.set(prefix, 0);
|
|
810
|
-
payload.set(bodyBytes, prefix.length);
|
|
811
|
-
const sig = hmac(
|
|
812
|
-
sha256,
|
|
813
|
-
new TextEncoder().encode(this.hmacSecret),
|
|
814
|
-
payload,
|
|
815
|
-
);
|
|
816
|
-
const signature = [...sig]
|
|
817
|
-
.map((b) => b.toString(16).padStart(2, "0"))
|
|
818
|
-
.join("");
|
|
819
|
-
headers["X-API-Key"] = this.apiKey;
|
|
820
|
-
headers["X-Timestamp"] = timestamp;
|
|
821
|
-
headers["X-Nonce"] = nonce;
|
|
822
|
-
headers["X-Signature"] = signature;
|
|
823
|
-
}
|
|
824
|
-
|
|
825
|
-
const res = await fetch(this.baseUrl + path, {
|
|
826
|
-
method,
|
|
827
|
-
headers,
|
|
828
|
-
...(fetchBody != null ? { body: fetchBody as BodyInit } : {}),
|
|
829
|
-
});
|
|
830
|
-
|
|
831
|
-
const contentType = res.headers.get("Content-Type") ?? "";
|
|
832
|
-
|
|
833
|
-
if (contentType.includes("application/octet-stream")) {
|
|
834
|
-
const buffer = await res.arrayBuffer();
|
|
835
|
-
return this.decryptPacket<T>(buffer);
|
|
836
|
-
}
|
|
837
|
-
|
|
838
|
-
const data = await res.json();
|
|
839
|
-
if (!data.ok) {
|
|
840
|
-
const err = new Error(
|
|
841
|
-
data.message ?? `EntityServer error (HTTP ${res.status})`,
|
|
842
|
-
);
|
|
843
|
-
(err as { status?: number }).status = res.status;
|
|
844
|
-
throw err;
|
|
845
|
-
}
|
|
846
|
-
return data as T;
|
|
847
|
-
}
|
|
848
|
-
|
|
849
|
-
/**
|
|
850
|
-
* 패킷 암호화 키를 유도합니다.
|
|
851
|
-
* - HMAC 모드 (`hmacSecret` 설정 시): HKDF-SHA256(hmac_secret, "entity-server:packet-encryption")
|
|
852
|
-
* - JWT 모드: sha256(jwt_token)
|
|
853
|
-
*/
|
|
854
|
-
private derivePacketKey(): Uint8Array {
|
|
855
|
-
if (this.hmacSecret) {
|
|
856
|
-
const salt = new TextEncoder().encode("entity-server:hkdf:v1");
|
|
857
|
-
const info = new TextEncoder().encode(
|
|
858
|
-
"entity-server:packet-encryption",
|
|
859
|
-
);
|
|
860
|
-
return hkdf(
|
|
861
|
-
sha256,
|
|
862
|
-
new TextEncoder().encode(this.hmacSecret),
|
|
863
|
-
salt,
|
|
864
|
-
info,
|
|
865
|
-
32,
|
|
866
|
-
);
|
|
867
|
-
}
|
|
868
|
-
return sha256(new TextEncoder().encode(this.token));
|
|
869
|
-
}
|
|
870
|
-
|
|
871
|
-
/**
|
|
872
|
-
* 평문 바이트를 XChaCha20-Poly1305로 암호화합니다.
|
|
873
|
-
* 포맷: [random_magic:packetMagicLen][random_nonce:24][ciphertext+tag]
|
|
874
|
-
*/
|
|
875
|
-
private encryptPacket(plaintext: Uint8Array): Uint8Array {
|
|
876
|
-
const key = this.derivePacketKey();
|
|
877
|
-
const magic = new Uint8Array(this.packetMagicLen);
|
|
878
|
-
const nonce = new Uint8Array(24);
|
|
879
|
-
crypto.getRandomValues(magic);
|
|
880
|
-
crypto.getRandomValues(nonce);
|
|
881
|
-
const cipher = xchacha20poly1305(key, nonce);
|
|
882
|
-
const ciphertext = cipher.encrypt(plaintext);
|
|
883
|
-
const result = new Uint8Array(
|
|
884
|
-
this.packetMagicLen + 24 + ciphertext.length,
|
|
885
|
-
);
|
|
886
|
-
result.set(magic, 0);
|
|
887
|
-
result.set(nonce, this.packetMagicLen);
|
|
888
|
-
result.set(ciphertext, this.packetMagicLen + 24);
|
|
889
|
-
return result;
|
|
890
|
-
}
|
|
891
|
-
|
|
892
|
-
/** 서버의 암호화 패킷을 복호화해 JSON 객체로 변환합니다. */
|
|
893
|
-
private decryptPacket<T>(buffer: ArrayBuffer): T {
|
|
894
|
-
const key = this.derivePacketKey();
|
|
895
|
-
const data = new Uint8Array(buffer);
|
|
896
|
-
|
|
897
|
-
if (data.length < this.packetMagicLen + 24 + 16) {
|
|
898
|
-
throw new Error("Encrypted packet too short");
|
|
899
|
-
}
|
|
900
|
-
|
|
901
|
-
const nonce = data.slice(this.packetMagicLen, this.packetMagicLen + 24);
|
|
902
|
-
const ciphertext = data.slice(this.packetMagicLen + 24);
|
|
903
|
-
const cipher = xchacha20poly1305(key, nonce);
|
|
904
|
-
const plaintext = cipher.decrypt(ciphertext);
|
|
905
|
-
return JSON.parse(new TextDecoder().decode(plaintext)) as T;
|
|
906
|
-
}
|
|
907
|
-
}
|
|
908
|
-
|
|
909
|
-
/** 쿼리 파라미터 객체를 URL 쿼리 문자열로 변환합니다. */
|
|
910
|
-
function buildQuery(params: Record<string, unknown>): string {
|
|
911
|
-
return Object.entries(params)
|
|
912
|
-
.filter(([, value]) => value != null)
|
|
913
|
-
.map(
|
|
914
|
-
([key, value]) =>
|
|
915
|
-
`${encodeURIComponent(key === "orderBy" ? "order_by" : key)}=${encodeURIComponent(String(value))}`,
|
|
916
|
-
)
|
|
917
|
-
.join("&");
|
|
918
|
-
}
|
|
4
|
+
import { EntityServerClient } from "./EntityServerClient";
|
|
919
5
|
|
|
920
6
|
export const entityServer = new EntityServerClient();
|