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
@@ -0,0 +1,453 @@
1
+ /**
2
+ * EntityServerClient — 브라우저 전용 ES Module
3
+ *
4
+ * 빌드 도구 불필요. 브라우저에서 직접 import map 또는 CDN URL로 사용합니다.
5
+ *
6
+ * 의존성: @noble/ciphers (XChaCha20-Poly1305, 패킷 암호화 기능 사용 시에만 필요)
7
+ * https://cdn.jsdelivr.net/npm/@noble/ciphers@1/chacha.js
8
+ *
9
+ * 패킷 암호화/복호화는 Web Crypto API + @noble/ciphers를 조합합니다.
10
+ * HMAC 서명, HKDF 키 유도, SHA-256은 Web Crypto API만으로 구현합니다.
11
+ *
12
+ * 사용 예:
13
+ * const es = new EntityServerClient({ baseUrl: 'http://localhost:47200', token: '...' });
14
+ * const list = await es.list('product', { page: 1, limit: 10 });
15
+ * const seq = await es.submit('product', { name: '키보드', price: 89000 });
16
+ * await es.delete('product', seq);
17
+ *
18
+ * 트랜잭션 사용 예:
19
+ * await es.transStart();
20
+ * try {
21
+ * const order = await es.submit('order', { ... });
22
+ * await es.submit('order_item', { order_seq: order.seq });
23
+ * await es.transCommit();
24
+ * } catch (e) {
25
+ * await es.transRollback();
26
+ * }
27
+ */
28
+
29
+ // XChaCha20-Poly1305: 패킷 암호화 기능이 필요 없으면 이 import를 제거해도 됩니다.
30
+ import { xchacha20_poly1305 } from "https://cdn.jsdelivr.net/npm/@noble/ciphers@1/chacha.js";
31
+
32
+ export class EntityServerClient {
33
+ #baseUrl;
34
+ #apiKey;
35
+ #hmacSecret;
36
+ #token;
37
+ #encryptRequests;
38
+ #packetEncryption = false;
39
+ #activeTxId = null;
40
+
41
+ /**
42
+ * @param {Object} opts
43
+ * @param {string} opts.baseUrl 서버 URL (예: "http://localhost:47200")
44
+ * @param {string} [opts.apiKey] X-API-Key (HMAC 모드)
45
+ * @param {string} [opts.hmacSecret] HMAC 서명 시크릿 (HMAC 모드)
46
+ * @param {string} [opts.token] JWT Bearer 토큰 (JWT 모드)
47
+ * @param {boolean} [opts.encryptRequests] true이면 POST 바디를 XChaCha20-Poly1305로 암호화
48
+ */
49
+ constructor({
50
+ baseUrl = "http://localhost:47200",
51
+ apiKey = "",
52
+ hmacSecret = "",
53
+ token = "",
54
+ encryptRequests = false,
55
+ } = {}) {
56
+ this.#baseUrl = baseUrl.replace(/\/$/, "");
57
+ this.#apiKey = apiKey;
58
+ this.#hmacSecret = hmacSecret;
59
+ this.#token = token;
60
+ this.#encryptRequests = encryptRequests;
61
+ }
62
+
63
+ /** JWT Bearer 토큰을 설정합니다. */
64
+ setToken(token) {
65
+ this.#token = token;
66
+ }
67
+
68
+ /**
69
+ * 서버 헬스 체크. packet_encryption이 활성화되어 있으면 이후 모든 요청에 암호화가 자동 적용됩니다.
70
+ * @returns {Promise<{ok: boolean, packet_encryption?: boolean}>}
71
+ */
72
+ async checkHealth() {
73
+ const res = await fetch(this.#baseUrl + "/v1/health");
74
+ const data = await res.json();
75
+ if (data.packet_encryption) this.#packetEncryption = true;
76
+ return data;
77
+ }
78
+
79
+ // ─── 트랜잭션 ─────────────────────────────────────────────────────────────
80
+
81
+ async transStart() {
82
+ const res = await this.#request("POST", "/v1/transaction/start");
83
+ this.#activeTxId = res.transaction_id;
84
+ return this.#activeTxId;
85
+ }
86
+
87
+ transRollback(transactionId) {
88
+ const txId = transactionId ?? this.#activeTxId;
89
+ if (!txId)
90
+ throw new Error("No active transaction. Call transStart() first.");
91
+ this.#activeTxId = null;
92
+ return this.#request("POST", `/v1/transaction/rollback/${txId}`);
93
+ }
94
+
95
+ transCommit(transactionId) {
96
+ const txId = transactionId ?? this.#activeTxId;
97
+ if (!txId)
98
+ throw new Error("No active transaction. Call transStart() first.");
99
+ this.#activeTxId = null;
100
+ return this.#request("POST", `/v1/transaction/commit/${txId}`);
101
+ }
102
+
103
+ // ─── CRUD ─────────────────────────────────────────────────────────────────
104
+
105
+ /** 단건 조회 */
106
+ get(entity, seq, { skipHooks = false } = {}) {
107
+ const q = skipHooks ? "?skipHooks=true" : "";
108
+ return this.#request("GET", `/v1/entity/${entity}/${seq}${q}`);
109
+ }
110
+
111
+ /**
112
+ * 조건으로 단건 조회 (POST + conditions body)
113
+ *
114
+ * @param {string} entity 엔티티 이름
115
+ * @param {Object} conditions 필터 조건. index/hash/unique 필드만 사용 가능
116
+ * @param {Object} [opts]
117
+ * @param {boolean} [opts.skipHooks] after_find 훅 미실행 여부 (기본 false)
118
+ */
119
+ find(entity, conditions, { skipHooks = false } = {}) {
120
+ const q = skipHooks ? "?skipHooks=true" : "";
121
+ return this.#request(
122
+ "POST",
123
+ `/v1/entity/${entity}/find${q}`,
124
+ conditions ?? {},
125
+ );
126
+ }
127
+
128
+ /**
129
+ * 목록 조회
130
+ * @param {Object} [opts]
131
+ * @param {number} [opts.page] 페이지 번호 (기본 1)
132
+ * @param {number} [opts.limit] 페이지당 건수 (기본 20, 최대 1000)
133
+ * @param {string} [opts.orderBy] 정렬 필드명 (- 접두사로 내림차순)
134
+ * @param {string} [opts.orderDir] 정렬 방향 ('ASC'|'DESC')
135
+ * @param {string[]} [opts.fields] 반환 필드 목록. 미지정 시 인덱스 필드만 반환. '*' 시 전체 반환
136
+ * @param {Object} [opts.conditions] 필터 조건 (index/hash/unique 필드 한정)
137
+ */
138
+ list(
139
+ entity,
140
+ { page = 1, limit = 20, orderBy, orderDir, fields, conditions } = {},
141
+ ) {
142
+ const qParams = { page, limit };
143
+ if (orderBy)
144
+ qParams.order_by = orderDir === "DESC" ? `-${orderBy}` : orderBy;
145
+ if (fields?.length) qParams.fields = fields.join(",");
146
+ const q = new URLSearchParams(qParams);
147
+ return this.#request(
148
+ "POST",
149
+ `/v1/entity/${entity}/list?${q}`,
150
+ conditions ?? {},
151
+ );
152
+ }
153
+
154
+ /** 건수 조회 */
155
+ count(entity, conditions) {
156
+ return this.#request(
157
+ "POST",
158
+ `/v1/entity/${entity}/count`,
159
+ conditions ?? {},
160
+ );
161
+ }
162
+
163
+ /**
164
+ * 커스텀 SQL 조회 (SELECT 전용, 인덱스 테이블만 접근 가능)
165
+ * @param {Object} req
166
+ * @param {string} req.sql SELECT SQL (파라미터는 반드시 params 로 바인딩)
167
+ * @param {Array} [req.params] ? 플레이스홀더 바인딩 값
168
+ * @param {number} [req.limit] 최대 반환 건수 (최대 1000)
169
+ */
170
+ query(entity, { sql, params = [], limit } = {}) {
171
+ const body = { sql, params };
172
+ if (limit != null) body.limit = limit;
173
+ return this.#request("POST", `/v1/entity/${entity}/query`, body);
174
+ }
175
+
176
+ /**
177
+ * 생성 또는 수정 (seq 포함 시 수정, 없으면 생성)
178
+ */
179
+ submit(entity, data, { transactionId, skipHooks = false } = {}) {
180
+ const txId = transactionId ?? this.#activeTxId;
181
+ const extra = txId ? { "X-Transaction-ID": txId } : {};
182
+ const q = skipHooks ? "?skipHooks=true" : "";
183
+ return this.#request(
184
+ "POST",
185
+ `/v1/entity/${entity}/submit${q}`,
186
+ data,
187
+ extra,
188
+ );
189
+ }
190
+
191
+ /**
192
+ * 삭제
193
+ */
194
+ delete(
195
+ entity,
196
+ seq,
197
+ { transactionId, hard = false, skipHooks = false } = {},
198
+ ) {
199
+ const params = new URLSearchParams();
200
+ if (hard) params.set("hard", "true");
201
+ if (skipHooks) params.set("skipHooks", "true");
202
+ const q = params.size ? `?${params}` : "";
203
+ const txId = transactionId ?? this.#activeTxId;
204
+ const extra = txId ? { "X-Transaction-ID": txId } : {};
205
+ return this.#request(
206
+ "POST",
207
+ `/v1/entity/${entity}/delete/${seq}${q}`,
208
+ null,
209
+ extra,
210
+ );
211
+ }
212
+
213
+ /** 변경 이력 조회 */
214
+ history(entity, seq, { page = 1, limit = 50 } = {}) {
215
+ return this.#request(
216
+ "GET",
217
+ `/v1/entity/${entity}/history/${seq}?page=${page}&limit=${limit}`,
218
+ );
219
+ }
220
+
221
+ /** history seq 단위 롤백 */
222
+ rollback(entity, historySeq) {
223
+ return this.#request(
224
+ "POST",
225
+ `/v1/entity/${entity}/rollback/${historySeq}`,
226
+ );
227
+ }
228
+
229
+ // ─── 푸시 헬퍼 ────────────────────────────────────────────────────────────
230
+
231
+ /**
232
+ * Web Push (브라우저) 디바이스를 등록/갱신합니다.
233
+ * @param {number} accountSeq
234
+ * @param {string} deviceId 브라우저 고유 식별자 (예: localStorage UUID)
235
+ * @param {string} pushToken PushSubscription을 JSON.stringify한 값
236
+ * @param {Object} [opts]
237
+ * @param {string} [opts.browser] 브라우저명 (예: "chrome", "firefox")
238
+ * @param {string} [opts.browserVersion] 브라우저 버전
239
+ * @param {boolean} [opts.pushEnabled] 수신 활성화 여부 (기본 true)
240
+ */
241
+ registerWebPushDevice(
242
+ accountSeq,
243
+ deviceId,
244
+ pushToken,
245
+ { browser, browserVersion, pushEnabled = true, transactionId } = {},
246
+ ) {
247
+ return this.submit(
248
+ "account_device",
249
+ {
250
+ id: deviceId,
251
+ account_seq: accountSeq,
252
+ push_token: pushToken,
253
+ platform: "web",
254
+ device_type: "browser",
255
+ push_enabled: pushEnabled,
256
+ ...(browser ? { browser } : {}),
257
+ ...(browserVersion ? { browser_version: browserVersion } : {}),
258
+ },
259
+ { transactionId },
260
+ );
261
+ }
262
+
263
+ /** 푸시 수신 비활성화 */
264
+ disablePushDevice(deviceSeq, { transactionId } = {}) {
265
+ return this.submit(
266
+ "account_device",
267
+ { seq: deviceSeq, push_enabled: false },
268
+ { transactionId },
269
+ );
270
+ }
271
+
272
+ // ─── 내부 구현 ────────────────────────────────────────────────────────────
273
+
274
+ async #request(method, path, body, extraHeaders = {}) {
275
+ const useEncryption = this.#encryptRequests || this.#packetEncryption;
276
+ const isHmacMode = !!(this.#apiKey && this.#hmacSecret);
277
+
278
+ // 바디 직렬화 / 암호화
279
+ let bodyBytes = null; // Uint8Array | null (암호화 시)
280
+ let bodyString = null; // string | null (평문 시)
281
+ let contentType = "application/json";
282
+
283
+ if (body != null) {
284
+ const json = JSON.stringify(body);
285
+ if (useEncryption) {
286
+ bodyBytes = await this.#encryptPacket(
287
+ new TextEncoder().encode(json),
288
+ );
289
+ contentType = "application/octet-stream";
290
+ } else {
291
+ bodyString = json;
292
+ }
293
+ }
294
+
295
+ const headers = { "Content-Type": contentType };
296
+
297
+ if (isHmacMode) {
298
+ const timestamp = String(Math.floor(Date.now() / 1000));
299
+ const nonce = crypto.randomUUID();
300
+ const rawBody = bodyBytes ?? bodyString ?? "";
301
+ const signature = await this.#sign(
302
+ method,
303
+ path,
304
+ timestamp,
305
+ nonce,
306
+ rawBody,
307
+ );
308
+ Object.assign(headers, {
309
+ "X-API-Key": this.#apiKey,
310
+ "X-Timestamp": timestamp,
311
+ "X-Nonce": nonce,
312
+ "X-Signature": signature,
313
+ });
314
+ } else if (this.#token) {
315
+ headers["Authorization"] = `Bearer ${this.#token}`;
316
+ }
317
+
318
+ Object.assign(headers, extraHeaders);
319
+
320
+ const fetchBody = bodyBytes ?? bodyString ?? undefined;
321
+ const res = await fetch(this.#baseUrl + path, {
322
+ method,
323
+ headers,
324
+ ...(fetchBody != null ? { body: fetchBody } : {}),
325
+ });
326
+
327
+ const ct = res.headers.get("Content-Type") ?? "";
328
+ if (ct.includes("application/octet-stream")) {
329
+ const buf = await res.arrayBuffer();
330
+ return this.#decryptPacket(new Uint8Array(buf));
331
+ }
332
+
333
+ const data = await res.json();
334
+ if (!data.ok) {
335
+ throw new Error(
336
+ `EntityServer error: ${data.message ?? "Unknown"} (HTTP ${res.status})`,
337
+ );
338
+ }
339
+ return data;
340
+ }
341
+
342
+ /**
343
+ * HMAC-SHA256 서명 (Web Crypto API)
344
+ * 서명 대상: prefix("METHOD|/path|ts|nonce|") + body 바이트
345
+ */
346
+ async #sign(method, path, timestamp, nonce, body) {
347
+ const enc = new TextEncoder();
348
+ const keyBytes = enc.encode(this.#hmacSecret);
349
+
350
+ const cryptoKey = await crypto.subtle.importKey(
351
+ "raw",
352
+ keyBytes,
353
+ { name: "HMAC", hash: "SHA-256" },
354
+ false,
355
+ ["sign"],
356
+ );
357
+
358
+ const prefix = enc.encode(`${method}|${path}|${timestamp}|${nonce}|`);
359
+
360
+ let msgBytes;
361
+ if (body != null && body !== "") {
362
+ const bodyBytes =
363
+ typeof body === "string" ? enc.encode(body) : body; // Uint8Array
364
+ msgBytes = new Uint8Array(prefix.length + bodyBytes.length);
365
+ msgBytes.set(prefix);
366
+ msgBytes.set(bodyBytes, prefix.length);
367
+ } else {
368
+ msgBytes = prefix;
369
+ }
370
+
371
+ const sigBuf = await crypto.subtle.sign("HMAC", cryptoKey, msgBytes);
372
+ return Array.from(new Uint8Array(sigBuf))
373
+ .map((b) => b.toString(16).padStart(2, "0"))
374
+ .join("");
375
+ }
376
+
377
+ /**
378
+ * 패킷 암호화 키 유도 (Web Crypto API)
379
+ * - HMAC 모드: HKDF-SHA256(hmacSecret, salt, info) → 32 bytes
380
+ * - JWT 모드: SHA-256(token) → 32 bytes
381
+ * @returns {Promise<Uint8Array>}
382
+ */
383
+ async #derivePacketKey() {
384
+ const enc = new TextEncoder();
385
+
386
+ if (this.#token && !this.#hmacSecret) {
387
+ const buf = await crypto.subtle.digest(
388
+ "SHA-256",
389
+ enc.encode(this.#token),
390
+ );
391
+ return new Uint8Array(buf);
392
+ }
393
+
394
+ const keyMaterial = await crypto.subtle.importKey(
395
+ "raw",
396
+ enc.encode(this.#hmacSecret),
397
+ "HKDF",
398
+ false,
399
+ ["deriveBits"],
400
+ );
401
+ const bits = await crypto.subtle.deriveBits(
402
+ {
403
+ name: "HKDF",
404
+ hash: "SHA-256",
405
+ salt: enc.encode("entity-server:hkdf:v1"),
406
+ info: enc.encode("entity-server:packet-encryption"),
407
+ },
408
+ keyMaterial,
409
+ 256, // 32 bytes
410
+ );
411
+ return new Uint8Array(bits);
412
+ }
413
+
414
+ /**
415
+ * XChaCha20-Poly1305 패킷 암호화
416
+ * 포맷: [magic:magicLen][nonce:24][ciphertext+tag]
417
+ * magicLen: 패킷 키의 마지막 바이트에서 파생 (2 + key[31] % 14)
418
+ * @param {Uint8Array} plaintext
419
+ * @returns {Promise<Uint8Array>}
420
+ */
421
+ async #encryptPacket(plaintext) {
422
+ const key = await this.#derivePacketKey();
423
+ const magicLen = 2 + (key[31] % 14);
424
+ const magic = crypto.getRandomValues(new Uint8Array(magicLen));
425
+ const nonce = crypto.getRandomValues(new Uint8Array(24));
426
+ const cipher = xchacha20_poly1305(key, nonce);
427
+ const ciphertext = cipher.encrypt(plaintext);
428
+ const out = new Uint8Array(
429
+ magic.length + nonce.length + ciphertext.length,
430
+ );
431
+ out.set(magic);
432
+ out.set(nonce, magic.length);
433
+ out.set(ciphertext, magic.length + nonce.length);
434
+ return out;
435
+ }
436
+
437
+ /**
438
+ * XChaCha20-Poly1305 패킷 복호화
439
+ * @param {Uint8Array} data
440
+ * @returns {Promise<any>}
441
+ */
442
+ async #decryptPacket(data) {
443
+ const key = await this.#derivePacketKey();
444
+ const magicLen = 2 + (key[31] % 14);
445
+ const nonce = data.slice(magicLen, magicLen + 24);
446
+ const ciphertext = data.slice(magicLen + 24);
447
+ const cipher = xchacha20_poly1305(key, nonce);
448
+ const plaintext = cipher.decrypt(ciphertext);
449
+ return JSON.parse(new TextDecoder().decode(plaintext));
450
+ }
451
+ }
452
+
453
+ export default EntityServerClient;