@payez/next-mvp 4.1.8 → 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 (155) 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/lib/idp-fetch.js +9 -25
  8. package/dist/pages/login/page.js +28 -28
  9. package/dist/pages/settings/EnhancedSettingsPage.js +17 -17
  10. package/package.json +1 -1
  11. package/scripts/dev-broker.ps1 +35 -35
  12. package/scripts/dev-local.ps1 +45 -45
  13. package/src/api/auth-handler.ts +548 -548
  14. package/src/api-handlers/account/change-password.ts +142 -142
  15. package/src/api-handlers/account/update-phone.ts +79 -79
  16. package/src/api-handlers/account/verify-email.ts +125 -125
  17. package/src/api-handlers/account/verify-sms.ts +125 -125
  18. package/src/api-handlers/admin/analytics.ts +443 -443
  19. package/src/api-handlers/admin/audit.ts +223 -223
  20. package/src/api-handlers/admin/index.ts +63 -63
  21. package/src/api-handlers/admin/redis-sessions.ts +251 -251
  22. package/src/api-handlers/admin/sessions.ts +318 -318
  23. package/src/api-handlers/admin/site-logs.ts +365 -365
  24. package/src/api-handlers/admin/users.ts +242 -242
  25. package/src/api-handlers/admin/vibe-data.ts +324 -324
  26. package/src/api-handlers/auth/refresh.ts +682 -682
  27. package/src/api-handlers/auth/signout.ts +199 -199
  28. package/src/api-handlers/auth/status.ts +20 -20
  29. package/src/api-handlers/auth/update-session.ts +110 -110
  30. package/src/api-handlers/auth/verify-code.ts +112 -112
  31. package/src/api-handlers/session/refresh-viability.ts +36 -36
  32. package/src/api-handlers/test/force-expire.ts +60 -60
  33. package/src/auth/auth-decision.ts +229 -229
  34. package/src/auth/better-auth.ts +15 -3
  35. package/src/auth/route-config.ts +219 -219
  36. package/src/auth/types/auth-types.ts +555 -555
  37. package/src/auth/types/index.ts +7 -7
  38. package/src/auth/utils/idp-client.ts +445 -445
  39. package/src/auth/utils/index.ts +6 -6
  40. package/src/auth/utils/token-utils.ts +243 -243
  41. package/src/client/AuthContext.tsx +143 -143
  42. package/src/client/fetch-with-auth.ts +47 -47
  43. package/src/components/SessionSync.tsx +138 -138
  44. package/src/components/SignalRHealthCheck.tsx +131 -131
  45. package/src/components/account/MobileNavDrawer.tsx +305 -305
  46. package/src/components/account/UserAvatarMenu.tsx +266 -266
  47. package/src/components/account/index.ts +13 -13
  48. package/src/components/admin/AlertSettingsTab.tsx +728 -728
  49. package/src/components/admin/AnalyticsTab.tsx +703 -703
  50. package/src/components/admin/DataBrowserTab.tsx +505 -505
  51. package/src/components/admin/LoggingSettingsTab.tsx +665 -665
  52. package/src/components/admin/SessionsTab.tsx +414 -414
  53. package/src/components/admin/StatsTab.tsx +379 -379
  54. package/src/components/admin/VibeAdminLayout.tsx +187 -187
  55. package/src/components/admin/index.ts +59 -59
  56. package/src/components/auth/FederatedAuthSection.tsx +95 -95
  57. package/src/components/auth/ModeAwareLoginPage.tsx +135 -135
  58. package/src/components/auth/ModeAwareSignupPage.tsx +267 -267
  59. package/src/components/auth/TraditionalAuthSection.tsx +99 -99
  60. package/src/components/recovery/CompleteStep.tsx +36 -36
  61. package/src/components/recovery/InitiateRecoveryStep.tsx +68 -68
  62. package/src/components/recovery/SelectMethodStep.tsx +73 -73
  63. package/src/components/recovery/SetPasswordStep.tsx +97 -97
  64. package/src/components/recovery/VerifyCodeStep.tsx +90 -90
  65. package/src/components/reserved/ReservedRecoveryWarning.tsx +160 -160
  66. package/src/components/reserved/ReservedStatusBox.tsx +118 -118
  67. package/src/components/ui/BetaBadge.tsx +58 -58
  68. package/src/components/ui/Footer.tsx +93 -93
  69. package/src/config/env.ts +57 -57
  70. package/src/config/logger.ts +62 -62
  71. package/src/config/vibe-log-transport.ts +264 -264
  72. package/src/hooks/useAuth.ts +117 -117
  73. package/src/hooks/useAuthSettings.ts +97 -97
  74. package/src/hooks/useAvailableProviders.ts +120 -120
  75. package/src/hooks/usePasswordValidation.ts +127 -127
  76. package/src/hooks/usePublicAuthSettings.ts +149 -149
  77. package/src/hooks/useSessionExpiration.ts +101 -101
  78. package/src/hooks/useViabilitySession.ts +336 -336
  79. package/src/index.ts +60 -60
  80. package/src/lib/api-handler.ts +643 -643
  81. package/src/lib/app-slug.ts +178 -178
  82. package/src/lib/geolocation.ts +265 -265
  83. package/src/lib/idp-fetch.ts +10 -26
  84. package/src/lib/jwt-decode.ts +82 -82
  85. package/src/lib/roles.ts +177 -177
  86. package/src/lib/site-logger.ts +245 -245
  87. package/src/lib/standardized-client-api.ts +901 -901
  88. package/src/lib/test-aware-get-token.ts +41 -41
  89. package/src/lib/user-agent-parser.ts +252 -252
  90. package/src/logging/api/admin-analytics.ts +51 -51
  91. package/src/logging/api/audit-log.ts +53 -53
  92. package/src/logging/components/AdminAnalyticsLayout.tsx +49 -49
  93. package/src/logging/components/AuditLogViewer.tsx +125 -125
  94. package/src/logging/components/ErrorMetricsCard.tsx +98 -98
  95. package/src/logging/components/HealthMetricsCard.tsx +70 -70
  96. package/src/logging/hooks/useAdminAnalytics.ts +22 -22
  97. package/src/logging/hooks/useAuditLog.ts +24 -24
  98. package/src/logging/hooks/useErrorMetrics.ts +40 -40
  99. package/src/logging/hooks/useHealthMetrics.ts +44 -44
  100. package/src/logging/index.ts +18 -18
  101. package/src/logging/types/analytics.ts +81 -81
  102. package/src/logging/types/audit.ts +31 -31
  103. package/src/logging/types/index.ts +3 -3
  104. package/src/middleware/create-middleware.ts +663 -663
  105. package/src/middleware/rbac-check.ts +279 -279
  106. package/src/middleware/twofa-presets.ts +224 -224
  107. package/src/pages/admin-login/page.tsx +221 -221
  108. package/src/pages/admin-page-permissions/PagePermissionsAdminPage.tsx +527 -527
  109. package/src/pages/admin-page-permissions/index.ts +7 -7
  110. package/src/pages/admin-roles/RolesAdminPage.tsx +533 -533
  111. package/src/pages/admin-roles/modals.tsx +469 -469
  112. package/src/pages/client-admin/ClientSiteAdminPage.tsx +382 -382
  113. package/src/pages/client-admin/index.ts +33 -33
  114. package/src/pages/coming-soon/page.tsx +83 -83
  115. package/src/pages/login/page.tsx +456 -456
  116. package/src/pages/profile/EnhancedProfilePage.tsx +485 -485
  117. package/src/pages/profile/index.ts +9 -9
  118. package/src/pages/profile/page.tsx +166 -166
  119. package/src/pages/recovery/page.tsx +234 -234
  120. package/src/pages/roles/MyRolesPage.tsx +211 -211
  121. package/src/pages/roles/components.tsx +294 -294
  122. package/src/pages/roles/index.ts +17 -17
  123. package/src/pages/security/EnhancedSecurityPage.tsx +565 -565
  124. package/src/pages/security/index.ts +9 -9
  125. package/src/pages/security/page.tsx +507 -507
  126. package/src/pages/settings/EnhancedSettingsPage.tsx +642 -642
  127. package/src/pages/settings/index.ts +9 -9
  128. package/src/pages/settings/page.tsx +47 -47
  129. package/src/pages/showcase/ShowcasePage.tsx +532 -532
  130. package/src/pages/showcase/index.ts +13 -13
  131. package/src/pages/test-env/EmergencyLogoutPage.tsx +180 -180
  132. package/src/pages/test-env/JwtInspectPage.tsx +456 -456
  133. package/src/pages/test-env/RefreshTokenPage.tsx +157 -157
  134. package/src/pages/test-env/TestEnvPage.tsx +118 -118
  135. package/src/pages/test-env/index.ts +25 -25
  136. package/src/pages/verify-code/page.tsx +652 -652
  137. package/src/routes/account/update-phone.ts +13 -13
  138. package/src/routes/auth/logout.ts +108 -108
  139. package/src/routes/auth/nextauth.ts +45 -45
  140. package/src/routes/auth/settings.ts +75 -75
  141. package/src/routes/auth/viability.ts +224 -224
  142. package/src/server/auth-guard.ts +170 -170
  143. package/src/server/slim-middleware.ts +117 -117
  144. package/src/server/with-auth.ts +93 -93
  145. package/src/stores/authStore.ts +1899 -1899
  146. package/src/templates/instrumentation.ts +41 -41
  147. package/src/utils/logout.ts +30 -30
  148. package/src/vibe/client.ts +590 -590
  149. package/src/vibe/enterprise-auth.ts +208 -208
  150. package/src/vibe/errors.ts +185 -185
  151. package/src/vibe/generic.ts +431 -431
  152. package/src/vibe/hooks/index.ts +367 -367
  153. package/src/vibe/index.ts +132 -132
  154. package/src/vibe/sessions.ts +551 -551
  155. 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
+ }