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
@@ -8,8 +8,7 @@
8
8
  * val client = EntityServerClient(
9
9
  * baseUrl = "http://your-server:47200",
10
10
  * apiKey = BuildConfig.ENTITY_API_KEY,
11
- * hmacSecret = BuildConfig.ENTITY_HMAC_SECRET,
12
- * magicLen = 4 // 서버 packet_magic_len 과 동일
11
+ * hmacSecret = BuildConfig.ENTITY_HMAC_SECRET
13
12
  * )
14
13
  *
15
14
  * 트랜잭션 사용 예:
@@ -34,9 +33,13 @@ import org.bouncycastle.crypto.engines.ChaCha7539Engine
34
33
  import org.bouncycastle.crypto.modes.ChaChaEngine
35
34
  import org.bouncycastle.crypto.params.KeyParameter
36
35
  import org.bouncycastle.crypto.params.ParametersWithIV
36
+ import org.bouncycastle.crypto.generators.HKDFBytesGenerator
37
+ import org.bouncycastle.crypto.params.HKDFParameters
38
+ import org.bouncycastle.crypto.digests.SHA256Digest
37
39
  import org.json.JSONArray
38
40
  import org.json.JSONObject
39
41
  import java.security.MessageDigest
42
+ import java.security.SecureRandom
40
43
  import java.util.UUID
41
44
  import javax.crypto.Mac
42
45
  import javax.crypto.spec.SecretKeySpec
@@ -45,24 +48,87 @@ class EntityServerClient(
45
48
  private val baseUrl: String = "http://localhost:47200",
46
49
  private val apiKey: String = "",
47
50
  private val hmacSecret: String = "",
48
- private val magicLen: Int = 4,
51
+ private var token: String = "",
52
+ /** true 이면 POST 요청 바디를 XChaCha20-Poly1305로 암호화합니다. */
53
+ private var encryptRequests: Boolean = false,
49
54
  ) {
50
55
  private val http = OkHttpClient()
51
- private var activeTxId: String? = null
56
+ private var activeTxId: String? = null private var packetEncryption: Boolean = false
52
57
 
58
+ /** JWT Bearer 토큰을 설정합니다. HMAC 모드와 배타적으로 사용합니다. */
59
+ fun setToken(newToken: String) { token = newToken }
60
+
61
+ /**
62
+ * 서버 헬스 체크를 수행하고 패킷 암호화 활성 여부를 자동으로 감지합니다.
63
+ * 서버가 packet_encryption: true 를 응답하면 이후 모든 요청에 암호화가 자동 적용됩니다.
64
+ */
65
+ fun checkHealth(): JSONObject {
66
+ val req = Request.Builder()
67
+ .url(baseUrl.trimEnd('/') + "/v1/health")
68
+ .get()
69
+ .build()
70
+ val res = http.newCall(req).execute()
71
+ val body = JSONObject(res.body?.string() ?: "{}")
72
+ if (body.optBoolean("packet_encryption", false)) {
73
+ packetEncryption = true
74
+ }
75
+ return body
76
+ }
53
77
  // ─── CRUD ─────────────────────────────────────────────────────────
54
78
 
55
- fun get(entity: String, seq: Long): JSONObject =
56
- request("GET", "/v1/entity/$entity/$seq")
79
+ fun get(entity: String, seq: Long, skipHooks: Boolean = false): JSONObject {
80
+ val q = if (skipHooks) "?skipHooks=true" else ""
81
+ return request("GET", "/v1/entity/$entity/$seq$q")
82
+ }
83
+
84
+ /**
85
+ * 조건으로 단건 조회 (POST + conditions body)
86
+ *
87
+ * @param conditions 필터 조건. index/hash/unique 필드만 사용 가능
88
+ * @param skipHooks after_find 훅 미실행 여부
89
+ */
90
+ fun find(entity: String, conditions: JSONObject, skipHooks: Boolean = false): JSONObject {
91
+ val q = if (skipHooks) "?skipHooks=true" else ""
92
+ return request("POST", "/v1/entity/$entity/find$q", conditions.toString())
93
+ }
57
94
 
58
- fun list(entity: String, page: Int = 1, limit: Int = 20): JSONObject =
59
- request("GET", "/v1/entity/$entity/list?page=$page&limit=$limit")
95
+ /**
96
+ * 목록 조회 (POST + conditions body)
97
+ * @param orderBy 정렬 기준 필드명. null 이면 기본 정렬. - 접두사로 내림차순
98
+ * @param fields 반환 필드 목록. 미지정 시 인덱스 필드만 반환 (기본, 가장 빠름). listOf("*") 지정 시 전체 필드 반환
99
+ * @param conditions 필터 조건. index/hash/unique 필드만 사용 가능
100
+ */
101
+ fun list(
102
+ entity: String,
103
+ page: Int = 1,
104
+ limit: Int = 20,
105
+ orderBy: String? = null,
106
+ fields: List<String>? = null,
107
+ conditions: JSONObject? = null,
108
+ ): JSONObject {
109
+ val qParts = mutableListOf("page=$page", "limit=$limit")
110
+ if (orderBy != null) qParts += "order_by=$orderBy"
111
+ if (!fields.isNullOrEmpty()) qParts += "fields=${fields.joinToString(",")}"
112
+ return request("POST", "/v1/entity/$entity/list?${qParts.joinToString("&")}", conditions?.toString() ?: "{}")
113
+ }
60
114
 
61
- fun count(entity: String): JSONObject =
62
- request("GET", "/v1/entity/$entity/count")
115
+ /** 건수 조회 */
116
+ fun count(entity: String, conditions: JSONObject? = null): JSONObject =
117
+ request("POST", "/v1/entity/$entity/count", conditions?.toString() ?: "{}")
63
118
 
64
- fun query(entity: String, filter: JSONArray, page: Int = 1, limit: Int = 20): JSONObject =
65
- request("POST", "/v1/entity/$entity/query?page=$page&limit=$limit", filter.toString())
119
+ /**
120
+ * 커스텀 SQL 조회 (SELECT 전용, 인덱스 테이블만, JOIN 지원)
121
+ * @param sql SELECT SQL문. 사용자 입력은 반드시 ? 로 바인딩 (SQL Injection 방지)
122
+ * @param params 바인딩 파라미터 JSON 배열
123
+ * @param limit 최대 반환 건수 (최대 1000. 0 이하이면 서버 기본값)
124
+ */
125
+ fun query(entity: String, sql: String, params: JSONArray? = null, limit: Int = 0): JSONObject {
126
+ val body = JSONObject()
127
+ body.put("sql", sql)
128
+ body.put("params", params ?: JSONArray())
129
+ if (limit > 0) body.put("limit", limit)
130
+ return request("POST", "/v1/entity/$entity/query", body.toString())
131
+ }
66
132
 
67
133
  /**
68
134
  * 트랜잭션 시작 — 서버에 큐를 등록하고 txId 를 저장합니다.
@@ -98,18 +164,23 @@ class EntityServerClient(
98
164
  }
99
165
 
100
166
  /** 생성 또는 수정 (seq 포함시 수정, 없으면 생성) */
101
- fun submit(entity: String, data: JSONObject, transactionId: String? = null): JSONObject {
167
+ fun submit(entity: String, data: JSONObject, transactionId: String? = null, skipHooks: Boolean = false): JSONObject {
102
168
  val txId = transactionId ?: activeTxId
103
169
  val extra = if (txId != null) mapOf("X-Transaction-ID" to txId) else emptyMap()
104
- return request("POST", "/v1/entity/$entity/submit", data.toString(), extra)
170
+ val q = if (skipHooks) "?skipHooks=true" else ""
171
+ return request("POST", "/v1/entity/$entity/submit$q", data.toString(), extra)
105
172
  }
106
173
 
107
174
  /** 삭제 */
108
- fun delete(entity: String, seq: Long, transactionId: String? = null, hard: Boolean = false): JSONObject {
109
- val q = if (hard) "?hard=true" else ""
175
+ /** 삭제. 서버는 POST /delete/:seq 로만 처리합니다. */
176
+ fun delete(entity: String, seq: Long, transactionId: String? = null, hard: Boolean = false, skipHooks: Boolean = false): JSONObject {
177
+ val qParts = mutableListOf<String>()
178
+ if (hard) qParts += "hard=true"
179
+ if (skipHooks) qParts += "skipHooks=true"
180
+ val q = if (qParts.isNotEmpty()) "?" + qParts.joinToString("&") else ""
110
181
  val txId = transactionId ?: activeTxId
111
182
  val extra = if (txId != null) mapOf("X-Transaction-ID" to txId) else emptyMap()
112
- return request("DELETE", "/v1/entity/$entity/delete/$seq$q", extraHeaders = extra)
183
+ return request("POST", "/v1/entity/$entity/delete/$seq$q", extraHeaders = extra)
113
184
  }
114
185
 
115
186
  fun history(entity: String, seq: Long, page: Int = 1, limit: Int = 50): JSONObject =
@@ -207,55 +278,113 @@ class EntityServerClient(
207
278
  // ─── 내부 ─────────────────────────────────────────────────────────
208
279
 
209
280
  private fun request(method: String, path: String, bodyStr: String = "", extraHeaders: Map<String, String> = emptyMap()): JSONObject {
281
+ // 요청 바디 결정: encryptRequests 시 POST 바디를 암호화
282
+ val bodyBytes: ByteArray
283
+ val contentType: String
284
+ if ((encryptRequests || packetEncryption) && bodyStr.isNotEmpty()) {
285
+ bodyBytes = encryptPacket(bodyStr.toByteArray(Charsets.UTF_8))
286
+ contentType = "application/octet-stream"
287
+ } else {
288
+ bodyBytes = bodyStr.toByteArray(Charsets.UTF_8)
289
+ contentType = "application/json"
290
+ }
291
+
210
292
  val timestamp = (System.currentTimeMillis() / 1000).toString()
211
293
  val nonce = UUID.randomUUID().toString()
212
- val signature = sign(method, path, timestamp, nonce, bodyStr)
294
+ val signature = sign(method, path, timestamp, nonce, bodyBytes)
295
+
296
+ val isHmacMode = apiKey.isNotEmpty() && hmacSecret.isNotEmpty()
213
297
 
214
298
  val requestBuilder = Request.Builder()
215
299
  .url(baseUrl.trimEnd('/') + path)
216
- .addHeader("Content-Type", "application/json")
217
- .addHeader("X-API-Key", apiKey)
218
- .addHeader("X-Timestamp", timestamp)
219
- .addHeader("X-Nonce", nonce)
220
- .addHeader("X-Signature", signature)
221
- .apply { extraHeaders.forEach { (k, v) -> addHeader(k, v) } }
222
-
223
- val body = if (bodyStr.isNotEmpty())
224
- bodyStr.toRequestBody("application/json".toMediaType())
300
+ .addHeader("Content-Type", contentType)
301
+ .apply {
302
+ if (isHmacMode) {
303
+ addHeader("X-API-Key", apiKey)
304
+ addHeader("X-Timestamp", timestamp)
305
+ addHeader("X-Nonce", nonce)
306
+ addHeader("X-Signature", signature)
307
+ } else if (token.isNotEmpty()) {
308
+ addHeader("Authorization", "Bearer $token")
309
+ }
310
+ extraHeaders.forEach { (k, v) -> addHeader(k, v) }
311
+ }
312
+
313
+ val reqBody = if (bodyBytes.isNotEmpty())
314
+ bodyBytes.toRequestBody(contentType.toMediaType())
225
315
  else null
226
316
 
227
317
  val req = when (method.uppercase()) {
228
318
  "GET" -> requestBuilder.get().build()
229
- "DELETE" -> requestBuilder.delete(body).build()
230
- else -> requestBuilder.method(method.uppercase(), body).build()
319
+ "DELETE" -> requestBuilder.delete(reqBody).build()
320
+ else -> requestBuilder.method(method.uppercase(), reqBody).build()
231
321
  }
232
322
 
233
323
  val res = http.newCall(req).execute()
234
- val contentType = res.header("Content-Type") ?: ""
324
+ val resContentType = res.header("Content-Type") ?: ""
235
325
  val rawBytes = res.body?.bytes() ?: byteArrayOf()
236
326
 
237
327
  // 패킷 암호화 응답: application/octet-stream → 복호화
238
- return if (contentType.contains("application/octet-stream")) {
328
+ return if (resContentType.contains("application/octet-stream")) {
239
329
  JSONObject(decryptPacket(rawBytes))
240
330
  } else {
241
331
  JSONObject(String(rawBytes, Charsets.UTF_8))
242
332
  }
243
333
  }
244
334
 
335
+ /**
336
+ * 패킷 암호화 키를 유도합니다.
337
+ * - HMAC 모드: HKDF-SHA256(hmac_secret, "entity-server:packet-encryption")
338
+ * - JWT 모드: SHA256(token)
339
+ */
340
+ private fun derivePacketKey(): ByteArray {
341
+ if (token.isNotEmpty() && hmacSecret.isEmpty()) {
342
+ val digest = java.security.MessageDigest.getInstance("SHA-256")
343
+ return digest.digest(token.toByteArray(Charsets.UTF_8))
344
+ }
345
+ val gen = HKDFBytesGenerator(SHA256Digest())
346
+ gen.init(HKDFParameters(
347
+ hmacSecret.toByteArray(Charsets.UTF_8),
348
+ "entity-server:hkdf:v1".toByteArray(Charsets.UTF_8),
349
+ "entity-server:packet-encryption".toByteArray(Charsets.UTF_8),
350
+ ))
351
+ val key = ByteArray(32)
352
+ gen.generateBytes(key, 0, 32)
353
+ return key
354
+ }
355
+
356
+ /**
357
+ * XChaCha20-Poly1305 패킷 암호화
358
+ * 포맷: [magic:magicLen][nonce:24][ciphertext+tag]
359
+ * magicLen: 2 + key[31] % 14
360
+ */
361
+ private fun encryptPacket(plaintext: ByteArray): ByteArray {
362
+ val key = derivePacketKey()
363
+ val magicLen = 2 + (key[31].toInt() and 0xFF) % 14
364
+ val magic = ByteArray(magicLen).also { SecureRandom().nextBytes(it) }
365
+ val nonce = ByteArray(24).also { SecureRandom().nextBytes(it) }
366
+
367
+ val aead = org.bouncycastle.crypto.modes.ChaCha20Poly1305()
368
+ aead.init(true, org.bouncycastle.crypto.params.AEADParameters(
369
+ KeyParameter(key), 128, nonce
370
+ ))
371
+ val ciphertext = ByteArray(aead.getOutputSize(plaintext.size))
372
+ val len = aead.processBytes(plaintext, 0, plaintext.size, ciphertext, 0)
373
+ aead.doFinal(ciphertext, len)
374
+ return magic + nonce + ciphertext
375
+ }
376
+
245
377
  /**
246
378
  * XChaCha20-Poly1305 패킷 복호화
247
379
  * 포맷: [magic:magicLen][nonce:24][ciphertext+tag]
248
- * 키: sha256(hmac_secret)
249
- *
250
- * Bouncy Castle XChaCha20-Poly1305 사용
380
+ * 키: HKDF-SHA256(hmac_secret, "entity-server:packet-encryption")
251
381
  */
252
382
  private fun decryptPacket(data: ByteArray): String {
253
- val key = sha256(hmacSecret.toByteArray(Charsets.UTF_8))
254
-
383
+ val key = derivePacketKey()
384
+ val magicLen = 2 + (key[31].toInt() and 0xFF) % 14
255
385
  val nonce = data.copyOfRange(magicLen, magicLen + 24)
256
386
  val ciphertext = data.copyOfRange(magicLen + 24, data.size)
257
387
 
258
- // Bouncy Castle: XChaCha20-Poly1305 (AEAD)
259
388
  val aead = org.bouncycastle.crypto.modes.ChaCha20Poly1305()
260
389
  aead.init(false, org.bouncycastle.crypto.params.AEADParameters(
261
390
  KeyParameter(key), 128, nonce
@@ -267,14 +396,15 @@ class EntityServerClient(
267
396
  return plaintext.toString(Charsets.UTF_8)
268
397
  }
269
398
 
270
- /** HMAC-SHA256 서명 */
271
- private fun sign(method: String, path: String, timestamp: String, nonce: String, body: String): String {
272
- val payload = listOf(method, path, timestamp, nonce, body).joinToString("|")
399
+ /**
400
+ * HMAC-SHA256 서명
401
+ * bodyBytes JSON 바이트 또는 암호화된 바리두 모두 지원합니다.
402
+ */
403
+ private fun sign(method: String, path: String, timestamp: String, nonce: String, bodyBytes: ByteArray): String {
273
404
  val mac = Mac.getInstance("HmacSHA256")
274
405
  mac.init(SecretKeySpec(hmacSecret.toByteArray(Charsets.UTF_8), "HmacSHA256"))
275
- return mac.doFinal(payload.toByteArray(Charsets.UTF_8)).joinToString("") { "%02x".format(it) }
406
+ val prefix = "$method|$path|$timestamp|$nonce|".toByteArray(Charsets.UTF_8)
407
+ mac.update(prefix)
408
+ if (bodyBytes.isNotEmpty()) mac.update(bodyBytes)
409
+ return mac.doFinal().joinToString("") { "%02x".format(it) }
276
410
  }
277
-
278
- private fun sha256(input: ByteArray): ByteArray =
279
- MessageDigest.getInstance("SHA-256").digest(input)
280
- }