create-entity-server 0.0.9

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 (63) hide show
  1. package/bin/create.js +280 -0
  2. package/package.json +42 -0
  3. package/template/.env.example +14 -0
  4. package/template/configs/cache.json +22 -0
  5. package/template/configs/cors.json +7 -0
  6. package/template/configs/database.json +23 -0
  7. package/template/configs/jwt.json +7 -0
  8. package/template/configs/logging.json +45 -0
  9. package/template/configs/security.json +21 -0
  10. package/template/configs/server.json +10 -0
  11. package/template/entities/Account/account_audit.json +17 -0
  12. package/template/entities/Auth/account.json +60 -0
  13. package/template/entities/Auth/api_keys.json +26 -0
  14. package/template/entities/Auth/license.json +36 -0
  15. package/template/entities/Auth/rbac_roles.json +76 -0
  16. package/template/entities/README.md +380 -0
  17. package/template/entities/System/system_audit_log.json +65 -0
  18. package/template/entities/company.json +22 -0
  19. package/template/entities/product.json +36 -0
  20. package/template/entities/todo.json +16 -0
  21. package/template/samples/README.md +65 -0
  22. package/template/samples/flutter/lib/entity_server_client.dart +218 -0
  23. package/template/samples/flutter/pubspec.yaml +14 -0
  24. package/template/samples/java/EntityServerClient.java +304 -0
  25. package/template/samples/java/EntityServerExample.java +49 -0
  26. package/template/samples/kotlin/EntityServerClient.kt +194 -0
  27. package/template/samples/node/package.json +16 -0
  28. package/template/samples/node/src/EntityServerClient.js +246 -0
  29. package/template/samples/node/src/example.js +39 -0
  30. package/template/samples/php/ci4/Controllers/ProductController.php +141 -0
  31. package/template/samples/php/ci4/Libraries/EntityServer.php +260 -0
  32. package/template/samples/php/laravel/Http/Controllers/ProductController.php +62 -0
  33. package/template/samples/php/laravel/Services/EntityServerService.php +210 -0
  34. package/template/samples/python/entity_server.py +225 -0
  35. package/template/samples/python/example.py +50 -0
  36. package/template/samples/react/src/api/entityServerClient.ts +290 -0
  37. package/template/samples/react/src/example.tsx +127 -0
  38. package/template/samples/react/src/hooks/useEntity.ts +105 -0
  39. package/template/samples/swift/EntityServerClient.swift +221 -0
  40. package/template/scripts/api-key.ps1 +123 -0
  41. package/template/scripts/api-key.sh +130 -0
  42. package/template/scripts/cleanup-history.ps1 +69 -0
  43. package/template/scripts/cleanup-history.sh +54 -0
  44. package/template/scripts/cli.ps1 +24 -0
  45. package/template/scripts/cli.sh +27 -0
  46. package/template/scripts/entity.ps1 +70 -0
  47. package/template/scripts/entity.sh +72 -0
  48. package/template/scripts/generate-env-keys.ps1 +125 -0
  49. package/template/scripts/generate-env-keys.sh +148 -0
  50. package/template/scripts/install-systemd.sh +222 -0
  51. package/template/scripts/normalize-entities.ps1 +87 -0
  52. package/template/scripts/normalize-entities.sh +132 -0
  53. package/template/scripts/rbac-role.ps1 +124 -0
  54. package/template/scripts/rbac-role.sh +127 -0
  55. package/template/scripts/remove-systemd.sh +158 -0
  56. package/template/scripts/reset-all.ps1 +83 -0
  57. package/template/scripts/reset-all.sh +95 -0
  58. package/template/scripts/run.ps1 +239 -0
  59. package/template/scripts/run.sh +315 -0
  60. package/template/scripts/sync.ps1 +145 -0
  61. package/template/scripts/sync.sh +178 -0
  62. package/template/scripts/update-server.ps1 +117 -0
  63. package/template/scripts/update-server.sh +165 -0
@@ -0,0 +1,260 @@
1
+ <?php
2
+
3
+ namespace App\Libraries;
4
+
5
+ /**
6
+ * Entity Server 클라이언트 라이브러리 (CodeIgniter 4)
7
+ *
8
+ * 필요 확장: ext-sodium (PHP 7.2+ 기본 내장) — XChaCha20-Poly1305 복호화
9
+ *
10
+ * 설치: app/Libraries/EntityServer.php 에 배치
11
+ *
12
+ * 설정: app/Config/EntityServer.php 또는 .env 에서
13
+ * ENTITY_SERVER_URL=http://localhost:47200
14
+ * ENTITY_SERVER_API_KEY=your-api-key
15
+ * ENTITY_SERVER_HMAC_SECRET=your-hmac-secret
16
+ * ENTITY_PACKET_MAGIC_LEN=4
17
+ *
18
+ * 컨트롤러 사용법:
19
+ * $es = new \App\Libraries\EntityServer();
20
+ * $result = $es->get('account', 1);
21
+ * $list = $es->list('account', ['page' => 1, 'limit' => 20]);
22
+ * $seq = $es->submit('account', ['name' => '홍길동', 'email' => 'hong@example.com']);
23
+ */
24
+ class EntityServer
25
+ {
26
+ private string $baseUrl;
27
+ private string $apiKey;
28
+ private string $hmacSecret;
29
+ private int $timeout;
30
+ private int $magicLen;
31
+ private ?string $activeTxId = null;
32
+
33
+ public function __construct(
34
+ string $baseUrl = '',
35
+ string $apiKey = '',
36
+ string $hmacSecret = '',
37
+ int $timeout = 10,
38
+ int $magicLen = 4
39
+ ) {
40
+ $this->baseUrl = rtrim($baseUrl ?: env('ENTITY_SERVER_URL', 'http://localhost:47200'), '/');
41
+ $this->apiKey = $apiKey ?: env('ENTITY_SERVER_API_KEY', '');
42
+ $this->hmacSecret = $hmacSecret ?: env('ENTITY_SERVER_HMAC_SECRET', '');
43
+ $this->timeout = $timeout;
44
+ $this->magicLen = (int) ($magicLen ?: env('ENTITY_PACKET_MAGIC_LEN', 4));
45
+ }
46
+
47
+ // ─── CRUD ────────────────────────────────────────────────────────────────
48
+
49
+ /** 단건 조회 */
50
+ public function get(string $entity, int $seq): array
51
+ {
52
+ return $this->request('GET', "/v1/entity/{$entity}/{$seq}");
53
+ }
54
+
55
+ /** 목록 조회 */
56
+ public function list(string $entity, array $params = []): array
57
+ {
58
+ $query = http_build_query(array_merge(['page' => 1, 'limit' => 20], $params));
59
+ return $this->request('GET', "/v1/entity/{$entity}/list?{$query}");
60
+ }
61
+
62
+ /** 건수 조회 */
63
+ public function count(string $entity): array
64
+ {
65
+ return $this->request('GET', "/v1/entity/{$entity}/count");
66
+ }
67
+
68
+ /**
69
+ * 필터 검색
70
+ *
71
+ * @param array $filter 예: [['field' => 'status', 'op' => 'eq', 'value' => 'active']]
72
+ * @param array $params 예: ['page' => 1, 'limit' => 20, 'order_by' => 'name']
73
+ */
74
+ public function query(string $entity, array $filter = [], array $params = []): array
75
+ {
76
+ $query = http_build_query(array_merge(['page' => 1, 'limit' => 20], $params));
77
+ return $this->request('POST', "/v1/entity/{$entity}/query?{$query}", $filter);
78
+ }
79
+
80
+ /**
81
+ * 트랜잭션 시작 — 서버에 큐를 등록하고 txId 를 저장합니다.
82
+ * 이후 submit / delete 가 실제 실행되지 않고 서버 큐에 쌓입니다.
83
+ * transCommit() 시 한 번에 DB 트랜잭션으로 실행됩니다.
84
+ */
85
+ public function transStart(): string
86
+ {
87
+ $result = $this->request('POST', '/v1/transaction/start');
88
+ $this->activeTxId = $result['transaction_id'];
89
+ return $this->activeTxId;
90
+ }
91
+
92
+ /**
93
+ * 트랜잭션 전체 롤백
94
+ * $transactionId 생략 시 transStart() 로 시작한 활성 트랜잭션을 롤백합니다.
95
+ */
96
+ public function transRollback(?string $transactionId = null): array
97
+ {
98
+ $txId = $transactionId ?? $this->activeTxId;
99
+ if ($txId === null) {
100
+ throw new \RuntimeException('No active transaction. Call transStart() first.');
101
+ }
102
+ $this->activeTxId = null;
103
+ return $this->request('POST', "/v1/transaction/rollback/{$txId}");
104
+ }
105
+
106
+ /**
107
+ * 트랜잭션 커밋 — 큐에 쌓인 모든 작업을 단일 DB 트랜잭션으로 일괄 실행합니다.
108
+ * 하나라도 실패하면 전체가 ROLLBACK 됩니다.
109
+ */
110
+ public function transCommit(): array
111
+ {
112
+ $txId = $this->activeTxId;
113
+ if ($txId === null) {
114
+ throw new \RuntimeException('No active transaction. Call transStart() first.');
115
+ }
116
+ $this->activeTxId = null;
117
+ return $this->request('POST', "/v1/transaction/commit/{$txId}");
118
+ }
119
+
120
+ /**
121
+ * 생성 또는 수정
122
+ * - body에 'seq' 포함 → 수정
123
+ * - body에 'seq' 없음 → 생성 (seq 반환)
124
+ * @param string|null $transactionId transStart() 로 얻은 ID (생략 시 활성 트랜잭션 자동 사용)
125
+ */
126
+ public function submit(string $entity, array $data, ?string $transactionId = null): array
127
+ {
128
+ $txId = $transactionId ?? $this->activeTxId;
129
+ $extra = $txId ? ['X-Transaction-ID: ' . $txId] : [];
130
+ return $this->request('POST', "/v1/entity/{$entity}/submit", $data, $extra);
131
+ }
132
+
133
+ /**
134
+ * 삭제
135
+ * @param bool $hard true 이면 물리 삭제
136
+ * @param string|null $transactionId transStart() 로 얻은 ID (생략 시 활성 트랜잭션 자동 사용)
137
+ */
138
+ public function delete(string $entity, int $seq, ?string $transactionId = null, bool $hard = false): array
139
+ {
140
+ $q = $hard ? '?hard=true' : '';
141
+ $txId = $transactionId ?? $this->activeTxId;
142
+ $extra = $txId ? ['X-Transaction-ID: ' . $txId] : [];
143
+ return $this->request('DELETE', "/v1/entity/{$entity}/delete/{$seq}{$q}", [], $extra);
144
+ }
145
+
146
+ /** 변경 이력 조회 */
147
+ public function history(string $entity, int $seq, int $page = 1, int $limit = 50): array
148
+ {
149
+ return $this->request('GET', "/v1/entity/{$entity}/history/{$seq}?page={$page}&limit={$limit}");
150
+ }
151
+
152
+ /** 트랜잭션 롤백 */
153
+ public function rollback(string $entity, int $historySeq): array
154
+ {
155
+ return $this->request('POST', "/v1/entity/{$entity}/rollback/{$historySeq}");
156
+ }
157
+
158
+ // ─── 내부 ─────────────────────────────────────────────────────────────────
159
+
160
+ private function request(string $method, string $path, array $body = [], array $extraHeaders = []): array
161
+ {
162
+ $bodyJson = empty($body) ? '' : json_encode($body, JSON_UNESCAPED_UNICODE);
163
+ $timestamp = (string) time();
164
+ $nonce = $this->generateNonce();
165
+ $signature = $this->sign($method, $path, $timestamp, $nonce, $bodyJson);
166
+
167
+ $headers = array_merge([
168
+ 'Content-Type: application/json',
169
+ 'X-API-Key: ' . $this->apiKey,
170
+ 'X-Timestamp: ' . $timestamp,
171
+ 'X-Nonce: ' . $nonce,
172
+ 'X-Signature: ' . $signature,
173
+ ], $extraHeaders);
174
+
175
+ $url = $this->baseUrl . $path;
176
+ $ch = curl_init($url);
177
+
178
+ curl_setopt_array($ch, [
179
+ CURLOPT_CUSTOMREQUEST => $method,
180
+ CURLOPT_HTTPHEADER => $headers,
181
+ CURLOPT_RETURNTRANSFER => true,
182
+ CURLOPT_TIMEOUT => $this->timeout,
183
+ ]);
184
+
185
+ if ($bodyJson !== '') {
186
+ curl_setopt($ch, CURLOPT_POSTFIELDS, $bodyJson);
187
+ }
188
+
189
+ $response = curl_exec($ch);
190
+ $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
191
+ $contentType = curl_getinfo($ch, CURLINFO_CONTENT_TYPE) ?? '';
192
+ $error = curl_error($ch);
193
+ curl_close($ch);
194
+
195
+ if ($error) {
196
+ throw new \RuntimeException("EntityServer curl error: {$error}");
197
+ }
198
+
199
+ // 패킷 암호화 응답: application/octet-stream → 복호화
200
+ if (str_contains($contentType, 'application/octet-stream')) {
201
+ $jsonStr = $this->decryptPacket($response);
202
+ $decoded = json_decode($jsonStr, true);
203
+ } else {
204
+ $decoded = json_decode($response, true);
205
+ }
206
+
207
+ if ($decoded === null) {
208
+ throw new \RuntimeException("EntityServer invalid JSON response (HTTP {$httpCode})");
209
+ }
210
+
211
+ if (!($decoded['ok'] ?? false)) {
212
+ throw new \RuntimeException("EntityServer error: " . ($decoded['message'] ?? 'Unknown') . " (HTTP {$httpCode})");
213
+ }
214
+
215
+ return $decoded;
216
+ }
217
+
218
+ /**
219
+ * XChaCha20-Poly1305 패킷 복호화
220
+ * 포맷: [magic:magicLen][nonce:24][ciphertext+tag]
221
+ * 키: sha256(hmac_secret)
222
+ *
223
+ * ext-sodium 사용 (PHP 7.2+ 내장)
224
+ */
225
+ private function decryptPacket(string $data): string
226
+ {
227
+ $key = hash('sha256', $this->hmacSecret, true);
228
+ $nonce = substr($data, $this->magicLen, 24);
229
+ $ciphertext = substr($data, $this->magicLen + 24);
230
+
231
+ $plaintext = sodium_crypto_aead_xchacha20poly1305_ietf_decrypt($ciphertext, '', $nonce, $key);
232
+ if ($plaintext === false) {
233
+ throw new \RuntimeException('Packet decryption failed: authentication tag mismatch');
234
+ }
235
+ return $plaintext;
236
+ }
237
+
238
+ /** HMAC-SHA256 서명 */
239
+ private function sign(string $method, string $path, string $timestamp, string $nonce, string $body): string
240
+ {
241
+ // PATH는 쿼리스트링 포함한 전체 경로
242
+ $payload = implode('|', [$method, $path, $timestamp, $nonce, $body]);
243
+ return hash_hmac('sha256', $payload, $this->hmacSecret);
244
+ }
245
+
246
+ private function generateNonce(): string
247
+ {
248
+ return sprintf(
249
+ '%04x%04x-%04x-%04x-%04x-%04x%04x%04x',
250
+ mt_rand(0, 0xffff),
251
+ mt_rand(0, 0xffff),
252
+ mt_rand(0, 0xffff),
253
+ mt_rand(0, 0x0fff) | 0x4000,
254
+ mt_rand(0, 0x3fff) | 0x8000,
255
+ mt_rand(0, 0xffff),
256
+ mt_rand(0, 0xffff),
257
+ mt_rand(0, 0xffff)
258
+ );
259
+ }
260
+ }
@@ -0,0 +1,62 @@
1
+ <?php
2
+
3
+ namespace App\Http\Controllers;
4
+
5
+ use App\Services\EntityServerService;
6
+ use Illuminate\Http\JsonResponse;
7
+ use Illuminate\Http\Request;
8
+
9
+ /**
10
+ * EntityServerService를 사용하는 Laravel 컨트롤러 예시
11
+ */
12
+ class ProductController extends Controller
13
+ {
14
+ public function __construct(private EntityServerService $es) {}
15
+
16
+ /** GET /api/products */
17
+ public function index(Request $request): JsonResponse
18
+ {
19
+ $result = $this->es->list('product', [
20
+ 'page' => $request->integer('page', 1),
21
+ 'limit' => $request->integer('limit', 20),
22
+ ]);
23
+ return response()->json($result);
24
+ }
25
+
26
+ /** GET /api/products/{seq} */
27
+ public function show(int $seq): JsonResponse
28
+ {
29
+ return response()->json($this->es->get('product', $seq));
30
+ }
31
+
32
+ /** POST /api/products/search */
33
+ public function search(Request $request): JsonResponse
34
+ {
35
+ $result = $this->es->query(
36
+ 'product',
37
+ $request->input('filter', []),
38
+ ['page' => $request->integer('page', 1), 'limit' => $request->integer('limit', 20)]
39
+ );
40
+ return response()->json($result);
41
+ }
42
+
43
+ /** POST /api/products */
44
+ public function store(Request $request): JsonResponse
45
+ {
46
+ $result = $this->es->submit('product', $request->all());
47
+ return response()->json($result, 201);
48
+ }
49
+
50
+ /** PUT /api/products/{seq} */
51
+ public function update(Request $request, int $seq): JsonResponse
52
+ {
53
+ $result = $this->es->submit('product', array_merge($request->all(), ['seq' => $seq]));
54
+ return response()->json($result);
55
+ }
56
+
57
+ /** DELETE /api/products/{seq} */
58
+ public function destroy(int $seq): JsonResponse
59
+ {
60
+ return response()->json($this->es->delete('product', $seq));
61
+ }
62
+ }
@@ -0,0 +1,210 @@
1
+ <?php
2
+
3
+ namespace App\Services;
4
+
5
+ use Illuminate\Support\Facades\Http;
6
+ use Illuminate\Support\Str;
7
+
8
+ /**
9
+ * Entity Server 클라이언트 서비스 (Laravel)
10
+ *
11
+ * 필요 확장: ext-sodium (PHP 7.2+ 기본 내장) — XChaCha20-Poly1305 복호화
12
+ *
13
+ * 설정: config/services.php 또는 .env
14
+ * ENTITY_SERVER_URL=http://localhost:47200
15
+ * ENTITY_SERVER_API_KEY=your-api-key
16
+ * ENTITY_SERVER_HMAC_SECRET=your-hmac-secret
17
+ * ENTITY_PACKET_MAGIC_LEN=4
18
+ *
19
+ * 서비스 프로바이더 등록:
20
+ * $this->app->singleton(EntityServerService::class);
21
+ *
22
+ * 컨트롤러 사용법:
23
+ * public function __construct(private EntityServerService $es) {}
24
+ * $result = $this->es->get('account', 1);
25
+ *
26
+ * 트랜잭션 사용 예:
27
+ * $es->transStart();
28
+ * try {
29
+ * $orderRef = $es->submit('order', ['user_seq' => 1, 'total' => 9900]); // seq: "$tx.0"
30
+ * $es->submit('order_item', ['order_seq' => $orderRef['seq'], 'item_seq' => 5]); // "$tx.0" 자동 치환
31
+ * $result = $es->transCommit();
32
+ * $orderSeq = $result['results'][0]['seq']; // 실제 seq
33
+ * } catch (\Throwable $e) {
34
+ * $es->transRollback();
35
+ * }
36
+ */
37
+ class EntityServerService
38
+ {
39
+ private string $baseUrl;
40
+ private string $apiKey;
41
+ private string $hmacSecret;
42
+ private int $magicLen;
43
+ private ?string $activeTxId = null;
44
+
45
+ public function __construct()
46
+ {
47
+ $this->baseUrl = rtrim(config('services.entity_server.url', env('ENTITY_SERVER_URL', 'http://localhost:47200')), '/');
48
+ $this->apiKey = config('services.entity_server.api_key', env('ENTITY_SERVER_API_KEY', ''));
49
+ $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));
51
+ }
52
+
53
+ // ─── CRUD ────────────────────────────────────────────────────────────────
54
+
55
+ public function get(string $entity, int $seq): array
56
+ {
57
+ return $this->request('GET', "/v1/entity/{$entity}/{$seq}");
58
+ }
59
+
60
+ public function list(string $entity, array $params = []): array
61
+ {
62
+ $query = http_build_query(array_merge(['page' => 1, 'limit' => 20], $params));
63
+ return $this->request('GET', "/v1/entity/{$entity}/list?{$query}");
64
+ }
65
+
66
+ public function count(string $entity): array
67
+ {
68
+ return $this->request('GET', "/v1/entity/{$entity}/count");
69
+ }
70
+
71
+ public function query(string $entity, array $filter = [], array $params = []): array
72
+ {
73
+ $query = http_build_query(array_merge(['page' => 1, 'limit' => 20], $params));
74
+ return $this->request('POST', "/v1/entity/{$entity}/query?{$query}", $filter);
75
+ }
76
+
77
+ /**
78
+ * 트랜잭션 시작 — 서버에 큐를 등록하고 txId 를 저장합니다.
79
+ * 이후 submit / delete 가 실제 실행되지 않고 서버 큐에 쌓입니다.
80
+ * transCommit() 시 한 번에 DB 트랜잭션으로 실행됩니다.
81
+ */
82
+ public function transStart(): string
83
+ {
84
+ $result = $this->request('POST', '/v1/transaction/start');
85
+ $this->activeTxId = $result['transaction_id'];
86
+ return $this->activeTxId;
87
+ }
88
+
89
+ /**
90
+ * 트랜잭션 전체 롤백
91
+ * $transactionId 생략 시 transStart() 로 시작한 활성 트랜잭션을 롤백합니다.
92
+ */
93
+ public function transRollback(?string $transactionId = null): array
94
+ {
95
+ $txId = $transactionId ?? $this->activeTxId;
96
+ if ($txId === null) {
97
+ throw new \RuntimeException('No active transaction. Call transStart() first.');
98
+ }
99
+ $this->activeTxId = null;
100
+ return $this->request('POST', "/v1/transaction/rollback/{$txId}");
101
+ }
102
+
103
+ /** 트랜잭션 커밋 — 큐에 쌓인 모든 작업을 단일 DB 트랜잭션으로 일괄 실행합니다. */
104
+ public function transCommit(): array
105
+ {
106
+ $txId = $this->activeTxId;
107
+ if ($txId === null) {
108
+ throw new \RuntimeException('No active transaction. Call transStart() first.');
109
+ }
110
+ $this->activeTxId = null;
111
+ return $this->request('POST', "/v1/transaction/commit/{$txId}");
112
+ }
113
+
114
+ /** 생성 또는 수정 (seq 포함시 수정, 없으면 생성) */
115
+ public function submit(string $entity, array $data, ?string $transactionId = null): array
116
+ {
117
+ $txId = $transactionId ?? $this->activeTxId;
118
+ $extra = $txId ? ['X-Transaction-ID' => $txId] : [];
119
+ return $this->request('POST', "/v1/entity/{$entity}/submit", $data, $extra);
120
+ }
121
+
122
+ /** 삭제 */
123
+ public function delete(string $entity, int $seq, ?string $transactionId = null, bool $hard = false): array
124
+ {
125
+ $q = $hard ? '?hard=true' : '';
126
+ $txId = $transactionId ?? $this->activeTxId;
127
+ $extra = $txId ? ['X-Transaction-ID' => $txId] : [];
128
+ return $this->request('DELETE', "/v1/entity/{$entity}/delete/{$seq}{$q}", [], $extra);
129
+ }
130
+
131
+ public function history(string $entity, int $seq, int $page = 1, int $limit = 50): array
132
+ {
133
+ return $this->request('GET', "/v1/entity/{$entity}/history/{$seq}?page={$page}&limit={$limit}");
134
+ }
135
+
136
+ public function rollback(string $entity, int $historySeq): array
137
+ {
138
+ return $this->request('POST', "/v1/entity/{$entity}/rollback/{$historySeq}");
139
+ }
140
+
141
+ // ─── 내부 ─────────────────────────────────────────────────────────────────
142
+
143
+ private function request(string $method, string $path, array $body = [], array $extraHeaders = []): array
144
+ {
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);
149
+
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);
156
+
157
+ $response = match ($method) {
158
+ 'GET' => $http->get($this->baseUrl . $path),
159
+ 'POST' => $http->withBody($bodyJson, 'application/json')->post($this->baseUrl . $path),
160
+ 'DELETE' => $http->delete($this->baseUrl . $path),
161
+ default => throw new \InvalidArgumentException("Unsupported method: {$method}"),
162
+ };
163
+
164
+ $decoded = $response->json();
165
+
166
+ // 패킷 암호화 응답: application/octet-stream → 복호화
167
+ $contentType = $response->header('Content-Type') ?? '';
168
+ if (str_contains($contentType, 'application/octet-stream')) {
169
+ $jsonStr = $this->decryptPacket($response->body());
170
+ $decoded = json_decode($jsonStr, true);
171
+ } else {
172
+ $decoded = $response->json();
173
+ }
174
+
175
+ if (!($decoded['ok'] ?? false)) {
176
+ throw new \RuntimeException(
177
+ 'EntityServer error: ' . ($decoded['message'] ?? 'Unknown') .
178
+ ' (HTTP ' . $response->status() . ')'
179
+ );
180
+ }
181
+
182
+ return $decoded;
183
+ }
184
+
185
+ /**
186
+ * XChaCha20-Poly1305 패킷 복호화
187
+ * 포맷: [magic:magicLen][nonce:24][ciphertext+tag]
188
+ * 키: sha256(hmac_secret)
189
+ *
190
+ * ext-sodium 사용 (PHP 7.2+ 내장)
191
+ */
192
+ private function decryptPacket(string $data): string
193
+ {
194
+ $key = hash('sha256', $this->hmacSecret, true);
195
+ $nonce = substr($data, $this->magicLen, 24);
196
+ $ciphertext = substr($data, $this->magicLen + 24);
197
+
198
+ $plaintext = sodium_crypto_aead_xchacha20poly1305_ietf_decrypt($ciphertext, '', $nonce, $key);
199
+ if ($plaintext === false) {
200
+ throw new \RuntimeException('Packet decryption failed: authentication tag mismatch');
201
+ }
202
+ return $plaintext;
203
+ }
204
+
205
+ private function sign(string $method, string $path, string $timestamp, string $nonce, string $body): string
206
+ {
207
+ $payload = implode('|', [$method, $path, $timestamp, $nonce, $body]);
208
+ return hash_hmac('sha256', $payload, $this->hmacSecret);
209
+ }
210
+ }