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
@@ -4,6 +4,7 @@ namespace App\Services;
4
4
 
5
5
  use Illuminate\Support\Facades\Http;
6
6
  use Illuminate\Support\Str;
7
+ use Illuminate\Http\Request;
7
8
 
8
9
  /**
9
10
  * Entity Server 클라이언트 서비스 (Laravel)
@@ -14,7 +15,6 @@ use Illuminate\Support\Str;
14
15
  * ENTITY_SERVER_URL=http://localhost:47200
15
16
  * ENTITY_SERVER_API_KEY=your-api-key
16
17
  * ENTITY_SERVER_HMAC_SECRET=your-hmac-secret
17
- * ENTITY_PACKET_MAGIC_LEN=4
18
18
  *
19
19
  * 서비스 프로바이더 등록:
20
20
  * $this->app->singleton(EntityServerService::class);
@@ -39,7 +39,10 @@ class EntityServerService
39
39
  private string $baseUrl;
40
40
  private string $apiKey;
41
41
  private string $hmacSecret;
42
- private int $magicLen;
42
+ private string $token = '';
43
+ private bool $requireEncryptedRequest;
44
+ private bool $encryptRequests;
45
+ private bool $packetEncryption = false;
43
46
  private ?string $activeTxId = null;
44
47
 
45
48
  public function __construct()
@@ -47,31 +50,115 @@ class EntityServerService
47
50
  $this->baseUrl = rtrim(config('services.entity_server.url', env('ENTITY_SERVER_URL', 'http://localhost:47200')), '/');
48
51
  $this->apiKey = config('services.entity_server.api_key', env('ENTITY_SERVER_API_KEY', ''));
49
52
  $this->hmacSecret = config('services.entity_server.hmac_secret', env('ENTITY_SERVER_HMAC_SECRET', ''));
50
- $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', ''));
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;
51
62
  }
52
63
 
53
64
  // ─── CRUD ────────────────────────────────────────────────────────────────
54
65
 
55
- public function get(string $entity, int $seq): array
66
+ /**
67
+ * 서버 헬스 체크를 수행하고 패킷 암호화 활성 여부를 자동으로 감지합니다.
68
+ * 서버가 packet_encryption: true 를 응답하면 이후 모든 요청에 암호화가 자동 적용됩니다.
69
+ */
70
+ public function checkHealth(): array
56
71
  {
57
- return $this->request('GET', "/v1/entity/{$entity}/{$seq}");
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;
58
78
  }
59
79
 
60
- public function list(string $entity, array $params = []): array
80
+ /**
81
+ * 단건 조회
82
+ *
83
+ * @param bool $skipHooks true 이면 after_get 훅 미실행
84
+ */
85
+ public function get(string $entity, int $seq, bool $skipHooks = false): array
61
86
  {
62
- $query = http_build_query(array_merge(['page' => 1, 'limit' => 20], $params));
63
- return $this->request('GET', "/v1/entity/{$entity}/list?{$query}");
87
+ $q = $skipHooks ? '?skipHooks=true' : '';
88
+ return $this->request('GET', "/v1/entity/{$entity}/{$seq}{$q}");
64
89
  }
65
90
 
66
- public function count(string $entity): array
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
67
99
  {
68
- return $this->request('GET', "/v1/entity/{$entity}/count");
100
+ $q = $skipHooks ? '?skipHooks=true' : '';
101
+ return $this->request('POST', "/v1/entity/{$entity}/find{$q}", $conditions);
69
102
  }
70
103
 
71
- public function query(string $entity, array $filter = [], 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
72
113
  {
73
- $query = http_build_query(array_merge(['page' => 1, 'limit' => 20], $params));
74
- return $this->request('POST', "/v1/entity/{$entity}/query?{$query}", $filter);
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);
131
+ }
132
+
133
+ /**
134
+ * 건수 조회
135
+ *
136
+ * @param array $conditions 필터 조건 (list() 와 동일 규칙)
137
+ */
138
+ public function count(string $entity, array $conditions = []): array
139
+ {
140
+ return $this->request('POST', "/v1/entity/{$entity}/count", $conditions);
141
+ }
142
+
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
156
+ {
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);
75
162
  }
76
163
 
77
164
  /**
@@ -112,20 +199,34 @@ class EntityServerService
112
199
  }
113
200
 
114
201
  /** 생성 또는 수정 (seq 포함시 수정, 없으면 생성) */
115
- 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
116
203
  {
117
204
  $txId = $transactionId ?? $this->activeTxId;
118
205
  $extra = $txId ? ['X-Transaction-ID' => $txId] : [];
119
- 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);
120
208
  }
121
209
 
122
- /** 삭제 */
123
- 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
124
218
  {
125
- $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) : '';
126
227
  $txId = $transactionId ?? $this->activeTxId;
127
228
  $extra = $txId ? ['X-Transaction-ID' => $txId] : [];
128
- return $this->request('DELETE', "/v1/entity/{$entity}/delete/{$seq}{$q}", [], $extra);
229
+ return $this->request('POST', "/v1/entity/{$entity}/delete/{$seq}{$q}", [], $extra);
129
230
  }
130
231
 
131
232
  public function history(string $entity, int $seq, int $page = 1, int $limit = 50): array
@@ -138,34 +239,101 @@ class EntityServerService
138
239
  return $this->request('POST', "/v1/entity/{$entity}/rollback/{$historySeq}");
139
240
  }
140
241
 
242
+ /**
243
+ * 푸시 발송 트리거 엔티티에 submit합니다.
244
+ */
245
+ public function push(string $pushEntity, array $payload, ?string $transactionId = null): array
246
+ {
247
+ return $this->submit($pushEntity, $payload, $transactionId);
248
+ }
249
+
250
+ /**
251
+ * push_log 목록 조회 헬퍼
252
+ */
253
+ public function pushLogList(array $params = []): array
254
+ {
255
+ return $this->list('push_log', $params);
256
+ }
257
+
258
+ /**
259
+ * Laravel Request에서 암호화 패킷 또는 평문 JSON 본문을 읽어 배열로 반환합니다.
260
+ */
261
+ public function readRequestBody(Request $request, ?bool $requireEncrypted = null): array
262
+ {
263
+ $requireEncrypted = $requireEncrypted ?? $this->requireEncryptedRequest;
264
+
265
+ $contentType = strtolower((string) $request->header('Content-Type', ''));
266
+ $rawBody = (string) $request->getContent();
267
+ $isEncrypted = str_contains($contentType, 'application/octet-stream');
268
+
269
+ if ($requireEncrypted && !$isEncrypted) {
270
+ throw new \RuntimeException('Encrypted request required: Content-Type must be application/octet-stream');
271
+ }
272
+
273
+ if ($isEncrypted) {
274
+ if ($rawBody === '') {
275
+ throw new \RuntimeException('Encrypted request body is empty');
276
+ }
277
+ $jsonStr = $this->decryptPacket($rawBody);
278
+ $decoded = json_decode($jsonStr, true);
279
+ if (!is_array($decoded)) {
280
+ throw new \RuntimeException('Invalid encrypted JSON payload');
281
+ }
282
+ return $decoded;
283
+ }
284
+
285
+ if ($rawBody === '') {
286
+ return [];
287
+ }
288
+ $decoded = json_decode($rawBody, true);
289
+ if (!is_array($decoded)) {
290
+ throw new \RuntimeException('Invalid JSON payload');
291
+ }
292
+ return $decoded;
293
+ }
294
+
141
295
  // ─── 내부 ─────────────────────────────────────────────────────────────────
142
296
 
143
297
  private function request(string $method, string $path, array $body = [], array $extraHeaders = []): array
144
298
  {
145
- $bodyJson = empty($body) ? '' : json_encode($body, JSON_UNESCAPED_UNICODE);
146
- $timestamp = (string) time();
147
- $nonce = (string) Str::uuid();
148
- $signature = $this->sign($method, $path, $timestamp, $nonce, $bodyJson);
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
+ }
149
308
 
150
- $http = Http::withHeaders(array_merge([
151
- 'X-API-Key' => $this->apiKey,
152
- 'X-Timestamp' => $timestamp,
153
- 'X-Nonce' => $nonce,
154
- 'X-Signature' => $signature,
155
- ], $extraHeaders))->timeout(10);
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
+ }
156
326
 
157
327
  $response = match ($method) {
158
328
  'GET' => $http->get($this->baseUrl . $path),
159
- 'POST' => $http->withBody($bodyJson, 'application/json')->post($this->baseUrl . $path),
329
+ 'POST' => $http->withBody($bodyData, $contentType)->post($this->baseUrl . $path),
160
330
  'DELETE' => $http->delete($this->baseUrl . $path),
161
331
  default => throw new \InvalidArgumentException("Unsupported method: {$method}"),
162
332
  };
163
333
 
164
- $decoded = $response->json();
165
-
166
334
  // 패킷 암호화 응답: application/octet-stream → 복호화
167
- $contentType = $response->header('Content-Type') ?? '';
168
- if (str_contains($contentType, 'application/octet-stream')) {
335
+ $respContentType = $response->header('Content-Type') ?? '';
336
+ if (str_contains($respContentType, 'application/octet-stream')) {
169
337
  $jsonStr = $this->decryptPacket($response->body());
170
338
  $decoded = json_decode($jsonStr, true);
171
339
  } else {
@@ -182,18 +350,51 @@ class EntityServerService
182
350
  return $decoded;
183
351
  }
184
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
+
185
385
  /**
186
386
  * XChaCha20-Poly1305 패킷 복호화
187
387
  * 포맷: [magic:magicLen][nonce:24][ciphertext+tag]
188
- * 키: sha256(hmac_secret)
388
+ * 키: HKDF-SHA256(hmac_secret, "entity-server:packet-encryption")
189
389
  *
190
390
  * ext-sodium 사용 (PHP 7.2+ 내장)
191
391
  */
192
392
  private function decryptPacket(string $data): string
193
393
  {
194
- $key = hash('sha256', $this->hmacSecret, true);
195
- $nonce = substr($data, $this->magicLen, 24);
196
- $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);
197
398
 
198
399
  $plaintext = sodium_crypto_aead_xchacha20poly1305_ietf_decrypt($ciphertext, '', $nonce, $key);
199
400
  if ($plaintext === false) {
@@ -202,9 +403,13 @@ class EntityServerService
202
403
  return $plaintext;
203
404
  }
204
405
 
406
+ /**
407
+ * HMAC-SHA256 서명. $body 는 JSON 스트링 또는 바이너리 암호화 페이로드 모두 지원합니다.
408
+ * prefix = "METHOD|path|timestamp|nonce|" 뒤에 $body 를 바로 이어 붙여 서명합니다.
409
+ */
205
410
  private function sign(string $method, string $path, string $timestamp, string $nonce, string $body): string
206
411
  {
207
- $payload = implode('|', [$method, $path, $timestamp, $nonce, $body]);
208
- return hash_hmac('sha256', $payload, $this->hmacSecret);
412
+ $prefix = implode('|', [$method, $path, $timestamp, $nonce]) . '|';
413
+ return hash_hmac('sha256', $prefix . $body, $this->hmacSecret);
209
414
  }
210
415
  }