@payez/next-mvp 4.1.9 → 4.1.11

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