create-entity-server 0.0.9 → 0.0.23
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/bin/create.js +26 -8
- package/package.json +1 -1
- package/template/.env.example +20 -3
- package/template/configs/database.json +173 -10
- package/template/configs/jwt.json +1 -0
- package/template/configs/oauth.json +37 -0
- package/template/configs/push.json +26 -0
- package/template/entities/Account/account_audit.json +4 -5
- package/template/entities/README.md +4 -4
- package/template/entities/{Auth → System/Auth}/account.json +0 -14
- package/template/entities/System/system_audit_log.json +14 -8
- package/template/samples/README.md +43 -21
- package/template/samples/browser/entity-server-client.js +453 -0
- package/template/samples/browser/example.html +498 -0
- package/template/samples/entities/01_basic_fields.json +39 -0
- package/template/samples/entities/02_types_and_defaults.json +67 -0
- package/template/samples/entities/03_hash_and_unique.json +33 -0
- package/template/samples/entities/04_fk_and_composite_unique.json +29 -0
- package/template/samples/entities/05_cache.json +55 -0
- package/template/samples/entities/06_history_and_hard_delete.json +60 -0
- package/template/samples/entities/07_license_scope.json +52 -0
- package/template/samples/entities/08_hook_sql.json +52 -0
- package/template/samples/entities/09_hook_entity.json +65 -0
- package/template/samples/entities/10_hook_submit_delete.json +78 -0
- package/template/samples/entities/11_hook_webhook.json +84 -0
- package/template/samples/entities/12_hook_push.json +73 -0
- package/template/samples/entities/13_read_only.json +54 -0
- package/template/samples/entities/14_optimistic_lock.json +29 -0
- package/template/samples/entities/15_reset_defaults.json +94 -0
- package/template/samples/entities/16_isolated_license.json +62 -0
- package/template/samples/entities/README.md +91 -0
- package/template/samples/flutter/lib/entity_server_client.dart +261 -48
- package/template/samples/java/EntityServerClient.java +325 -61
- package/template/samples/java/EntityServerExample.java +4 -3
- package/template/samples/kotlin/EntityServerClient.kt +261 -45
- package/template/samples/node/src/EntityServerClient.js +348 -59
- package/template/samples/node/src/example.js +9 -9
- package/template/samples/php/ci4/Config/EntityServer.php +14 -0
- package/template/samples/php/ci4/Controllers/EntityController.php +202 -0
- package/template/samples/php/ci4/Controllers/ProductController.php +16 -76
- package/template/samples/php/ci4/Libraries/EntityServer.php +352 -60
- package/template/samples/php/laravel/Services/EntityServerService.php +245 -40
- package/template/samples/python/entity_server.py +287 -68
- package/template/samples/python/example.py +7 -6
- package/template/samples/react/src/example.tsx +41 -25
- package/template/samples/swift/EntityServerClient.swift +248 -37
- package/template/scripts/normalize-entities.sh +10 -10
- package/template/scripts/run.ps1 +12 -3
- package/template/scripts/run.sh +120 -37
- package/template/scripts/update-server.ps1 +160 -4
- package/template/scripts/update-server.sh +132 -4
- package/template/samples/react/src/api/entityServerClient.ts +0 -290
- package/template/samples/react/src/hooks/useEntity.ts +0 -105
- /package/template/entities/{Auth → System/Auth}/api_keys.json +0 -0
- /package/template/entities/{Auth → System/Auth}/license.json +0 -0
- /package/template/entities/{Auth → System/Auth}/rbac_roles.json +0 -0
|
@@ -6,9 +6,9 @@
|
|
|
6
6
|
*
|
|
7
7
|
* 환경변수:
|
|
8
8
|
* ENTITY_SERVER_URL http://localhost:47200
|
|
9
|
-
* ENTITY_SERVER_API_KEY your-api-key
|
|
10
|
-
* ENTITY_SERVER_HMAC_SECRET your-hmac-secret
|
|
11
|
-
*
|
|
9
|
+
* ENTITY_SERVER_API_KEY your-api-key (HMAC 모드)
|
|
10
|
+
* ENTITY_SERVER_HMAC_SECRET your-hmac-secret (HMAC 모드)
|
|
11
|
+
* ENTITY_SERVER_TOKEN your-jwt-token (JWT 모드)
|
|
12
12
|
*
|
|
13
13
|
* 사용 예:
|
|
14
14
|
* const es = new EntityServerClient();
|
|
@@ -27,27 +27,59 @@
|
|
|
27
27
|
* }
|
|
28
28
|
*/
|
|
29
29
|
|
|
30
|
-
import { createHmac, randomUUID } from "crypto";
|
|
30
|
+
import { createHmac, randomFillSync, randomUUID } from "crypto";
|
|
31
31
|
import { xchacha20_poly1305 } from "@noble/ciphers/chacha";
|
|
32
32
|
import { sha256 } from "@noble/hashes/sha2";
|
|
33
|
+
import { hkdf } from "@noble/hashes/hkdf";
|
|
33
34
|
|
|
34
35
|
export class EntityServerClient {
|
|
35
36
|
#baseUrl;
|
|
36
37
|
#apiKey;
|
|
37
38
|
#hmacSecret;
|
|
38
|
-
#
|
|
39
|
+
#token;
|
|
40
|
+
#encryptRequests;
|
|
41
|
+
#packetEncryption = false;
|
|
39
42
|
#activeTxId = null;
|
|
40
43
|
|
|
44
|
+
/**
|
|
45
|
+
* @param {Object} [opts]
|
|
46
|
+
* @param {string} [opts.baseUrl] ENTITY_SERVER_URL 환경변수 또는 기본값
|
|
47
|
+
* @param {string} [opts.apiKey] X-API-Key 헤더값 (HMAC 모드)
|
|
48
|
+
* @param {string} [opts.hmacSecret] HMAC 서명 시크릿 (HMAC 모드)
|
|
49
|
+
* @param {string} [opts.token] JWT Bearer 토큰 (JWT 모드)
|
|
50
|
+
* @param {boolean} [opts.encryptRequests] true 이면 POST 요청 바디를 XChaCha20-Poly1305로 암호화
|
|
51
|
+
*/
|
|
41
52
|
constructor({
|
|
42
53
|
baseUrl = process.env.ENTITY_SERVER_URL ?? "http://localhost:47200",
|
|
43
54
|
apiKey = process.env.ENTITY_SERVER_API_KEY ?? "",
|
|
44
55
|
hmacSecret = process.env.ENTITY_SERVER_HMAC_SECRET ?? "",
|
|
45
|
-
|
|
56
|
+
token = process.env.ENTITY_SERVER_TOKEN ?? "",
|
|
57
|
+
encryptRequests = false,
|
|
46
58
|
} = {}) {
|
|
47
59
|
this.#baseUrl = baseUrl.replace(/\/$/, "");
|
|
48
60
|
this.#apiKey = apiKey;
|
|
49
61
|
this.#hmacSecret = hmacSecret;
|
|
50
|
-
this.#
|
|
62
|
+
this.#token = token;
|
|
63
|
+
this.#encryptRequests = encryptRequests;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** JWT Bearer 토큰을 설정합니다. HMAC 모드와 배타적으로 사용합니다. */
|
|
67
|
+
setToken(token) {
|
|
68
|
+
this.#token = token;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* 서버 헬스 체크를 수행하고 패킷 암호화 활성 여부를 자동으로 감지합니다.
|
|
73
|
+
* 서버가 packet_encryption: true 를 응답하면 이후 모든 요청에 암호화가 자동 적용됩니다.
|
|
74
|
+
* @returns {Promise<{ok: boolean, packet_encryption?: boolean}>}
|
|
75
|
+
*/
|
|
76
|
+
async checkHealth() {
|
|
77
|
+
const res = await fetch(this.#baseUrl + "/v1/health");
|
|
78
|
+
const data = await res.json();
|
|
79
|
+
if (data.packet_encryption) {
|
|
80
|
+
this.#packetEncryption = true;
|
|
81
|
+
}
|
|
82
|
+
return data;
|
|
51
83
|
}
|
|
52
84
|
|
|
53
85
|
// ─── 트랜잭션 ─────────────────────────────────────────────────────────────
|
|
@@ -89,38 +121,91 @@ export class EntityServerClient {
|
|
|
89
121
|
|
|
90
122
|
// ─── CRUD ────────────────────────────────────────────────────────────────
|
|
91
123
|
|
|
92
|
-
/**
|
|
93
|
-
|
|
94
|
-
|
|
124
|
+
/**
|
|
125
|
+
* 단건 조회
|
|
126
|
+
* @param {Object} [opts]
|
|
127
|
+
* @param {boolean} [opts.skipHooks] true 이면 after_get 훅 미실행
|
|
128
|
+
*/
|
|
129
|
+
get(entity, seq, { skipHooks = false } = {}) {
|
|
130
|
+
const q = skipHooks ? "?skipHooks=true" : "";
|
|
131
|
+
return this.#request("GET", `/v1/entity/${entity}/${seq}${q}`);
|
|
95
132
|
}
|
|
96
133
|
|
|
97
|
-
/**
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
134
|
+
/**
|
|
135
|
+
* 조건으로 단건 조회 (POST + conditions body)
|
|
136
|
+
*
|
|
137
|
+
* @param {string} entity 엔티티 이름
|
|
138
|
+
* @param {Object} conditions 필터 조건. index/hash/unique 필드만 사용 가능
|
|
139
|
+
* @param {Object} [opts]
|
|
140
|
+
* @param {boolean} [opts.skipHooks] after_find 훅 미실행 여부 (기본 false)
|
|
141
|
+
*/
|
|
142
|
+
find(entity, conditions, { skipHooks = false } = {}) {
|
|
143
|
+
const q = skipHooks ? "?skipHooks=true" : "";
|
|
144
|
+
return this.#request(
|
|
145
|
+
"POST",
|
|
146
|
+
`/v1/entity/${entity}/find${q}`,
|
|
147
|
+
conditions ?? {},
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* 목록 조회 (POST + conditions body)
|
|
153
|
+
*
|
|
154
|
+
* @param {Object} [opts]
|
|
155
|
+
* @param {number} [opts.page] 페이지 번호 (기본 1)
|
|
156
|
+
* @param {number} [opts.limit] 페이지당 건수 (기본 20, 최대 1000)
|
|
157
|
+
* @param {string} [opts.orderBy] 정렬 기준 필드명 (- 접두사로 내림차순)
|
|
158
|
+
* @param {string} [opts.orderDir] 정렬 방향 ('ASC'|'DESC')
|
|
159
|
+
* @param {string[]} [opts.fields] 반환 필드 목록. 미지정 시 인덱스 필드만 반환 (기본, 가장 빠름). '*' 지정 시 전체 필드 반환
|
|
160
|
+
* @param {Object} [opts.conditions] 필터 조건. index/hash/unique 필드만 사용 가능
|
|
161
|
+
*/
|
|
162
|
+
list(
|
|
163
|
+
entity,
|
|
164
|
+
{ page = 1, limit = 20, orderBy, orderDir, fields, conditions } = {},
|
|
165
|
+
) {
|
|
166
|
+
const qParams = { page, limit };
|
|
167
|
+
if (orderBy)
|
|
168
|
+
qParams.order_by = orderDir === "DESC" ? `-${orderBy}` : orderBy;
|
|
169
|
+
if (fields?.length) qParams.fields = fields.join(",");
|
|
170
|
+
const q = new URLSearchParams(qParams);
|
|
171
|
+
return this.#request(
|
|
172
|
+
"POST",
|
|
173
|
+
`/v1/entity/${entity}/list?${q}`,
|
|
174
|
+
conditions ?? {},
|
|
175
|
+
);
|
|
105
176
|
}
|
|
106
177
|
|
|
107
|
-
/**
|
|
108
|
-
|
|
109
|
-
|
|
178
|
+
/**
|
|
179
|
+
* 건수 조회
|
|
180
|
+
* @param {Object} [conditions] 필터 조건 (list() 와 동일 규칙)
|
|
181
|
+
*/
|
|
182
|
+
count(entity, conditions) {
|
|
183
|
+
return this.#request(
|
|
184
|
+
"POST",
|
|
185
|
+
`/v1/entity/${entity}/count`,
|
|
186
|
+
conditions ?? {},
|
|
187
|
+
);
|
|
110
188
|
}
|
|
111
189
|
|
|
112
190
|
/**
|
|
113
|
-
*
|
|
114
|
-
*
|
|
115
|
-
* @param {Object}
|
|
191
|
+
* 커스텀 SQL 조회 (SELECT 전용, 인덱스 테이블만, JOIN 지원)
|
|
192
|
+
*
|
|
193
|
+
* @param {Object} req
|
|
194
|
+
* @param {string} req.sql SELECT SQL문. 사용자 입력은 반드시 params 로 바인딩 (SQL Injection 방지)
|
|
195
|
+
* @param {Array} [req.params] ? 플레이스홀더 바인딩 값
|
|
196
|
+
* @param {number} [req.limit] 최대 반환 건수 (최대 1000)
|
|
197
|
+
*
|
|
198
|
+
* @example
|
|
199
|
+
* es.query('order', {
|
|
200
|
+
* sql: 'SELECT o.seq, u.name FROM order o JOIN account u ON u.data_seq = o.account_seq WHERE o.status = ?',
|
|
201
|
+
* params: ['pending'],
|
|
202
|
+
* limit: 100,
|
|
203
|
+
* });
|
|
116
204
|
*/
|
|
117
|
-
query(entity,
|
|
118
|
-
const
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
...(orderBy && { order_by: orderBy }),
|
|
122
|
-
});
|
|
123
|
-
return this.#request("POST", `/v1/entity/${entity}/query?${q}`, filter);
|
|
205
|
+
query(entity, { sql, params = [], limit } = {}) {
|
|
206
|
+
const body = { sql, params };
|
|
207
|
+
if (limit != null) body.limit = limit;
|
|
208
|
+
return this.#request("POST", `/v1/entity/${entity}/query`, body);
|
|
124
209
|
}
|
|
125
210
|
|
|
126
211
|
/**
|
|
@@ -131,12 +216,13 @@ export class EntityServerClient {
|
|
|
131
216
|
* @param {Object} [opts]
|
|
132
217
|
* @param {string} [opts.transactionId] transStart() 가 반환한 ID (생략 시 활성 트랜잭션 자동 사용)
|
|
133
218
|
*/
|
|
134
|
-
submit(entity, data, { transactionId } = {}) {
|
|
219
|
+
submit(entity, data, { transactionId, skipHooks = false } = {}) {
|
|
135
220
|
const txId = transactionId ?? this.#activeTxId;
|
|
136
221
|
const extra = txId ? { "X-Transaction-ID": txId } : {};
|
|
222
|
+
const q = skipHooks ? "?skipHooks=true" : "";
|
|
137
223
|
return this.#request(
|
|
138
224
|
"POST",
|
|
139
|
-
`/v1/entity/${entity}/submit`,
|
|
225
|
+
`/v1/entity/${entity}/submit${q}`,
|
|
140
226
|
data,
|
|
141
227
|
extra,
|
|
142
228
|
);
|
|
@@ -150,12 +236,19 @@ export class EntityServerClient {
|
|
|
150
236
|
* @param {string} [opts.transactionId] transStart() 가 반환한 ID (생략 시 활성 트랜잭션 자동 사용)
|
|
151
237
|
* @param {boolean} [opts.hard] 하드 삭제 여부 (기본 false)
|
|
152
238
|
*/
|
|
153
|
-
delete(
|
|
154
|
-
|
|
239
|
+
delete(
|
|
240
|
+
entity,
|
|
241
|
+
seq,
|
|
242
|
+
{ transactionId, hard = false, skipHooks = false } = {},
|
|
243
|
+
) {
|
|
244
|
+
const params = new URLSearchParams();
|
|
245
|
+
if (hard) params.set("hard", "true");
|
|
246
|
+
if (skipHooks) params.set("skipHooks", "true");
|
|
247
|
+
const q = params.size ? `?${params}` : "";
|
|
155
248
|
const txId = transactionId ?? this.#activeTxId;
|
|
156
249
|
const extra = txId ? { "X-Transaction-ID": txId } : {};
|
|
157
250
|
return this.#request(
|
|
158
|
-
"
|
|
251
|
+
"POST",
|
|
159
252
|
`/v1/entity/${entity}/delete/${seq}${q}`,
|
|
160
253
|
null,
|
|
161
254
|
extra,
|
|
@@ -178,27 +271,170 @@ export class EntityServerClient {
|
|
|
178
271
|
);
|
|
179
272
|
}
|
|
180
273
|
|
|
274
|
+
/** 푸시 발송 트리거 엔티티에 submit합니다. */
|
|
275
|
+
push(pushEntity, payload, { transactionId } = {}) {
|
|
276
|
+
return this.submit(pushEntity, payload, { transactionId });
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/** push_log 목록 조회 헬퍼 */
|
|
280
|
+
pushLogList({ page = 1, limit = 20, orderBy } = {}) {
|
|
281
|
+
return this.list("push_log", { page, limit, orderBy });
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/** account_device 디바이스 등록/갱신 헬퍼 (push_token 단일 필드) */
|
|
285
|
+
registerPushDevice(
|
|
286
|
+
accountSeq,
|
|
287
|
+
deviceId,
|
|
288
|
+
pushToken,
|
|
289
|
+
{
|
|
290
|
+
platform,
|
|
291
|
+
deviceType,
|
|
292
|
+
browser,
|
|
293
|
+
browserVersion,
|
|
294
|
+
pushEnabled = true,
|
|
295
|
+
transactionId,
|
|
296
|
+
} = {},
|
|
297
|
+
) {
|
|
298
|
+
return this.submit(
|
|
299
|
+
"account_device",
|
|
300
|
+
{
|
|
301
|
+
id: deviceId,
|
|
302
|
+
account_seq: accountSeq,
|
|
303
|
+
push_token: pushToken,
|
|
304
|
+
push_enabled: pushEnabled,
|
|
305
|
+
...(platform ? { platform } : {}),
|
|
306
|
+
...(deviceType ? { device_type: deviceType } : {}),
|
|
307
|
+
...(browser ? { browser } : {}),
|
|
308
|
+
...(browserVersion ? { browser_version: browserVersion } : {}),
|
|
309
|
+
},
|
|
310
|
+
{ transactionId },
|
|
311
|
+
);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/** account_device.seq 기준 push_token 갱신 헬퍼 */
|
|
315
|
+
updatePushDeviceToken(
|
|
316
|
+
deviceSeq,
|
|
317
|
+
pushToken,
|
|
318
|
+
{ pushEnabled = true, transactionId } = {},
|
|
319
|
+
) {
|
|
320
|
+
return this.submit(
|
|
321
|
+
"account_device",
|
|
322
|
+
{
|
|
323
|
+
seq: deviceSeq,
|
|
324
|
+
push_token: pushToken,
|
|
325
|
+
push_enabled: pushEnabled,
|
|
326
|
+
},
|
|
327
|
+
{ transactionId },
|
|
328
|
+
);
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/** account_device.seq 기준 푸시 수신 비활성화 헬퍼 */
|
|
332
|
+
disablePushDevice(deviceSeq, { transactionId } = {}) {
|
|
333
|
+
return this.submit(
|
|
334
|
+
"account_device",
|
|
335
|
+
{
|
|
336
|
+
seq: deviceSeq,
|
|
337
|
+
push_enabled: false,
|
|
338
|
+
},
|
|
339
|
+
{ transactionId },
|
|
340
|
+
);
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/**
|
|
344
|
+
* 요청 본문을 읽어 JSON으로 반환합니다.
|
|
345
|
+
* - application/octet-stream: 암호 패킷 복호화
|
|
346
|
+
* - 그 외: 평문 JSON 파싱
|
|
347
|
+
*/
|
|
348
|
+
readRequestBody(
|
|
349
|
+
body,
|
|
350
|
+
contentType = "application/json",
|
|
351
|
+
{ requireEncrypted = false } = {},
|
|
352
|
+
) {
|
|
353
|
+
const lowered = String(contentType || "").toLowerCase();
|
|
354
|
+
const isEncrypted = lowered.includes("application/octet-stream");
|
|
355
|
+
|
|
356
|
+
if (requireEncrypted && !isEncrypted) {
|
|
357
|
+
throw new Error(
|
|
358
|
+
"Encrypted request required: Content-Type must be application/octet-stream",
|
|
359
|
+
);
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
if (isEncrypted) {
|
|
363
|
+
if (body == null) {
|
|
364
|
+
throw new Error("Encrypted request body is empty");
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
if (body instanceof ArrayBuffer) {
|
|
368
|
+
return this.#decryptPacket(body);
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
if (ArrayBuffer.isView(body)) {
|
|
372
|
+
const view = body;
|
|
373
|
+
const sliced = view.buffer.slice(
|
|
374
|
+
view.byteOffset,
|
|
375
|
+
view.byteOffset + view.byteLength,
|
|
376
|
+
);
|
|
377
|
+
return this.#decryptPacket(sliced);
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
throw new Error(
|
|
381
|
+
"Encrypted request body must be ArrayBuffer, Buffer, or Uint8Array",
|
|
382
|
+
);
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
if (body == null || body === "") return {};
|
|
386
|
+
if (typeof body === "object") return body;
|
|
387
|
+
return JSON.parse(String(body));
|
|
388
|
+
}
|
|
389
|
+
|
|
181
390
|
// ─── 내부 ─────────────────────────────────────────────────────────────────
|
|
182
391
|
|
|
183
392
|
async #request(method, path, body, extraHeaders = {}) {
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
393
|
+
// 요청 바디 결정: encryptRequests 시 POST 바디를 암호화합니다.
|
|
394
|
+
let bodyData = null; // string | Buffer | null
|
|
395
|
+
if (body != null) {
|
|
396
|
+
if (this.#encryptRequests || this.#packetEncryption) {
|
|
397
|
+
const plaintext = Buffer.from(JSON.stringify(body));
|
|
398
|
+
bodyData = this.#encryptPacket(plaintext); // Buffer
|
|
399
|
+
} else {
|
|
400
|
+
bodyData = JSON.stringify(body);
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
const isHmacMode = !!(this.#apiKey && this.#hmacSecret);
|
|
405
|
+
|
|
406
|
+
const contentType =
|
|
407
|
+
(this.#encryptRequests || this.#packetEncryption) &&
|
|
408
|
+
bodyData instanceof Buffer
|
|
409
|
+
? "application/octet-stream"
|
|
410
|
+
: "application/json";
|
|
411
|
+
|
|
412
|
+
const headers = { "Content-Type": contentType };
|
|
413
|
+
|
|
414
|
+
if (isHmacMode) {
|
|
415
|
+
const timestamp = String(Math.floor(Date.now() / 1000));
|
|
416
|
+
const nonce = randomUUID();
|
|
417
|
+
const signature = this.#sign(
|
|
418
|
+
method,
|
|
419
|
+
path,
|
|
420
|
+
timestamp,
|
|
421
|
+
nonce,
|
|
422
|
+
bodyData ?? "",
|
|
423
|
+
);
|
|
424
|
+
headers["X-API-Key"] = this.#apiKey;
|
|
425
|
+
headers["X-Timestamp"] = timestamp;
|
|
426
|
+
headers["X-Nonce"] = nonce;
|
|
427
|
+
headers["X-Signature"] = signature;
|
|
428
|
+
} else if (this.#token) {
|
|
429
|
+
headers["Authorization"] = `Bearer ${this.#token}`;
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
Object.assign(headers, extraHeaders);
|
|
197
433
|
|
|
198
434
|
const res = await fetch(this.#baseUrl + path, {
|
|
199
435
|
method,
|
|
200
436
|
headers,
|
|
201
|
-
...(
|
|
437
|
+
...(bodyData != null ? { body: bodyData } : {}),
|
|
202
438
|
});
|
|
203
439
|
|
|
204
440
|
const contentType = res.headers.get("Content-Type") ?? "";
|
|
@@ -219,27 +455,80 @@ export class EntityServerClient {
|
|
|
219
455
|
return data;
|
|
220
456
|
}
|
|
221
457
|
|
|
458
|
+
/**
|
|
459
|
+
* 패킷 암호화 키를 유도합니다.
|
|
460
|
+
* - HMAC 모드: HKDF-SHA256(hmac_secret, "entity-server:packet-encryption")
|
|
461
|
+
* - JWT 모드: SHA256(token)
|
|
462
|
+
*/
|
|
463
|
+
#derivePacketKey() {
|
|
464
|
+
if (this.#token && !this.#hmacSecret) {
|
|
465
|
+
return sha256(new TextEncoder().encode(this.#token));
|
|
466
|
+
}
|
|
467
|
+
const salt = new TextEncoder().encode("entity-server:hkdf:v1");
|
|
468
|
+
const info = new TextEncoder().encode(
|
|
469
|
+
"entity-server:packet-encryption",
|
|
470
|
+
);
|
|
471
|
+
return hkdf(
|
|
472
|
+
sha256,
|
|
473
|
+
new TextEncoder().encode(this.#hmacSecret),
|
|
474
|
+
salt,
|
|
475
|
+
info,
|
|
476
|
+
32,
|
|
477
|
+
);
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
/**
|
|
481
|
+
* XChaCha20-Poly1305 패킷 암호화
|
|
482
|
+
* 포맷: [magic:magicLen][nonce:24][ciphertext+tag]
|
|
483
|
+
* magicLen: 패킷 키의 마지막 바이트에서 파생 (2 + key[31] % 14)
|
|
484
|
+
* @param {Buffer} plaintext
|
|
485
|
+
* @returns {Buffer}
|
|
486
|
+
*/
|
|
487
|
+
#encryptPacket(plaintext) {
|
|
488
|
+
const key = this.#derivePacketKey();
|
|
489
|
+
const magicLen = 2 + (key[31] % 14);
|
|
490
|
+
const magic = Buffer.allocUnsafe(magicLen);
|
|
491
|
+
const nonce = Buffer.allocUnsafe(24);
|
|
492
|
+
randomFillSync(magic);
|
|
493
|
+
randomFillSync(nonce);
|
|
494
|
+
const cipher = xchacha20_poly1305(key, nonce);
|
|
495
|
+
const ciphertext = cipher.encrypt(
|
|
496
|
+
plaintext instanceof Uint8Array
|
|
497
|
+
? plaintext
|
|
498
|
+
: new Uint8Array(plaintext),
|
|
499
|
+
);
|
|
500
|
+
return Buffer.concat([magic, nonce, Buffer.from(ciphertext)]);
|
|
501
|
+
}
|
|
502
|
+
|
|
222
503
|
/**
|
|
223
504
|
* XChaCha20-Poly1305 패킷 복호화
|
|
224
505
|
* 포맷: [magic:magicLen][nonce:24][ciphertext+tag]
|
|
225
|
-
* 키:
|
|
506
|
+
* 키: HKDF-SHA256(hmac_secret, "entity-server:packet-encryption")
|
|
226
507
|
*/
|
|
227
508
|
#decryptPacket(buffer) {
|
|
228
|
-
const key =
|
|
509
|
+
const key = this.#derivePacketKey();
|
|
510
|
+
const magicLen = 2 + (key[31] % 14);
|
|
229
511
|
const data = new Uint8Array(buffer);
|
|
230
|
-
const nonce = data.slice(
|
|
231
|
-
const ciphertext = data.slice(
|
|
512
|
+
const nonce = data.slice(magicLen, magicLen + 24);
|
|
513
|
+
const ciphertext = data.slice(magicLen + 24);
|
|
232
514
|
const cipher = xchacha20_poly1305(key, nonce);
|
|
233
515
|
const plaintext = cipher.decrypt(ciphertext);
|
|
234
516
|
return JSON.parse(new TextDecoder().decode(plaintext));
|
|
235
517
|
}
|
|
236
518
|
|
|
237
|
-
/**
|
|
519
|
+
/**
|
|
520
|
+
* HMAC-SHA256 서명
|
|
521
|
+
* body 는 문자열(JSON) 또는 Buffer(암호화된 바디) 모두 지원합니다.
|
|
522
|
+
*/
|
|
238
523
|
#sign(method, path, timestamp, nonce, body) {
|
|
239
|
-
const
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
524
|
+
const mac = createHmac("sha256", this.#hmacSecret);
|
|
525
|
+
const prefix = `${method}|${path}|${timestamp}|${nonce}|`;
|
|
526
|
+
mac.update(prefix);
|
|
527
|
+
if (body != null && body !== "") {
|
|
528
|
+
// Buffer(binary) 또는 string 모두 처리 — Go 서버의 string(c.Body()) 와 동일한 바이트
|
|
529
|
+
mac.update(typeof body === "string" ? body : Buffer.from(body));
|
|
530
|
+
}
|
|
531
|
+
return mac.digest("hex");
|
|
243
532
|
}
|
|
244
533
|
}
|
|
245
534
|
|
|
@@ -8,7 +8,7 @@ const es = new EntityServerClient({
|
|
|
8
8
|
|
|
9
9
|
// 목록 조회
|
|
10
10
|
const list = await es.list("product", { page: 1, limit: 10 });
|
|
11
|
-
console.log("List:", list.data?.
|
|
11
|
+
console.log("List:", list.data?.total, "items");
|
|
12
12
|
|
|
13
13
|
// 생성
|
|
14
14
|
const created = await es.submit("product", {
|
|
@@ -22,17 +22,17 @@ console.log("Created seq:", created.seq);
|
|
|
22
22
|
await es.submit("product", { seq: created.seq, price: 79000 });
|
|
23
23
|
console.log("Updated");
|
|
24
24
|
|
|
25
|
-
//
|
|
26
|
-
const results = await es.query(
|
|
27
|
-
"product",
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
);
|
|
31
|
-
console.log("Query results:", results.data?.length);
|
|
25
|
+
// 커스텀 SQL 검색
|
|
26
|
+
const results = await es.query("product", {
|
|
27
|
+
sql: "SELECT seq, name, category FROM product WHERE category = ?",
|
|
28
|
+
params: ["peripherals"],
|
|
29
|
+
limit: 5,
|
|
30
|
+
});
|
|
31
|
+
console.log("Query results:", results.data?.items?.length);
|
|
32
32
|
|
|
33
33
|
// 이력 조회
|
|
34
34
|
const hist = await es.history("product", created.seq);
|
|
35
|
-
console.log("History count:", hist.data?.length);
|
|
35
|
+
console.log("History count:", hist.data?.items?.length);
|
|
36
36
|
|
|
37
37
|
// 삭제
|
|
38
38
|
await es.delete("product", created.seq);
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
<?php
|
|
2
|
+
|
|
3
|
+
namespace Config;
|
|
4
|
+
|
|
5
|
+
use CodeIgniter\Config\BaseConfig;
|
|
6
|
+
|
|
7
|
+
class EntityServer extends BaseConfig
|
|
8
|
+
{
|
|
9
|
+
public string $baseUrl = 'http://localhost:47200';
|
|
10
|
+
public string $apiKey = '';
|
|
11
|
+
public string $hmacSecret = '';
|
|
12
|
+
public int $timeout = 10;
|
|
13
|
+
public bool $requireEncryptedRequest = true;
|
|
14
|
+
}
|