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
@@ -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
+ }
57
83
 
58
- fun list(entity: String, page: Int = 1, limit: Int = 20): JSONObject =
59
- request("GET", "/v1/entity/$entity/list?page=$page&limit=$limit")
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
+ }
94
+
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 =
@@ -118,58 +189,202 @@ class EntityServerClient(
118
189
  fun rollback(entity: String, historySeq: Long): JSONObject =
119
190
  request("POST", "/v1/entity/$entity/rollback/$historySeq")
120
191
 
192
+ /** 푸시 발송 트리거 엔티티에 submit합니다. */
193
+ fun push(pushEntity: String, payload: JSONObject, transactionId: String? = null): JSONObject =
194
+ submit(pushEntity, payload, transactionId)
195
+
196
+ /** push_log 목록 조회 헬퍼 */
197
+ fun pushLogList(page: Int = 1, limit: Int = 20): JSONObject =
198
+ list("push_log", page, limit)
199
+
200
+ /** account_device 디바이스 등록/갱신 헬퍼 (push_token 단일 필드) */
201
+ fun registerPushDevice(
202
+ accountSeq: Long,
203
+ deviceId: String,
204
+ pushToken: String,
205
+ platform: String? = null,
206
+ deviceType: String? = null,
207
+ pushEnabled: Boolean = true,
208
+ transactionId: String? = null,
209
+ ): JSONObject {
210
+ val payload = JSONObject().apply {
211
+ put("id", deviceId)
212
+ put("account_seq", accountSeq)
213
+ put("push_token", pushToken)
214
+ put("push_enabled", pushEnabled)
215
+ if (!platform.isNullOrBlank()) put("platform", platform)
216
+ if (!deviceType.isNullOrBlank()) put("device_type", deviceType)
217
+ }
218
+ return submit("account_device", payload, transactionId)
219
+ }
220
+
221
+ /** account_device.seq 기준 push_token 갱신 헬퍼 */
222
+ fun updatePushDeviceToken(
223
+ deviceSeq: Long,
224
+ pushToken: String,
225
+ pushEnabled: Boolean = true,
226
+ transactionId: String? = null,
227
+ ): JSONObject =
228
+ submit(
229
+ "account_device",
230
+ JSONObject().apply {
231
+ put("seq", deviceSeq)
232
+ put("push_token", pushToken)
233
+ put("push_enabled", pushEnabled)
234
+ },
235
+ transactionId,
236
+ )
237
+
238
+ /** account_device.seq 기준 푸시 수신 비활성화 헬퍼 */
239
+ fun disablePushDevice(
240
+ deviceSeq: Long,
241
+ transactionId: String? = null,
242
+ ): JSONObject =
243
+ submit(
244
+ "account_device",
245
+ JSONObject().apply {
246
+ put("seq", deviceSeq)
247
+ put("push_enabled", false)
248
+ },
249
+ transactionId,
250
+ )
251
+
252
+ /**
253
+ * 요청 본문을 읽어 JSON으로 반환합니다.
254
+ * - application/octet-stream: 암호 패킷 복호화
255
+ * - 그 외: 평문 JSON 파싱
256
+ */
257
+ fun readRequestBody(
258
+ rawBody: ByteArray,
259
+ contentType: String = "application/json",
260
+ requireEncrypted: Boolean = false,
261
+ ): JSONObject {
262
+ val lowered = contentType.lowercase()
263
+ val isEncrypted = lowered.contains("application/octet-stream")
264
+
265
+ if (requireEncrypted && !isEncrypted) {
266
+ error("Encrypted request required: Content-Type must be application/octet-stream")
267
+ }
268
+
269
+ if (isEncrypted) {
270
+ if (rawBody.isEmpty()) error("Encrypted request body is empty")
271
+ return JSONObject(decryptPacket(rawBody))
272
+ }
273
+
274
+ if (rawBody.isEmpty()) return JSONObject()
275
+ return JSONObject(String(rawBody, Charsets.UTF_8))
276
+ }
277
+
121
278
  // ─── 내부 ─────────────────────────────────────────────────────────
122
279
 
123
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
+
124
292
  val timestamp = (System.currentTimeMillis() / 1000).toString()
125
293
  val nonce = UUID.randomUUID().toString()
126
- 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()
127
297
 
128
298
  val requestBuilder = Request.Builder()
129
299
  .url(baseUrl.trimEnd('/') + path)
130
- .addHeader("Content-Type", "application/json")
131
- .addHeader("X-API-Key", apiKey)
132
- .addHeader("X-Timestamp", timestamp)
133
- .addHeader("X-Nonce", nonce)
134
- .addHeader("X-Signature", signature)
135
- .apply { extraHeaders.forEach { (k, v) -> addHeader(k, v) } }
136
-
137
- val body = if (bodyStr.isNotEmpty())
138
- 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())
139
315
  else null
140
316
 
141
317
  val req = when (method.uppercase()) {
142
318
  "GET" -> requestBuilder.get().build()
143
- "DELETE" -> requestBuilder.delete(body).build()
144
- else -> requestBuilder.method(method.uppercase(), body).build()
319
+ "DELETE" -> requestBuilder.delete(reqBody).build()
320
+ else -> requestBuilder.method(method.uppercase(), reqBody).build()
145
321
  }
146
322
 
147
323
  val res = http.newCall(req).execute()
148
- val contentType = res.header("Content-Type") ?: ""
324
+ val resContentType = res.header("Content-Type") ?: ""
149
325
  val rawBytes = res.body?.bytes() ?: byteArrayOf()
150
326
 
151
327
  // 패킷 암호화 응답: application/octet-stream → 복호화
152
- return if (contentType.contains("application/octet-stream")) {
328
+ return if (resContentType.contains("application/octet-stream")) {
153
329
  JSONObject(decryptPacket(rawBytes))
154
330
  } else {
155
331
  JSONObject(String(rawBytes, Charsets.UTF_8))
156
332
  }
157
333
  }
158
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
+
159
377
  /**
160
378
  * XChaCha20-Poly1305 패킷 복호화
161
379
  * 포맷: [magic:magicLen][nonce:24][ciphertext+tag]
162
- * 키: sha256(hmac_secret)
163
- *
164
- * Bouncy Castle XChaCha20-Poly1305 사용
380
+ * 키: HKDF-SHA256(hmac_secret, "entity-server:packet-encryption")
165
381
  */
166
382
  private fun decryptPacket(data: ByteArray): String {
167
- val key = sha256(hmacSecret.toByteArray(Charsets.UTF_8))
168
-
383
+ val key = derivePacketKey()
384
+ val magicLen = 2 + (key[31].toInt() and 0xFF) % 14
169
385
  val nonce = data.copyOfRange(magicLen, magicLen + 24)
170
386
  val ciphertext = data.copyOfRange(magicLen + 24, data.size)
171
387
 
172
- // Bouncy Castle: XChaCha20-Poly1305 (AEAD)
173
388
  val aead = org.bouncycastle.crypto.modes.ChaCha20Poly1305()
174
389
  aead.init(false, org.bouncycastle.crypto.params.AEADParameters(
175
390
  KeyParameter(key), 128, nonce
@@ -181,14 +396,15 @@ class EntityServerClient(
181
396
  return plaintext.toString(Charsets.UTF_8)
182
397
  }
183
398
 
184
- /** HMAC-SHA256 서명 */
185
- private fun sign(method: String, path: String, timestamp: String, nonce: String, body: String): String {
186
- 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 {
187
404
  val mac = Mac.getInstance("HmacSHA256")
188
405
  mac.init(SecretKeySpec(hmacSecret.toByteArray(Charsets.UTF_8), "HmacSHA256"))
189
- 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) }
190
410
  }
191
-
192
- private fun sha256(input: ByteArray): ByteArray =
193
- MessageDigest.getInstance("SHA-256").digest(input)
194
- }