create-entity-server 0.0.15 → 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 (42) hide show
  1. package/bin/create.js +15 -7
  2. package/package.json +1 -1
  3. package/template/.env.example +8 -7
  4. package/template/configs/database.json +173 -10
  5. package/template/entities/Account/account_audit.json +4 -5
  6. package/template/entities/System/system_audit_log.json +14 -8
  7. package/template/samples/README.md +28 -22
  8. package/template/samples/browser/entity-server-client.js +453 -0
  9. package/template/samples/browser/example.html +498 -0
  10. package/template/samples/entities/02_types_and_defaults.json +15 -16
  11. package/template/samples/entities/04_fk_and_composite_unique.json +0 -2
  12. package/template/samples/entities/05_cache.json +9 -8
  13. package/template/samples/entities/06_history_and_hard_delete.json +27 -9
  14. package/template/samples/entities/07_license_scope.json +40 -31
  15. package/template/samples/entities/09_hook_entity.json +0 -6
  16. package/template/samples/entities/10_hook_submit_delete.json +5 -2
  17. package/template/samples/entities/11_hook_webhook.json +9 -7
  18. package/template/samples/entities/12_hook_push.json +3 -3
  19. package/template/samples/entities/13_read_only.json +13 -10
  20. package/template/samples/entities/15_reset_defaults.json +0 -1
  21. package/template/samples/entities/16_isolated_license.json +62 -0
  22. package/template/samples/entities/README.md +36 -39
  23. package/template/samples/flutter/lib/entity_server_client.dart +170 -48
  24. package/template/samples/java/EntityServerClient.java +208 -61
  25. package/template/samples/java/EntityServerExample.java +4 -3
  26. package/template/samples/kotlin/EntityServerClient.kt +175 -45
  27. package/template/samples/node/src/EntityServerClient.js +232 -59
  28. package/template/samples/node/src/example.js +9 -9
  29. package/template/samples/php/ci4/Config/EntityServer.php +0 -1
  30. package/template/samples/php/ci4/Libraries/EntityServer.php +206 -53
  31. package/template/samples/php/laravel/Services/EntityServerService.php +190 -41
  32. package/template/samples/python/entity_server.py +181 -68
  33. package/template/samples/python/example.py +7 -6
  34. package/template/samples/react/src/example.tsx +41 -25
  35. package/template/samples/swift/EntityServerClient.swift +143 -37
  36. package/template/scripts/run.ps1 +12 -3
  37. package/template/scripts/run.sh +12 -8
  38. package/template/scripts/update-server.ps1 +68 -2
  39. package/template/scripts/update-server.sh +59 -2
  40. package/template/samples/entities/order_notification.json +0 -51
  41. package/template/samples/react/src/api/entityServerClient.ts +0 -413
  42. package/template/samples/react/src/hooks/useEntity.ts +0 -173
@@ -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
+ );
105
149
  }
106
150
 
107
- /** 건수 조회 */
108
- count(entity) {
109
- return this.#request("GET", `/v1/entity/${entity}/count`);
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
+ );
110
176
  }
111
177
 
112
178
  /**
113
- * 필터 검색
114
- * @param {Array} filter 예: [{ field: 'status', op: 'eq', value: 'active' }]
115
- * @param {Object} params 예: { page: 1, limit: 20, orderBy: 'name' }
179
+ * 건수 조회
180
+ * @param {Object} [conditions] 필터 조건 (list() 동일 규칙)
116
181
  */
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);
182
+ count(entity, conditions) {
183
+ return this.#request(
184
+ "POST",
185
+ `/v1/entity/${entity}/count`,
186
+ conditions ?? {},
187
+ );
188
+ }
189
+
190
+ /**
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
+ * });
204
+ */
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,
@@ -297,24 +390,51 @@ export class EntityServerClient {
297
390
  // ─── 내부 ─────────────────────────────────────────────────────────────────
298
391
 
299
392
  async #request(method, path, body, extraHeaders = {}) {
300
- const bodyStr = body != null ? JSON.stringify(body) : "";
301
- const timestamp = String(Math.floor(Date.now() / 1000));
302
- const nonce = randomUUID();
303
- const signature = this.#sign(method, path, timestamp, nonce, bodyStr);
304
-
305
- const headers = {
306
- "Content-Type": "application/json",
307
- "X-API-Key": this.#apiKey,
308
- "X-Timestamp": timestamp,
309
- "X-Nonce": nonce,
310
- "X-Signature": signature,
311
- ...extraHeaders,
312
- };
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);
313
433
 
314
434
  const res = await fetch(this.#baseUrl + path, {
315
435
  method,
316
436
  headers,
317
- ...(bodyStr ? { body: bodyStr } : {}),
437
+ ...(bodyData != null ? { body: bodyData } : {}),
318
438
  });
319
439
 
320
440
  const contentType = res.headers.get("Content-Type") ?? "";
@@ -335,27 +455,80 @@ export class EntityServerClient {
335
455
  return data;
336
456
  }
337
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
+
338
503
  /**
339
504
  * XChaCha20-Poly1305 패킷 복호화
340
505
  * 포맷: [magic:magicLen][nonce:24][ciphertext+tag]
341
- * 키: sha256(hmac_secret)
506
+ * 키: HKDF-SHA256(hmac_secret, "entity-server:packet-encryption")
342
507
  */
343
508
  #decryptPacket(buffer) {
344
- const key = sha256(new TextEncoder().encode(this.#hmacSecret));
509
+ const key = this.#derivePacketKey();
510
+ const magicLen = 2 + (key[31] % 14);
345
511
  const data = new Uint8Array(buffer);
346
- const nonce = data.slice(this.#magicLen, this.#magicLen + 24);
347
- const ciphertext = data.slice(this.#magicLen + 24);
512
+ const nonce = data.slice(magicLen, magicLen + 24);
513
+ const ciphertext = data.slice(magicLen + 24);
348
514
  const cipher = xchacha20_poly1305(key, nonce);
349
515
  const plaintext = cipher.decrypt(ciphertext);
350
516
  return JSON.parse(new TextDecoder().decode(plaintext));
351
517
  }
352
518
 
353
- /** HMAC-SHA256 서명 */
519
+ /**
520
+ * HMAC-SHA256 서명
521
+ * body 는 문자열(JSON) 또는 Buffer(암호화된 바디) 모두 지원합니다.
522
+ */
354
523
  #sign(method, path, timestamp, nonce, body) {
355
- const payload = [method, path, timestamp, nonce, body].join("|");
356
- return createHmac("sha256", this.#hmacSecret)
357
- .update(payload)
358
- .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");
359
532
  }
360
533
  }
361
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);
@@ -10,6 +10,5 @@ class EntityServer extends BaseConfig
10
10
  public string $apiKey = '';
11
11
  public string $hmacSecret = '';
12
12
  public int $timeout = 10;
13
- public int $magicLen = 4;
14
13
  public bool $requireEncryptedRequest = true;
15
14
  }