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,913 @@
1
+ import { getLogger } from "@logtape/logtape";
2
+ import { type BetterAuthPlugin } from "better-auth";
3
+ import { createAuthMiddleware } from "better-auth/api";
4
+
5
+ import { DB } from "../../database/db";
6
+ import { ingestAuditEvent } from "../audit-log-ingestor";
7
+ import { buildAuditEventCatalog } from "./builders";
8
+ import {
9
+ type AccountSnapshot,
10
+ type AuditLogEvent,
11
+ type BuilderLocation,
12
+ type BuilderTrigger,
13
+ type InvitationSnapshot,
14
+ type MemberSnapshot,
15
+ type OrganizationSnapshot,
16
+ ROUTES,
17
+ type SessionSnapshot,
18
+ type TeamSnapshot,
19
+ type UserProfileLite,
20
+ type UserSnapshot,
21
+ type VerificationSnapshot,
22
+ } from "./events";
23
+
24
+ // ============================================================================
25
+ // 라우팅/트리거 유틸
26
+ // ============================================================================
27
+ const stripQuery = (value: string): string => value.split("?")[0] || value;
28
+ const escapeRegex = (value: string): string => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
29
+ const routeToRegex = (route: string): RegExp => {
30
+ const pattern = escapeRegex(stripQuery(route)).replace(/\/:([^/]+)/g, "/[^/]+");
31
+ return new RegExp(`${pattern}(?:$|[/?])`);
32
+ };
33
+ const matchesAnyRoute = (routePath: string | undefined, routes: readonly string[]): boolean => {
34
+ if (!routePath) return false;
35
+ const cleanPath = stripQuery(routePath);
36
+ return routes.some((route) => routeToRegex(route).test(cleanPath));
37
+ };
38
+
39
+ const LOGIN_PATHS = [
40
+ ROUTES.SIGN_IN_SOCIAL_CALLBACK,
41
+ ROUTES.SIGN_IN_OAUTH_CALLBACK,
42
+ ROUTES.SIGN_IN_EMAIL,
43
+ ROUTES.SIGN_IN_SOCIAL,
44
+ ROUTES.SIGN_IN_EMAIL_OTP,
45
+ ROUTES.SIGN_UP_EMAIL,
46
+ ] as const;
47
+
48
+ // dash 319-323 미러: 현재 요청 path에서 로그인 방식을 추출한다.
49
+ const getLoginMethod = (ctxPath: string | undefined, paramsId?: string): string | null => {
50
+ if (!ctxPath) return null;
51
+ if (matchesAnyRoute(ctxPath, LOGIN_PATHS)) {
52
+ if (paramsId) return paramsId;
53
+ return ctxPath.split("/").pop() ?? null;
54
+ }
55
+ return null;
56
+ };
57
+
58
+ // dash 797-803 미러: 세션/요청에서 트리거 주체와 컨텍스트를 도출한다.
59
+ const getTriggerInfo = (
60
+ ctxPath: string | undefined,
61
+ sessionUserId: string | null,
62
+ userId: string,
63
+ ): BuilderTrigger => {
64
+ const resolved = sessionUserId ?? userId;
65
+ const triggerContext =
66
+ resolved === userId
67
+ ? "user"
68
+ : matchesAnyRoute(ctxPath, [ROUTES.ADMIN_ROUTE])
69
+ ? "admin"
70
+ : matchesAnyRoute(ctxPath, [ROUTES.DASH_ROUTE])
71
+ ? "dashboard"
72
+ : resolved === "unknown"
73
+ ? "user"
74
+ : "unknown";
75
+ return { triggeredBy: resolved, triggerContext };
76
+ };
77
+
78
+ // dash 809-814 미러: organization hook은 인증 컨텍스트 없이도 호출되므로
79
+ // 주어진 user 객체로부터 트리거 정보를 합성한다.
80
+ const getOrganizationTriggerInfo = (user: { id?: string } | null | undefined): BuilderTrigger => ({
81
+ triggeredBy: user?.id ?? "unknown",
82
+ triggerContext: "organization",
83
+ });
84
+
85
+ // ============================================================================
86
+ // better-auth ctx 타입 helpers (내부 shape은 런타임 구조를 기준으로 좁혀 사용한다)
87
+ // ============================================================================
88
+ type BetterAuthRequestCtx = {
89
+ path?: string;
90
+ body?: Record<string, unknown> | null | undefined;
91
+ params?: Record<string, string | undefined> | null | undefined;
92
+ context: {
93
+ session?: {
94
+ session?: { userId?: string };
95
+ user?: { id?: string };
96
+ } | null;
97
+ location?: BuilderLocation | null;
98
+ adapter?: {
99
+ findOne: (args: {
100
+ model: string;
101
+ select?: string[];
102
+ where: { field: string; value: unknown }[];
103
+ }) => Promise<Record<string, unknown> | null>;
104
+ };
105
+ returned?: unknown;
106
+ };
107
+ };
108
+
109
+ // databaseHooks after 콜백의 ctx는 선택적이며 shape을 런타임에서 좁힌다.
110
+ const narrowRequestCtx = (raw: unknown): BetterAuthRequestCtx | null => {
111
+ if (!raw || typeof raw !== "object") return null;
112
+ const candidate = raw as { context?: unknown };
113
+ if (!candidate.context || typeof candidate.context !== "object") return null;
114
+ return raw as BetterAuthRequestCtx;
115
+ };
116
+
117
+ // adapter.findOne 호출 실패 시 null을 반환한다. dash 헬퍼와 동일 정책.
118
+ const fetchUserBy = async (
119
+ ctx: BetterAuthRequestCtx,
120
+ field: "id" | "email",
121
+ value: string | null | undefined,
122
+ ): Promise<UserProfileLite> => {
123
+ if (!value) return null;
124
+ const adapter = ctx.context.adapter;
125
+ if (!adapter) return null;
126
+ try {
127
+ const row = await adapter.findOne({
128
+ model: "user",
129
+ select: ["id", "name", "email"],
130
+ where: [{ field, value }],
131
+ });
132
+ if (!row) return null;
133
+ return {
134
+ id: String(row.id),
135
+ name: typeof row.name === "string" ? row.name : undefined,
136
+ email: typeof row.email === "string" ? row.email : undefined,
137
+ };
138
+ } catch {
139
+ return null;
140
+ }
141
+ };
142
+
143
+ const isNonEmptyString = (v: unknown): v is string => typeof v === "string" && v.length > 0;
144
+
145
+ // dash 제거 시 함께 사라진 location 공급 경로를 대체한다.
146
+ // 우선순위: cf-connecting-ip > x-forwarded-for(첫 항목) > x-real-ip > x-vercel-forwarded-for
147
+ // (sonamu.ts IP_HEADERS 상수와 동일)
148
+ const IP_HEADER_ORDER = [
149
+ "cf-connecting-ip",
150
+ "x-forwarded-for",
151
+ "x-real-ip",
152
+ "x-vercel-forwarded-for",
153
+ ] as const;
154
+
155
+ const readHeader = (headers: unknown, key: string): string | null => {
156
+ if (!headers) return null;
157
+ if (headers instanceof Headers) {
158
+ return headers.get(key);
159
+ }
160
+ if (typeof headers === "object") {
161
+ const map = headers as Record<string, unknown>;
162
+ const v = map[key] ?? map[key.toLowerCase()];
163
+ if (typeof v === "string") return v;
164
+ if (Array.isArray(v) && typeof v[0] === "string") return v[0];
165
+ }
166
+ return null;
167
+ };
168
+
169
+ const extractLocationFromHeaders = (headers: unknown): BuilderLocation => {
170
+ let ipAddress: string | null = null;
171
+ for (const key of IP_HEADER_ORDER) {
172
+ const raw = readHeader(headers, key);
173
+ if (typeof raw === "string" && raw.length > 0) {
174
+ const first = raw.split(",")[0]?.trim();
175
+ if (first) {
176
+ ipAddress = first;
177
+ break;
178
+ }
179
+ }
180
+ }
181
+ const countryCode = readHeader(headers, "cf-ipcountry");
182
+ const city = readHeader(headers, "cf-ipcity");
183
+ return {
184
+ ipAddress: ipAddress ?? undefined,
185
+ city: city ?? undefined,
186
+ country: undefined,
187
+ countryCode: countryCode ?? undefined,
188
+ };
189
+ };
190
+
191
+ // ============================================================================
192
+ // Organization hook 래핑 헬퍼
193
+ // ============================================================================
194
+ type OrgHookFn = (...args: unknown[]) => Promise<unknown>;
195
+
196
+ // 주어진 organizationHooks 레코드에 대해 해당 name의 기존 hook을 chain한다.
197
+ // handler는 payload 객체(첫 번째 인자)만 받아서 audit emit을 수행한다.
198
+ const wrapOrgHook = <Payload>(
199
+ hooks: Record<string, unknown>,
200
+ name: string,
201
+ handler: (payload: Payload) => Promise<void>,
202
+ ): void => {
203
+ const prev = hooks[name] as OrgHookFn | undefined;
204
+ hooks[name] = async (...args: unknown[]): Promise<unknown> => {
205
+ await handler(args[0] as Payload);
206
+ if (prev) return prev(...args);
207
+ return undefined;
208
+ };
209
+ };
210
+
211
+ // ============================================================================
212
+ // Plugin entry
213
+ // ============================================================================
214
+ /**
215
+ * Better Auth databaseHooks/organizationHooks/middleware에서 수집한
216
+ * 이벤트를 `DB.getDB("w")`로 얻은 knex에 `ingestAuditEvent`로 적재한다.
217
+ *
218
+ * - dash(@better-auth/infra)의 audit-event 수집 훅 구조를 참고해 Sonamu 내부 적재 경로로 포팅한다.
219
+ * - dash의 infra 연결/API endpoint 제공 범위는 포함하지 않고, audit-event emit/ingest 경로만 유지한다.
220
+ * - security 4종은 R1 결정에 따라 scope out (builders.ts의 TODO 주석 참조).
221
+ */
222
+ export function sonamuAuditLog(): BetterAuthPlugin {
223
+ const logger = getLogger(["sonamu", "audit-log"]);
224
+ const catalog = buildAuditEventCatalog();
225
+
226
+ // dash 7394: 동일 요청에서 세션 벌크 삭제가 다회 발생할 때 all_sessions_revoked를
227
+ // 한 번만 emit하도록 처리 컨텍스트를 기억한다.
228
+ const processedBulkOperationContexts = new WeakSet<object>();
229
+
230
+ const emit = async (event: AuditLogEvent): Promise<void> => {
231
+ try {
232
+ await ingestAuditEvent(DB.getDB("w"), event);
233
+ } catch (err) {
234
+ logger.error("audit event ingest failed: {error}", { error: err });
235
+ }
236
+ };
237
+
238
+ // ctx.path + session에서 사용자 트리거를 도출한다(entity.id를 subject로 사용).
239
+ const triggerFor = (ctx: BetterAuthRequestCtx, subjectUserId: string): BuilderTrigger =>
240
+ getTriggerInfo(ctx.path, ctx.context.session?.session?.userId ?? null, subjectUserId);
241
+
242
+ const locationFor = (ctx: BetterAuthRequestCtx): BuilderLocation | undefined =>
243
+ ctx.context.location ?? undefined;
244
+
245
+ return {
246
+ id: "sonamu-audit-log",
247
+
248
+ init(pluginCtx: unknown) {
249
+ installOrganizationHooks(pluginCtx, catalog, emit, logger);
250
+
251
+ // dash 7283-7449 미러: databaseHooks (user/session/account/verification).
252
+ return {
253
+ options: {
254
+ databaseHooks: {
255
+ user: {
256
+ create: {
257
+ after: async (rawUser: unknown, rawCtx?: unknown): Promise<void> => {
258
+ const ctx = narrowRequestCtx(rawCtx);
259
+ if (!ctx) return;
260
+ const user = rawUser as UserSnapshot;
261
+ await emit(
262
+ catalog.user.trackUserSignedUp(
263
+ user,
264
+ triggerFor(ctx, user.id),
265
+ locationFor(ctx),
266
+ ),
267
+ );
268
+ },
269
+ },
270
+ update: {
271
+ after: async (rawUser: unknown, rawCtx?: unknown): Promise<void> => {
272
+ const ctx = narrowRequestCtx(rawCtx);
273
+ if (!ctx) return;
274
+ const user = rawUser as UserSnapshot & {
275
+ emailVerified?: boolean;
276
+ image?: string | null;
277
+ };
278
+ const path = ctx.path;
279
+ const trigger = triggerFor(ctx, user.id);
280
+ const location = locationFor(ctx);
281
+
282
+ if (matchesAnyRoute(path, [ROUTES.UPDATE_USER, ROUTES.DASH_UPDATE_USER])) {
283
+ const updatedFields = Object.keys((ctx.body as object) ?? {});
284
+ const isOnlyImageUpdate =
285
+ updatedFields.length === 1 && updatedFields[0] === "image";
286
+ const isOnlyEmailVerifiedUpdate =
287
+ updatedFields.length === 1 && updatedFields[0] === "emailVerified";
288
+ const hasEmailVerifiedUpdate = updatedFields.includes("emailVerified");
289
+ if (isOnlyEmailVerifiedUpdate && user.emailVerified) {
290
+ await emit(catalog.user.trackUserEmailVerified(user, trigger, location));
291
+ } else if (isOnlyImageUpdate && user.image) {
292
+ await emit(
293
+ catalog.user.trackUserProfileImageUpdated(user, trigger, location),
294
+ );
295
+ } else if (!isOnlyImageUpdate && !isOnlyEmailVerifiedUpdate) {
296
+ await emit(
297
+ catalog.user.trackUserProfileUpdated(
298
+ user,
299
+ updatedFields,
300
+ trigger,
301
+ location,
302
+ ),
303
+ );
304
+ if (hasEmailVerifiedUpdate && user.emailVerified) {
305
+ await emit(catalog.user.trackUserEmailVerified(user, trigger, location));
306
+ }
307
+ }
308
+ } else if (matchesAnyRoute(path, [ROUTES.CHANGE_EMAIL])) {
309
+ const updatedFields = Object.keys((ctx.body as object) ?? {});
310
+ await emit(
311
+ catalog.user.trackUserProfileUpdated(user, updatedFields, trigger, location),
312
+ );
313
+ }
314
+ if (matchesAnyRoute(path, [ROUTES.VERIFY_EMAIL]) && user.emailVerified) {
315
+ await emit(catalog.user.trackUserEmailVerified(user, trigger, location));
316
+ }
317
+ if (
318
+ matchesAnyRoute(path, [ROUTES.ADMIN_BAN_USER]) &&
319
+ "banned" in user &&
320
+ user.banned
321
+ ) {
322
+ await emit(catalog.user.trackUserBanned(user, trigger, location));
323
+ }
324
+ if (
325
+ matchesAnyRoute(path, [ROUTES.ADMIN_UNBAN_USER]) &&
326
+ "banned" in user &&
327
+ !user.banned
328
+ ) {
329
+ await emit(catalog.user.trackUserUnBanned(user, trigger, location));
330
+ }
331
+ },
332
+ },
333
+ delete: {
334
+ after: async (rawUser: unknown, rawCtx?: unknown): Promise<void> => {
335
+ const ctx = narrowRequestCtx(rawCtx);
336
+ if (!ctx) return;
337
+ const user = rawUser as UserSnapshot;
338
+ await emit(
339
+ catalog.user.trackUserDeleted(user, triggerFor(ctx, user.id), locationFor(ctx)),
340
+ );
341
+ },
342
+ },
343
+ },
344
+ session: {
345
+ create: {
346
+ before: async (
347
+ rawSession: unknown,
348
+ rawCtx?: unknown,
349
+ ): Promise<{ data: { loginMethod: string | null } } | undefined> => {
350
+ void rawSession;
351
+ const ctx = narrowRequestCtx(rawCtx);
352
+ if (!ctx) return undefined;
353
+ return { data: { loginMethod: getLoginMethod(ctx.path, ctx.params?.id) } };
354
+ },
355
+ after: async (rawSession: unknown, rawCtx?: unknown): Promise<void> => {
356
+ const ctx = narrowRequestCtx(rawCtx);
357
+ if (!ctx) return;
358
+ const session = rawSession as SessionSnapshot;
359
+ if (!session.userId) return;
360
+ const location = locationFor(ctx);
361
+ const loginMethod = getLoginMethod(ctx.path, ctx.params?.id) ?? undefined;
362
+ const enrichedSession: SessionSnapshot = {
363
+ ...session,
364
+ loginMethod: loginMethod ?? session.loginMethod ?? null,
365
+ };
366
+ const user = await fetchUserBy(ctx, "id", session.userId);
367
+
368
+ let trigger: BuilderTrigger;
369
+ if (
370
+ matchesAnyRoute(ctx.path, [
371
+ ROUTES.SIGN_IN,
372
+ ROUTES.SIGN_UP,
373
+ ROUTES.SIGN_IN_SOCIAL_CALLBACK,
374
+ ROUTES.SIGN_IN_OAUTH_CALLBACK,
375
+ ])
376
+ ) {
377
+ trigger = getTriggerInfo(ctx.path, session.userId, session.userId);
378
+ await emit(
379
+ catalog.session.trackUserSignedIn(enrichedSession, user, trigger, location),
380
+ );
381
+ } else {
382
+ trigger = triggerFor(ctx, session.userId);
383
+ }
384
+ await emit(
385
+ catalog.session.trackSessionCreated(enrichedSession, user, trigger, location),
386
+ );
387
+ if (isNonEmptyString(session.impersonatedBy)) {
388
+ const impersonator = await fetchUserBy(ctx, "id", session.impersonatedBy);
389
+ await emit(
390
+ catalog.session.trackUserImpersonated(
391
+ enrichedSession,
392
+ user,
393
+ impersonator,
394
+ {
395
+ triggeredBy: session.impersonatedBy,
396
+ triggerContext: trigger.triggerContext,
397
+ },
398
+ location,
399
+ ),
400
+ );
401
+ }
402
+ },
403
+ },
404
+ delete: {
405
+ after: async (rawSession: unknown, rawCtx?: unknown): Promise<void> => {
406
+ const ctx = narrowRequestCtx(rawCtx);
407
+ if (!ctx) return;
408
+ const session = rawSession as SessionSnapshot;
409
+ const location = locationFor(ctx);
410
+ const enrichedSession: SessionSnapshot = { ...session };
411
+ const user = await fetchUserBy(ctx, "id", session.userId);
412
+ const trigger = triggerFor(ctx, session.userId);
413
+ if (
414
+ matchesAnyRoute(ctx.path, [
415
+ ROUTES.REVOKE_ALL_SESSIONS,
416
+ ROUTES.ADMIN_REVOKE_USER_SESSIONS,
417
+ ROUTES.DASH_REVOKE_SESSIONS_ALL,
418
+ ROUTES.DASH_BAN_USER,
419
+ ])
420
+ ) {
421
+ if (!processedBulkOperationContexts.has(ctx)) {
422
+ await emit(
423
+ catalog.session.trackSessionRevokedAll(enrichedSession, user, trigger),
424
+ );
425
+ processedBulkOperationContexts.add(ctx);
426
+ }
427
+ } else if (matchesAnyRoute(ctx.path, [ROUTES.SIGN_OUT])) {
428
+ await emit(
429
+ catalog.session.trackUserSignedOut(enrichedSession, user, trigger, location),
430
+ );
431
+ } else {
432
+ await emit(
433
+ catalog.session.trackSessionRevoked(enrichedSession, user, trigger, location),
434
+ );
435
+ }
436
+ if (isNonEmptyString(session.impersonatedBy)) {
437
+ const impersonator = await fetchUserBy(ctx, "id", session.impersonatedBy);
438
+ await emit(
439
+ catalog.session.trackUserImpersonationStop(
440
+ enrichedSession,
441
+ user,
442
+ impersonator,
443
+ trigger,
444
+ location,
445
+ ),
446
+ );
447
+ }
448
+ },
449
+ },
450
+ },
451
+ account: {
452
+ create: {
453
+ after: async (rawAccount: unknown, rawCtx?: unknown): Promise<void> => {
454
+ const ctx = narrowRequestCtx(rawCtx);
455
+ if (!ctx) return;
456
+ const account = rawAccount as AccountSnapshot;
457
+ if (!account.userId) return;
458
+ const user = await fetchUserBy(ctx, "id", account.userId);
459
+ await emit(
460
+ catalog.account.trackAccountLinking(
461
+ account,
462
+ user,
463
+ triggerFor(ctx, account.userId),
464
+ locationFor(ctx),
465
+ ),
466
+ );
467
+ },
468
+ },
469
+ update: {
470
+ after: async (rawAccount: unknown, rawCtx?: unknown): Promise<void> => {
471
+ const ctx = narrowRequestCtx(rawCtx);
472
+ if (!ctx) return;
473
+ const account = rawAccount as AccountSnapshot;
474
+ if (!account.userId) return;
475
+ if (
476
+ !matchesAnyRoute(ctx.path, [
477
+ ROUTES.CHANGE_PASSWORD,
478
+ ROUTES.SET_PASSWORD,
479
+ ROUTES.RESET_PASSWORD,
480
+ ROUTES.ADMIN_SET_PASSWORD,
481
+ ])
482
+ ) {
483
+ return;
484
+ }
485
+ const user = await fetchUserBy(ctx, "id", account.userId);
486
+ await emit(
487
+ catalog.account.trackAccountPasswordChange(
488
+ account,
489
+ user,
490
+ triggerFor(ctx, account.userId),
491
+ locationFor(ctx),
492
+ ),
493
+ );
494
+ },
495
+ },
496
+ delete: {
497
+ after: async (rawAccount: unknown, rawCtx?: unknown): Promise<void> => {
498
+ const ctx = narrowRequestCtx(rawCtx);
499
+ if (!ctx) return;
500
+ const account = rawAccount as AccountSnapshot;
501
+ if (!account.userId) return;
502
+ const user = await fetchUserBy(ctx, "id", account.userId);
503
+ await emit(
504
+ catalog.account.trackAccountUnlink(
505
+ account,
506
+ user,
507
+ triggerFor(ctx, account.userId),
508
+ locationFor(ctx),
509
+ ),
510
+ );
511
+ },
512
+ },
513
+ },
514
+ verification: {
515
+ create: {
516
+ after: async (rawVerification: unknown, rawCtx?: unknown): Promise<void> => {
517
+ const ctx = narrowRequestCtx(rawCtx);
518
+ if (!ctx) return;
519
+ if (!matchesAnyRoute(ctx.path, [ROUTES.REQUEST_PASSWORD_RESET])) return;
520
+ const verification = rawVerification as VerificationSnapshot;
521
+ const sessionUserId = ctx.context.session?.user?.id ?? "unknown";
522
+ const trigger = getTriggerInfo(ctx.path, sessionUserId, sessionUserId);
523
+ const user = await fetchUserBy(ctx, "id", verification.value);
524
+ await emit(
525
+ catalog.verification.trackPasswordResetRequest(
526
+ verification,
527
+ user,
528
+ trigger,
529
+ locationFor(ctx),
530
+ ),
531
+ );
532
+ },
533
+ },
534
+ delete: {
535
+ after: async (rawVerification: unknown, rawCtx?: unknown): Promise<void> => {
536
+ const ctx = narrowRequestCtx(rawCtx);
537
+ if (!ctx) return;
538
+ if (!matchesAnyRoute(ctx.path, [ROUTES.RESET_PASSWORD])) return;
539
+ const verification = rawVerification as VerificationSnapshot;
540
+ const sessionUserId = ctx.context.session?.user?.id ?? "unknown";
541
+ const trigger = getTriggerInfo(ctx.path, sessionUserId, sessionUserId);
542
+ const user = await fetchUserBy(ctx, "id", verification.value);
543
+ await emit(
544
+ catalog.verification.trackPasswordResetRequestCompletion(
545
+ verification,
546
+ user,
547
+ trigger,
548
+ locationFor(ctx),
549
+ ),
550
+ );
551
+ },
552
+ },
553
+ },
554
+ },
555
+ },
556
+ };
557
+ },
558
+
559
+ hooks: {
560
+ before: [
561
+ {
562
+ // dash 제거로 사라진 location 공급 경로를 복구한다.
563
+ // 모든 요청에서 ctx.context.location을 채워 이후 빌더들이 ipAddress/city/countryCode를 기록할 수 있게 한다.
564
+ matcher: () => true,
565
+ handler: createAuthMiddleware(async (rawCtx) => {
566
+ const ctx = rawCtx as {
567
+ headers?: unknown;
568
+ request?: { headers?: unknown } | undefined;
569
+ context?: { location?: BuilderLocation | null } & Record<string, unknown>;
570
+ };
571
+ if (!ctx.context) return;
572
+ const headers = ctx.headers ?? ctx.request?.headers;
573
+ ctx.context.location = extractLocationFromHeaders(headers);
574
+ }),
575
+ },
576
+ ],
577
+ after: [
578
+ {
579
+ // dash 7462-7487 미러: verification email send, sign-in attempts.
580
+ // GET 요청은 콜백 경로만 통과시킨다.
581
+ matcher: (ctx: unknown): boolean => {
582
+ const c = ctx as { request?: { method?: string; url?: string } };
583
+ if (c.request?.method !== "GET") return true;
584
+ if (!c.request.url) return false;
585
+ try {
586
+ const p = new URL(c.request.url).pathname;
587
+ return matchesAnyRoute(p, [
588
+ ROUTES.SIGN_IN_SOCIAL_CALLBACK,
589
+ ROUTES.SIGN_IN_OAUTH_CALLBACK,
590
+ ]);
591
+ } catch {
592
+ return false;
593
+ }
594
+ },
595
+ handler: createAuthMiddleware(async (rawCtx) => {
596
+ const ctx = narrowRequestCtx(rawCtx);
597
+ if (!ctx) return;
598
+ const sessionUser = ctx.context.session?.user;
599
+ const sessionUserId = sessionUser?.id ?? "unknown";
600
+ const trigger = getTriggerInfo(ctx.path, sessionUserId, sessionUserId);
601
+ const location = locationFor(ctx);
602
+ const returned = ctx.context.returned;
603
+ const isErrored = returned instanceof Error;
604
+
605
+ // verification email sent
606
+ if (
607
+ matchesAnyRoute(ctx.path, [ROUTES.SEND_VERIFICATION_EMAIL]) &&
608
+ ctx.context.session &&
609
+ !isErrored
610
+ ) {
611
+ const sessionEntity = ctx.context.session.session as SessionSnapshot | undefined;
612
+ const user = ctx.context.session.user as
613
+ | { name?: string; email?: string }
614
+ | undefined;
615
+ if (sessionEntity && user) {
616
+ await emit(
617
+ catalog.session.trackEmailVerificationSent(sessionEntity, user, trigger),
618
+ );
619
+ }
620
+ }
621
+
622
+ const body =
623
+ (ctx.body as { email?: string; provider?: string; idToken?: string } | null) ?? null;
624
+ // email sign-in attempt failed
625
+ if (
626
+ matchesAnyRoute(ctx.path, [ROUTES.SIGN_IN_EMAIL, ROUTES.SIGN_IN_EMAIL_OTP]) &&
627
+ isErrored &&
628
+ body?.email
629
+ ) {
630
+ const user = await fetchUserBy(ctx, "email", body.email);
631
+ await emit(
632
+ catalog.session.trackEmailSignInAttempt(
633
+ { email: body.email, loginMethod: getLoginMethod(ctx.path, ctx.params?.id) },
634
+ user,
635
+ trigger,
636
+ location,
637
+ ),
638
+ );
639
+ }
640
+ // social sign-in attempt failed (POST)
641
+ if (
642
+ matchesAnyRoute(ctx.path, [ROUTES.SIGN_IN_SOCIAL]) &&
643
+ isErrored &&
644
+ body?.provider &&
645
+ body?.idToken
646
+ ) {
647
+ await emit(
648
+ catalog.session.trackSocialSignInAttempt(
649
+ { loginMethod: getLoginMethod(ctx.path, ctx.params?.id) },
650
+ null,
651
+ trigger,
652
+ location,
653
+ ),
654
+ );
655
+ }
656
+ // social redirection callback failed (GET)
657
+ if (matchesAnyRoute(ctx.path, [ROUTES.SIGN_IN_SOCIAL_CALLBACK]) && isErrored) {
658
+ await emit(
659
+ catalog.session.trackSocialSignInRedirectionAttempt(
660
+ { loginMethod: getLoginMethod(ctx.path, ctx.params?.id) },
661
+ null,
662
+ trigger,
663
+ location,
664
+ ),
665
+ );
666
+ }
667
+ }),
668
+ },
669
+ ],
670
+ },
671
+ };
672
+ }
673
+
674
+ // ============================================================================
675
+ // Organization hook 합성 (organization 플러그인이 활성화된 경우에만)
676
+ // dash 7192-7281 미러.
677
+ // ============================================================================
678
+ type EventEmitter = (event: AuditLogEvent) => Promise<void>;
679
+ type AuditLogger = ReturnType<typeof getLogger>;
680
+
681
+ function installOrganizationHooks(
682
+ pluginCtx: unknown,
683
+ catalog: ReturnType<typeof buildAuditEventCatalog>,
684
+ emit: EventEmitter,
685
+ logger: AuditLogger,
686
+ ): void {
687
+ const getPlugin = (pluginCtx as { getPlugin?: (id: string) => unknown })?.getPlugin;
688
+ const organizationPlugin =
689
+ typeof getPlugin === "function" ? getPlugin.call(pluginCtx, "organization") : null;
690
+
691
+ if (!organizationPlugin || typeof organizationPlugin !== "object") {
692
+ logger.debug("organization plugin not active; skipping instrumentation");
693
+ return;
694
+ }
695
+
696
+ const orgPlugin = organizationPlugin as {
697
+ options?: { organizationHooks?: Record<string, unknown> };
698
+ };
699
+ orgPlugin.options = orgPlugin.options ?? {};
700
+ const hooks = (orgPlugin.options.organizationHooks = orgPlugin.options.organizationHooks ?? {});
701
+
702
+ wrapOrgHook<{ organization: OrganizationSnapshot; user: UserSnapshot }>(
703
+ hooks,
704
+ "afterCreateOrganization",
705
+ async (p) =>
706
+ emit(
707
+ catalog.organization.trackOrganizationCreated(
708
+ p.organization,
709
+ getOrganizationTriggerInfo(p.user),
710
+ ),
711
+ ),
712
+ );
713
+
714
+ wrapOrgHook<{ organization?: OrganizationSnapshot; user: UserSnapshot }>(
715
+ hooks,
716
+ "afterUpdateOrganization",
717
+ async (p) => {
718
+ if (!p.organization) return;
719
+ await emit(
720
+ catalog.organization.trackOrganizationUpdated(
721
+ p.organization,
722
+ getOrganizationTriggerInfo(p.user),
723
+ ),
724
+ );
725
+ },
726
+ );
727
+
728
+ wrapOrgHook<{
729
+ organization: OrganizationSnapshot;
730
+ member: MemberSnapshot;
731
+ user: UserSnapshot;
732
+ }>(hooks, "afterAddMember", async (p) =>
733
+ emit(
734
+ catalog.member.trackOrganizationMemberAdded(
735
+ p.organization,
736
+ p.member,
737
+ p.user,
738
+ getOrganizationTriggerInfo(p.user),
739
+ ),
740
+ ),
741
+ );
742
+
743
+ wrapOrgHook<{
744
+ organization: OrganizationSnapshot;
745
+ member: MemberSnapshot;
746
+ user: UserSnapshot;
747
+ }>(hooks, "afterRemoveMember", async (p) =>
748
+ emit(
749
+ catalog.member.trackOrganizationMemberRemoved(
750
+ p.organization,
751
+ p.member,
752
+ p.user,
753
+ getOrganizationTriggerInfo(p.user),
754
+ ),
755
+ ),
756
+ );
757
+
758
+ wrapOrgHook<{
759
+ organization: OrganizationSnapshot;
760
+ member: MemberSnapshot;
761
+ user: UserSnapshot;
762
+ previousRole: string;
763
+ }>(hooks, "afterUpdateMemberRole", async (p) =>
764
+ emit(
765
+ catalog.member.trackOrganizationMemberRoleUpdated(
766
+ p.organization,
767
+ p.member,
768
+ p.user,
769
+ p.previousRole,
770
+ getOrganizationTriggerInfo(p.user),
771
+ ),
772
+ ),
773
+ );
774
+
775
+ wrapOrgHook<{
776
+ organization: OrganizationSnapshot;
777
+ invitation: InvitationSnapshot;
778
+ inviter: UserSnapshot;
779
+ }>(hooks, "afterCreateInvitation", async (p) =>
780
+ emit(
781
+ catalog.invitation.trackOrganizationMemberInvited(
782
+ p.organization,
783
+ p.invitation,
784
+ p.inviter,
785
+ getOrganizationTriggerInfo(p.inviter),
786
+ ),
787
+ ),
788
+ );
789
+
790
+ wrapOrgHook<{
791
+ organization: OrganizationSnapshot;
792
+ invitation: InvitationSnapshot;
793
+ member: MemberSnapshot;
794
+ user: UserSnapshot;
795
+ }>(hooks, "afterAcceptInvitation", async (p) =>
796
+ emit(
797
+ catalog.invitation.trackOrganizationMemberInviteAccepted(
798
+ p.organization,
799
+ p.invitation,
800
+ p.member,
801
+ p.user,
802
+ getOrganizationTriggerInfo(p.user),
803
+ ),
804
+ ),
805
+ );
806
+
807
+ wrapOrgHook<{
808
+ organization: OrganizationSnapshot;
809
+ invitation: InvitationSnapshot;
810
+ user: UserSnapshot;
811
+ }>(hooks, "afterRejectInvitation", async (p) =>
812
+ emit(
813
+ catalog.invitation.trackOrganizationMemberInviteRejected(
814
+ p.organization,
815
+ p.invitation,
816
+ p.user,
817
+ getOrganizationTriggerInfo(p.user),
818
+ ),
819
+ ),
820
+ );
821
+
822
+ wrapOrgHook<{
823
+ organization: OrganizationSnapshot;
824
+ invitation: InvitationSnapshot;
825
+ cancelledBy: UserSnapshot;
826
+ }>(hooks, "afterCancelInvitation", async (p) =>
827
+ emit(
828
+ catalog.invitation.trackOrganizationMemberInviteCanceled(
829
+ p.organization,
830
+ p.invitation,
831
+ p.cancelledBy,
832
+ getOrganizationTriggerInfo(p.cancelledBy),
833
+ ),
834
+ ),
835
+ );
836
+
837
+ wrapOrgHook<{
838
+ organization: OrganizationSnapshot;
839
+ team: TeamSnapshot;
840
+ user: UserSnapshot;
841
+ }>(hooks, "afterCreateTeam", async (p) =>
842
+ emit(
843
+ catalog.team.trackOrganizationTeamCreated(
844
+ p.organization,
845
+ p.team,
846
+ getOrganizationTriggerInfo(p.user),
847
+ ),
848
+ ),
849
+ );
850
+
851
+ wrapOrgHook<{
852
+ organization: OrganizationSnapshot;
853
+ team?: TeamSnapshot;
854
+ user: UserSnapshot;
855
+ }>(hooks, "afterUpdateTeam", async (p) => {
856
+ if (!p.team) return;
857
+ await emit(
858
+ catalog.team.trackOrganizationTeamUpdated(
859
+ p.organization,
860
+ p.team,
861
+ getOrganizationTriggerInfo(p.user),
862
+ ),
863
+ );
864
+ });
865
+
866
+ wrapOrgHook<{
867
+ organization: OrganizationSnapshot;
868
+ team: TeamSnapshot;
869
+ user: UserSnapshot;
870
+ }>(hooks, "afterDeleteTeam", async (p) =>
871
+ emit(
872
+ catalog.team.trackOrganizationTeamDeleted(
873
+ p.organization,
874
+ p.team,
875
+ getOrganizationTriggerInfo(p.user),
876
+ ),
877
+ ),
878
+ );
879
+
880
+ wrapOrgHook<{
881
+ organization: OrganizationSnapshot;
882
+ team: TeamSnapshot;
883
+ user: UserSnapshot;
884
+ teamMember: { teamId: string; userId: string };
885
+ }>(hooks, "afterAddTeamMember", async (p) =>
886
+ emit(
887
+ catalog.team.trackOrganizationTeamMemberAdded(
888
+ p.organization,
889
+ p.team,
890
+ p.user,
891
+ p.teamMember,
892
+ getOrganizationTriggerInfo(p.user),
893
+ ),
894
+ ),
895
+ );
896
+
897
+ wrapOrgHook<{
898
+ organization: OrganizationSnapshot;
899
+ team: TeamSnapshot;
900
+ user: UserSnapshot;
901
+ teamMember: { teamId: string; userId: string };
902
+ }>(hooks, "afterRemoveTeamMember", async (p) =>
903
+ emit(
904
+ catalog.team.trackOrganizationTeamMemberRemoved(
905
+ p.organization,
906
+ p.team,
907
+ p.user,
908
+ p.teamMember,
909
+ getOrganizationTriggerInfo(p.user),
910
+ ),
911
+ ),
912
+ );
913
+ }