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.
Files changed (56) hide show
  1. package/bin/create.js +26 -8
  2. package/package.json +1 -1
  3. package/template/.env.example +20 -3
  4. package/template/configs/database.json +173 -10
  5. package/template/configs/jwt.json +1 -0
  6. package/template/configs/oauth.json +37 -0
  7. package/template/configs/push.json +26 -0
  8. package/template/entities/Account/account_audit.json +4 -5
  9. package/template/entities/README.md +4 -4
  10. package/template/entities/{Auth → System/Auth}/account.json +0 -14
  11. package/template/entities/System/system_audit_log.json +14 -8
  12. package/template/samples/README.md +43 -21
  13. package/template/samples/browser/entity-server-client.js +453 -0
  14. package/template/samples/browser/example.html +498 -0
  15. package/template/samples/entities/01_basic_fields.json +39 -0
  16. package/template/samples/entities/02_types_and_defaults.json +67 -0
  17. package/template/samples/entities/03_hash_and_unique.json +33 -0
  18. package/template/samples/entities/04_fk_and_composite_unique.json +29 -0
  19. package/template/samples/entities/05_cache.json +55 -0
  20. package/template/samples/entities/06_history_and_hard_delete.json +60 -0
  21. package/template/samples/entities/07_license_scope.json +52 -0
  22. package/template/samples/entities/08_hook_sql.json +52 -0
  23. package/template/samples/entities/09_hook_entity.json +65 -0
  24. package/template/samples/entities/10_hook_submit_delete.json +78 -0
  25. package/template/samples/entities/11_hook_webhook.json +84 -0
  26. package/template/samples/entities/12_hook_push.json +73 -0
  27. package/template/samples/entities/13_read_only.json +54 -0
  28. package/template/samples/entities/14_optimistic_lock.json +29 -0
  29. package/template/samples/entities/15_reset_defaults.json +94 -0
  30. package/template/samples/entities/16_isolated_license.json +62 -0
  31. package/template/samples/entities/README.md +91 -0
  32. package/template/samples/flutter/lib/entity_server_client.dart +261 -48
  33. package/template/samples/java/EntityServerClient.java +325 -61
  34. package/template/samples/java/EntityServerExample.java +4 -3
  35. package/template/samples/kotlin/EntityServerClient.kt +261 -45
  36. package/template/samples/node/src/EntityServerClient.js +348 -59
  37. package/template/samples/node/src/example.js +9 -9
  38. package/template/samples/php/ci4/Config/EntityServer.php +14 -0
  39. package/template/samples/php/ci4/Controllers/EntityController.php +202 -0
  40. package/template/samples/php/ci4/Controllers/ProductController.php +16 -76
  41. package/template/samples/php/ci4/Libraries/EntityServer.php +352 -60
  42. package/template/samples/php/laravel/Services/EntityServerService.php +245 -40
  43. package/template/samples/python/entity_server.py +287 -68
  44. package/template/samples/python/example.py +7 -6
  45. package/template/samples/react/src/example.tsx +41 -25
  46. package/template/samples/swift/EntityServerClient.swift +248 -37
  47. package/template/scripts/normalize-entities.sh +10 -10
  48. package/template/scripts/run.ps1 +12 -3
  49. package/template/scripts/run.sh +120 -37
  50. package/template/scripts/update-server.ps1 +160 -4
  51. package/template/scripts/update-server.sh +132 -4
  52. package/template/samples/react/src/api/entityServerClient.ts +0 -290
  53. package/template/samples/react/src/hooks/useEntity.ts +0 -105
  54. /package/template/entities/{Auth → System/Auth}/api_keys.json +0 -0
  55. /package/template/entities/{Auth → System/Auth}/license.json +0 -0
  56. /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
- * ENTITY_SERVER_MAGIC_LEN 4 (서버 packet_magic_len 과 동일하게)
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
- #magicLen;
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
- magicLen = Number(process.env.ENTITY_SERVER_MAGIC_LEN ?? "4"),
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.#magicLen = magicLen > 0 ? magicLen : 4;
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
- get(entity, seq) {
94
- return this.#request("GET", `/v1/entity/${entity}/${seq}`);
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
- list(entity, { page = 1, limit = 20, orderBy } = {}) {
99
- const q = new URLSearchParams({
100
- page,
101
- limit,
102
- ...(orderBy && { order_by: orderBy }),
103
- });
104
- return this.#request("GET", `/v1/entity/${entity}/list?${q}`);
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
- count(entity) {
109
- return this.#request("GET", `/v1/entity/${entity}/count`);
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
- * @param {Array} filter 예: [{ field: 'status', op: 'eq', value: 'active' }]
115
- * @param {Object} params 예: { page: 1, limit: 20, orderBy: 'name' }
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, filter = [], { page = 1, limit = 20, orderBy } = {}) {
118
- const q = new URLSearchParams({
119
- page,
120
- limit,
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(entity, seq, { transactionId, hard = false } = {}) {
154
- const q = hard ? "?hard=true" : "";
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
- "DELETE",
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
- const bodyStr = body != null ? JSON.stringify(body) : "";
185
- const timestamp = String(Math.floor(Date.now() / 1000));
186
- const nonce = randomUUID();
187
- const signature = this.#sign(method, path, timestamp, nonce, bodyStr);
188
-
189
- const headers = {
190
- "Content-Type": "application/json",
191
- "X-API-Key": this.#apiKey,
192
- "X-Timestamp": timestamp,
193
- "X-Nonce": nonce,
194
- "X-Signature": signature,
195
- ...extraHeaders,
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
- ...(bodyStr ? { body: bodyStr } : {}),
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
- * 키: sha256(hmac_secret)
506
+ * 키: HKDF-SHA256(hmac_secret, "entity-server:packet-encryption")
226
507
  */
227
508
  #decryptPacket(buffer) {
228
- const key = sha256(new TextEncoder().encode(this.#hmacSecret));
509
+ const key = this.#derivePacketKey();
510
+ const magicLen = 2 + (key[31] % 14);
229
511
  const data = new Uint8Array(buffer);
230
- const nonce = data.slice(this.#magicLen, this.#magicLen + 24);
231
- const ciphertext = data.slice(this.#magicLen + 24);
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
- /** HMAC-SHA256 서명 */
519
+ /**
520
+ * HMAC-SHA256 서명
521
+ * body 는 문자열(JSON) 또는 Buffer(암호화된 바디) 모두 지원합니다.
522
+ */
238
523
  #sign(method, path, timestamp, nonce, body) {
239
- const payload = [method, path, timestamp, nonce, body].join("|");
240
- return createHmac("sha256", this.#hmacSecret)
241
- .update(payload)
242
- .digest("hex");
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?.length, "items");
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
- [{ field: "category", op: "eq", value: "peripherals" }],
29
- { page: 1, limit: 5 },
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
+ }