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
@@ -12,7 +12,6 @@
12
12
  /// baseUrl: 'http://your-server:47200',
13
13
  /// apiKey: 'your-api-key',
14
14
  /// hmacSecret: 'your-hmac-secret',
15
- /// magicLen: 4, // 서버 packet_magic_len 과 동일
16
15
  /// );
17
16
  /// final result = await client.list('product');
18
17
  /// ```
@@ -31,6 +30,7 @@
31
30
  /// ```
32
31
 
33
32
  import 'dart:convert';
33
+ import 'dart:math';
34
34
  import 'dart:typed_data';
35
35
 
36
36
  import 'package:cryptography/cryptography.dart';
@@ -41,37 +41,94 @@ class EntityServerClient {
41
41
  final String baseUrl;
42
42
  final String apiKey;
43
43
  final String hmacSecret;
44
+ String token;
44
45
  final int magicLen;
46
+ /// true 이면 POST 요청 바디를 XChaCha20-Poly1305로 암호화합니다.
47
+ final bool encryptRequests;
45
48
 
46
49
  final _uuid = const Uuid();
47
50
  String? _activeTxId;
51
+ bool _packetEncryption = false;
48
52
 
49
53
  EntityServerClient({
50
54
  this.baseUrl = 'http://localhost:47200',
51
55
  this.apiKey = '',
52
56
  this.hmacSecret = '',
53
- this.magicLen = 4,
57
+ this.token = '',
58
+ this.encryptRequests = false,
54
59
  });
55
60
 
56
- // ─── CRUD ─────────────────────────────────────────────────────────
61
+ /// JWT Bearer 토큰을 설정합니다. HMAC 모드와 배타적으로 사용합니다.
62
+ void setToken(String newToken) => token = newToken;
57
63
 
58
- Future<Map<String, dynamic>> get(String entity, int seq) =>
59
- _request('GET', '/v1/entity/$entity/$seq');
64
+ // ─── Health Check ──────────────────────────────────────────────
60
65
 
61
- Future<Map<String, dynamic>> list(String entity, {int page = 1, int limit = 20}) =>
62
- _request('GET', '/v1/entity/$entity/list?page=$page&limit=$limit');
66
+ /// 서버 헬스 체크를 수행하고 패킷 암호화 활성 여부를 자동으로 감지합니다.
67
+ /// 서버가 packet_encryption: true 를 응답하면 이후 모든 요청에 암호화가 자동 적용됩니다.
68
+ Future<Map<String, dynamic>> checkHealth() async {
69
+ final uri = Uri.parse('${baseUrl.replaceAll(RegExp(r'/$'), '')}/v1/health');
70
+ final res = await http.get(uri);
71
+ final data = jsonDecode(res.body) as Map<String, dynamic>;
72
+ if (data['packet_encryption'] == true) {
73
+ _packetEncryption = true;
74
+ }
75
+ return data;
76
+ }
63
77
 
64
- Future<Map<String, dynamic>> count(String entity) =>
65
- _request('GET', '/v1/entity/$entity/count');
78
+ // ─── CRUD ─────────────────────────────────────────────────────────
66
79
 
67
- Future<Map<String, dynamic>> query(
80
+ Future<Map<String, dynamic>> get(String entity, int seq, {bool skipHooks = false}) =>
81
+ _request('GET', '/v1/entity/$entity/$seq${skipHooks ? "?skipHooks=true" : ""}');
82
+
83
+ /// 조건으로 단건 조회 (POST + conditions body)
84
+ ///
85
+ /// [conditions] 는 index/hash/unique 필드에만 사용 가능합니다.
86
+ /// 조건에 맞는 행이 없으면 예외가 발생합니다 (404).
87
+ Future<Map<String, dynamic>> find(
68
88
  String entity,
69
- List<Map<String, dynamic>> filter, {
89
+ Map<String, dynamic> conditions, {
90
+ bool skipHooks = false,
91
+ }) =>
92
+ _request('POST', '/v1/entity/$entity/find${skipHooks ? "?skipHooks=true" : ""}',
93
+ body: conditions);
94
+
95
+ /// 목록 조회 (POST + conditions body)
96
+ ///
97
+ /// [fields] 를 미지정하면 기본적으로 인덱스 필드만 반환합니다 (가장 빠름).
98
+ /// 전체 필드 반환이 필요하면 `['*']` 를 지정하세요.
99
+ /// [conditions] 는 index/hash/unique 필드에만 사용 가능합니다.
100
+ Future<Map<String, dynamic>> list(
101
+ String entity, {
70
102
  int page = 1,
71
103
  int limit = 20,
72
- }) =>
73
- _request('POST', '/v1/entity/$entity/query?page=$page&limit=$limit',
74
- body: filter);
104
+ String? orderBy,
105
+ List<String>? fields,
106
+ Map<String, dynamic>? conditions,
107
+ }) {
108
+ final qParts = <String>['page=$page', 'limit=$limit'];
109
+ if (orderBy != null) qParts.add('order_by=$orderBy');
110
+ if (fields != null && fields.isNotEmpty) qParts.add('fields=${fields.join(",")}');
111
+ return _request('POST', '/v1/entity/$entity/list?${qParts.join("&")}',
112
+ body: conditions ?? {});
113
+ }
114
+
115
+ /// 건수 조회
116
+ Future<Map<String, dynamic>> count(String entity, {Map<String, dynamic>? conditions}) =>
117
+ _request('POST', '/v1/entity/$entity/count', body: conditions ?? {});
118
+
119
+ /// 커스텀 SQL 조회 (SELECT 전용, 인덱스 테이블만, JOIN 지원)
120
+ ///
121
+ /// [sql] 은 SELECT 쿼리만 허용합니다. 사용자 입력은 반드시 [params] 로 바인딩하세요.
122
+ Future<Map<String, dynamic>> query(
123
+ String entity,
124
+ String sql, {
125
+ List<dynamic>? params,
126
+ int? limit,
127
+ }) {
128
+ final body = <String, dynamic>{'sql': sql, 'params': params ?? []};
129
+ if (limit != null) body['limit'] = limit;
130
+ return _request('POST', '/v1/entity/$entity/query', body: body);
131
+ }
75
132
 
76
133
  /// 트랜잭션 시작 — 서버에 트랜잭션 큐를 등록하고 transaction_id 를 반환합니다.
77
134
  /// 이후 submit / delete 가 서버 큐에 쌓이고 transCommit() 시 일괄 처리됩니다.
@@ -103,18 +160,24 @@ class EntityServerClient {
103
160
  String entity,
104
161
  Map<String, dynamic> data, {
105
162
  String? transactionId,
163
+ bool skipHooks = false,
106
164
  }) {
107
165
  final txId = transactionId ?? _activeTxId;
108
- return _request('POST', '/v1/entity/$entity/submit',
166
+ final q = skipHooks ? '?skipHooks=true' : '';
167
+ return _request('POST', '/v1/entity/$entity/submit$q',
109
168
  body: data,
110
169
  extraHeaders: txId != null ? {'X-Transaction-ID': txId} : null);
111
170
  }
112
171
 
172
+ /// 삭제. 서버는 POST /delete/:seq 로만 처리합니다.
113
173
  Future<Map<String, dynamic>> delete(String entity, int seq,
114
- {String? transactionId, bool hard = false}) {
115
- final q = hard ? '?hard=true' : '';
174
+ {String? transactionId, bool hard = false, bool skipHooks = false}) {
175
+ final qParts = <String>[];
176
+ if (hard) qParts.add('hard=true');
177
+ if (skipHooks) qParts.add('skipHooks=true');
178
+ final q = qParts.isNotEmpty ? '?${qParts.join("&")}' : '';
116
179
  final txId = transactionId ?? _activeTxId;
117
- return _request('DELETE', '/v1/entity/$entity/delete/$seq$q',
180
+ return _request('POST', '/v1/entity/$entity/delete/$seq$q',
118
181
  extraHeaders: txId != null ? {'X-Transaction-ID': txId} : null);
119
182
  }
120
183
 
@@ -224,21 +287,37 @@ class EntityServerClient {
224
287
  Object? body,
225
288
  Map<String, String>? extraHeaders,
226
289
  }) async {
227
- final bodyStr = body != null ? jsonEncode(body) : '';
228
- final timestamp =
229
- (DateTime.now().millisecondsSinceEpoch ~/ 1000).toString();
230
- final nonce = _uuid.v4();
231
- final signature = await _sign(method, path, timestamp, nonce, bodyStr);
290
+ // 요청 바디 결정: encryptRequests POST 바디를 암호화
291
+ Uint8List? bodyBytes;
292
+ String contentType = 'application/json';
293
+ if (body != null) {
294
+ final jsonBytes = Uint8List.fromList(utf8.encode(jsonEncode(body)));
295
+ if (encryptRequests || _packetEncryption) {
296
+ bodyBytes = await _encryptPacket(jsonBytes);
297
+ contentType = 'application/octet-stream';
298
+ } else {
299
+ bodyBytes = jsonBytes;
300
+ }
301
+ }
302
+
303
+ final isHmacMode = apiKey.isNotEmpty && hmacSecret.isNotEmpty;
232
304
 
233
305
  final uri = Uri.parse('${baseUrl.replaceAll(RegExp(r'/$'), '')}$path');
234
- final headers = <String, String>{
235
- 'Content-Type': 'application/json',
236
- 'X-API-Key': apiKey,
237
- 'X-Timestamp': timestamp,
238
- 'X-Nonce': nonce,
239
- 'X-Signature': signature,
240
- if (extraHeaders != null) ...extraHeaders,
241
- };
306
+ final headers = <String, String>{'Content-Type': contentType};
307
+
308
+ if (isHmacMode) {
309
+ final timestamp =
310
+ (DateTime.now().millisecondsSinceEpoch ~/ 1000).toString();
311
+ final nonce = _uuid.v4();
312
+ final signature = await _sign(method, path, timestamp, nonce, bodyBytes ?? Uint8List(0));
313
+ headers['X-API-Key'] = apiKey;
314
+ headers['X-Timestamp'] = timestamp;
315
+ headers['X-Nonce'] = nonce;
316
+ headers['X-Signature'] = signature;
317
+ } else if (token.isNotEmpty) {
318
+ headers['Authorization'] = 'Bearer $token';
319
+ }
320
+ if (extraHeaders != null) headers.addAll(extraHeaders);
242
321
 
243
322
  final http.Response res;
244
323
  switch (method.toUpperCase()) {
@@ -247,38 +326,78 @@ class EntityServerClient {
247
326
  break;
248
327
  case 'DELETE':
249
328
  res = await http.delete(uri, headers: headers,
250
- body: bodyStr.isNotEmpty ? bodyStr : null);
329
+ body: bodyBytes != null && bodyBytes.isNotEmpty ? bodyBytes : null);
251
330
  break;
252
331
  default:
253
332
  res = await http.post(uri, headers: headers,
254
- body: bodyStr.isNotEmpty ? bodyStr : null);
333
+ body: bodyBytes != null && bodyBytes.isNotEmpty ? bodyBytes : null);
255
334
  }
256
335
 
257
- final contentType = res.headers['content-type'] ?? '';
336
+ final ct = res.headers['content-type'] ?? '';
258
337
 
259
338
  // 패킷 암호화 응답: application/octet-stream → 복호화
260
- if (contentType.contains('application/octet-stream')) {
339
+ if (ct.contains('application/octet-stream')) {
261
340
  return await _decryptPacket(res.bodyBytes);
262
341
  }
263
342
 
264
343
  final data = jsonDecode(res.body) as Map<String, dynamic>;
265
344
  if (data['ok'] != true) {
266
- throw Exception('EntityServer error: ${data['message']} (HTTP ${res.statusCode})');
345
+ throw Exception('EntityServer error: \${data['message']} (HTTP \${res.statusCode})');
267
346
  }
268
347
  return data;
269
348
  }
270
349
 
350
+ /// 패킷 암호화 키를 유도합니다.
351
+ /// - HMAC 모드: HKDF-SHA256(hmac_secret, "entity-server:packet-encryption")
352
+ /// - JWT 모드: SHA256(token)
353
+ Future<Uint8List> _derivePacketKey() async {
354
+ if (token.isNotEmpty && hmacSecret.isEmpty) {
355
+ final sha = Sha256();
356
+ final hash = await sha.hash(utf8.encode(token));
357
+ return Uint8List.fromList(hash.bytes);
358
+ }
359
+ final hkdf = Hkdf(hmac: Hmac.sha256(), outputLength: 32);
360
+ final output = await hkdf.deriveKey(
361
+ secretKey: SecretKey(utf8.encode(hmacSecret)),
362
+ nonce: utf8.encode('entity-server:hkdf:v1'),
363
+ info: utf8.encode('entity-server:packet-encryption'),
364
+ );
365
+ return Uint8List.fromList(await output.extractBytes());
366
+ }
367
+
368
+ static Uint8List _randomBytes(int count) {
369
+ final rng = Random.secure();
370
+ return Uint8List.fromList(List.generate(count, (_) => rng.nextInt(256)));
371
+ }
372
+
373
+ /// XChaCha20-Poly1305 패킷 암호화
374
+ /// 포맷: [magic:magicLen][nonce:24][ciphertext+tag]
375
+ /// magicLen: 2 + keyBytes[31] % 14
376
+ Future<Uint8List> _encryptPacket(Uint8List plaintext) async {
377
+ final keyBytes = await _derivePacketKey();
378
+ final key = SecretKey(keyBytes);
379
+ final magicLen = 2 + keyBytes[31] % 14;
380
+ final magic = _randomBytes(magicLen);
381
+ final nonce = _randomBytes(24);
382
+
383
+ final algorithm = Xchacha20.poly1305Aead();
384
+ final secretBox = await algorithm.encrypt(plaintext, secretKey: key, nonce: nonce);
385
+ // 포맷: ciphertext + mac(16)
386
+ final cipherBytes = Uint8List.fromList([
387
+ ...secretBox.cipherText,
388
+ ...secretBox.mac.bytes,
389
+ ]);
390
+ return Uint8List.fromList([...magic, ...nonce, ...cipherBytes]);
391
+ }
392
+
271
393
  /// XChaCha20-Poly1305 패킷 복호화
272
394
  /// 포맷: [magic:magicLen][nonce:24][ciphertext+tag:...]
273
- /// 키: sha256(hmac_secret)
395
+ /// 키: HKDF-SHA256(hmac_secret, "entity-server:packet-encryption")
274
396
  Future<Map<String, dynamic>> _decryptPacket(Uint8List data) async {
275
- // 유도: sha256(hmac_secret)
276
- final sha256 = Sha256();
277
- final keyHash = await sha256.hash(utf8.encode(hmacSecret));
278
- final key = SecretKey(keyHash.bytes);
279
-
397
+ final keyBytes = await _derivePacketKey();
398
+ final key = SecretKey(keyBytes);
399
+ final magicLen = 2 + keyBytes[31] % 14;
280
400
  final nonce = data.sublist(magicLen, magicLen + 24);
281
- // ciphertext 마지막 16바이트가 Poly1305 MAC
282
401
  final ciphertextWithMac = data.sublist(magicLen + 24);
283
402
 
284
403
  final algorithm = Xchacha20.poly1305Aead();
@@ -292,18 +411,21 @@ class EntityServerClient {
292
411
  return jsonDecode(utf8.decode(plaintext)) as Map<String, dynamic>;
293
412
  }
294
413
 
295
- /// HMAC-SHA256 서명
414
+ /// HMAC-SHA256 서명. bodyBytes 는 JSON 또는 암호화된 바이너리 모두 지원합니다.
296
415
  Future<String> _sign(
297
416
  String method,
298
417
  String path,
299
418
  String timestamp,
300
419
  String nonce,
301
- String body,
420
+ Uint8List bodyBytes,
302
421
  ) async {
303
- final payload = [method, path, timestamp, nonce, body].join('|');
304
422
  final algorithm = Hmac.sha256();
305
- final key = SecretKey(utf8.encode(hmacSecret));
306
- final mac = await algorithm.calculateMac(utf8.encode(payload), secretKey: key);
423
+ final secretKey = SecretKey(utf8.encode(hmacSecret));
424
+ final prefix = utf8.encode('\$method|\$path|\$timestamp|\$nonce|');
425
+ final mac = await algorithm.calculateMac(
426
+ Uint8List.fromList([...prefix, ...bodyBytes]),
427
+ secretKey: secretKey,
428
+ );
307
429
  return mac.bytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join();
308
430
  }
309
431
  }