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.
- package/bin/create.js +15 -7
- package/package.json +1 -1
- package/template/.env.example +8 -7
- package/template/configs/database.json +173 -10
- package/template/entities/Account/account_audit.json +4 -5
- package/template/entities/System/system_audit_log.json +14 -8
- package/template/samples/README.md +28 -22
- package/template/samples/browser/entity-server-client.js +453 -0
- package/template/samples/browser/example.html +498 -0
- package/template/samples/entities/02_types_and_defaults.json +15 -16
- package/template/samples/entities/04_fk_and_composite_unique.json +0 -2
- package/template/samples/entities/05_cache.json +9 -8
- package/template/samples/entities/06_history_and_hard_delete.json +27 -9
- package/template/samples/entities/07_license_scope.json +40 -31
- package/template/samples/entities/09_hook_entity.json +0 -6
- package/template/samples/entities/10_hook_submit_delete.json +5 -2
- package/template/samples/entities/11_hook_webhook.json +9 -7
- package/template/samples/entities/12_hook_push.json +3 -3
- package/template/samples/entities/13_read_only.json +13 -10
- package/template/samples/entities/15_reset_defaults.json +0 -1
- package/template/samples/entities/16_isolated_license.json +62 -0
- package/template/samples/entities/README.md +36 -39
- package/template/samples/flutter/lib/entity_server_client.dart +170 -48
- package/template/samples/java/EntityServerClient.java +208 -61
- package/template/samples/java/EntityServerExample.java +4 -3
- package/template/samples/kotlin/EntityServerClient.kt +175 -45
- package/template/samples/node/src/EntityServerClient.js +232 -59
- package/template/samples/node/src/example.js +9 -9
- package/template/samples/php/ci4/Config/EntityServer.php +0 -1
- package/template/samples/php/ci4/Libraries/EntityServer.php +206 -53
- package/template/samples/php/laravel/Services/EntityServerService.php +190 -41
- package/template/samples/python/entity_server.py +181 -68
- package/template/samples/python/example.py +7 -6
- package/template/samples/react/src/example.tsx +41 -25
- package/template/samples/swift/EntityServerClient.swift +143 -37
- package/template/scripts/run.ps1 +12 -3
- package/template/scripts/run.sh +12 -8
- package/template/scripts/update-server.ps1 +68 -2
- package/template/scripts/update-server.sh +59 -2
- package/template/samples/entities/order_notification.json +0 -51
- package/template/samples/react/src/api/entityServerClient.ts +0 -413
- 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.
|
|
57
|
+
this.token = '',
|
|
58
|
+
this.encryptRequests = false,
|
|
54
59
|
});
|
|
55
60
|
|
|
56
|
-
|
|
61
|
+
/// JWT Bearer 토큰을 설정합니다. HMAC 모드와 배타적으로 사용합니다.
|
|
62
|
+
void setToken(String newToken) => token = newToken;
|
|
57
63
|
|
|
58
|
-
|
|
59
|
-
_request('GET', '/v1/entity/$entity/$seq');
|
|
64
|
+
// ─── Health Check ──────────────────────────────────────────────
|
|
60
65
|
|
|
61
|
-
|
|
62
|
-
|
|
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
|
-
|
|
65
|
-
_request('GET', '/v1/entity/$entity/count');
|
|
78
|
+
// ─── CRUD ─────────────────────────────────────────────────────────
|
|
66
79
|
|
|
67
|
-
Future<Map<String, dynamic>>
|
|
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
|
-
|
|
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
|
-
|
|
74
|
-
|
|
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
|
-
|
|
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
|
|
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('
|
|
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
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
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
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
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:
|
|
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:
|
|
333
|
+
body: bodyBytes != null && bodyBytes.isNotEmpty ? bodyBytes : null);
|
|
255
334
|
}
|
|
256
335
|
|
|
257
|
-
final
|
|
336
|
+
final ct = res.headers['content-type'] ?? '';
|
|
258
337
|
|
|
259
338
|
// 패킷 암호화 응답: application/octet-stream → 복호화
|
|
260
|
-
if (
|
|
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:
|
|
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
|
-
/// 키:
|
|
395
|
+
/// 키: HKDF-SHA256(hmac_secret, "entity-server:packet-encryption")
|
|
274
396
|
Future<Map<String, dynamic>> _decryptPacket(Uint8List data) async {
|
|
275
|
-
|
|
276
|
-
final
|
|
277
|
-
final
|
|
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
|
-
|
|
420
|
+
Uint8List bodyBytes,
|
|
302
421
|
) async {
|
|
303
|
-
final payload = [method, path, timestamp, nonce, body].join('|');
|
|
304
422
|
final algorithm = Hmac.sha256();
|
|
305
|
-
final
|
|
306
|
-
final
|
|
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
|
}
|