sonamu 0.9.2 → 0.9.4

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 (84) hide show
  1. package/dist/api/config.d.ts +1 -2
  2. package/dist/api/config.d.ts.map +1 -1
  3. package/dist/api/config.js +1 -1
  4. package/dist/api/sonamu.d.ts.map +1 -1
  5. package/dist/api/sonamu.js +10 -1
  6. package/dist/auth/audit-log/builders.d.ts +216 -0
  7. package/dist/auth/audit-log/builders.d.ts.map +1 -0
  8. package/dist/auth/audit-log/builders.js +307 -0
  9. package/dist/auth/audit-log/events.d.ts +143 -0
  10. package/dist/auth/audit-log/events.d.ts.map +1 -0
  11. package/dist/auth/audit-log/events.js +74 -0
  12. package/dist/auth/audit-log/plugin.d.ts +11 -0
  13. package/dist/auth/audit-log/plugin.d.ts.map +1 -0
  14. package/dist/auth/audit-log/plugin.js +427 -0
  15. package/dist/auth/audit-log-ingestor.d.ts +9 -0
  16. package/dist/auth/audit-log-ingestor.d.ts.map +1 -0
  17. package/dist/auth/audit-log-ingestor.js +194 -0
  18. package/dist/auth/index.d.ts +3 -0
  19. package/dist/auth/index.d.ts.map +1 -1
  20. package/dist/auth/index.js +4 -2
  21. package/dist/auth/plugins/entity-definitions/admin.d.ts +1 -1
  22. package/dist/auth/plugins/entity-definitions/admin.js +4 -4
  23. package/dist/auth/plugins/entity-definitions/audit-log.d.ts +12 -0
  24. package/dist/auth/plugins/entity-definitions/audit-log.d.ts.map +1 -0
  25. package/dist/auth/plugins/entity-definitions/audit-log.js +291 -0
  26. package/dist/auth/plugins/entity-definitions/index.d.ts +1 -0
  27. package/dist/auth/plugins/entity-definitions/index.d.ts.map +1 -1
  28. package/dist/auth/plugins/entity-definitions/index.js +5 -3
  29. package/dist/auth/plugins/entity-definitions/types.d.ts +1 -1
  30. package/dist/auth/plugins/entity-definitions/types.d.ts.map +1 -1
  31. package/dist/bin/fixture.d.ts.map +1 -1
  32. package/dist/bin/fixture.js +111 -1
  33. package/dist/database/_batch_update.d.ts +1 -1
  34. package/dist/database/_batch_update.js +2 -2
  35. package/dist/entity/entity-manager.d.ts.map +1 -1
  36. package/dist/entity/entity-manager.js +14 -4
  37. package/dist/index.js +4 -2
  38. package/dist/storage/buffered-file.d.ts +1 -1
  39. package/dist/storage/buffered-file.js +2 -2
  40. package/dist/syncer/syncer.d.ts.map +1 -1
  41. package/dist/syncer/syncer.js +2 -9
  42. package/dist/template/implementations/entry-server.template.js +3 -2
  43. package/dist/template/implementations/generated.template.d.ts.map +1 -1
  44. package/dist/template/implementations/generated.template.js +2 -1
  45. package/dist/template/implementations/generated_sso.template.d.ts.map +1 -1
  46. package/dist/template/implementations/generated_sso.template.js +2 -1
  47. package/dist/template/implementations/queries.template.d.ts.map +1 -1
  48. package/dist/template/implementations/queries.template.js +3 -1
  49. package/dist/template/implementations/sd.template.js +3 -2
  50. package/dist/template/implementations/services.template.d.ts.map +1 -1
  51. package/dist/template/implementations/services.template.js +44 -7
  52. package/dist/template/zod-converter.d.ts.map +1 -1
  53. package/dist/template/zod-converter.js +2 -2
  54. package/dist/ui-web/assets/{index-CfgbCoOJ.js → index-C5KUjXm0.js} +48 -45
  55. package/dist/ui-web/index.html +1 -1
  56. package/dist/utils/fs-utils.d.ts.map +1 -1
  57. package/dist/utils/fs-utils.js +4 -4
  58. package/package.json +3 -3
  59. package/src/api/config.ts +1 -2
  60. package/src/api/sonamu.ts +14 -0
  61. package/src/auth/audit-log/builders.ts +791 -0
  62. package/src/auth/audit-log/events.ts +149 -0
  63. package/src/auth/audit-log/plugin.ts +913 -0
  64. package/src/auth/audit-log-ingestor.ts +233 -0
  65. package/src/auth/index.ts +3 -0
  66. package/src/auth/plugins/entity-definitions/admin.ts +3 -3
  67. package/src/auth/plugins/entity-definitions/audit-log.ts +171 -0
  68. package/src/auth/plugins/entity-definitions/index.ts +3 -0
  69. package/src/auth/plugins/entity-definitions/types.ts +2 -1
  70. package/src/bin/fixture.ts +143 -0
  71. package/src/database/_batch_update.ts +1 -1
  72. package/src/entity/entity-manager.ts +10 -3
  73. package/src/shared/app.shared.ts.txt +2 -3
  74. package/src/shared/web.shared.ts.txt +2 -2
  75. package/src/storage/buffered-file.ts +1 -1
  76. package/src/syncer/syncer.ts +1 -11
  77. package/src/template/implementations/entry-server.template.ts +1 -1
  78. package/src/template/implementations/generated.template.ts +1 -0
  79. package/src/template/implementations/generated_sso.template.ts +1 -0
  80. package/src/template/implementations/queries.template.ts +10 -1
  81. package/src/template/implementations/sd.template.ts +1 -1
  82. package/src/template/implementations/services.template.ts +62 -6
  83. package/src/template/zod-converter.ts +2 -1
  84. package/src/utils/fs-utils.ts +6 -4
@@ -0,0 +1,233 @@
1
+ import { createHash } from "node:crypto";
2
+
3
+ import { type Knex } from "knex";
4
+
5
+ import { type AuditLogEvent } from "./audit-log/events";
6
+
7
+ const AUDIT_EVENT_SOURCE = "better_auth";
8
+ const AUDIT_EVENT_SOURCE_VERSION = "better-auth|@better-auth/infra";
9
+
10
+ const SESSION_EVENT_TYPES = new Set<string>([
11
+ "user_signed_in",
12
+ "user_signed_out",
13
+ "user_sign_in_failed",
14
+ "session_created",
15
+ "session_revoked",
16
+ "all_sessions_revoked",
17
+ "user_impersonated",
18
+ "user_impersonated_stopped",
19
+ ]);
20
+
21
+ const ACCOUNT_EVENT_TYPES = new Set<string>([
22
+ "account_linked",
23
+ "account_unlinked",
24
+ "password_changed",
25
+
26
+ // @better-auth/infra@0.1.14 기준 EVENT_TYPES 상수에는 정의되어 있으나 실제 trackEvent() 호출이 없는 미구현 이벤트임
27
+ // 향후 버전에서 emit될 경우를 대비해 account로 임시 등록
28
+ "two_factor_enabled",
29
+ "two_factor_disabled",
30
+ "two_factor_verified",
31
+ ]);
32
+
33
+ const VERIFICATION_EVENT_TYPES = new Set<string>([
34
+ "email_verification_sent",
35
+ "password_reset_requested",
36
+ "password_reset_completed",
37
+ ]);
38
+
39
+ const SECURITY_EVENT_TYPES = new Set<string>([
40
+ "security_blocked",
41
+ "security_allowed",
42
+ "security_challenged",
43
+ "security_stale_account",
44
+ ]);
45
+
46
+ const USER_EVENT_TYPES = new Set<string>([
47
+ "user_created",
48
+ "profile_updated",
49
+ "profile_image_updated",
50
+ "email_verified",
51
+ "user_banned",
52
+ "user_unbanned",
53
+ "user_deleted",
54
+ ]);
55
+
56
+ function pickString(source: Record<string, unknown>, key: string): string | null {
57
+ const value = source[key];
58
+ return typeof value === "string" ? value : null;
59
+ }
60
+
61
+ function pickFirstString(source: Record<string, unknown>, keys: readonly string[]): string | null {
62
+ for (const key of keys) {
63
+ const value = pickString(source, key);
64
+ if (value !== null) {
65
+ return value;
66
+ }
67
+ }
68
+ return null;
69
+ }
70
+
71
+ function parseOccurredAt(raw: unknown): Date {
72
+ if (raw instanceof Date && !Number.isNaN(raw.getTime())) {
73
+ return raw;
74
+ }
75
+ if (typeof raw === "string") {
76
+ const parsed = new Date(raw);
77
+ if (!Number.isNaN(parsed.getTime())) {
78
+ return parsed;
79
+ }
80
+ }
81
+ return new Date();
82
+ }
83
+
84
+ function classifyCategory(
85
+ eventType: string,
86
+ ): "user" | "session" | "account" | "verification" | "organization" | "security" {
87
+ if (eventType.startsWith("organization_")) {
88
+ return "organization";
89
+ }
90
+ if (SESSION_EVENT_TYPES.has(eventType)) {
91
+ return "session";
92
+ }
93
+ if (ACCOUNT_EVENT_TYPES.has(eventType)) {
94
+ return "account";
95
+ }
96
+ if (VERIFICATION_EVENT_TYPES.has(eventType)) {
97
+ return "verification";
98
+ }
99
+ if (SECURITY_EVENT_TYPES.has(eventType)) {
100
+ return "security";
101
+ }
102
+ if (USER_EVENT_TYPES.has(eventType)) {
103
+ return "user";
104
+ }
105
+ return "user";
106
+ }
107
+
108
+ function computeDedupeKey(parts: {
109
+ source: string;
110
+ event_type: string;
111
+ event_key: string;
112
+ actor_user_id: string | null;
113
+ subject_user_id: string | null;
114
+ organization_id: string | null;
115
+ team_id: string | null;
116
+ session_id: string | null;
117
+ identifier: string | null;
118
+ reason: string | null;
119
+ action: string | null;
120
+ occurred_at: Date;
121
+ }): string {
122
+ const norm = (v: string | null): string => v ?? "";
123
+ const raw = [
124
+ parts.source,
125
+ parts.event_type,
126
+ parts.event_key,
127
+ norm(parts.actor_user_id),
128
+ norm(parts.subject_user_id),
129
+ norm(parts.organization_id),
130
+ norm(parts.team_id),
131
+ norm(parts.session_id),
132
+ norm(parts.identifier),
133
+ norm(parts.reason),
134
+ norm(parts.action),
135
+ parts.occurred_at.toISOString(),
136
+ ].join("|");
137
+ return createHash("sha256").update(raw).digest("hex");
138
+ }
139
+
140
+ /**
141
+ * sonamuAuditLog 플러그인이 구성한 AuditLogEvent를 audit_events 테이블에 적재합니다.
142
+ * ON CONFLICT (dedupe_key) DO NOTHING으로 중복을 silent 무시합니다.
143
+ * auth.plugins에 sonamuAuditLog() 추가 시 sonamu 내부에서 자동으로 호출됩니다.
144
+ */
145
+ export async function ingestAuditEvent(db: Knex, event: AuditLogEvent): Promise<void> {
146
+ const eventData = event.eventData;
147
+ const occurred_at = parseOccurredAt(eventData["occurredAt"]);
148
+
149
+ const actor_user_id = pickString(eventData, "triggeredBy");
150
+ const subject_user_id = pickFirstString(eventData, [
151
+ "userId",
152
+ "userid",
153
+ "acceptedById",
154
+ "rejectedById",
155
+ ]);
156
+ const organization_id = pickString(eventData, "organizationId");
157
+ const team_id = pickFirstString(eventData, ["teamId", "inviteeTeamId"]);
158
+ const session_id = pickString(eventData, "sessionId");
159
+ const provider_id = pickString(eventData, "providerId");
160
+ const login_method = pickString(eventData, "loginMethod");
161
+ const identifier = pickFirstString(eventData, [
162
+ "identifier",
163
+ "userEmail",
164
+ "memberEmail",
165
+ "inviteeEmail",
166
+ "acceptedByEmail",
167
+ "rejectedByEmail",
168
+ ]);
169
+ const visitor_id = pickString(eventData, "visitorId");
170
+ const reason = pickFirstString(eventData, ["reason", "banReason"]);
171
+ const action = pickString(eventData, "action");
172
+ const trigger_context = pickString(eventData, "triggerContext");
173
+ const user_agent = pickString(eventData, "userAgent");
174
+
175
+ const ip_address = event.ipAddress ?? null;
176
+ const city = event.city ?? null;
177
+ const country = event.country ?? null;
178
+ const country_code = event.countryCode ?? null;
179
+
180
+ const category = classifyCategory(event.eventType);
181
+ const dedupe_key = computeDedupeKey({
182
+ source: AUDIT_EVENT_SOURCE,
183
+ event_type: event.eventType,
184
+ event_key: event.eventKey,
185
+ actor_user_id,
186
+ subject_user_id,
187
+ organization_id,
188
+ team_id,
189
+ session_id,
190
+ identifier,
191
+ reason,
192
+ action,
193
+ occurred_at,
194
+ });
195
+
196
+ // ON CONFLICT DO NOTHING: dedupe_key UNIQUE 위반을 silent 무시.
197
+ // try/catch 방식은 PG 커넥션을 aborted 상태로 만드므로 사용하지 않는다.
198
+ const inserted = await db("audit_events")
199
+ .insert({
200
+ source: AUDIT_EVENT_SOURCE,
201
+ source_version: AUDIT_EVENT_SOURCE_VERSION,
202
+ category,
203
+ event_type: event.eventType,
204
+ event_key: event.eventKey,
205
+ dedupe_key,
206
+ actor_user_id,
207
+ subject_user_id,
208
+ organization_id,
209
+ team_id,
210
+ session_id,
211
+ provider_id,
212
+ login_method,
213
+ identifier,
214
+ visitor_id,
215
+ reason,
216
+ action,
217
+ trigger_context,
218
+ ip_address,
219
+ country_code,
220
+ country,
221
+ city,
222
+ user_agent,
223
+ payload_json: eventData,
224
+ occurred_at,
225
+ })
226
+ .onConflict("dedupe_key")
227
+ .ignore()
228
+ .returning("dedupe_key");
229
+
230
+ if (inserted.length === 0) {
231
+ return; // silent dedupe
232
+ }
233
+ }
package/src/auth/index.ts CHANGED
@@ -1,5 +1,8 @@
1
1
  export type { GenerateBetterAuthEntitiesOptions } from "./auth-generator";
2
2
  export { generateBetterAuthEntities } from "./auth-generator";
3
+ export { ingestAuditEvent } from "./audit-log-ingestor";
4
+ export { type AuditLogEvent } from "./audit-log/events";
5
+ export { sonamuAuditLog } from "./audit-log/plugin";
3
6
  export { BASE_FIELD_MAPPINGS, betterAuthV1 } from "./better-auth-entities";
4
7
  export { sonamuKnexAdapter } from "./knex-adapter";
5
8
 
@@ -10,7 +10,7 @@ import { type BetterAuthEntityDef } from "./types";
10
10
  * - role: 사용자 역할 (기본값: "user")
11
11
  * - banned: 차단 여부
12
12
  * - ban_reason: 차단 사유
13
- * - ban_expires: 차단 만료 시간 (Unix timestamp)
13
+ * - ban_expires: 차단 만료 시간 (Date)
14
14
  *
15
15
  * Session 테이블:
16
16
  * - impersonated_by: 대리 로그인한 관리자 ID
@@ -43,9 +43,9 @@ export const adminEntityDef: BetterAuthEntityDef = {
43
43
  },
44
44
  {
45
45
  name: "ban_expires",
46
- type: "bigInteger",
46
+ type: "date",
47
47
  nullable: true,
48
- desc: "차단 만료 (Unix timestamp)",
48
+ desc: "차단 만료",
49
49
  },
50
50
  ],
51
51
  Session: [
@@ -0,0 +1,171 @@
1
+ import { type BetterAuthEntityDef } from "./types";
2
+
3
+ /**
4
+ * better-auth AuditLog 플러그인 엔티티 정의
5
+ *
6
+ * auth.plugins에 sonamuAuditLog() 추가 시 audit_events 테이블이 사용됩니다.
7
+ * - sonamuAuditLog 플러그인이 Better Auth databaseHooks/organizationHooks/middleware에서 수신한 이벤트를 1건씩 적재합니다.
8
+ * - dedupe_key(sha256 hex)로 중복 적재를 방지합니다.
9
+ *
10
+ * 생성 방법: pnpm sonamu auth generate --plugins audit-log
11
+ */
12
+ export const auditLogEntityDef: BetterAuthEntityDef = {
13
+ id: "audit-log",
14
+ name: "AuditLog",
15
+ entities: [
16
+ {
17
+ id: "AuditEvent",
18
+ table: "audit_events",
19
+ title: "감사이벤트",
20
+ props: [
21
+ { name: "id", type: "integer", desc: "ID" },
22
+ { name: "source", type: "string", length: 32, desc: "이벤트 소스" },
23
+ { name: "source_version", type: "string", length: 96, nullable: true, desc: "소스 버전" },
24
+ { name: "category", type: "enum", id: "AuditEventCategory", desc: "카테고리" },
25
+ { name: "event_type", type: "string", length: 64, desc: "이벤트 타입" },
26
+ { name: "event_key", type: "string", length: 191, desc: "이벤트 키" },
27
+ { name: "dedupe_key", type: "string", length: 64, desc: "중복 제거 키" },
28
+ {
29
+ name: "actor_user_id",
30
+ type: "string",
31
+ length: 191,
32
+ nullable: true,
33
+ desc: "액터 사용자 ID",
34
+ },
35
+ {
36
+ name: "subject_user_id",
37
+ type: "string",
38
+ length: 191,
39
+ nullable: true,
40
+ desc: "대상 사용자 ID",
41
+ },
42
+ {
43
+ name: "organization_id",
44
+ type: "string",
45
+ length: 191,
46
+ nullable: true,
47
+ desc: "조직 ID",
48
+ },
49
+ { name: "team_id", type: "string", length: 191, nullable: true, desc: "팀 ID" },
50
+ { name: "session_id", type: "string", length: 191, nullable: true, desc: "세션 ID" },
51
+ { name: "provider_id", type: "string", length: 64, nullable: true, desc: "프로바이더 ID" },
52
+ { name: "login_method", type: "string", length: 64, nullable: true, desc: "로그인 방식" },
53
+ { name: "identifier", type: "string", length: 255, nullable: true, desc: "식별자" },
54
+ { name: "visitor_id", type: "string", length: 191, nullable: true, desc: "방문자 ID" },
55
+ { name: "reason", type: "string", length: 128, nullable: true, desc: "사유" },
56
+ { name: "action", type: "string", length: 64, nullable: true, desc: "액션" },
57
+ {
58
+ name: "trigger_context",
59
+ type: "string",
60
+ length: 64,
61
+ nullable: true,
62
+ desc: "트리거 컨텍스트",
63
+ },
64
+ { name: "ip_address", type: "string", length: 45, nullable: true, desc: "IP 주소" },
65
+ { name: "country_code", type: "string", length: 8, nullable: true, desc: "국가 코드" },
66
+ { name: "country", type: "string", length: 100, nullable: true, desc: "국가" },
67
+ { name: "city", type: "string", length: 100, nullable: true, desc: "도시" },
68
+ { name: "user_agent", type: "string", nullable: true, desc: "User-Agent" },
69
+ { name: "payload_json", type: "json", id: "AuditEventPayload", desc: "원본 payload" },
70
+ { name: "occurred_at", type: "date", desc: "발생 시각" },
71
+ {
72
+ name: "ingested_at",
73
+ type: "date",
74
+ dbDefault: "CURRENT_TIMESTAMP",
75
+ desc: "적재 시각",
76
+ },
77
+ ],
78
+ indexes: [
79
+ {
80
+ type: "unique",
81
+ name: "audit_events_dedupe_key_unique",
82
+ columns: [{ name: "dedupe_key" }],
83
+ },
84
+ {
85
+ type: "index",
86
+ name: "audit_events_occurred_at_index",
87
+ columns: [{ name: "occurred_at" }],
88
+ },
89
+ {
90
+ type: "index",
91
+ name: "audit_events_event_type_occurred_at_index",
92
+ columns: [{ name: "event_type" }, { name: "occurred_at" }],
93
+ },
94
+ {
95
+ type: "index",
96
+ name: "audit_events_subject_user_id_occurred_at_index",
97
+ columns: [{ name: "subject_user_id" }, { name: "occurred_at" }],
98
+ },
99
+ {
100
+ type: "index",
101
+ name: "audit_events_actor_user_id_occurred_at_index",
102
+ columns: [{ name: "actor_user_id" }, { name: "occurred_at" }],
103
+ },
104
+ {
105
+ type: "index",
106
+ name: "audit_events_organization_id_occurred_at_index",
107
+ columns: [{ name: "organization_id" }, { name: "occurred_at" }],
108
+ },
109
+ {
110
+ type: "index",
111
+ name: "audit_events_team_id_occurred_at_index",
112
+ columns: [{ name: "team_id" }, { name: "occurred_at" }],
113
+ },
114
+ {
115
+ type: "index",
116
+ name: "audit_events_session_id_index",
117
+ columns: [{ name: "session_id" }],
118
+ },
119
+ {
120
+ type: "index",
121
+ name: "audit_events_reason_occurred_at_index",
122
+ columns: [{ name: "reason" }, { name: "occurred_at" }],
123
+ },
124
+ ],
125
+ subsets: {
126
+ A: [
127
+ "id",
128
+ "source",
129
+ "source_version",
130
+ "category",
131
+ "event_type",
132
+ "event_key",
133
+ "dedupe_key",
134
+ "actor_user_id",
135
+ "subject_user_id",
136
+ "organization_id",
137
+ "team_id",
138
+ "session_id",
139
+ "provider_id",
140
+ "login_method",
141
+ "identifier",
142
+ "visitor_id",
143
+ "reason",
144
+ "action",
145
+ "trigger_context",
146
+ "ip_address",
147
+ "country_code",
148
+ "country",
149
+ "city",
150
+ "user_agent",
151
+ "payload_json",
152
+ "occurred_at",
153
+ "ingested_at",
154
+ ],
155
+ },
156
+ enums: {
157
+ AuditEventOrderBy: { "id-desc": "ID최신순" },
158
+ AuditEventSearchField: { id: "ID" },
159
+ AuditEventCategory: {
160
+ user: "사용자",
161
+ session: "세션",
162
+ account: "계정",
163
+ verification: "인증",
164
+ organization: "조직",
165
+ security: "보안",
166
+ },
167
+ },
168
+ },
169
+ ],
170
+ additionalProps: {},
171
+ };
@@ -1,6 +1,7 @@
1
1
  export { adminEntityDef } from "./admin";
2
2
  export { anonymousEntityDef } from "./anonymous";
3
3
  export { apiKeyEntityDef } from "./api-key";
4
+ export { auditLogEntityDef } from "./audit-log";
4
5
  export { jwtEntityDef } from "./jwt";
5
6
  export { organizationEntityDef } from "./organization";
6
7
  export { passkeyEntityDef } from "./passkey";
@@ -13,6 +14,7 @@ export { usernameEntityDef } from "./username";
13
14
  import { adminEntityDef } from "./admin";
14
15
  import { anonymousEntityDef } from "./anonymous";
15
16
  import { apiKeyEntityDef } from "./api-key";
17
+ import { auditLogEntityDef } from "./audit-log";
16
18
  import { jwtEntityDef } from "./jwt";
17
19
  import { organizationEntityDef } from "./organization";
18
20
  import { passkeyEntityDef } from "./passkey";
@@ -37,6 +39,7 @@ export const ENTITY_DEFINITIONS: Record<BetterAuthPluginId, BetterAuthEntityDef>
37
39
  "api-key": apiKeyEntityDef,
38
40
  jwt: jwtEntityDef,
39
41
  anonymous: anonymousEntityDef,
42
+ "audit-log": auditLogEntityDef,
40
43
  };
41
44
 
42
45
  /**
@@ -14,7 +14,8 @@ export type BetterAuthPluginId =
14
14
  | "organization"
15
15
  | "api-key"
16
16
  | "jwt"
17
- | "anonymous";
17
+ | "anonymous"
18
+ | "audit-log";
18
19
 
19
20
  /**
20
21
  * better-auth 엔티티 정의
@@ -22,6 +22,23 @@ interface FixtureCommandOptions {
22
22
  "no-cache"?: boolean;
23
23
  }
24
24
 
25
+ /**
26
+ * username을 일반적인 규칙(영문자 시작, 영문자/숫자만, 1-20자)에 맞도록 정규화합니다.
27
+ */
28
+ function sanitizeUsername(raw: string): string {
29
+ // 소문자 변환 후 영문자/숫자 외 문자 제거
30
+ let username = raw.toLowerCase().replace(/[^a-z0-9]/g, "");
31
+ if (username.length === 0) {
32
+ username = "user";
33
+ }
34
+ // 첫 글자가 숫자면 'u' 접두어 추가
35
+ if (/^[0-9]/.test(username)) {
36
+ username = `u${username}`;
37
+ }
38
+ // 20자 초과 시 truncate
39
+ return username.slice(0, 20);
40
+ }
41
+
25
42
  /**
26
43
  * fixture gen 명령어 - cone 메타데이터를 활용하여 자동으로 fixture를 생성합니다.
27
44
  */
@@ -76,6 +93,132 @@ export async function fixtureGenCommand(options: FixtureCommandOptions) {
76
93
  count = result.count;
77
94
  }
78
95
 
96
+ // User 엔티티가 포함된 경우: 로그인 가능/확인용 분기 선택
97
+ const hasUser = entityNames.includes("User");
98
+
99
+ if (hasUser) {
100
+ const userModeResult = await prompts({
101
+ type: "select",
102
+ name: "userMode",
103
+ message: "User 엔티티가 포함되어 있습니다. 생성 방식을 선택하세요:",
104
+ choices: [
105
+ { title: "1. 로그인 가능한 사용자 fixture 생성", value: "login" },
106
+ { title: "2. 확인용 데이터(로그인 불가) 생성", value: "dummy" },
107
+ ],
108
+ });
109
+
110
+ if (!userModeResult.userMode) {
111
+ console.log(chalk.yellow("취소되었습니다."));
112
+ return;
113
+ }
114
+
115
+ if (userModeResult.userMode === "login") {
116
+ // LLM 사용 여부
117
+ let useLLM = options["use-llm"] ?? false;
118
+ if (!options["use-llm"]) {
119
+ const llmResult = await prompts({
120
+ type: "confirm",
121
+ name: "useLLM",
122
+ message:
123
+ "LLM으로 더 현실적인 데이터를 생성할까요? (fixtureHint 기반, ANTHROPIC_API_KEY 필요)",
124
+ initial: false,
125
+ });
126
+ useLLM = llmResult.useLLM ?? false;
127
+ }
128
+
129
+ const enableLLMCache = !options["no-cache"];
130
+ const DEFAULT_PASSWORD = "Test1234!";
131
+
132
+ // 로그인 가능 경로에서는 sourceDb로 development_master 사용
133
+ const sourceDb = DB.getDB("r");
134
+ const generator = new FixtureGenerator(
135
+ sourceDb,
136
+ sourceDb,
137
+ "production_master",
138
+ EntityManager,
139
+ { useLLM, enableLLMCache },
140
+ );
141
+
142
+ const createdCredentials: Array<{ email: string; password: string }> = [];
143
+ const basePath = Sonamu.config.server.auth?.basePath ?? "/api/auth";
144
+
145
+ if (useLLM) {
146
+ console.log(
147
+ chalk.cyan(
148
+ `\nLLM 모드로 로그인 가능한 사용자 ${count}명 생성 중... (캐싱: ${enableLLMCache ? "ON" : "OFF"})`,
149
+ ),
150
+ );
151
+ } else {
152
+ console.log(chalk.cyan(`\n로그인 가능한 사용자 ${count}명 생성 중...`));
153
+ }
154
+
155
+ for (let i = 0; i < count; i++) {
156
+ const userData = await generator.generate("User");
157
+
158
+ const name = String(userData.name ?? "");
159
+ const email = String(userData.email ?? "");
160
+ const username = sanitizeUsername(String(userData.username ?? ""));
161
+ const displayUsername =
162
+ userData.display_username !== undefined ? String(userData.display_username) : undefined;
163
+
164
+ const body: Record<string, unknown> = {
165
+ name,
166
+ email,
167
+ username,
168
+ password: DEFAULT_PASSWORD,
169
+ };
170
+ if (displayUsername !== undefined) {
171
+ body.display_username = displayUsername;
172
+ }
173
+
174
+ const req = new Request(`http://localhost${basePath}/sign-up/email`, {
175
+ method: "POST",
176
+ headers: { "Content-Type": "application/json" },
177
+ body: JSON.stringify(body),
178
+ });
179
+
180
+ const response = await Sonamu.auth.handler(req);
181
+
182
+ if (!response.ok) {
183
+ const responseData = (await response.json()) as Record<string, unknown>;
184
+ const code = typeof responseData.code === "string" ? responseData.code : undefined;
185
+ if (code === "USER_ALREADY_EXISTS") {
186
+ console.log(chalk.yellow(` ⚠️ ${email} 이미 존재 - 건너뜁니다.`));
187
+ continue;
188
+ }
189
+ console.log(chalk.red(` ❌ ${email} 생성 실패: ${JSON.stringify(responseData)}`));
190
+ continue;
191
+ }
192
+
193
+ createdCredentials.push({ email, password: DEFAULT_PASSWORD });
194
+ console.log(chalk.green(` ✅ ${email} 생성 완료`));
195
+ }
196
+
197
+ // email_verified = true 직접 업데이트 (dev 편의)
198
+ if (createdCredentials.length > 0) {
199
+ const emails = createdCredentials.map((c) => c.email);
200
+ await DB.getDB("w")("users").whereIn("email", emails).update({ email_verified: true });
201
+ console.log(chalk.green(`\nemail_verified = true 업데이트 완료`));
202
+ }
203
+
204
+ if (useLLM) {
205
+ const stats = generator.getLLMCacheStats();
206
+ console.log(chalk.cyan(`[LLM Cache] 캐시 크기: ${stats.size}`));
207
+ }
208
+
209
+ console.log(
210
+ chalk.green(`\n✅ ${createdCredentials.length}명의 로그인 가능한 사용자 생성 완료`),
211
+ );
212
+ if (createdCredentials.length > 0) {
213
+ console.log("\n생성된 계정 목록:");
214
+ console.table(createdCredentials);
215
+ }
216
+
217
+ return;
218
+ }
219
+ // userMode === "dummy": 기존 generateBatch() 흐름 계속
220
+ }
221
+
79
222
  let saveTarget = options["save-to"] || "db";
80
223
  if (!options["save-to"]) {
81
224
  const result = await prompts({
@@ -15,7 +15,7 @@ export type RowWithId<Id extends string> = {
15
15
 
16
16
  /**
17
17
  * Batch update rows in a table. Technically its a patch since it only updates the specified columns. Any omitted columns will not be affected
18
- * @param db
18
+ * @param knex
19
19
  * @param tableName
20
20
  * @param ids
21
21
  * @param rows
@@ -267,9 +267,16 @@ class EntityManagerClass {
267
267
  * @returns
268
268
  */
269
269
  getEntityIdFromPath(filePath: AbsolutePath): string {
270
- const matched = filePath.match(/application\/(.+)\//);
271
- assert(matched?.[1]);
272
- return inflection.camelize(matched[1].replace(/-/g, "_"));
270
+ const fileName = path.basename(filePath);
271
+ const supportedSuffixes = [".model.ts", ".model.js", ".entity.json", ".frame.ts", ".frame.js"];
272
+ const matchedSuffix = supportedSuffixes.find((suffix) => fileName.endsWith(suffix));
273
+
274
+ assert(matchedSuffix, `지원하지 않는 entity 경로입니다: ${filePath}`);
275
+
276
+ const entityBaseName = fileName.slice(0, -matchedSuffix.length);
277
+ assert(entityBaseName.length > 0, `EntityId를 계산할 수 없는 경로입니다: ${filePath}`);
278
+
279
+ return inflection.camelize(entityBaseName.replace(/-/g, "_"));
273
280
  }
274
281
 
275
282
  private async registerNonEntityTypeModulePaths(): Promise<void> {
@@ -3,8 +3,8 @@
3
3
  * 최초 1회 생성되며, 이후에는 덮어쓰지 않습니다.
4
4
  * 필요시 직접 수정할 수 있습니다.
5
5
  */
6
+
6
7
  /* oxlint-disable react-hooks/exhaustive-deps */ // shared
7
- /* oxlint-disable @typescript-eslint/no-explicit-any */ // shared
8
8
 
9
9
  /*
10
10
  fetch
@@ -15,9 +15,8 @@ import qs from "qs";
15
15
  import { useEffect, useRef, useState } from "react";
16
16
  import { Alert } from "react-native";
17
17
  import { type core, z } from "zod";
18
- import { getCurrentLocale } from "~/i18n/sd.generated";
18
+ import { getCurrentLocale } from "@/i18n/sd.generated";
19
19
  import { ExpoEventSource as EventSource } from "@falcondev-oss/expo-event-source-polyfill";
20
- import { getCurrentLocale } from "../i18n/sd.generated"
21
20
 
22
21
  // AbortSignal.timeout polyfill for React Native
23
22
  if (typeof AbortSignal !== "undefined" && !AbortSignal.timeout) {