create-entity-server 0.0.15 → 0.0.25

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
@@ -15,7 +15,7 @@ use Config\EntityServer as EntityServerConfig;
15
15
  * ENTITY_SERVER_URL=http://localhost:47200
16
16
  * ENTITY_SERVER_API_KEY=your-api-key
17
17
  * ENTITY_SERVER_HMAC_SECRET=your-hmac-secret
18
- * ENTITY_PACKET_MAGIC_LEN=4
18
+
19
19
  *
20
20
  * 컨트롤러 사용법:
21
21
  * $es = new \App\Libraries\EntityServer();
@@ -28,67 +28,158 @@ class EntityServer
28
28
  private string $baseUrl;
29
29
  private string $apiKey;
30
30
  private string $hmacSecret;
31
+ private string $token = '';
31
32
  private int $timeout;
32
- private int $magicLen;
33
33
  private bool $requireEncryptedRequest;
34
+ private bool $encryptRequests;
35
+ private bool $packetEncryption = false;
34
36
  private ?string $activeTxId = null;
35
37
 
36
38
  public function __construct(
37
39
  ?string $baseUrl = null,
38
40
  ?string $apiKey = null,
39
41
  ?string $hmacSecret = null,
42
+ ?string $token = null,
40
43
  ?int $timeout = null,
41
- ?int $magicLen = null,
42
- ?bool $requireEncryptedRequest = null
44
+ ?bool $requireEncryptedRequest = null,
45
+ ?bool $encryptRequests = null
43
46
  ) {
44
47
  $config = class_exists(EntityServerConfig::class) ? new EntityServerConfig() : null;
45
48
 
46
49
  $configBaseUrl = $config?->baseUrl ?? env('ENTITY_SERVER_URL', 'http://localhost:47200');
47
50
  $configApiKey = $config?->apiKey ?? env('ENTITY_SERVER_API_KEY', '');
48
51
  $configHmacSecret = $config?->hmacSecret ?? env('ENTITY_SERVER_HMAC_SECRET', '');
52
+ $configToken = $config?->token ?? env('ENTITY_SERVER_TOKEN', '');
49
53
  $configTimeout = $config?->timeout ?? (int) env('ENTITY_SERVER_TIMEOUT', 10);
50
- $configMagicLen = $config?->magicLen ?? (int) env('ENTITY_PACKET_MAGIC_LEN', 4);
51
54
  $configRequireEncrypted = $config?->requireEncryptedRequest ?? true;
55
+ $configEncryptRequests = $config?->encryptRequests ?? false;
52
56
 
53
57
  $this->baseUrl = rtrim($baseUrl ?? (string) $configBaseUrl, '/');
54
58
  $this->apiKey = (string) ($apiKey ?? $configApiKey);
55
59
  $this->hmacSecret = (string) ($hmacSecret ?? $configHmacSecret);
60
+ $this->token = (string) ($token ?? $configToken);
56
61
  $this->timeout = (int) ($timeout ?? $configTimeout);
57
- $this->magicLen = (int) ($magicLen ?? $configMagicLen);
58
62
  $this->requireEncryptedRequest = (bool) ($requireEncryptedRequest ?? $configRequireEncrypted);
63
+ $this->encryptRequests = (bool) ($encryptRequests ?? $configEncryptRequests);
64
+ }
65
+
66
+ /** JWT Bearer 토큰을 설정합니다. HMAC 모드와 배타적으로 사용합니다. */
67
+ public function setToken(string $token): void
68
+ {
69
+ $this->token = $token;
59
70
  }
60
71
 
61
72
  // ─── CRUD ────────────────────────────────────────────────────────────────
62
73
 
63
- /** 단건 조회 */
64
- public function get(string $entity, int $seq): array
74
+ /** * 서버 헬스 체크를 수행하고 패킷 암호화 활성 여부를 자동으로 감지합니다.
75
+ * 서버가 packet_encryption: true 응답하면 이후 모든 요청에 암호화가 자동 적용됩니다.
76
+ */
77
+ public function checkHealth(): array
78
+ {
79
+ $ch = curl_init($this->baseUrl . '/v1/health');
80
+ curl_setopt_array($ch, [
81
+ CURLOPT_RETURNTRANSFER => true,
82
+ CURLOPT_TIMEOUT => $this->timeout,
83
+ ]);
84
+ $response = curl_exec($ch);
85
+ curl_close($ch);
86
+ $decoded = json_decode($response, true) ?? [];
87
+ if (!empty($decoded['packet_encryption'])) {
88
+ $this->packetEncryption = true;
89
+ }
90
+ return $decoded;
91
+ }
92
+
93
+ /** * 단건 조회
94
+ *
95
+ * @param bool $skipHooks true 이면 after_get 훅 미실행
96
+ */
97
+ public function get(string $entity, int $seq, bool $skipHooks = false): array
65
98
  {
66
- return $this->request('GET', "/v1/entity/{$entity}/{$seq}");
99
+ $q = $skipHooks ? '?skipHooks=true' : '';
100
+ return $this->request('GET', "/v1/entity/{$entity}/{$seq}{$q}");
67
101
  }
68
102
 
69
- /** 목록 조회 */
70
- public function list(string $entity, array $params = []): array
103
+ /**
104
+ * 조건으로 단건 조회 (POST + conditions body)
105
+ *
106
+ * @param array $conditions 필터 조건. index/hash/unique 필드만 사용 가능.
107
+ * 예: ['email' => 'user@example.com']
108
+ * @param bool $skipHooks true 이면 after_find 훅 미실행
109
+ */
110
+ public function find(string $entity, array $conditions, bool $skipHooks = false): array
71
111
  {
72
- $query = http_build_query(array_merge(['page' => 1, 'limit' => 20], $params));
73
- return $this->request('GET', "/v1/entity/{$entity}/list?{$query}");
112
+ $q = $skipHooks ? '?skipHooks=true' : '';
113
+ return $this->request('POST', "/v1/entity/{$entity}/find{$q}", $conditions);
74
114
  }
75
115
 
76
- /** 건수 조회 */
77
- public function count(string $entity): array
116
+ /**
117
+ * 목록 조회
118
+ *
119
+ * @param array $params 페이지/정렬 파라미터 (page, limit, orderBy, orderDir, fields)
120
+ * @param array $conditions 필터 조건 POST body. index/hash/unique 필드만 사용 가능.
121
+ * 예: ['status' => 'active']
122
+ * fields 예: ['*'] 시 전체 필드 반환, 미지정 시 인덱스 필드만 반환 (기본, 가장 빠름)
123
+ * fields 예: ['name','email'] 또는 미지정
124
+ */
125
+ public function list(string $entity, array $params = [], array $conditions = []): array
78
126
  {
79
- return $this->request('GET', "/v1/entity/{$entity}/count");
127
+ $queryParams = array_merge(['page' => 1, 'limit' => 20], $params);
128
+
129
+ // orderBy + orderDir → orderBy 앞에 - 접두사 방식으로 변환
130
+ if (isset($queryParams['orderDir'])) {
131
+ $dir = strtoupper((string) $queryParams['orderDir']);
132
+ $orderBy = (string) ($queryParams['orderBy'] ?? '');
133
+ if ($dir === 'DESC' && $orderBy !== '') {
134
+ $queryParams['orderBy'] = '-' . ltrim($orderBy, '-');
135
+ }
136
+ unset($queryParams['orderDir']);
137
+ }
138
+
139
+ // fields 배열 → 쉼표 구분 문자열
140
+ if (isset($queryParams['fields']) && is_array($queryParams['fields'])) {
141
+ $queryParams['fields'] = implode(',', $queryParams['fields']);
142
+ }
143
+
144
+ $query = http_build_query($queryParams);
145
+ return $this->request('POST', "/v1/entity/{$entity}/list?{$query}", $conditions);
80
146
  }
81
147
 
82
148
  /**
83
- * 필터 검색
149
+ * 건수 조회
84
150
  *
85
- * @param array $filter 예: [['field' => 'status', 'op' => 'eq', 'value' => 'active']]
86
- * @param array $params 예: ['page' => 1, 'limit' => 20, 'order_by' => 'name']
151
+ * @param array $conditions 필터 조건 (list()와 동일 규칙)
87
152
  */
88
- public function query(string $entity, array $filter = [], array $params = []): array
153
+ public function count(string $entity, array $conditions = []): array
89
154
  {
90
- $query = http_build_query(array_merge(['page' => 1, 'limit' => 20], $params));
91
- return $this->request('POST', "/v1/entity/{$entity}/query?{$query}", $filter);
155
+ return $this->request('POST', "/v1/entity/{$entity}/count", $conditions);
156
+ }
157
+
158
+ /**
159
+ * 커스텀 SQL 조회 (SELECT 전용, 인덱스 테이블만)
160
+ *
161
+ * - SELECT 쿼리만 허용 (INSERT/UPDATE/DELETE 불가)
162
+ * - 인덱스 테이블(`entity_idx_*`)만 접근 가능. SELECT * 불가
163
+ * - JOIN 지원. 최대 반환 건수 1000
164
+ * - 사용자 입력은 반드시 params 로 바인딩 (SQL Injection 방지)
165
+ *
166
+ * @param string $entity URL 라우트용 기본 엔티티명
167
+ * @param string $sql SELECT SQL
168
+ * @param array $params ? 플레이스홀더 바인딩 값
169
+ * @param int|null $limit 최대 반환 건수 (최대 1000)
170
+ *
171
+ * 예:
172
+ * $es->query('order',
173
+ * 'SELECT o.seq, o.status, u.name FROM order o JOIN account u ON u.data_seq = o.account_seq WHERE o.status = ?',
174
+ * ['pending'], 100);
175
+ */
176
+ public function query(string $entity, string $sql, array $params = [], ?int $limit = null): array
177
+ {
178
+ $body = ['sql' => $sql, 'params' => $params];
179
+ if ($limit !== null) {
180
+ $body['limit'] = $limit;
181
+ }
182
+ return $this->request('POST', "/v1/entity/{$entity}/query", $body);
92
183
  }
93
184
 
94
185
  /**
@@ -135,26 +226,39 @@ class EntityServer
135
226
  * 생성 또는 수정
136
227
  * - body에 'seq' 포함 → 수정
137
228
  * - body에 'seq' 없음 → 생성 (seq 반환)
229
+ * - unique 필드 기준 중복 시 자동 UPDATE (upsert)
230
+ *
138
231
  * @param string|null $transactionId transStart() 로 얻은 ID (생략 시 활성 트랜잭션 자동 사용)
232
+ * @param bool $skipHooks true 이면 before/after_insert, before/after_update 훅 미실행
139
233
  */
140
- public function submit(string $entity, array $data, ?string $transactionId = null): array
234
+ public function submit(string $entity, array $data, ?string $transactionId = null, bool $skipHooks = false): array
141
235
  {
142
236
  $txId = $transactionId ?? $this->activeTxId;
143
237
  $extra = $txId ? ['X-Transaction-ID: ' . $txId] : [];
144
- return $this->request('POST', "/v1/entity/{$entity}/submit", $data, $extra);
238
+ $q = $skipHooks ? '?skipHooks=true' : '';
239
+ return $this->request('POST', "/v1/entity/{$entity}/submit{$q}", $data, $extra);
145
240
  }
146
241
 
147
242
  /**
148
- * 삭제
149
- * @param bool $hard true 이면 물리 삭제
243
+ * 삭제 (서버는 POST /delete/:seq 로만 처리)
244
+ *
245
+ * @param bool $hard true 이면 하드(물리) 삭제. false(기본) 이면 소프트 삭제 (rollback 으로 복원 가능)
150
246
  * @param string|null $transactionId transStart() 로 얻은 ID (생략 시 활성 트랜잭션 자동 사용)
247
+ * @param bool $skipHooks true 이면 before/after_delete 훅 미실행
151
248
  */
152
- public function delete(string $entity, int $seq, ?string $transactionId = null, bool $hard = false): array
249
+ public function delete(string $entity, int $seq, ?string $transactionId = null, bool $hard = false, bool $skipHooks = false): array
153
250
  {
154
- $q = $hard ? '?hard=true' : '';
251
+ $queryParams = [];
252
+ if ($hard) {
253
+ $queryParams[] = 'hard=true';
254
+ }
255
+ if ($skipHooks) {
256
+ $queryParams[] = 'skipHooks=true';
257
+ }
258
+ $q = $queryParams ? '?' . implode('&', $queryParams) : '';
155
259
  $txId = $transactionId ?? $this->activeTxId;
156
260
  $extra = $txId ? ['X-Transaction-ID: ' . $txId] : [];
157
- return $this->request('DELETE', "/v1/entity/{$entity}/delete/{$seq}{$q}", [], $extra);
261
+ return $this->request('POST', "/v1/entity/{$entity}/delete/{$seq}{$q}", [], $extra);
158
262
  }
159
263
 
160
264
  /** 변경 이력 조회 */
@@ -298,18 +402,32 @@ class EntityServer
298
402
 
299
403
  private function request(string $method, string $path, array $body = [], array $extraHeaders = []): array
300
404
  {
301
- $bodyJson = empty($body) ? '' : json_encode($body, JSON_UNESCAPED_UNICODE);
302
- $timestamp = (string) time();
303
- $nonce = $this->generateNonce();
304
- $signature = $this->sign($method, $path, $timestamp, $nonce, $bodyJson);
305
-
306
- $headers = array_merge([
307
- 'Content-Type: application/json',
308
- 'X-API-Key: ' . $this->apiKey,
309
- 'X-Timestamp: ' . $timestamp,
310
- 'X-Nonce: ' . $nonce,
311
- 'X-Signature: ' . $signature,
312
- ], $extraHeaders);
405
+ // 요청 바디 결정: encryptRequests POST 바디를 암호화
406
+ $bodyJson = empty($body) ? '' : json_encode($body, JSON_UNESCAPED_UNICODE);
407
+ $bodyData = $bodyJson;
408
+ $contentType = 'application/json';
409
+ if (($this->encryptRequests || $this->packetEncryption) && $bodyJson !== '') {
410
+ $bodyData = $this->encryptPacket($bodyJson);
411
+ $contentType = 'application/octet-stream';
412
+ }
413
+
414
+ $isHmacMode = $this->apiKey !== '' && $this->hmacSecret !== '';
415
+
416
+ $headers = ["Content-Type: {$contentType}"];
417
+ if ($isHmacMode) {
418
+ $timestamp = (string) time();
419
+ $nonce = $this->generateNonce();
420
+ $signature = $this->sign($method, $path, $timestamp, $nonce, $bodyData);
421
+ $headers = array_merge($headers, [
422
+ 'X-API-Key: ' . $this->apiKey,
423
+ 'X-Timestamp: ' . $timestamp,
424
+ 'X-Nonce: ' . $nonce,
425
+ 'X-Signature: ' . $signature,
426
+ ]);
427
+ } elseif ($this->token !== '') {
428
+ $headers[] = 'Authorization: Bearer ' . $this->token;
429
+ }
430
+ $headers = array_merge($headers, $extraHeaders);
313
431
 
314
432
  $url = $this->baseUrl . $path;
315
433
  $ch = curl_init($url);
@@ -321,13 +439,13 @@ class EntityServer
321
439
  CURLOPT_TIMEOUT => $this->timeout,
322
440
  ]);
323
441
 
324
- if ($bodyJson !== '') {
325
- curl_setopt($ch, CURLOPT_POSTFIELDS, $bodyJson);
442
+ if ($bodyData !== '') {
443
+ curl_setopt($ch, CURLOPT_POSTFIELDS, $bodyData);
326
444
  }
327
445
 
328
446
  $response = curl_exec($ch);
329
447
  $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
330
- $contentType = curl_getinfo($ch, CURLINFO_CONTENT_TYPE) ?? '';
448
+ $respContentType = curl_getinfo($ch, CURLINFO_CONTENT_TYPE) ?? '';
331
449
  $error = curl_error($ch);
332
450
  curl_close($ch);
333
451
 
@@ -336,7 +454,7 @@ class EntityServer
336
454
  }
337
455
 
338
456
  // 패킷 암호화 응답: application/octet-stream → 복호화
339
- if (str_contains($contentType, 'application/octet-stream')) {
457
+ if (str_contains($respContentType, 'application/octet-stream')) {
340
458
  $jsonStr = $this->decryptPacket($response);
341
459
  $decoded = json_decode($jsonStr, true);
342
460
  } else {
@@ -354,18 +472,51 @@ class EntityServer
354
472
  return $decoded;
355
473
  }
356
474
 
475
+ /**
476
+ * 패킷 암호화 키를 유도합니다.
477
+ * - HMAC 모드: HKDF-SHA256(hmac_secret, "entity-server:packet-encryption")
478
+ * - JWT 모드: SHA256(token)
479
+ */
480
+ private function derivePacketKey(): string
481
+ {
482
+ if ($this->token !== '' && $this->hmacSecret === '') {
483
+ return hash('sha256', $this->token, true);
484
+ }
485
+ $salt = 'entity-server:hkdf:v1';
486
+ $info = 'entity-server:packet-encryption';
487
+ // HKDF-Extract: PRK = HMAC-SHA256(salt, IKM)
488
+ $prk = hash_hmac('sha256', $this->hmacSecret, $salt, true);
489
+ // HKDF-Expand(PRK, info, 32): T(1) = HMAC-SHA256(PRK, info || 0x01)
490
+ return substr(hash_hmac('sha256', $info . chr(1), $prk, true), 0, 32);
491
+ }
492
+
493
+ /**
494
+ * XChaCha20-Poly1305 패킷 암호화
495
+ * 포맷: [magic:magicLen][nonce:24][ciphertext+tag]
496
+ */
497
+ private function encryptPacket(string $plaintext): string
498
+ {
499
+ $key = $this->derivePacketKey();
500
+ $magicLen = 2 + (ord($key[31]) % 14);
501
+ $magic = random_bytes($magicLen);
502
+ $nonce = random_bytes(SODIUM_CRYPTO_AEAD_XCHACHA20POLY1305_IETF_NPUBBYTES); // 24
503
+ $ct = sodium_crypto_aead_xchacha20poly1305_ietf_encrypt($plaintext, '', $nonce, $key);
504
+ return $magic . $nonce . $ct;
505
+ }
506
+
357
507
  /**
358
508
  * XChaCha20-Poly1305 패킷 복호화
359
509
  * 포맷: [magic:magicLen][nonce:24][ciphertext+tag]
360
- * 키: sha256(hmac_secret)
510
+ * 키: HKDF-SHA256(hmac_secret, "entity-server:packet-encryption")
361
511
  *
362
512
  * ext-sodium 사용 (PHP 7.2+ 내장)
363
513
  */
364
514
  private function decryptPacket(string $data): string
365
515
  {
366
- $key = hash('sha256', $this->hmacSecret, true);
367
- $nonce = substr($data, $this->magicLen, 24);
368
- $ciphertext = substr($data, $this->magicLen + 24);
516
+ $key = $this->derivePacketKey();
517
+ $magicLen = 2 + (ord($key[31]) % 14);
518
+ $nonce = substr($data, $magicLen, 24);
519
+ $ciphertext = substr($data, $magicLen + 24);
369
520
 
370
521
  $plaintext = sodium_crypto_aead_xchacha20poly1305_ietf_decrypt($ciphertext, '', $nonce, $key);
371
522
  if ($plaintext === false) {
@@ -374,12 +525,14 @@ class EntityServer
374
525
  return $plaintext;
375
526
  }
376
527
 
377
- /** HMAC-SHA256 서명 */
528
+ /**
529
+ * HMAC-SHA256 서명. $body 는 JSON 스트링 또는 바이너리 암호화 페이로드 모두 지원합니다.
530
+ * prefix = "METHOD|path|timestamp|nonce|" 뒤에 $body 를 바로 이어 붙여 서명합니다.
531
+ */
378
532
  private function sign(string $method, string $path, string $timestamp, string $nonce, string $body): string
379
533
  {
380
- // PATH는 쿼리스트링 포함한 전체 경로
381
- $payload = implode('|', [$method, $path, $timestamp, $nonce, $body]);
382
- return hash_hmac('sha256', $payload, $this->hmacSecret);
534
+ $prefix = implode('|', [$method, $path, $timestamp, $nonce]) . '|';
535
+ return hash_hmac('sha256', $prefix . $body, $this->hmacSecret);
383
536
  }
384
537
 
385
538
  private function generateNonce(): string