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
@@ -15,7 +15,6 @@ use Illuminate\Http\Request;
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
19
18
  *
20
19
  * 서비스 프로바이더 등록:
21
20
  * $this->app->singleton(EntityServerService::class);
@@ -40,8 +39,10 @@ class EntityServerService
40
39
  private string $baseUrl;
41
40
  private string $apiKey;
42
41
  private string $hmacSecret;
43
- private int $magicLen;
42
+ private string $token = '';
44
43
  private bool $requireEncryptedRequest;
44
+ private bool $encryptRequests;
45
+ private bool $packetEncryption = false;
45
46
  private ?string $activeTxId = null;
46
47
 
47
48
  public function __construct()
@@ -49,32 +50,115 @@ class EntityServerService
49
50
  $this->baseUrl = rtrim(config('services.entity_server.url', env('ENTITY_SERVER_URL', 'http://localhost:47200')), '/');
50
51
  $this->apiKey = config('services.entity_server.api_key', env('ENTITY_SERVER_API_KEY', ''));
51
52
  $this->hmacSecret = config('services.entity_server.hmac_secret', env('ENTITY_SERVER_HMAC_SECRET', ''));
52
- $this->magicLen = (int) config('services.entity_server.packet_magic_len', env('ENTITY_PACKET_MAGIC_LEN', 4));
53
+ $this->token = config('services.entity_server.token', env('ENTITY_SERVER_TOKEN', ''));
53
54
  $this->requireEncryptedRequest = (bool) config('services.entity_server.require_encrypted_request', env('ENTITY_REQUIRE_ENCRYPTED_REQUEST', true));
55
+ $this->encryptRequests = (bool) config('services.entity_server.encrypt_requests', env('ENTITY_ENCRYPT_REQUESTS', false));
56
+ }
57
+
58
+ /** JWT Bearer 토큰을 설정합니다. HMAC 모드와 배타적으로 사용합니다. */
59
+ public function setToken(string $token): void
60
+ {
61
+ $this->token = $token;
54
62
  }
55
63
 
56
64
  // ─── CRUD ────────────────────────────────────────────────────────────────
57
65
 
58
- public function get(string $entity, int $seq): array
66
+ /**
67
+ * 서버 헬스 체크를 수행하고 패킷 암호화 활성 여부를 자동으로 감지합니다.
68
+ * 서버가 packet_encryption: true 를 응답하면 이후 모든 요청에 암호화가 자동 적용됩니다.
69
+ */
70
+ public function checkHealth(): array
71
+ {
72
+ $response = Http::timeout(10)->get($this->baseUrl . '/v1/health');
73
+ $decoded = $response->json() ?? [];
74
+ if (!empty($decoded['packet_encryption'])) {
75
+ $this->packetEncryption = true;
76
+ }
77
+ return $decoded;
78
+ }
79
+
80
+ /**
81
+ * 단건 조회
82
+ *
83
+ * @param bool $skipHooks true 이면 after_get 훅 미실행
84
+ */
85
+ public function get(string $entity, int $seq, bool $skipHooks = false): array
86
+ {
87
+ $q = $skipHooks ? '?skipHooks=true' : '';
88
+ return $this->request('GET', "/v1/entity/{$entity}/{$seq}{$q}");
89
+ }
90
+
91
+ /**
92
+ * 조건으로 단건 조회 (POST + conditions body)
93
+ *
94
+ * @param array $conditions 필터 조건. index/hash/unique 필드만 사용 가능.
95
+ * 예: ['email' => 'user@example.com']
96
+ * @param bool $skipHooks true 이면 after_find 훅 미실행
97
+ */
98
+ public function find(string $entity, array $conditions, bool $skipHooks = false): array
59
99
  {
60
- return $this->request('GET', "/v1/entity/{$entity}/{$seq}");
100
+ $q = $skipHooks ? '?skipHooks=true' : '';
101
+ return $this->request('POST', "/v1/entity/{$entity}/find{$q}", $conditions);
61
102
  }
62
103
 
63
- public function list(string $entity, array $params = []): array
104
+ /**
105
+ * 목록 조회 (POST + conditions body)
106
+ *
107
+ * @param array $params 펙이지/정렬 파라미터 (page, limit, orderBy, orderDir, fields)
108
+ * @param array $conditions 필터 조건 POST body. index/hash/unique 필드만 사용 가능.
109
+ * fields 예: ['*'] 시 전체 필드 반환, 미지정 시 인덱스 필드만 반환 (기본, 가장 빠름)
110
+ * fields 예: ['name','email'] 또는 미지정
111
+ */
112
+ public function list(string $entity, array $params = [], array $conditions = []): array
64
113
  {
65
- $query = http_build_query(array_merge(['page' => 1, 'limit' => 20], $params));
66
- return $this->request('GET', "/v1/entity/{$entity}/list?{$query}");
114
+ $queryParams = array_merge(['page' => 1, 'limit' => 20], $params);
115
+
116
+ if (isset($queryParams['orderDir'])) {
117
+ $dir = strtoupper((string) $queryParams['orderDir']);
118
+ $orderBy = (string) ($queryParams['orderBy'] ?? '');
119
+ if ($dir === 'DESC' && $orderBy !== '') {
120
+ $queryParams['orderBy'] = '-' . ltrim($orderBy, '-');
121
+ }
122
+ unset($queryParams['orderDir']);
123
+ }
124
+
125
+ if (isset($queryParams['fields']) && is_array($queryParams['fields'])) {
126
+ $queryParams['fields'] = implode(',', $queryParams['fields']);
127
+ }
128
+
129
+ $query = http_build_query($queryParams);
130
+ return $this->request('POST', "/v1/entity/{$entity}/list?{$query}", $conditions);
67
131
  }
68
132
 
69
- public function count(string $entity): array
133
+ /**
134
+ * 건수 조회
135
+ *
136
+ * @param array $conditions 필터 조건 (list() 와 동일 규칙)
137
+ */
138
+ public function count(string $entity, array $conditions = []): array
70
139
  {
71
- return $this->request('GET', "/v1/entity/{$entity}/count");
140
+ return $this->request('POST', "/v1/entity/{$entity}/count", $conditions);
72
141
  }
73
142
 
74
- public function query(string $entity, array $filter = [], array $params = []): array
143
+ /**
144
+ * 커스텀 SQL 조회 (SELECT 전용, 인덱스 테이블만, JOIN 지원)
145
+ *
146
+ * - SELECT 쿼리만 허용 (INSERT/UPDATE/DELETE 불가)
147
+ * - SELECT * 불가. 최대 반환 건수 1000
148
+ * - 사용자 입력은 반드시 $params 로 바인딩 (SQL Injection 방지)
149
+ *
150
+ * @param string $entity URL 라우트용 기본 엔티티명
151
+ * @param string $sql SELECT SQL
152
+ * @param array $params ? 플레이스홀더 바인딩 값
153
+ * @param int|null $limit 최대 반환 건수 (최대 1000)
154
+ */
155
+ public function query(string $entity, string $sql, array $params = [], ?int $limit = null): array
75
156
  {
76
- $query = http_build_query(array_merge(['page' => 1, 'limit' => 20], $params));
77
- return $this->request('POST', "/v1/entity/{$entity}/query?{$query}", $filter);
157
+ $body = ['sql' => $sql, 'params' => $params];
158
+ if ($limit !== null) {
159
+ $body['limit'] = $limit;
160
+ }
161
+ return $this->request('POST', "/v1/entity/{$entity}/query", $body);
78
162
  }
79
163
 
80
164
  /**
@@ -115,20 +199,34 @@ class EntityServerService
115
199
  }
116
200
 
117
201
  /** 생성 또는 수정 (seq 포함시 수정, 없으면 생성) */
118
- public function submit(string $entity, array $data, ?string $transactionId = null): array
202
+ public function submit(string $entity, array $data, ?string $transactionId = null, bool $skipHooks = false): array
119
203
  {
120
204
  $txId = $transactionId ?? $this->activeTxId;
121
205
  $extra = $txId ? ['X-Transaction-ID' => $txId] : [];
122
- return $this->request('POST', "/v1/entity/{$entity}/submit", $data, $extra);
206
+ $q = $skipHooks ? '?skipHooks=true' : '';
207
+ return $this->request('POST', "/v1/entity/{$entity}/submit{$q}", $data, $extra);
123
208
  }
124
209
 
125
- /** 삭제 */
126
- public function delete(string $entity, int $seq, ?string $transactionId = null, bool $hard = false): array
210
+ /**
211
+ * 삭제 (서버는 POST /delete/:seq 로만 처리)
212
+ *
213
+ * @param bool $hard true 이면 하드(물리) 삭제. false(기본) 소프트 삭제 (rollback 복원 가능)
214
+ * @param string|null $transactionId transStart() 로 얻은 ID (생략 시 활성 트랜잭션 자동)
215
+ * @param bool $skipHooks true 이면 before/after_delete 훅 미실행
216
+ */
217
+ public function delete(string $entity, int $seq, ?string $transactionId = null, bool $hard = false, bool $skipHooks = false): array
127
218
  {
128
- $q = $hard ? '?hard=true' : '';
219
+ $queryParams = [];
220
+ if ($hard) {
221
+ $queryParams[] = 'hard=true';
222
+ }
223
+ if ($skipHooks) {
224
+ $queryParams[] = 'skipHooks=true';
225
+ }
226
+ $q = $queryParams ? '?' . implode('&', $queryParams) : '';
129
227
  $txId = $transactionId ?? $this->activeTxId;
130
228
  $extra = $txId ? ['X-Transaction-ID' => $txId] : [];
131
- return $this->request('DELETE', "/v1/entity/{$entity}/delete/{$seq}{$q}", [], $extra);
229
+ return $this->request('POST', "/v1/entity/{$entity}/delete/{$seq}{$q}", [], $extra);
132
230
  }
133
231
 
134
232
  public function history(string $entity, int $seq, int $page = 1, int $limit = 50): array
@@ -198,30 +296,44 @@ class EntityServerService
198
296
 
199
297
  private function request(string $method, string $path, array $body = [], array $extraHeaders = []): array
200
298
  {
201
- $bodyJson = empty($body) ? '' : json_encode($body, JSON_UNESCAPED_UNICODE);
202
- $timestamp = (string) time();
203
- $nonce = (string) Str::uuid();
204
- $signature = $this->sign($method, $path, $timestamp, $nonce, $bodyJson);
205
-
206
- $http = Http::withHeaders(array_merge([
207
- 'X-API-Key' => $this->apiKey,
208
- 'X-Timestamp' => $timestamp,
209
- 'X-Nonce' => $nonce,
210
- 'X-Signature' => $signature,
211
- ], $extraHeaders))->timeout(10);
299
+ // 요청 바디 결정: encryptRequests POST 바디를 암호화
300
+ $bodyJson = empty($body) ? '' : json_encode($body, JSON_UNESCAPED_UNICODE);
301
+ $bodyData = $bodyJson;
302
+ $contentType = 'application/json';
303
+
304
+ if (($this->encryptRequests || $this->packetEncryption) && $bodyJson !== '') {
305
+ $bodyData = $this->encryptPacket($bodyJson);
306
+ $contentType = 'application/octet-stream';
307
+ }
308
+
309
+ $isHmacMode = $this->apiKey !== '' && $this->hmacSecret !== '';
310
+
311
+ $http = Http::timeout(10);
312
+ if ($isHmacMode) {
313
+ $timestamp = (string) time();
314
+ $nonce = (string) Str::uuid();
315
+ $signature = $this->sign($method, $path, $timestamp, $nonce, $bodyData);
316
+ $http = $http->withHeaders(array_merge([
317
+ 'X-API-Key' => $this->apiKey,
318
+ 'X-Timestamp' => $timestamp,
319
+ 'X-Nonce' => $nonce,
320
+ 'X-Signature' => $signature,
321
+ ], $extraHeaders));
322
+ } else {
323
+ $authHeaders = $this->token !== '' ? ['Authorization' => 'Bearer ' . $this->token] : [];
324
+ $http = $http->withHeaders(array_merge($authHeaders, $extraHeaders));
325
+ }
212
326
 
213
327
  $response = match ($method) {
214
328
  'GET' => $http->get($this->baseUrl . $path),
215
- 'POST' => $http->withBody($bodyJson, 'application/json')->post($this->baseUrl . $path),
329
+ 'POST' => $http->withBody($bodyData, $contentType)->post($this->baseUrl . $path),
216
330
  'DELETE' => $http->delete($this->baseUrl . $path),
217
331
  default => throw new \InvalidArgumentException("Unsupported method: {$method}"),
218
332
  };
219
333
 
220
- $decoded = $response->json();
221
-
222
334
  // 패킷 암호화 응답: application/octet-stream → 복호화
223
- $contentType = $response->header('Content-Type') ?? '';
224
- if (str_contains($contentType, 'application/octet-stream')) {
335
+ $respContentType = $response->header('Content-Type') ?? '';
336
+ if (str_contains($respContentType, 'application/octet-stream')) {
225
337
  $jsonStr = $this->decryptPacket($response->body());
226
338
  $decoded = json_decode($jsonStr, true);
227
339
  } else {
@@ -238,18 +350,51 @@ class EntityServerService
238
350
  return $decoded;
239
351
  }
240
352
 
353
+ /**
354
+ * 패킷 암호화 키를 유도합니다.
355
+ * - HMAC 모드: HKDF-SHA256(hmac_secret, "entity-server:packet-encryption")
356
+ * - JWT 모드: SHA256(token)
357
+ */
358
+ private function derivePacketKey(): string
359
+ {
360
+ if ($this->token !== '' && $this->hmacSecret === '') {
361
+ return hash('sha256', $this->token, true);
362
+ }
363
+ $salt = 'entity-server:hkdf:v1';
364
+ $info = 'entity-server:packet-encryption';
365
+ // HKDF-Extract: PRK = HMAC-SHA256(salt, IKM)
366
+ $prk = hash_hmac('sha256', $this->hmacSecret, $salt, true);
367
+ // HKDF-Expand(PRK, info, 32): T(1) = HMAC-SHA256(PRK, info || 0x01)
368
+ return substr(hash_hmac('sha256', $info . chr(1), $prk, true), 0, 32);
369
+ }
370
+
371
+ /**
372
+ * XChaCha20-Poly1305 패킷 암호화
373
+ * 포맷: [magic:magicLen][nonce:24][ciphertext+tag]
374
+ */
375
+ private function encryptPacket(string $plaintext): string
376
+ {
377
+ $key = $this->derivePacketKey();
378
+ $magicLen = 2 + (ord($key[31]) % 14);
379
+ $magic = random_bytes($magicLen);
380
+ $nonce = random_bytes(SODIUM_CRYPTO_AEAD_XCHACHA20POLY1305_IETF_NPUBBYTES); // 24
381
+ $ct = sodium_crypto_aead_xchacha20poly1305_ietf_encrypt($plaintext, '', $nonce, $key);
382
+ return $magic . $nonce . $ct;
383
+ }
384
+
241
385
  /**
242
386
  * XChaCha20-Poly1305 패킷 복호화
243
387
  * 포맷: [magic:magicLen][nonce:24][ciphertext+tag]
244
- * 키: sha256(hmac_secret)
388
+ * 키: HKDF-SHA256(hmac_secret, "entity-server:packet-encryption")
245
389
  *
246
390
  * ext-sodium 사용 (PHP 7.2+ 내장)
247
391
  */
248
392
  private function decryptPacket(string $data): string
249
393
  {
250
- $key = hash('sha256', $this->hmacSecret, true);
251
- $nonce = substr($data, $this->magicLen, 24);
252
- $ciphertext = substr($data, $this->magicLen + 24);
394
+ $key = $this->derivePacketKey();
395
+ $magicLen = 2 + (ord($key[31]) % 14);
396
+ $nonce = substr($data, $magicLen, 24);
397
+ $ciphertext = substr($data, $magicLen + 24);
253
398
 
254
399
  $plaintext = sodium_crypto_aead_xchacha20poly1305_ietf_decrypt($ciphertext, '', $nonce, $key);
255
400
  if ($plaintext === false) {
@@ -258,9 +403,13 @@ class EntityServerService
258
403
  return $plaintext;
259
404
  }
260
405
 
406
+ /**
407
+ * HMAC-SHA256 서명. $body 는 JSON 스트링 또는 바이너리 암호화 페이로드 모두 지원합니다.
408
+ * prefix = "METHOD|path|timestamp|nonce|" 뒤에 $body 를 바로 이어 붙여 서명합니다.
409
+ */
261
410
  private function sign(string $method, string $path, string $timestamp, string $nonce, string $body): string
262
411
  {
263
- $payload = implode('|', [$method, $path, $timestamp, $nonce, $body]);
264
- return hash_hmac('sha256', $payload, $this->hmacSecret);
412
+ $prefix = implode('|', [$method, $path, $timestamp, $nonce]) . '|';
413
+ return hash_hmac('sha256', $prefix . $body, $this->hmacSecret);
265
414
  }
266
415
  }
@@ -8,7 +8,6 @@ Entity Server 클라이언트 (Python)
8
8
  ENTITY_SERVER_URL http://localhost:47200
9
9
  ENTITY_SERVER_API_KEY your-api-key
10
10
  ENTITY_SERVER_HMAC_SECRET your-hmac-secret
11
- ENTITY_PACKET_MAGIC_LEN 4 (서버 packet_magic_len 과 동일)
12
11
 
13
12
  사용 예:
14
13
  es = EntityServerClient()
@@ -40,25 +39,36 @@ from typing import Any
40
39
 
41
40
  import requests
42
41
  from cryptography.hazmat.primitives.ciphers.aead import XChaCha20Poly1305
42
+ from cryptography.hazmat.primitives.kdf.hkdf import HKDF
43
+ from cryptography.hazmat.primitives import hashes
44
+
45
+ import secrets as _secrets
43
46
 
44
47
 
45
48
  class EntityServerClient:
46
49
  def __init__(
47
50
  self,
48
- base_url: str = "",
49
- api_key: str = "",
50
- hmac_secret: str = "",
51
- timeout: int = 10,
52
- magic_len: int = 4,
51
+ base_url: str = "",
52
+ api_key: str = "",
53
+ hmac_secret: str = "",
54
+ token: str = "",
55
+ timeout: int = 10,
56
+ encrypt_requests: bool = False,
53
57
  ) -> None:
54
- self.base_url = (base_url or os.getenv("ENTITY_SERVER_URL", "http://localhost:47200")).rstrip("/")
55
- self.api_key = api_key or os.getenv("ENTITY_SERVER_API_KEY", "")
56
- self.hmac_secret = hmac_secret or os.getenv("ENTITY_SERVER_HMAC_SECRET", "")
57
- self.timeout = timeout
58
- self.magic_len = int(os.getenv("ENTITY_PACKET_MAGIC_LEN", magic_len))
59
- self._session = requests.Session()
58
+ self.base_url = (base_url or os.getenv("ENTITY_SERVER_URL", "http://localhost:47200")).rstrip("/")
59
+ self.api_key = api_key or os.getenv("ENTITY_SERVER_API_KEY", "")
60
+ self.hmac_secret = hmac_secret or os.getenv("ENTITY_SERVER_HMAC_SECRET", "")
61
+ self.token = token or os.getenv("ENTITY_SERVER_TOKEN", "")
62
+ self.timeout = timeout
63
+ self.encrypt_requests = encrypt_requests
64
+ self._packet_encryption: bool = False
65
+ self._session = requests.Session()
60
66
  self._active_tx_id: str | None = None
61
67
 
68
+ def set_token(self, token: str) -> None:
69
+ """JWT Bearer 토큰을 설정합니다. HMAC 모드와 배타적으로 사용해야 합니다."""
70
+ self.token = token
71
+
62
72
  # ─── 트랜잭션 ──────────────────────────────────────────────────────────────
63
73
 
64
74
  def trans_start(self) -> str:
@@ -89,58 +99,108 @@ class EntityServerClient:
89
99
  return self._request("POST", f"/v1/transaction/commit/{tx_id}")
90
100
 
91
101
  # ─── CRUD ─────────────────────────────────────────────────────────────────
102
+ def check_health(self) -> dict:
103
+ """서버 헬스 체크를 수행하고 패킷 암호화 활성 여부를 자동으로 감지합니다.
104
+ 서버가 packet_encryption: true 를 응답하면 이후 모든 요청에 암호화가 자동 적용됩니다."""
105
+ resp = self._session.get(self.base_url + "/v1/health", timeout=self.timeout)
106
+ data = resp.json()
107
+ if data.get("packet_encryption"):
108
+ self._packet_encryption = True
109
+ return data
110
+ def get(self, entity: str, seq: int, *, skip_hooks: bool = False) -> dict:
111
+ """단건 조회. skip_hooks=True 이면 after_get 훅 미실행."""
112
+ q = "?skipHooks=true" if skip_hooks else ""
113
+ return self._request("GET", f"/v1/entity/{entity}/{seq}{q}")
114
+
115
+ def find(self, entity: str, conditions: dict, *, skip_hooks: bool = False) -> dict:
116
+ """
117
+ 조건으로 단건 조회 (POST + conditions body).
92
118
 
93
- def get(self, entity: str, seq: int) -> dict:
94
- """단건 조회"""
95
- return self._request("GET", f"/v1/entity/{entity}/{seq}")
119
+ - conditions: index/hash/unique 필드에만 필터 조건 사용 가능
120
+ - skip_hooks=True 이면 after_find 훅 미실행
121
+ """
122
+ q = "?skipHooks=true" if skip_hooks else ""
123
+ return self._request("POST", f"/v1/entity/{entity}/find{q}", body=conditions)
96
124
 
97
- def list(self, entity: str, page: int = 1, limit: int = 20, order_by: str | None = None) -> dict:
98
- """목록 조회"""
99
- params: dict = {"page": page, "limit": limit}
125
+ def list(
126
+ self,
127
+ entity: str,
128
+ page: int = 1,
129
+ limit: int = 20,
130
+ order_by: str | None = None,
131
+ order_dir: str | None = None,
132
+ fields: list[str] | None = None,
133
+ conditions: dict | None = None,
134
+ ) -> dict:
135
+ """
136
+ 목록 조회 (POST + conditions body)
137
+
138
+ - fields: 미지정 시 인덱스 필드만 반환 (기본, 가장 빠름). ['*'] 지정 시 전체 필드 반환
139
+ - conditions: index/hash/unique 필드에만 필터 조건 사용 가능
140
+ """
141
+ query_params: dict = {"page": page, "limit": limit}
100
142
  if order_by:
101
- params["order_by"] = order_by
102
- return self._request("GET", f"/v1/entity/{entity}/list", params=params)
143
+ query_params["order_by"] = f"-{order_by}" if order_dir == "DESC" else order_by
144
+ if fields:
145
+ query_params["fields"] = ",".join(fields)
146
+ return self._request("POST", f"/v1/entity/{entity}/list", body=conditions or {}, params=query_params)
103
147
 
104
- def count(self, entity: str) -> dict:
105
- """건수 조회"""
106
- return self._request("GET", f"/v1/entity/{entity}/count")
148
+ def count(self, entity: str, conditions: dict | None = None) -> dict:
149
+ """건수 조회. conditions 는 list() 와 동일한 필터 규칙."""
150
+ return self._request("POST", f"/v1/entity/{entity}/count", body=conditions or {})
107
151
 
108
152
  def query(
109
153
  self,
110
- entity: str,
111
- filter: list[dict] | None = None,
112
- page: int = 1,
113
- limit: int = 20,
114
- order_by: str | None = None,
154
+ entity: str,
155
+ sql: str,
156
+ params: list | None = None,
157
+ limit: int | None = None,
115
158
  ) -> dict:
116
159
  """
117
- 필터 검색
118
- filter 예: [{"field": "status", "op": "eq", "value": "active"}]
160
+ 커스텀 SQL 조회 (SELECT 전용, 인덱스 테이블만, JOIN 지원)
161
+
162
+ - SELECT 쿼리만 허용. SELECT * 불가. 최대 1000건.
163
+ - 사용자 입력은 반드시 params 로 바인딩 (SQL Injection 방지)
164
+
165
+ 예::
166
+ es.query(
167
+ 'order',
168
+ 'SELECT o.seq, u.name FROM order o JOIN account u ON u.data_seq = o.account_seq WHERE o.status = ?',
169
+ params=['pending'],
170
+ limit=100,
171
+ )
119
172
  """
120
- params: dict = {"page": page, "limit": limit}
121
- if order_by:
122
- params["order_by"] = order_by
123
- return self._request("POST", f"/v1/entity/{entity}/query", body=filter or [], params=params)
173
+ body: dict[str, Any] = {"sql": sql, "params": params or []}
174
+ if limit is not None:
175
+ body["limit"] = limit
176
+ return self._request("POST", f"/v1/entity/{entity}/query", body=body)
124
177
 
125
- def submit(self, entity: str, data: dict, *, transaction_id: str | None = None) -> dict:
178
+ def submit(self, entity: str, data: dict, *, transaction_id: str | None = None, skip_hooks: bool = False) -> dict:
126
179
  """
127
180
  생성 또는 수정
128
181
  data에 'seq' 포함 시 수정, 없으면 생성
129
182
  :param transaction_id: trans_start() 가 반환한 ID (생략 시 활성 트랜잭션 자동 사용)
183
+ :param skip_hooks: True 이면 before/after_insert, before/after_update 훅 미실행
130
184
  """
131
185
  tx_id = transaction_id or self._active_tx_id
132
186
  extra = {"X-Transaction-ID": tx_id} if tx_id else {}
133
- return self._request("POST", f"/v1/entity/{entity}/submit", body=data, extra_headers=extra)
187
+ q = "?skipHooks=true" if skip_hooks else ""
188
+ return self._request("POST", f"/v1/entity/{entity}/submit{q}", body=data, extra_headers=extra)
134
189
 
135
- def delete(self, entity: str, seq: int, *, transaction_id: str | None = None, hard: bool = False) -> dict:
136
- """삭제
190
+ def delete(self, entity: str, seq: int, *, transaction_id: str | None = None, hard: bool = False, skip_hooks: bool = False) -> dict:
191
+ """
192
+ 삭제
137
193
  :param transaction_id: trans_start() 가 반환한 ID (생략 시 활성 트랜잭션 자동 사용)
138
- :param hard: True 시 하드 삭제
194
+ :param hard: True 시 하드(물리) 삭제. False(기본) 이면 소프트 삭제 (rollback 으로 복원 가능)
195
+ :param skip_hooks: True 이면 before/after_delete 훅 미실행
139
196
  """
140
- params = {"hard": "true"} if hard else {}
197
+ query_parts: list[str] = []
198
+ if hard: query_parts.append("hard=true")
199
+ if skip_hooks: query_parts.append("skipHooks=true")
200
+ q = "?" + "&".join(query_parts) if query_parts else ""
141
201
  tx_id = transaction_id or self._active_tx_id
142
202
  extra = {"X-Transaction-ID": tx_id} if tx_id else {}
143
- return self._request("DELETE", f"/v1/entity/{entity}/delete/{seq}", params=params, extra_headers=extra)
203
+ return self._request("POST", f"/v1/entity/{entity}/delete/{seq}{q}", extra_headers=extra)
144
204
 
145
205
  def history(self, entity: str, seq: int, page: int = 1, limit: int = 50) -> dict:
146
206
  """변경 이력 조회"""
@@ -273,18 +333,35 @@ class EntityServerClient:
273
333
  else:
274
334
  signed_path = path
275
335
 
276
- body_str = json.dumps(body, ensure_ascii=False) if body is not None else ""
277
- timestamp = str(int(time.time()))
278
- nonce = str(uuid.uuid4())
279
- signature = self._sign(method, signed_path, timestamp, nonce, body_str)
280
-
281
- headers: dict = {
282
- "Content-Type": "application/json",
283
- "X-API-Key": self.api_key,
284
- "X-Timestamp": timestamp,
285
- "X-Nonce": nonce,
286
- "X-Signature": signature,
287
- }
336
+ # 요청 바디 결정: encrypt_requests POST 바디를 암호화
337
+ body_data: bytes | None = None # 네트워크로 보낼 바이트
338
+ body_for_sign: bytes = b"" # HMAC 서명 대상
339
+ content_type_header = "application/json"
340
+
341
+ if body is not None:
342
+ json_bytes = json.dumps(body, ensure_ascii=False).encode("utf-8")
343
+ if self.encrypt_requests or self._packet_encryption:
344
+ encrypted = self._encrypt_packet(json_bytes)
345
+ body_data = encrypted
346
+ body_for_sign = encrypted
347
+ content_type_header = "application/octet-stream"
348
+ else:
349
+ body_data = json_bytes
350
+ body_for_sign = json_bytes
351
+
352
+ is_hmac_mode = bool(self.api_key and self.hmac_secret)
353
+
354
+ headers: dict = {"Content-Type": content_type_header}
355
+ if is_hmac_mode:
356
+ timestamp = str(int(time.time()))
357
+ nonce = str(uuid.uuid4())
358
+ signature = self._sign(method, signed_path, timestamp, nonce, body_for_sign)
359
+ headers["X-API-Key"] = self.api_key
360
+ headers["X-Timestamp"] = timestamp
361
+ headers["X-Nonce"] = nonce
362
+ headers["X-Signature"] = signature
363
+ elif self.token:
364
+ headers["Authorization"] = f"Bearer {self.token}"
288
365
  if extra_headers:
289
366
  headers.update(extra_headers)
290
367
 
@@ -293,14 +370,14 @@ class EntityServerClient:
293
370
  method=method,
294
371
  url=url,
295
372
  headers=headers,
296
- data=body_str.encode("utf-8") if body_str else None,
373
+ data=body_data,
297
374
  params=params,
298
375
  timeout=self.timeout,
299
376
  )
300
377
 
301
378
  # 패킷 암호화 응답: application/octet-stream → 복호화
302
- content_type = resp.headers.get("Content-Type", "")
303
- if "application/octet-stream" in content_type:
379
+ ct = resp.headers.get("Content-Type", "")
380
+ if "application/octet-stream" in ct:
304
381
  data = json.loads(self._decrypt_packet(resp.content))
305
382
  else:
306
383
  data = resp.json()
@@ -310,22 +387,58 @@ class EntityServerClient:
310
387
 
311
388
  return data
312
389
 
390
+ def _derive_packet_key(self) -> bytes:
391
+ """
392
+ 패킷 암호화 키를 유도합니다.
393
+ - HMAC 모드: HKDF-SHA256(hmac_secret, "entity-server:packet-encryption")
394
+ - JWT 모드: SHA256(token)
395
+ """
396
+ if self.token and not self.hmac_secret:
397
+ return hashlib.sha256(self.token.encode("utf-8")).digest()
398
+ h = HKDF(
399
+ algorithm=hashes.SHA256(),
400
+ length=32,
401
+ salt=b"entity-server:hkdf:v1",
402
+ info=b"entity-server:packet-encryption",
403
+ )
404
+ return h.derive(self.hmac_secret.encode("utf-8"))
405
+
406
+ def _encrypt_packet(self, plaintext: bytes) -> bytes:
407
+ """
408
+ XChaCha20-Poly1305 패킷 암호화
409
+ 포맷: [magic:magic_len][nonce:24][ciphertext+tag]
410
+ magic_len: 2 + key[31] % 14 (패킷 키에서 자동 파생)
411
+ """
412
+ key = self._derive_packet_key()
413
+ magic_len = 2 + key[31] % 14
414
+ magic = _secrets.token_bytes(magic_len)
415
+ nonce = _secrets.token_bytes(24)
416
+ ct = XChaCha20Poly1305(key).encrypt(nonce, plaintext, b"")
417
+ return magic + nonce + ct
418
+
313
419
  def _decrypt_packet(self, data: bytes) -> bytes:
314
420
  """
315
421
  XChaCha20-Poly1305 패킷 복호화
316
422
  포맷: [magic:magic_len][nonce:24][ciphertext+tag]
317
- 키: sha256(hmac_secret)
423
+ 키: HKDF-SHA256(hmac_secret, "entity-server:packet-encryption")
318
424
  """
319
- key = hashlib.sha256(self.hmac_secret.encode("utf-8")).digest()
320
- nonce = data[self.magic_len : self.magic_len + 24]
321
- ciphertext = data[self.magic_len + 24 :]
425
+ key = self._derive_packet_key()
426
+ magic_len = 2 + key[31] % 14
427
+ nonce = data[magic_len : magic_len + 24]
428
+ ciphertext = data[magic_len + 24 :]
322
429
  return XChaCha20Poly1305(key).decrypt(nonce, ciphertext, b"")
323
430
 
324
- def _sign(self, method: str, path: str, timestamp: str, nonce: str, body: str) -> str:
325
- """HMAC-SHA256 서명"""
326
- payload = "|".join([method, path, timestamp, nonce, body])
327
- return hmac.new(
328
- key=self.hmac_secret.encode("utf-8"),
329
- msg=payload.encode("utf-8"),
330
- digestmod=hashlib.sha256,
331
- ).hexdigest()
431
+ def _sign(self, method: str, path: str, timestamp: str, nonce: str, body: bytes | str) -> str:
432
+ """
433
+ HMAC-SHA256 서명.
434
+ body 는 bytes(암호화된 바디 포함) 또는 str 모두 지원합니다.
435
+ """
436
+ prefix = f"{method}|{path}|{timestamp}|{nonce}|".encode("utf-8")
437
+ h = hmac.new(key=self.hmac_secret.encode("utf-8"), digestmod=hashlib.sha256)
438
+ h.update(prefix)
439
+ if isinstance(body, bytes):
440
+ if body:
441
+ h.update(body)
442
+ elif body:
443
+ h.update(body.encode("utf-8"))
444
+ return h.hexdigest()