@payez/next-mvp 4.1.9 → 4.1.10

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