@payez/next-mvp 4.1.7 → 4.1.9

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 (154) hide show
  1. package/README.md +792 -792
  2. package/dist/auth/better-auth.js +15 -3
  3. package/dist/components/account/MobileNavDrawer.js +18 -18
  4. package/dist/components/account/UserAvatarMenu.js +1 -1
  5. package/dist/components/admin/AlertSettingsTab.js +40 -40
  6. package/dist/components/admin/LoggingSettingsTab.js +28 -28
  7. package/dist/pages/login/page.js +28 -28
  8. package/dist/pages/settings/EnhancedSettingsPage.js +17 -17
  9. package/package.json +1 -1
  10. package/scripts/dev-broker.ps1 +35 -35
  11. package/scripts/dev-local.ps1 +45 -45
  12. package/src/api/auth-handler.ts +548 -548
  13. package/src/api-handlers/account/change-password.ts +142 -142
  14. package/src/api-handlers/account/update-phone.ts +79 -79
  15. package/src/api-handlers/account/verify-email.ts +125 -125
  16. package/src/api-handlers/account/verify-sms.ts +125 -125
  17. package/src/api-handlers/admin/analytics.ts +443 -443
  18. package/src/api-handlers/admin/audit.ts +223 -223
  19. package/src/api-handlers/admin/index.ts +63 -63
  20. package/src/api-handlers/admin/redis-sessions.ts +251 -251
  21. package/src/api-handlers/admin/sessions.ts +318 -318
  22. package/src/api-handlers/admin/site-logs.ts +365 -365
  23. package/src/api-handlers/admin/users.ts +242 -242
  24. package/src/api-handlers/admin/vibe-data.ts +324 -324
  25. package/src/api-handlers/auth/refresh.ts +682 -682
  26. package/src/api-handlers/auth/signout.ts +199 -199
  27. package/src/api-handlers/auth/status.ts +20 -20
  28. package/src/api-handlers/auth/update-session.ts +110 -110
  29. package/src/api-handlers/auth/verify-code.ts +112 -112
  30. package/src/api-handlers/session/refresh-viability.ts +36 -36
  31. package/src/api-handlers/test/force-expire.ts +60 -60
  32. package/src/auth/auth-decision.ts +229 -229
  33. package/src/auth/better-auth.ts +15 -3
  34. package/src/auth/route-config.ts +219 -219
  35. package/src/auth/types/auth-types.ts +555 -555
  36. package/src/auth/types/index.ts +7 -7
  37. package/src/auth/utils/idp-client.ts +445 -445
  38. package/src/auth/utils/index.ts +6 -6
  39. package/src/auth/utils/token-utils.ts +243 -243
  40. package/src/client/AuthContext.tsx +143 -143
  41. package/src/client/fetch-with-auth.ts +47 -47
  42. package/src/components/SessionSync.tsx +138 -138
  43. package/src/components/SignalRHealthCheck.tsx +131 -131
  44. package/src/components/account/MobileNavDrawer.tsx +305 -305
  45. package/src/components/account/UserAvatarMenu.tsx +266 -266
  46. package/src/components/account/index.ts +13 -13
  47. package/src/components/admin/AlertSettingsTab.tsx +728 -728
  48. package/src/components/admin/AnalyticsTab.tsx +703 -703
  49. package/src/components/admin/DataBrowserTab.tsx +505 -505
  50. package/src/components/admin/LoggingSettingsTab.tsx +665 -665
  51. package/src/components/admin/SessionsTab.tsx +414 -414
  52. package/src/components/admin/StatsTab.tsx +379 -379
  53. package/src/components/admin/VibeAdminLayout.tsx +187 -187
  54. package/src/components/admin/index.ts +59 -59
  55. package/src/components/auth/FederatedAuthSection.tsx +95 -95
  56. package/src/components/auth/ModeAwareLoginPage.tsx +135 -135
  57. package/src/components/auth/ModeAwareSignupPage.tsx +267 -267
  58. package/src/components/auth/TraditionalAuthSection.tsx +99 -99
  59. package/src/components/recovery/CompleteStep.tsx +36 -36
  60. package/src/components/recovery/InitiateRecoveryStep.tsx +68 -68
  61. package/src/components/recovery/SelectMethodStep.tsx +73 -73
  62. package/src/components/recovery/SetPasswordStep.tsx +97 -97
  63. package/src/components/recovery/VerifyCodeStep.tsx +90 -90
  64. package/src/components/reserved/ReservedRecoveryWarning.tsx +160 -160
  65. package/src/components/reserved/ReservedStatusBox.tsx +118 -118
  66. package/src/components/ui/BetaBadge.tsx +58 -58
  67. package/src/components/ui/Footer.tsx +93 -93
  68. package/src/config/env.ts +57 -57
  69. package/src/config/logger.ts +62 -62
  70. package/src/config/vibe-log-transport.ts +264 -264
  71. package/src/hooks/useAuth.ts +117 -117
  72. package/src/hooks/useAuthSettings.ts +97 -97
  73. package/src/hooks/useAvailableProviders.ts +120 -120
  74. package/src/hooks/usePasswordValidation.ts +127 -127
  75. package/src/hooks/usePublicAuthSettings.ts +149 -149
  76. package/src/hooks/useSessionExpiration.ts +101 -101
  77. package/src/hooks/useViabilitySession.ts +336 -336
  78. package/src/index.ts +60 -60
  79. package/src/lib/api-handler.ts +643 -643
  80. package/src/lib/app-slug.ts +178 -178
  81. package/src/lib/geolocation.ts +265 -265
  82. package/src/lib/idp-fetch.ts +110 -110
  83. package/src/lib/jwt-decode.ts +82 -82
  84. package/src/lib/roles.ts +177 -177
  85. package/src/lib/site-logger.ts +245 -245
  86. package/src/lib/standardized-client-api.ts +901 -901
  87. package/src/lib/test-aware-get-token.ts +41 -41
  88. package/src/lib/user-agent-parser.ts +252 -252
  89. package/src/logging/api/admin-analytics.ts +51 -51
  90. package/src/logging/api/audit-log.ts +53 -53
  91. package/src/logging/components/AdminAnalyticsLayout.tsx +49 -49
  92. package/src/logging/components/AuditLogViewer.tsx +125 -125
  93. package/src/logging/components/ErrorMetricsCard.tsx +98 -98
  94. package/src/logging/components/HealthMetricsCard.tsx +70 -70
  95. package/src/logging/hooks/useAdminAnalytics.ts +22 -22
  96. package/src/logging/hooks/useAuditLog.ts +24 -24
  97. package/src/logging/hooks/useErrorMetrics.ts +40 -40
  98. package/src/logging/hooks/useHealthMetrics.ts +44 -44
  99. package/src/logging/index.ts +18 -18
  100. package/src/logging/types/analytics.ts +81 -81
  101. package/src/logging/types/audit.ts +31 -31
  102. package/src/logging/types/index.ts +3 -3
  103. package/src/middleware/create-middleware.ts +663 -663
  104. package/src/middleware/rbac-check.ts +279 -279
  105. package/src/middleware/twofa-presets.ts +224 -224
  106. package/src/pages/admin-login/page.tsx +221 -221
  107. package/src/pages/admin-page-permissions/PagePermissionsAdminPage.tsx +527 -527
  108. package/src/pages/admin-page-permissions/index.ts +7 -7
  109. package/src/pages/admin-roles/RolesAdminPage.tsx +533 -533
  110. package/src/pages/admin-roles/modals.tsx +469 -469
  111. package/src/pages/client-admin/ClientSiteAdminPage.tsx +382 -382
  112. package/src/pages/client-admin/index.ts +33 -33
  113. package/src/pages/coming-soon/page.tsx +83 -83
  114. package/src/pages/login/page.tsx +456 -456
  115. package/src/pages/profile/EnhancedProfilePage.tsx +485 -485
  116. package/src/pages/profile/index.ts +9 -9
  117. package/src/pages/profile/page.tsx +166 -166
  118. package/src/pages/recovery/page.tsx +234 -234
  119. package/src/pages/roles/MyRolesPage.tsx +211 -211
  120. package/src/pages/roles/components.tsx +294 -294
  121. package/src/pages/roles/index.ts +17 -17
  122. package/src/pages/security/EnhancedSecurityPage.tsx +565 -565
  123. package/src/pages/security/index.ts +9 -9
  124. package/src/pages/security/page.tsx +507 -507
  125. package/src/pages/settings/EnhancedSettingsPage.tsx +642 -642
  126. package/src/pages/settings/index.ts +9 -9
  127. package/src/pages/settings/page.tsx +47 -47
  128. package/src/pages/showcase/ShowcasePage.tsx +532 -532
  129. package/src/pages/showcase/index.ts +13 -13
  130. package/src/pages/test-env/EmergencyLogoutPage.tsx +180 -180
  131. package/src/pages/test-env/JwtInspectPage.tsx +456 -456
  132. package/src/pages/test-env/RefreshTokenPage.tsx +157 -157
  133. package/src/pages/test-env/TestEnvPage.tsx +118 -118
  134. package/src/pages/test-env/index.ts +25 -25
  135. package/src/pages/verify-code/page.tsx +652 -652
  136. package/src/routes/account/update-phone.ts +13 -13
  137. package/src/routes/auth/logout.ts +108 -108
  138. package/src/routes/auth/nextauth.ts +45 -45
  139. package/src/routes/auth/settings.ts +75 -75
  140. package/src/routes/auth/viability.ts +224 -224
  141. package/src/server/auth-guard.ts +170 -170
  142. package/src/server/slim-middleware.ts +117 -117
  143. package/src/server/with-auth.ts +93 -93
  144. package/src/stores/authStore.ts +1899 -1899
  145. package/src/templates/instrumentation.ts +41 -41
  146. package/src/utils/logout.ts +30 -30
  147. package/src/vibe/client.ts +590 -590
  148. package/src/vibe/enterprise-auth.ts +208 -208
  149. package/src/vibe/errors.ts +185 -185
  150. package/src/vibe/generic.ts +431 -431
  151. package/src/vibe/hooks/index.ts +367 -367
  152. package/src/vibe/index.ts +132 -132
  153. package/src/vibe/sessions.ts +551 -551
  154. package/src/vibe/types.ts +577 -577
@@ -1,551 +1,551 @@
1
- /**
2
- * =============================================================================
3
- * VIBE LOGIN SESSIONS
4
- * =============================================================================
5
- *
6
- * Server-side utilities for managing login sessions in Vibe.
7
- * Tracks user logins with location, device info, and allows session control.
8
- *
9
- * This module uses the MVP's vibe client with proper auth token handling
10
- * for member-to-vibe calls.
11
- *
12
- * USAGE:
13
- * ------
14
- * import { createLoginSession, getUserSessions, revokeSession } from '@payez/next-mvp/vibe/sessions';
15
- *
16
- * // On login (requires user's access token):
17
- * await createLoginSession({
18
- * idpUserId: 22,
19
- * email: 'user@example.com',
20
- * ipAddress: '1.2.3.4',
21
- * userAgent: '...',
22
- * location: { city: 'NYC', ... },
23
- * accessToken: userAccessToken, // Required for member-to-vibe
24
- * });
25
- *
26
- * // Admin: revoke a session
27
- * await revokeSession(sessionId, 'admin@example.com', accessToken, 'Suspicious activity');
28
- *
29
- * =============================================================================
30
- */
31
-
32
- import crypto from 'crypto';
33
- import type { IVibeLoginSession, SessionStatus } from './types';
34
- import type { LocationInfo } from '../lib/geolocation';
35
- import type { DeviceInfo } from '../lib/user-agent-parser';
36
-
37
- // -----------------------------------------------------------------------------
38
- // CONFIGURATION
39
- // -----------------------------------------------------------------------------
40
-
41
- // Sessions expire after 30 days
42
- const SESSION_EXPIRY_DAYS = 30;
43
-
44
- // Table name in vibe_app collection
45
- const TABLE = 'login_sessions';
46
- const COLLECTION = 'vibe_app';
47
-
48
- // Environment variables
49
- const getEnv = (key: string): string => {
50
- if (typeof process !== 'undefined' && process.env) {
51
- return process.env[key] || '';
52
- }
53
- return '';
54
- };
55
-
56
- /**
57
- * Generate HMAC-SHA256 signature for proxy authentication
58
- */
59
- function generateHmacSignature(
60
- signingKey: string,
61
- timestamp: number,
62
- method: string,
63
- endpoint: string
64
- ): string {
65
- const stringToSign = `${timestamp}|${method}|${endpoint}`;
66
- return crypto
67
- .createHmac('sha256', Buffer.from(signingKey, 'base64'))
68
- .update(stringToSign)
69
- .digest('base64');
70
- }
71
-
72
- /**
73
- * Make a request through the IDP Vibe proxy
74
- */
75
- async function vibeProxyRequest<T>(
76
- endpoint: string,
77
- method: 'GET' | 'POST' | 'PUT' | 'DELETE',
78
- accessToken: string,
79
- body?: unknown
80
- ): Promise<T | null> {
81
- const idpUrl = getEnv('IDP_URL');
82
- const clientId = getEnv('VIBE_CLIENT_ID');
83
- const signingKey = getEnv('VIBE_HMAC_KEY');
84
-
85
- if (!idpUrl || !clientId || !signingKey) {
86
- console.error('[vibe-sessions] Missing env vars: IDP_URL, VIBE_CLIENT_ID, or VIBE_HMAC_KEY');
87
- return null;
88
- }
89
-
90
- const proxyUrl = `${idpUrl}/api/vibe/proxy`;
91
- const timestamp = Math.floor(Date.now() / 1000);
92
- const signature = generateHmacSignature(signingKey, timestamp, method, endpoint);
93
-
94
- const headers: Record<string, string> = {
95
- 'Content-Type': 'application/json',
96
- 'Authorization': `Bearer ${accessToken}`,
97
- 'X-Vibe-Client-Id': clientId,
98
- 'X-Vibe-Timestamp': String(timestamp),
99
- 'X-Vibe-Signature': signature,
100
- };
101
-
102
- try {
103
- const response = await fetch(proxyUrl, {
104
- method: 'POST',
105
- headers,
106
- body: JSON.stringify({
107
- endpoint,
108
- method,
109
- data: body ?? null,
110
- }),
111
- });
112
-
113
- if (response.status === 204) {
114
- return null;
115
- }
116
-
117
- if (!response.ok) {
118
- const errorText = await response.text();
119
- console.error(`[vibe-sessions] Proxy error ${response.status}:`, errorText);
120
- return null;
121
- }
122
-
123
- const data = await response.json();
124
- // Unwrap IDP envelope if present
125
- return (data?.data ?? data) as T;
126
- } catch (error) {
127
- console.error('[vibe-sessions] Proxy request failed:', error);
128
- return null;
129
- }
130
- }
131
-
132
- // -----------------------------------------------------------------------------
133
- // TYPES
134
- // -----------------------------------------------------------------------------
135
-
136
- export interface CreateSessionInput {
137
- /** IDP user ID */
138
- idpUserId: number;
139
- /** User email */
140
- email: string;
141
- /** User's display name */
142
- name?: string;
143
- /** Client IP address */
144
- ipAddress: string;
145
- /** User agent string */
146
- userAgent: string;
147
- /** Location info from geolocation lookup */
148
- location?: LocationInfo | null;
149
- /** Parsed device info */
150
- device?: DeviceInfo;
151
- /** OAuth provider used for login */
152
- oauthProvider?: string;
153
- /** Hashed session token for lookup */
154
- sessionTokenHash?: string;
155
- /**
156
- * User's access token - REQUIRED for member-to-vibe calls.
157
- * Without this, the request will fail with 401.
158
- */
159
- accessToken: string;
160
- }
161
-
162
- export interface SessionQueryOptions {
163
- status?: SessionStatus;
164
- page?: number;
165
- pageSize?: number;
166
- }
167
-
168
- // -----------------------------------------------------------------------------
169
- // CREATE SESSION
170
- // -----------------------------------------------------------------------------
171
-
172
- /**
173
- * Create a new login session record.
174
- * Called when a user successfully logs in.
175
- *
176
- * @param input - Session creation parameters including accessToken
177
- * @returns The created session, or null on failure
178
- */
179
- export async function createLoginSession(
180
- input: CreateSessionInput
181
- ): Promise<IVibeLoginSession | null> {
182
- if (!input.accessToken) {
183
- console.error('[vibe-sessions] accessToken is required for createLoginSession');
184
- return null;
185
- }
186
-
187
- const now = new Date();
188
- const expiresAt = new Date(now.getTime() + SESSION_EXPIRY_DAYS * 24 * 60 * 60 * 1000);
189
-
190
- const sessionData = {
191
- idp_user_id: input.idpUserId,
192
- email: input.email,
193
- name: input.name || null,
194
-
195
- // Location
196
- ip_address: input.ipAddress,
197
- city: input.location?.city || null,
198
- region: input.location?.region || null,
199
- country: input.location?.country || null,
200
- country_code: input.location?.countryCode || null,
201
- latitude: input.location?.latitude || null,
202
- longitude: input.location?.longitude || null,
203
- timezone: input.location?.timezone || null,
204
-
205
- // Device
206
- user_agent: input.userAgent,
207
- device_type: input.device?.deviceType || 'unknown',
208
- browser: input.device?.browser || 'Unknown',
209
- browser_version: input.device?.browserVersion || null,
210
- os: input.device?.os || 'Unknown',
211
- os_version: input.device?.osVersion || null,
212
-
213
- // Auth
214
- oauth_provider: input.oauthProvider || null,
215
- session_token_hash: input.sessionTokenHash || null,
216
-
217
- // Status
218
- status: 'active' as SessionStatus,
219
- revoked_by: null,
220
- revoked_at: null,
221
- revoke_reason: null,
222
-
223
- // Timestamps
224
- last_activity: now.toISOString(),
225
- expires_at: expiresAt.toISOString(),
226
- };
227
-
228
- const endpoint = `/v1/collections/${COLLECTION}/tables/${TABLE}`;
229
- const session = await vibeProxyRequest<IVibeLoginSession>(
230
- endpoint,
231
- 'POST',
232
- input.accessToken,
233
- sessionData
234
- );
235
-
236
- if (session) {
237
- console.log(`[vibe-sessions] Created session for ${input.email} from ${input.ipAddress}`);
238
- }
239
-
240
- return session;
241
- }
242
-
243
- // -----------------------------------------------------------------------------
244
- // GET SESSIONS
245
- // -----------------------------------------------------------------------------
246
-
247
- /**
248
- * Get all sessions for a specific user.
249
- *
250
- * @param idpUserId - The user's IDP ID
251
- * @param accessToken - User's access token for auth
252
- * @returns Array of sessions
253
- */
254
- export async function getUserSessions(
255
- idpUserId: number,
256
- accessToken: string
257
- ): Promise<IVibeLoginSession[]> {
258
- const endpoint = `/v1/collections/${COLLECTION}/tables/${TABLE}/query`;
259
- const result = await vibeProxyRequest<{ data?: IVibeLoginSession[]; items?: IVibeLoginSession[] }>(
260
- endpoint,
261
- 'POST',
262
- accessToken,
263
- {
264
- filter: [{ field: 'idp_user_id', operator: 'eq', value: idpUserId }],
265
- pageSize: 100,
266
- }
267
- );
268
-
269
- return result?.data || result?.items || [];
270
- }
271
-
272
- /**
273
- * Get all sessions (admin only).
274
- * Returns sessions from all users, sorted by most recent first.
275
- *
276
- * @param options - Query options
277
- * @param accessToken - Admin's access token
278
- * @returns Array of sessions with total count
279
- */
280
- export async function getAllSessions(
281
- options: SessionQueryOptions = {},
282
- accessToken: string
283
- ): Promise<{ sessions: IVibeLoginSession[]; total: number }> {
284
- const page = options.page || 1;
285
- const pageSize = options.pageSize || 50;
286
-
287
- const query: Record<string, unknown> = {
288
- page,
289
- pageSize,
290
- orderBy: 'created_at',
291
- orderDir: 'desc',
292
- };
293
-
294
- if (options.status) {
295
- query.filter = [{ field: 'status', operator: 'eq', value: options.status }];
296
- }
297
-
298
- const endpoint = `/v1/collections/${COLLECTION}/tables/${TABLE}/query`;
299
- const result = await vibeProxyRequest<{
300
- data?: IVibeLoginSession[];
301
- items?: IVibeLoginSession[];
302
- meta?: { total?: number; totalCount?: number };
303
- totalCount?: number;
304
- }>(endpoint, 'POST', accessToken, query);
305
-
306
- const sessions = result?.data || result?.items || [];
307
- const total = result?.meta?.total ?? result?.meta?.totalCount ?? result?.totalCount ?? sessions.length;
308
-
309
- return { sessions, total };
310
- }
311
-
312
- /**
313
- * Get a single session by ID.
314
- *
315
- * @param sessionId - The session document ID
316
- * @param accessToken - Access token for auth
317
- */
318
- export async function getSessionById(
319
- sessionId: number,
320
- accessToken: string
321
- ): Promise<IVibeLoginSession | null> {
322
- const endpoint = `/v1/collections/${COLLECTION}/tables/${TABLE}/${sessionId}`;
323
- return vibeProxyRequest<IVibeLoginSession>(endpoint, 'GET', accessToken);
324
- }
325
-
326
- // -----------------------------------------------------------------------------
327
- // REVOKE SESSION
328
- // -----------------------------------------------------------------------------
329
-
330
- /**
331
- * Revoke a specific session.
332
- * The user will be logged out on their next request.
333
- *
334
- * @param sessionId - The session document ID
335
- * @param adminEmail - Email of the admin performing the revocation
336
- * @param accessToken - Admin's access token
337
- * @param reason - Optional reason for revocation
338
- * @returns The updated session, or null on failure
339
- */
340
- export async function revokeSession(
341
- sessionId: number,
342
- adminEmail: string,
343
- accessToken: string,
344
- reason?: string
345
- ): Promise<IVibeLoginSession | null> {
346
- const now = new Date().toISOString();
347
-
348
- const endpoint = `/v1/collections/${COLLECTION}/tables/${TABLE}/${sessionId}`;
349
- const session = await vibeProxyRequest<IVibeLoginSession>(
350
- endpoint,
351
- 'PUT',
352
- accessToken,
353
- {
354
- status: 'revoked',
355
- revoked_by: adminEmail,
356
- revoked_at: now,
357
- revoke_reason: reason || 'Revoked by administrator',
358
- }
359
- );
360
-
361
- if (session) {
362
- console.log(`[vibe-sessions] Session ${sessionId} revoked by ${adminEmail}`);
363
- }
364
-
365
- return session;
366
- }
367
-
368
- /**
369
- * Revoke all sessions for a specific user.
370
- * Useful when an account is compromised.
371
- *
372
- * @param idpUserId - The user's IDP ID
373
- * @param adminEmail - Email of the admin performing the revocation
374
- * @param accessToken - Admin's access token
375
- * @param reason - Optional reason for revocation
376
- * @returns Number of sessions revoked
377
- */
378
- export async function revokeAllUserSessions(
379
- idpUserId: number,
380
- adminEmail: string,
381
- accessToken: string,
382
- reason?: string
383
- ): Promise<number> {
384
- const sessions = await getUserSessions(idpUserId, accessToken);
385
- const activeSessions = sessions.filter(s => s.status === 'active');
386
-
387
- let revokedCount = 0;
388
- for (const session of activeSessions) {
389
- const result = await revokeSession(session.id, adminEmail, accessToken, reason);
390
- if (result) revokedCount++;
391
- }
392
-
393
- console.log(`[vibe-sessions] Revoked ${revokedCount} sessions for user ${idpUserId}`);
394
- return revokedCount;
395
- }
396
-
397
- // -----------------------------------------------------------------------------
398
- // UPDATE ACTIVITY
399
- // -----------------------------------------------------------------------------
400
-
401
- /**
402
- * Update the last_activity timestamp for a session.
403
- * Call this periodically during user activity.
404
- *
405
- * @param sessionId - The session document ID
406
- * @param accessToken - Access token for auth
407
- * @returns true if updated successfully
408
- */
409
- export async function updateSessionActivity(
410
- sessionId: number,
411
- accessToken: string
412
- ): Promise<boolean> {
413
- const endpoint = `/v1/collections/${COLLECTION}/tables/${TABLE}/${sessionId}`;
414
- const result = await vibeProxyRequest<IVibeLoginSession>(
415
- endpoint,
416
- 'PUT',
417
- accessToken,
418
- { last_activity: new Date().toISOString() }
419
- );
420
-
421
- return result !== null;
422
- }
423
-
424
- // -----------------------------------------------------------------------------
425
- // CHECK SESSION STATUS
426
- // -----------------------------------------------------------------------------
427
-
428
- /**
429
- * Check if a session is still valid (not revoked/expired).
430
- * Use this in middleware to enforce session revocation.
431
- *
432
- * @param sessionId - The session document ID
433
- * @param accessToken - Access token for auth
434
- * @returns true if session is active and not expired
435
- */
436
- export async function isSessionValid(
437
- sessionId: number,
438
- accessToken: string
439
- ): Promise<boolean> {
440
- const session = await getSessionById(sessionId, accessToken);
441
-
442
- if (!session) return false;
443
- if (session.status !== 'active') return false;
444
-
445
- // Check expiry
446
- const expiresAt = new Date(session.expires_at);
447
- if (expiresAt < new Date()) {
448
- return false;
449
- }
450
-
451
- return true;
452
- }
453
-
454
- /**
455
- * Check if a user has any revoked sessions that need enforcement.
456
- * Returns the most recently revoked session if found.
457
- *
458
- * @param idpUserId - The user's IDP ID
459
- * @param accessToken - Access token for auth
460
- * @param sessionTokenHash - Optional token hash to check specific session
461
- * @returns Revoked session info if enforcement needed, null otherwise
462
- */
463
- export async function checkSessionRevocation(
464
- idpUserId: number,
465
- accessToken: string,
466
- sessionTokenHash?: string
467
- ): Promise<IVibeLoginSession | null> {
468
- const sessions = await getUserSessions(idpUserId, accessToken);
469
-
470
- // If we have a specific token hash, check that session
471
- if (sessionTokenHash) {
472
- const matchingSession = sessions.find(s => s.session_token_hash === sessionTokenHash);
473
- if (matchingSession && matchingSession.status === 'revoked') {
474
- return matchingSession;
475
- }
476
- }
477
-
478
- // Otherwise, check if there are any recently revoked sessions
479
- // (revoked within the last hour - gives time for enforcement)
480
- const oneHourAgo = new Date(Date.now() - 60 * 60 * 1000);
481
- const recentlyRevoked = sessions.find(
482
- s =>
483
- s.status === 'revoked' &&
484
- s.revoked_at &&
485
- new Date(s.revoked_at) > oneHourAgo
486
- );
487
-
488
- return recentlyRevoked || null;
489
- }
490
-
491
- // -----------------------------------------------------------------------------
492
- // STATISTICS
493
- // -----------------------------------------------------------------------------
494
-
495
- /**
496
- * Get session statistics for admin dashboard.
497
- *
498
- * @param accessToken - Admin's access token
499
- */
500
- export async function getSessionStats(accessToken: string): Promise<{
501
- totalActive: number;
502
- totalRevoked: number;
503
- uniqueUsers: number;
504
- byCountry: Record<string, number>;
505
- byDevice: Record<string, number>;
506
- recentLogins: number; // Last 24 hours
507
- }> {
508
- const { sessions } = await getAllSessions({ pageSize: 1000 }, accessToken);
509
-
510
- const stats = {
511
- totalActive: 0,
512
- totalRevoked: 0,
513
- uniqueUsers: new Set<number>(),
514
- byCountry: {} as Record<string, number>,
515
- byDevice: {} as Record<string, number>,
516
- recentLogins: 0,
517
- };
518
-
519
- const oneDayAgo = new Date(Date.now() - 24 * 60 * 60 * 1000);
520
-
521
- for (const session of sessions) {
522
- // Status counts
523
- if (session.status === 'active') stats.totalActive++;
524
- if (session.status === 'revoked') stats.totalRevoked++;
525
-
526
- // Unique users
527
- stats.uniqueUsers.add(session.idp_user_id);
528
-
529
- // By country
530
- const country = session.country_code || 'Unknown';
531
- stats.byCountry[country] = (stats.byCountry[country] || 0) + 1;
532
-
533
- // By device type
534
- const device = session.device_type || 'unknown';
535
- stats.byDevice[device] = (stats.byDevice[device] || 0) + 1;
536
-
537
- // Recent logins
538
- if (new Date(session.created_at) > oneDayAgo) {
539
- stats.recentLogins++;
540
- }
541
- }
542
-
543
- return {
544
- totalActive: stats.totalActive,
545
- totalRevoked: stats.totalRevoked,
546
- uniqueUsers: stats.uniqueUsers.size,
547
- byCountry: stats.byCountry,
548
- byDevice: stats.byDevice,
549
- recentLogins: stats.recentLogins,
550
- };
551
- }
1
+ /**
2
+ * =============================================================================
3
+ * VIBE LOGIN SESSIONS
4
+ * =============================================================================
5
+ *
6
+ * Server-side utilities for managing login sessions in Vibe.
7
+ * Tracks user logins with location, device info, and allows session control.
8
+ *
9
+ * This module uses the MVP's vibe client with proper auth token handling
10
+ * for member-to-vibe calls.
11
+ *
12
+ * USAGE:
13
+ * ------
14
+ * import { createLoginSession, getUserSessions, revokeSession } from '@payez/next-mvp/vibe/sessions';
15
+ *
16
+ * // On login (requires user's access token):
17
+ * await createLoginSession({
18
+ * idpUserId: 22,
19
+ * email: 'user@example.com',
20
+ * ipAddress: '1.2.3.4',
21
+ * userAgent: '...',
22
+ * location: { city: 'NYC', ... },
23
+ * accessToken: userAccessToken, // Required for member-to-vibe
24
+ * });
25
+ *
26
+ * // Admin: revoke a session
27
+ * await revokeSession(sessionId, 'admin@example.com', accessToken, 'Suspicious activity');
28
+ *
29
+ * =============================================================================
30
+ */
31
+
32
+ import crypto from 'crypto';
33
+ import type { IVibeLoginSession, SessionStatus } from './types';
34
+ import type { LocationInfo } from '../lib/geolocation';
35
+ import type { DeviceInfo } from '../lib/user-agent-parser';
36
+
37
+ // -----------------------------------------------------------------------------
38
+ // CONFIGURATION
39
+ // -----------------------------------------------------------------------------
40
+
41
+ // Sessions expire after 30 days
42
+ const SESSION_EXPIRY_DAYS = 30;
43
+
44
+ // Table name in vibe_app collection
45
+ const TABLE = 'login_sessions';
46
+ const COLLECTION = 'vibe_app';
47
+
48
+ // Environment variables
49
+ const getEnv = (key: string): string => {
50
+ if (typeof process !== 'undefined' && process.env) {
51
+ return process.env[key] || '';
52
+ }
53
+ return '';
54
+ };
55
+
56
+ /**
57
+ * Generate HMAC-SHA256 signature for proxy authentication
58
+ */
59
+ function generateHmacSignature(
60
+ signingKey: string,
61
+ timestamp: number,
62
+ method: string,
63
+ endpoint: string
64
+ ): string {
65
+ const stringToSign = `${timestamp}|${method}|${endpoint}`;
66
+ return crypto
67
+ .createHmac('sha256', Buffer.from(signingKey, 'base64'))
68
+ .update(stringToSign)
69
+ .digest('base64');
70
+ }
71
+
72
+ /**
73
+ * Make a request through the IDP Vibe proxy
74
+ */
75
+ async function vibeProxyRequest<T>(
76
+ endpoint: string,
77
+ method: 'GET' | 'POST' | 'PUT' | 'DELETE',
78
+ accessToken: string,
79
+ body?: unknown
80
+ ): Promise<T | null> {
81
+ const idpUrl = getEnv('IDP_URL');
82
+ const clientId = getEnv('VIBE_CLIENT_ID');
83
+ const signingKey = getEnv('VIBE_HMAC_KEY');
84
+
85
+ if (!idpUrl || !clientId || !signingKey) {
86
+ console.error('[vibe-sessions] Missing env vars: IDP_URL, VIBE_CLIENT_ID, or VIBE_HMAC_KEY');
87
+ return null;
88
+ }
89
+
90
+ const proxyUrl = `${idpUrl}/api/vibe/proxy`;
91
+ const timestamp = Math.floor(Date.now() / 1000);
92
+ const signature = generateHmacSignature(signingKey, timestamp, method, endpoint);
93
+
94
+ const headers: Record<string, string> = {
95
+ 'Content-Type': 'application/json',
96
+ 'Authorization': `Bearer ${accessToken}`,
97
+ 'X-Vibe-Client-Id': clientId,
98
+ 'X-Vibe-Timestamp': String(timestamp),
99
+ 'X-Vibe-Signature': signature,
100
+ };
101
+
102
+ try {
103
+ const response = await fetch(proxyUrl, {
104
+ method: 'POST',
105
+ headers,
106
+ body: JSON.stringify({
107
+ endpoint,
108
+ method,
109
+ data: body ?? null,
110
+ }),
111
+ });
112
+
113
+ if (response.status === 204) {
114
+ return null;
115
+ }
116
+
117
+ if (!response.ok) {
118
+ const errorText = await response.text();
119
+ console.error(`[vibe-sessions] Proxy error ${response.status}:`, errorText);
120
+ return null;
121
+ }
122
+
123
+ const data = await response.json();
124
+ // Unwrap IDP envelope if present
125
+ return (data?.data ?? data) as T;
126
+ } catch (error) {
127
+ console.error('[vibe-sessions] Proxy request failed:', error);
128
+ return null;
129
+ }
130
+ }
131
+
132
+ // -----------------------------------------------------------------------------
133
+ // TYPES
134
+ // -----------------------------------------------------------------------------
135
+
136
+ export interface CreateSessionInput {
137
+ /** IDP user ID */
138
+ idpUserId: number;
139
+ /** User email */
140
+ email: string;
141
+ /** User's display name */
142
+ name?: string;
143
+ /** Client IP address */
144
+ ipAddress: string;
145
+ /** User agent string */
146
+ userAgent: string;
147
+ /** Location info from geolocation lookup */
148
+ location?: LocationInfo | null;
149
+ /** Parsed device info */
150
+ device?: DeviceInfo;
151
+ /** OAuth provider used for login */
152
+ oauthProvider?: string;
153
+ /** Hashed session token for lookup */
154
+ sessionTokenHash?: string;
155
+ /**
156
+ * User's access token - REQUIRED for member-to-vibe calls.
157
+ * Without this, the request will fail with 401.
158
+ */
159
+ accessToken: string;
160
+ }
161
+
162
+ export interface SessionQueryOptions {
163
+ status?: SessionStatus;
164
+ page?: number;
165
+ pageSize?: number;
166
+ }
167
+
168
+ // -----------------------------------------------------------------------------
169
+ // CREATE SESSION
170
+ // -----------------------------------------------------------------------------
171
+
172
+ /**
173
+ * Create a new login session record.
174
+ * Called when a user successfully logs in.
175
+ *
176
+ * @param input - Session creation parameters including accessToken
177
+ * @returns The created session, or null on failure
178
+ */
179
+ export async function createLoginSession(
180
+ input: CreateSessionInput
181
+ ): Promise<IVibeLoginSession | null> {
182
+ if (!input.accessToken) {
183
+ console.error('[vibe-sessions] accessToken is required for createLoginSession');
184
+ return null;
185
+ }
186
+
187
+ const now = new Date();
188
+ const expiresAt = new Date(now.getTime() + SESSION_EXPIRY_DAYS * 24 * 60 * 60 * 1000);
189
+
190
+ const sessionData = {
191
+ idp_user_id: input.idpUserId,
192
+ email: input.email,
193
+ name: input.name || null,
194
+
195
+ // Location
196
+ ip_address: input.ipAddress,
197
+ city: input.location?.city || null,
198
+ region: input.location?.region || null,
199
+ country: input.location?.country || null,
200
+ country_code: input.location?.countryCode || null,
201
+ latitude: input.location?.latitude || null,
202
+ longitude: input.location?.longitude || null,
203
+ timezone: input.location?.timezone || null,
204
+
205
+ // Device
206
+ user_agent: input.userAgent,
207
+ device_type: input.device?.deviceType || 'unknown',
208
+ browser: input.device?.browser || 'Unknown',
209
+ browser_version: input.device?.browserVersion || null,
210
+ os: input.device?.os || 'Unknown',
211
+ os_version: input.device?.osVersion || null,
212
+
213
+ // Auth
214
+ oauth_provider: input.oauthProvider || null,
215
+ session_token_hash: input.sessionTokenHash || null,
216
+
217
+ // Status
218
+ status: 'active' as SessionStatus,
219
+ revoked_by: null,
220
+ revoked_at: null,
221
+ revoke_reason: null,
222
+
223
+ // Timestamps
224
+ last_activity: now.toISOString(),
225
+ expires_at: expiresAt.toISOString(),
226
+ };
227
+
228
+ const endpoint = `/v1/collections/${COLLECTION}/tables/${TABLE}`;
229
+ const session = await vibeProxyRequest<IVibeLoginSession>(
230
+ endpoint,
231
+ 'POST',
232
+ input.accessToken,
233
+ sessionData
234
+ );
235
+
236
+ if (session) {
237
+ console.log(`[vibe-sessions] Created session for ${input.email} from ${input.ipAddress}`);
238
+ }
239
+
240
+ return session;
241
+ }
242
+
243
+ // -----------------------------------------------------------------------------
244
+ // GET SESSIONS
245
+ // -----------------------------------------------------------------------------
246
+
247
+ /**
248
+ * Get all sessions for a specific user.
249
+ *
250
+ * @param idpUserId - The user's IDP ID
251
+ * @param accessToken - User's access token for auth
252
+ * @returns Array of sessions
253
+ */
254
+ export async function getUserSessions(
255
+ idpUserId: number,
256
+ accessToken: string
257
+ ): Promise<IVibeLoginSession[]> {
258
+ const endpoint = `/v1/collections/${COLLECTION}/tables/${TABLE}/query`;
259
+ const result = await vibeProxyRequest<{ data?: IVibeLoginSession[]; items?: IVibeLoginSession[] }>(
260
+ endpoint,
261
+ 'POST',
262
+ accessToken,
263
+ {
264
+ filter: [{ field: 'idp_user_id', operator: 'eq', value: idpUserId }],
265
+ pageSize: 100,
266
+ }
267
+ );
268
+
269
+ return result?.data || result?.items || [];
270
+ }
271
+
272
+ /**
273
+ * Get all sessions (admin only).
274
+ * Returns sessions from all users, sorted by most recent first.
275
+ *
276
+ * @param options - Query options
277
+ * @param accessToken - Admin's access token
278
+ * @returns Array of sessions with total count
279
+ */
280
+ export async function getAllSessions(
281
+ options: SessionQueryOptions = {},
282
+ accessToken: string
283
+ ): Promise<{ sessions: IVibeLoginSession[]; total: number }> {
284
+ const page = options.page || 1;
285
+ const pageSize = options.pageSize || 50;
286
+
287
+ const query: Record<string, unknown> = {
288
+ page,
289
+ pageSize,
290
+ orderBy: 'created_at',
291
+ orderDir: 'desc',
292
+ };
293
+
294
+ if (options.status) {
295
+ query.filter = [{ field: 'status', operator: 'eq', value: options.status }];
296
+ }
297
+
298
+ const endpoint = `/v1/collections/${COLLECTION}/tables/${TABLE}/query`;
299
+ const result = await vibeProxyRequest<{
300
+ data?: IVibeLoginSession[];
301
+ items?: IVibeLoginSession[];
302
+ meta?: { total?: number; totalCount?: number };
303
+ totalCount?: number;
304
+ }>(endpoint, 'POST', accessToken, query);
305
+
306
+ const sessions = result?.data || result?.items || [];
307
+ const total = result?.meta?.total ?? result?.meta?.totalCount ?? result?.totalCount ?? sessions.length;
308
+
309
+ return { sessions, total };
310
+ }
311
+
312
+ /**
313
+ * Get a single session by ID.
314
+ *
315
+ * @param sessionId - The session document ID
316
+ * @param accessToken - Access token for auth
317
+ */
318
+ export async function getSessionById(
319
+ sessionId: number,
320
+ accessToken: string
321
+ ): Promise<IVibeLoginSession | null> {
322
+ const endpoint = `/v1/collections/${COLLECTION}/tables/${TABLE}/${sessionId}`;
323
+ return vibeProxyRequest<IVibeLoginSession>(endpoint, 'GET', accessToken);
324
+ }
325
+
326
+ // -----------------------------------------------------------------------------
327
+ // REVOKE SESSION
328
+ // -----------------------------------------------------------------------------
329
+
330
+ /**
331
+ * Revoke a specific session.
332
+ * The user will be logged out on their next request.
333
+ *
334
+ * @param sessionId - The session document ID
335
+ * @param adminEmail - Email of the admin performing the revocation
336
+ * @param accessToken - Admin's access token
337
+ * @param reason - Optional reason for revocation
338
+ * @returns The updated session, or null on failure
339
+ */
340
+ export async function revokeSession(
341
+ sessionId: number,
342
+ adminEmail: string,
343
+ accessToken: string,
344
+ reason?: string
345
+ ): Promise<IVibeLoginSession | null> {
346
+ const now = new Date().toISOString();
347
+
348
+ const endpoint = `/v1/collections/${COLLECTION}/tables/${TABLE}/${sessionId}`;
349
+ const session = await vibeProxyRequest<IVibeLoginSession>(
350
+ endpoint,
351
+ 'PUT',
352
+ accessToken,
353
+ {
354
+ status: 'revoked',
355
+ revoked_by: adminEmail,
356
+ revoked_at: now,
357
+ revoke_reason: reason || 'Revoked by administrator',
358
+ }
359
+ );
360
+
361
+ if (session) {
362
+ console.log(`[vibe-sessions] Session ${sessionId} revoked by ${adminEmail}`);
363
+ }
364
+
365
+ return session;
366
+ }
367
+
368
+ /**
369
+ * Revoke all sessions for a specific user.
370
+ * Useful when an account is compromised.
371
+ *
372
+ * @param idpUserId - The user's IDP ID
373
+ * @param adminEmail - Email of the admin performing the revocation
374
+ * @param accessToken - Admin's access token
375
+ * @param reason - Optional reason for revocation
376
+ * @returns Number of sessions revoked
377
+ */
378
+ export async function revokeAllUserSessions(
379
+ idpUserId: number,
380
+ adminEmail: string,
381
+ accessToken: string,
382
+ reason?: string
383
+ ): Promise<number> {
384
+ const sessions = await getUserSessions(idpUserId, accessToken);
385
+ const activeSessions = sessions.filter(s => s.status === 'active');
386
+
387
+ let revokedCount = 0;
388
+ for (const session of activeSessions) {
389
+ const result = await revokeSession(session.id, adminEmail, accessToken, reason);
390
+ if (result) revokedCount++;
391
+ }
392
+
393
+ console.log(`[vibe-sessions] Revoked ${revokedCount} sessions for user ${idpUserId}`);
394
+ return revokedCount;
395
+ }
396
+
397
+ // -----------------------------------------------------------------------------
398
+ // UPDATE ACTIVITY
399
+ // -----------------------------------------------------------------------------
400
+
401
+ /**
402
+ * Update the last_activity timestamp for a session.
403
+ * Call this periodically during user activity.
404
+ *
405
+ * @param sessionId - The session document ID
406
+ * @param accessToken - Access token for auth
407
+ * @returns true if updated successfully
408
+ */
409
+ export async function updateSessionActivity(
410
+ sessionId: number,
411
+ accessToken: string
412
+ ): Promise<boolean> {
413
+ const endpoint = `/v1/collections/${COLLECTION}/tables/${TABLE}/${sessionId}`;
414
+ const result = await vibeProxyRequest<IVibeLoginSession>(
415
+ endpoint,
416
+ 'PUT',
417
+ accessToken,
418
+ { last_activity: new Date().toISOString() }
419
+ );
420
+
421
+ return result !== null;
422
+ }
423
+
424
+ // -----------------------------------------------------------------------------
425
+ // CHECK SESSION STATUS
426
+ // -----------------------------------------------------------------------------
427
+
428
+ /**
429
+ * Check if a session is still valid (not revoked/expired).
430
+ * Use this in middleware to enforce session revocation.
431
+ *
432
+ * @param sessionId - The session document ID
433
+ * @param accessToken - Access token for auth
434
+ * @returns true if session is active and not expired
435
+ */
436
+ export async function isSessionValid(
437
+ sessionId: number,
438
+ accessToken: string
439
+ ): Promise<boolean> {
440
+ const session = await getSessionById(sessionId, accessToken);
441
+
442
+ if (!session) return false;
443
+ if (session.status !== 'active') return false;
444
+
445
+ // Check expiry
446
+ const expiresAt = new Date(session.expires_at);
447
+ if (expiresAt < new Date()) {
448
+ return false;
449
+ }
450
+
451
+ return true;
452
+ }
453
+
454
+ /**
455
+ * Check if a user has any revoked sessions that need enforcement.
456
+ * Returns the most recently revoked session if found.
457
+ *
458
+ * @param idpUserId - The user's IDP ID
459
+ * @param accessToken - Access token for auth
460
+ * @param sessionTokenHash - Optional token hash to check specific session
461
+ * @returns Revoked session info if enforcement needed, null otherwise
462
+ */
463
+ export async function checkSessionRevocation(
464
+ idpUserId: number,
465
+ accessToken: string,
466
+ sessionTokenHash?: string
467
+ ): Promise<IVibeLoginSession | null> {
468
+ const sessions = await getUserSessions(idpUserId, accessToken);
469
+
470
+ // If we have a specific token hash, check that session
471
+ if (sessionTokenHash) {
472
+ const matchingSession = sessions.find(s => s.session_token_hash === sessionTokenHash);
473
+ if (matchingSession && matchingSession.status === 'revoked') {
474
+ return matchingSession;
475
+ }
476
+ }
477
+
478
+ // Otherwise, check if there are any recently revoked sessions
479
+ // (revoked within the last hour - gives time for enforcement)
480
+ const oneHourAgo = new Date(Date.now() - 60 * 60 * 1000);
481
+ const recentlyRevoked = sessions.find(
482
+ s =>
483
+ s.status === 'revoked' &&
484
+ s.revoked_at &&
485
+ new Date(s.revoked_at) > oneHourAgo
486
+ );
487
+
488
+ return recentlyRevoked || null;
489
+ }
490
+
491
+ // -----------------------------------------------------------------------------
492
+ // STATISTICS
493
+ // -----------------------------------------------------------------------------
494
+
495
+ /**
496
+ * Get session statistics for admin dashboard.
497
+ *
498
+ * @param accessToken - Admin's access token
499
+ */
500
+ export async function getSessionStats(accessToken: string): Promise<{
501
+ totalActive: number;
502
+ totalRevoked: number;
503
+ uniqueUsers: number;
504
+ byCountry: Record<string, number>;
505
+ byDevice: Record<string, number>;
506
+ recentLogins: number; // Last 24 hours
507
+ }> {
508
+ const { sessions } = await getAllSessions({ pageSize: 1000 }, accessToken);
509
+
510
+ const stats = {
511
+ totalActive: 0,
512
+ totalRevoked: 0,
513
+ uniqueUsers: new Set<number>(),
514
+ byCountry: {} as Record<string, number>,
515
+ byDevice: {} as Record<string, number>,
516
+ recentLogins: 0,
517
+ };
518
+
519
+ const oneDayAgo = new Date(Date.now() - 24 * 60 * 60 * 1000);
520
+
521
+ for (const session of sessions) {
522
+ // Status counts
523
+ if (session.status === 'active') stats.totalActive++;
524
+ if (session.status === 'revoked') stats.totalRevoked++;
525
+
526
+ // Unique users
527
+ stats.uniqueUsers.add(session.idp_user_id);
528
+
529
+ // By country
530
+ const country = session.country_code || 'Unknown';
531
+ stats.byCountry[country] = (stats.byCountry[country] || 0) + 1;
532
+
533
+ // By device type
534
+ const device = session.device_type || 'unknown';
535
+ stats.byDevice[device] = (stats.byDevice[device] || 0) + 1;
536
+
537
+ // Recent logins
538
+ if (new Date(session.created_at) > oneDayAgo) {
539
+ stats.recentLogins++;
540
+ }
541
+ }
542
+
543
+ return {
544
+ totalActive: stats.totalActive,
545
+ totalRevoked: stats.totalRevoked,
546
+ uniqueUsers: stats.uniqueUsers.size,
547
+ byCountry: stats.byCountry,
548
+ byDevice: stats.byDevice,
549
+ recentLogins: stats.recentLogins,
550
+ };
551
+ }