@payez/next-mvp 4.8.1 → 4.8.2
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.
- package/POST-INSTALL.md +408 -408
- package/README.md +859 -859
- package/dist/components/account/MobileNavDrawer.js +18 -18
- package/dist/components/account/UserAvatarMenu.js +1 -1
- package/dist/components/admin/AlertSettingsTab.js +40 -40
- package/dist/components/admin/LoggingSettingsTab.js +28 -28
- package/dist/lib/session-store.js +21 -21
- package/dist/lib/startup-init.js +19 -19
- package/dist/pages/login/page.js +28 -28
- package/dist/pages/settings/EnhancedSettingsPage.js +17 -17
- package/dist/vibe/sessions.js +28 -3
- package/package.json +2 -2
- package/scripts/check-publish-eol.mjs +33 -15
- package/src/api/auth-handler.ts +550 -550
- package/src/api/index.ts +17 -17
- package/src/api-handlers/account/change-password.ts +170 -170
- package/src/api-handlers/account/masked-info.ts +46 -46
- package/src/api-handlers/account/profile.ts +99 -99
- package/src/api-handlers/account/recovery/forward.ts +117 -117
- package/src/api-handlers/account/recovery/initiate.ts +59 -59
- package/src/api-handlers/account/recovery/send-code.ts +21 -21
- package/src/api-handlers/account/recovery/verify-code.ts +20 -20
- package/src/api-handlers/account/reset-password.ts +87 -87
- package/src/api-handlers/account/send-code.ts +70 -70
- package/src/api-handlers/account/update-phone.ts +84 -84
- package/src/api-handlers/account/validate-password.ts +139 -139
- package/src/api-handlers/account/verify-email.ts +134 -134
- package/src/api-handlers/account/verify-sms.ts +134 -134
- package/src/api-handlers/admin/analytics.ts +452 -452
- package/src/api-handlers/admin/audit.ts +232 -232
- package/src/api-handlers/admin/index.ts +63 -63
- package/src/api-handlers/admin/redis-sessions.ts +259 -259
- package/src/api-handlers/admin/sessions.ts +327 -327
- package/src/api-handlers/admin/site-logs.ts +388 -388
- package/src/api-handlers/admin/stats.ts +260 -260
- package/src/api-handlers/admin/users.ts +251 -251
- package/src/api-handlers/admin/vibe-data.ts +333 -333
- package/src/api-handlers/anon/preferences.ts +123 -123
- package/src/api-handlers/auth/credential-login-email-code.ts +92 -92
- package/src/api-handlers/auth/credential-login.ts +948 -948
- package/src/api-handlers/auth/jwks.ts +31 -31
- package/src/api-handlers/auth/login.ts +252 -252
- package/src/api-handlers/auth/passkey-signin.ts +116 -116
- package/src/api-handlers/auth/refresh.ts +829 -829
- package/src/api-handlers/auth/signout.ts +201 -201
- package/src/api-handlers/auth/status.ts +23 -23
- package/src/api-handlers/auth/update-session.ts +144 -144
- package/src/api-handlers/auth/validate.ts +45 -45
- package/src/api-handlers/auth/verify-code.ts +116 -116
- package/src/api-handlers/mfa/backup-code-verify.ts +170 -170
- package/src/api-handlers/mfa/backup-codes.ts +42 -42
- package/src/api-handlers/mfa/methods.ts +47 -47
- package/src/api-handlers/mfa/totp-confirm.ts +43 -43
- package/src/api-handlers/mfa/totp-enroll.ts +43 -43
- package/src/api-handlers/mfa/totp-verify.ts +172 -172
- package/src/api-handlers/oauth/resume.ts +182 -182
- package/src/api-handlers/onboarding/send-email-code.ts +18 -18
- package/src/api-handlers/onboarding/set-password.ts +21 -21
- package/src/api-handlers/onboarding/verify-code.ts +20 -20
- package/src/api-handlers/onboarding/verify-welcome-email.ts +17 -17
- package/src/api-handlers/passkeys/list.ts +32 -32
- package/src/api-handlers/passkeys/manage.ts +55 -55
- package/src/api-handlers/passkeys/register-options.ts +43 -43
- package/src/api-handlers/passkeys/register-verify.ts +42 -42
- package/src/api-handlers/session/refresh-viability.ts +56 -56
- package/src/api-handlers/session/viability.ts +238 -238
- package/src/api-handlers/test/force-expire.ts +167 -167
- package/src/auth/auth-decision.ts +292 -292
- package/src/auth/better-auth.ts +1688 -1688
- package/src/auth/idp-oauth.ts +310 -310
- package/src/auth/password-reset-bridge.ts +98 -98
- package/src/auth/revocation-fields.ts +16 -16
- package/src/auth/route-config.ts +236 -236
- package/src/auth/safe-callback.ts +199 -199
- package/src/auth/session-cookie-clear.ts +30 -30
- package/src/auth/session-reset-link.ts +159 -159
- package/src/auth/session-response-sanitizer.ts +151 -151
- package/src/auth/types/auth-types.ts +555 -555
- package/src/auth/types/index.ts +7 -7
- package/src/auth/unauthenticated-routes.ts +2 -2
- package/src/auth/utils/idp-client.ts +364 -364
- package/src/auth/utils/index.ts +6 -6
- package/src/auth/utils/token-utils.ts +243 -243
- package/src/client/AuthContext.tsx +192 -192
- package/src/client/better-auth-client.ts +70 -70
- package/src/client/fetch-with-auth.ts +47 -47
- package/src/client/fetchWithSession.ts +21 -21
- package/src/client/index.ts +13 -13
- package/src/client/useAnonSession.ts +131 -131
- package/src/components/SessionSync.tsx +138 -138
- package/src/components/SignalRHealthCheck.tsx +131 -131
- package/src/components/account/MobileNavDrawer.tsx +305 -305
- package/src/components/account/UserAvatarMenu.tsx +277 -277
- package/src/components/account/index.ts +13 -13
- package/src/components/admin/AlertSettingsTab.tsx +728 -728
- package/src/components/admin/AnalyticsTab.tsx +703 -703
- package/src/components/admin/DataBrowserTab.tsx +508 -508
- package/src/components/admin/LoggingSettingsTab.tsx +665 -665
- package/src/components/admin/SessionsTab.tsx +421 -421
- package/src/components/admin/StatsTab.tsx +379 -379
- package/src/components/admin/VibeAdminContext.tsx +87 -87
- package/src/components/admin/VibeAdminLayout.tsx +187 -187
- package/src/components/admin/index.ts +59 -59
- package/src/components/auth/CredentialSignInForm.tsx +240 -240
- package/src/components/auth/FederatedAuthSection.tsx +129 -129
- package/src/components/auth/ModeAwareLoginPage.tsx +146 -146
- package/src/components/auth/ModeAwareSignupPage.tsx +200 -200
- package/src/components/auth/PasswordResetBridge.tsx +30 -30
- package/src/components/auth/TraditionalAuthSection.tsx +103 -103
- package/src/components/recovery/CompleteStep.tsx +36 -36
- package/src/components/recovery/InitiateRecoveryStep.tsx +68 -68
- package/src/components/recovery/RequestedStep.tsx +30 -30
- package/src/components/recovery/SelectMethodStep.tsx +73 -73
- package/src/components/recovery/SetPasswordStep.tsx +118 -118
- package/src/components/recovery/VerifyCodeStep.tsx +90 -90
- package/src/components/reserved/ReservedRecoveryWarning.tsx +160 -160
- package/src/components/reserved/ReservedStatusBox.tsx +118 -118
- package/src/components/ui/BetaBadge.tsx +58 -58
- package/src/components/ui/Footer.tsx +93 -93
- package/src/config/env.ts +55 -55
- package/src/config/logger.ts +62 -62
- package/src/config/logging-config.ts +82 -82
- package/src/config/unauthenticated-routes.ts +19 -19
- package/src/config/vibe-log-transport.ts +264 -264
- package/src/edge/internal-api-url.ts +65 -65
- package/src/edge/middleware.ts +42 -42
- package/src/hooks/useAuth.ts +117 -117
- package/src/hooks/useAuthSettings.ts +109 -109
- package/src/hooks/useAvailableProviders.ts +143 -143
- package/src/hooks/usePasswordValidation.ts +127 -127
- package/src/hooks/useProfile.ts +82 -82
- package/src/hooks/usePublicAuthSettings.ts +171 -171
- package/src/hooks/useSessionExpiration.ts +101 -101
- package/src/hooks/useViabilitySession.ts +337 -337
- package/src/index.ts +64 -64
- package/src/lib/anon-session.ts +213 -213
- package/src/lib/api-handler.ts +643 -643
- package/src/lib/app-slug.ts +228 -228
- package/src/lib/authorize-resume.ts +172 -172
- package/src/lib/bearer-fetch.ts +31 -31
- package/src/lib/client-key.ts +88 -88
- package/src/lib/code-step-copy.ts +83 -83
- package/src/lib/credential-sign-in-client.ts +101 -101
- package/src/lib/demo-mode.ts +18 -18
- package/src/lib/device-id.ts +67 -67
- package/src/lib/ensure-fresh-access-token.ts +509 -509
- package/src/lib/geolocation.ts +265 -265
- package/src/lib/idp-client-config.ts +1005 -1005
- package/src/lib/idp-fetch.ts +169 -169
- package/src/lib/idp-url-guard.ts +37 -37
- package/src/lib/internal-api.ts +171 -171
- package/src/lib/jwt-decode-client.ts +45 -45
- package/src/lib/jwt-decode.ts +82 -82
- package/src/lib/lock-window.ts +49 -49
- package/src/lib/login-error-message.ts +41 -41
- package/src/lib/never-in-response.ts +64 -64
- package/src/lib/nextauth-secret.ts +168 -168
- package/src/lib/onboarding-proxy.ts +57 -57
- package/src/lib/password-rules.ts +50 -50
- package/src/lib/rate-limit-service.ts +9 -9
- package/src/lib/redis.ts +105 -105
- package/src/lib/refresh-token-validator.ts +64 -64
- package/src/lib/roles.ts +177 -177
- package/src/lib/safe-error-response.ts +149 -149
- package/src/lib/secret-validation.ts +8 -8
- package/src/lib/session-cookie-shape.ts +185 -185
- package/src/lib/session-store.ts +1092 -1092
- package/src/lib/session.ts +34 -34
- package/src/lib/single-flight.ts +64 -64
- package/src/lib/site-logger.ts +245 -245
- package/src/lib/standardized-client-api.ts +901 -901
- package/src/lib/startup-init.ts +276 -276
- package/src/lib/test-aware-get-token.ts +41 -41
- package/src/lib/token-expiry.ts +40 -40
- package/src/lib/token-lifecycle.ts +546 -546
- package/src/lib/token-log-id.ts +69 -69
- package/src/lib/twofa-rejection.ts +91 -91
- package/src/lib/types/api-responses.ts +336 -336
- package/src/lib/user-agent-parser.ts +252 -252
- package/src/logging/api/admin-analytics.ts +51 -51
- package/src/logging/api/audit-log.ts +53 -53
- package/src/logging/components/AdminAnalyticsLayout.tsx +49 -49
- package/src/logging/components/AuditLogViewer.tsx +125 -125
- package/src/logging/components/ErrorMetricsCard.tsx +98 -98
- package/src/logging/components/HealthMetricsCard.tsx +70 -70
- package/src/logging/hooks/useAdminAnalytics.ts +22 -22
- package/src/logging/hooks/useAuditLog.ts +24 -24
- package/src/logging/hooks/useErrorMetrics.ts +40 -40
- package/src/logging/hooks/useHealthMetrics.ts +44 -44
- package/src/logging/index.ts +18 -18
- package/src/logging/types/analytics.ts +81 -81
- package/src/logging/types/audit.ts +31 -31
- package/src/logging/types/index.ts +3 -3
- package/src/middleware/create-middleware.ts +1141 -1141
- package/src/middleware/rbac-check.ts +279 -279
- package/src/middleware/twofa-presets.ts +224 -224
- package/src/models/DecodedAccessToken.ts +16 -16
- package/src/models/SessionModel.ts +331 -331
- package/src/pages/admin-login/page.tsx +111 -111
- package/src/pages/admin-page-permissions/PagePermissionsAdminPage.tsx +527 -527
- package/src/pages/admin-page-permissions/index.ts +7 -7
- package/src/pages/admin-roles/RolesAdminPage.tsx +533 -533
- package/src/pages/admin-roles/index.ts +9 -9
- package/src/pages/admin-roles/modals.tsx +469 -469
- package/src/pages/client-admin/ClientSiteAdminPage.tsx +382 -382
- package/src/pages/client-admin/index.ts +33 -33
- package/src/pages/coming-soon/page.tsx +83 -83
- package/src/pages/login/page.tsx +338 -338
- package/src/pages/onboarding/index.ts +11 -11
- package/src/pages/onboarding/page.tsx +212 -212
- package/src/pages/profile/EnhancedProfilePage.tsx +514 -514
- package/src/pages/profile/index.ts +9 -9
- package/src/pages/profile/page.tsx +166 -166
- package/src/pages/recovery/page.tsx +267 -267
- package/src/pages/roles/MyRolesPage.tsx +211 -211
- package/src/pages/roles/components.tsx +294 -294
- package/src/pages/roles/index.ts +17 -17
- package/src/pages/security/EnhancedSecurityPage.tsx +660 -660
- package/src/pages/security/index.ts +12 -12
- package/src/pages/security/page.tsx +507 -507
- package/src/pages/service-unavailable/page.tsx +63 -63
- package/src/pages/settings/EnhancedSettingsPage.tsx +642 -642
- package/src/pages/settings/index.ts +9 -9
- package/src/pages/settings/page.tsx +47 -47
- package/src/pages/showcase/ShowcasePage.tsx +532 -532
- package/src/pages/showcase/index.ts +13 -13
- package/src/pages/test-env/EmergencyLogoutPage.tsx +192 -192
- package/src/pages/test-env/JwtInspectPage.tsx +449 -449
- package/src/pages/test-env/RefreshTokenPage.tsx +178 -178
- package/src/pages/test-env/TestEnvPage.tsx +130 -130
- package/src/pages/test-env/guarded.tsx +81 -81
- package/src/pages/test-env/index.ts +77 -77
- package/src/pages/test-env/redact-credentials.ts +96 -96
- package/src/pages/verify-code/page.tsx +746 -746
- package/src/routes/account/index.ts +31 -31
- package/src/routes/account/masked-info.ts +36 -36
- package/src/routes/account/send-code.ts +39 -39
- package/src/routes/account/update-phone.ts +13 -13
- package/src/routes/account/verify-email.ts +40 -40
- package/src/routes/account/verify-sms.ts +40 -40
- package/src/routes/auth/credential-login-email-code.ts +6 -6
- package/src/routes/auth/credential-login.ts +6 -6
- package/src/routes/auth/index.ts +22 -22
- package/src/routes/auth/logout.ts +111 -111
- package/src/routes/auth/nextauth.ts +45 -45
- package/src/routes/auth/passkey-signin.ts +13 -13
- package/src/routes/auth/refresh.ts +77 -77
- package/src/routes/auth/session-reset.ts +37 -37
- package/src/routes/auth/session.ts +261 -261
- package/src/routes/auth/settings.ts +181 -181
- package/src/routes/auth/viability.ts +519 -519
- package/src/routes/index.ts +17 -17
- package/src/routes/mfa/backup-code-verify.ts +1 -1
- package/src/routes/mfa/backup-codes.ts +10 -10
- package/src/routes/mfa/methods.ts +22 -22
- package/src/routes/mfa/totp-confirm.ts +10 -10
- package/src/routes/mfa/totp-enroll.ts +10 -10
- package/src/routes/mfa/totp-verify.ts +14 -14
- package/src/routes/onboarding/index.ts +15 -15
- package/src/routes/onboarding/send-email-code.ts +2 -2
- package/src/routes/onboarding/set-password.ts +2 -2
- package/src/routes/onboarding/verify-code.ts +2 -2
- package/src/routes/onboarding/verify-welcome-email.ts +2 -2
- package/src/routes/passkeys/list.ts +10 -10
- package/src/routes/passkeys/manage.ts +23 -23
- package/src/routes/passkeys/register-options.ts +10 -10
- package/src/routes/passkeys/register-verify.ts +10 -10
- package/src/routes/session/index.ts +7 -7
- package/src/routes/session/refresh-viability.ts +17 -17
- package/src/server/auth-guard.ts +203 -203
- package/src/server/auth.ts +486 -486
- package/src/server/decode-session.ts +309 -309
- package/src/server/slim-middleware.ts +117 -117
- package/src/server/verified-session-token.ts +103 -103
- package/src/server/with-auth.ts +93 -93
- package/src/services/signalrActivityService.ts +258 -258
- package/src/stores/authStore.ts +1900 -1900
- package/src/templates/instrumentation.ts +60 -60
- package/src/theme/ThemeProvider.tsx +39 -39
- package/src/theme/default.ts +33 -33
- package/src/theme/index.ts +31 -31
- package/src/theme/types.ts +69 -69
- package/src/theme/useTheme.ts +57 -57
- package/src/theme/utils.ts +40 -40
- package/src/types/api.ts +12 -12
- package/src/types/auth.d.ts +23 -23
- package/src/types/auth.ts +48 -48
- package/src/types/logging.ts +11 -11
- package/src/types/recovery.ts +54 -54
- package/src/utils/api.ts +401 -401
- package/src/utils/circuitBreaker.ts +40 -40
- package/src/utils/error-message.ts +108 -108
- package/src/utils/layout/reservedSpace.ts +124 -124
- package/src/utils/logout.ts +30 -30
- package/src/vibe/client.ts +877 -877
- package/src/vibe/enterprise-auth.ts +208 -208
- package/src/vibe/errors.ts +209 -209
- package/src/vibe/generic.ts +339 -339
- package/src/vibe/hooks/index.ts +367 -367
- package/src/vibe/index.ts +133 -133
- package/src/vibe/sessions.ts +925 -899
- package/src/vibe/types.ts +577 -577
package/src/vibe/sessions.ts
CHANGED
|
@@ -1,902 +1,928 @@
|
|
|
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
|
-
import { decodeIdpAccessToken } from '../auth/utils/token-utils';
|
|
37
|
-
import { tokenLogId } from '../lib/token-log-id';
|
|
38
|
-
|
|
39
|
-
// -----------------------------------------------------------------------------
|
|
40
|
-
// CONFIGURATION
|
|
41
|
-
// -----------------------------------------------------------------------------
|
|
42
|
-
|
|
43
|
-
// Sessions expire after 30 days
|
|
44
|
-
const SESSION_EXPIRY_DAYS = 30;
|
|
45
|
-
|
|
46
|
-
// Table name in vibe_app collection
|
|
47
|
-
const TABLE = 'login_sessions';
|
|
48
|
-
const COLLECTION = 'vibe_app';
|
|
49
|
-
|
|
50
|
-
// Environment variables
|
|
51
|
-
const getEnv = (key: string): string => {
|
|
52
|
-
if (typeof process !== 'undefined' && process.env) {
|
|
53
|
-
return process.env[key] || '';
|
|
54
|
-
}
|
|
55
|
-
return '';
|
|
56
|
-
};
|
|
57
|
-
|
|
58
|
-
/**
|
|
59
|
-
* Generate HMAC-SHA256 signature for proxy authentication
|
|
60
|
-
*/
|
|
61
|
-
function generateHmacSignature(
|
|
62
|
-
signingKey: string,
|
|
63
|
-
timestamp: number,
|
|
64
|
-
method: string,
|
|
65
|
-
endpoint: string
|
|
66
|
-
): string {
|
|
67
|
-
const stringToSign = `${timestamp}|${method}|${endpoint}`;
|
|
68
|
-
return crypto
|
|
69
|
-
.createHmac('sha256', Buffer.from(signingKey, 'base64'))
|
|
70
|
-
.update(stringToSign)
|
|
71
|
-
.digest('base64');
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
/**
|
|
75
|
-
* Make a request through the IDP Vibe proxy
|
|
76
|
-
*/
|
|
77
|
-
async function vibeProxyRequest<T>(
|
|
78
|
-
endpoint: string,
|
|
79
|
-
method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE',
|
|
80
|
-
accessToken: string,
|
|
81
|
-
body?: unknown
|
|
82
|
-
): Promise<T | null> {
|
|
83
|
-
const idpUrl = getEnv('IDP_URL');
|
|
84
|
-
const clientId = getEnv('VIBE_CLIENT_ID');
|
|
85
|
-
const signingKey = getEnv('VIBE_HMAC_KEY');
|
|
86
|
-
|
|
87
|
-
if (!idpUrl || !clientId || !signingKey) {
|
|
88
|
-
console.error('[vibe-sessions] Missing env vars: IDP_URL, VIBE_CLIENT_ID, or VIBE_HMAC_KEY');
|
|
89
|
-
return null;
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
const proxyUrl = `${idpUrl}/api/vibe/proxy`;
|
|
93
|
-
const timestamp = Math.floor(Date.now() / 1000);
|
|
94
|
-
const signature = generateHmacSignature(signingKey, timestamp, method, endpoint);
|
|
95
|
-
|
|
96
|
-
const headers: Record<string, string> = {
|
|
97
|
-
'Content-Type': 'application/json',
|
|
98
|
-
'Authorization': `Bearer ${accessToken}`,
|
|
99
|
-
'X-Vibe-Client-Id': clientId,
|
|
100
|
-
'X-Vibe-Timestamp': String(timestamp),
|
|
101
|
-
'X-Vibe-Signature': signature,
|
|
102
|
-
};
|
|
103
|
-
|
|
104
|
-
try {
|
|
105
|
-
const response = await fetch(proxyUrl, {
|
|
106
|
-
method: 'POST',
|
|
107
|
-
headers,
|
|
108
|
-
body: JSON.stringify({
|
|
109
|
-
endpoint,
|
|
110
|
-
method,
|
|
111
|
-
data: body ?? null,
|
|
112
|
-
}),
|
|
113
|
-
});
|
|
114
|
-
|
|
115
|
-
if (response.status === 204) {
|
|
116
|
-
return null;
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
if (!response.ok) {
|
|
120
|
-
const errorText = await response.text();
|
|
121
|
-
// PAY-1427: never the raw proxy body - the request carried a Bearer token and an error page can echo it.
|
|
122
|
-
console.error(`[vibe-sessions] Proxy error ${response.status}:`, { bodyLength: errorText.length });
|
|
123
|
-
return null;
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
const data = await response.json();
|
|
127
|
-
// Unwrap IDP envelope if present
|
|
128
|
-
return (data?.data ?? data) as T;
|
|
129
|
-
} catch (error) {
|
|
130
|
-
console.error('[vibe-sessions] Proxy request failed:', error);
|
|
131
|
-
return null;
|
|
132
|
-
}
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
// -----------------------------------------------------------------------------
|
|
136
|
-
// TYPES
|
|
137
|
-
// -----------------------------------------------------------------------------
|
|
138
|
-
|
|
139
|
-
export interface CreateSessionInput {
|
|
140
|
-
/** IDP user ID */
|
|
141
|
-
idpUserId: number;
|
|
142
|
-
/** User email */
|
|
143
|
-
email: string;
|
|
144
|
-
/** User's display name */
|
|
145
|
-
name?: string;
|
|
146
|
-
/** Client IP address */
|
|
147
|
-
ipAddress: string;
|
|
148
|
-
/** User agent string */
|
|
149
|
-
userAgent: string;
|
|
150
|
-
/** Location info from geolocation lookup */
|
|
151
|
-
location?: LocationInfo | null;
|
|
152
|
-
/** Parsed device info */
|
|
153
|
-
device?: DeviceInfo;
|
|
154
|
-
/** OAuth provider used for login */
|
|
155
|
-
oauthProvider?: string;
|
|
156
|
-
/** Hashed session token for lookup */
|
|
157
|
-
sessionTokenHash?: string;
|
|
158
|
-
/**
|
|
159
|
-
* User's access token - REQUIRED for member-to-vibe calls.
|
|
160
|
-
* Without this, the request will fail with 401.
|
|
161
|
-
*/
|
|
162
|
-
accessToken: string;
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
export interface SessionQueryOptions {
|
|
166
|
-
status?: SessionStatus;
|
|
167
|
-
page?: number;
|
|
168
|
-
pageSize?: number;
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
// -----------------------------------------------------------------------------
|
|
172
|
-
// CREATE SESSION
|
|
173
|
-
// -----------------------------------------------------------------------------
|
|
174
|
-
|
|
175
|
-
/**
|
|
176
|
-
* Create a new login session record.
|
|
177
|
-
* Called when a user successfully logs in.
|
|
178
|
-
*
|
|
179
|
-
* @param input - Session creation parameters including accessToken
|
|
180
|
-
* @returns The created session, or null on failure
|
|
181
|
-
*/
|
|
182
|
-
export async function createLoginSession(
|
|
183
|
-
input: CreateSessionInput
|
|
184
|
-
): Promise<IVibeLoginSession | null> {
|
|
185
|
-
if (!input.accessToken) {
|
|
186
|
-
console.error('[vibe-sessions] accessToken is required for createLoginSession');
|
|
187
|
-
return null;
|
|
188
|
-
}
|
|
189
|
-
|
|
190
|
-
const now = new Date();
|
|
191
|
-
const expiresAt = new Date(now.getTime() + SESSION_EXPIRY_DAYS * 24 * 60 * 60 * 1000);
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
//
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
const
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
}
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
*
|
|
340
|
-
*
|
|
341
|
-
* @param
|
|
342
|
-
* @param accessToken -
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
)
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
export
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
*
|
|
521
|
-
*
|
|
522
|
-
*
|
|
523
|
-
*
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
*
|
|
543
|
-
*
|
|
544
|
-
*
|
|
545
|
-
*
|
|
546
|
-
*
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
*
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
return '
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
*
|
|
589
|
-
*
|
|
590
|
-
*
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
}
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
const
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
return false;
|
|
685
|
-
}
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
}
|
|
696
|
-
|
|
697
|
-
/**
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
):
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
}
|
|
763
|
-
}
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
*
|
|
773
|
-
*
|
|
774
|
-
*
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
return '
|
|
835
|
-
}
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
//
|
|
840
|
-
//
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
}
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
}
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
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
|
+
import { decodeIdpAccessToken } from '../auth/utils/token-utils';
|
|
37
|
+
import { tokenLogId } from '../lib/token-log-id';
|
|
38
|
+
|
|
39
|
+
// -----------------------------------------------------------------------------
|
|
40
|
+
// CONFIGURATION
|
|
41
|
+
// -----------------------------------------------------------------------------
|
|
42
|
+
|
|
43
|
+
// Sessions expire after 30 days
|
|
44
|
+
const SESSION_EXPIRY_DAYS = 30;
|
|
45
|
+
|
|
46
|
+
// Table name in vibe_app collection
|
|
47
|
+
const TABLE = 'login_sessions';
|
|
48
|
+
const COLLECTION = 'vibe_app';
|
|
49
|
+
|
|
50
|
+
// Environment variables
|
|
51
|
+
const getEnv = (key: string): string => {
|
|
52
|
+
if (typeof process !== 'undefined' && process.env) {
|
|
53
|
+
return process.env[key] || '';
|
|
54
|
+
}
|
|
55
|
+
return '';
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Generate HMAC-SHA256 signature for proxy authentication
|
|
60
|
+
*/
|
|
61
|
+
function generateHmacSignature(
|
|
62
|
+
signingKey: string,
|
|
63
|
+
timestamp: number,
|
|
64
|
+
method: string,
|
|
65
|
+
endpoint: string
|
|
66
|
+
): string {
|
|
67
|
+
const stringToSign = `${timestamp}|${method}|${endpoint}`;
|
|
68
|
+
return crypto
|
|
69
|
+
.createHmac('sha256', Buffer.from(signingKey, 'base64'))
|
|
70
|
+
.update(stringToSign)
|
|
71
|
+
.digest('base64');
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Make a request through the IDP Vibe proxy
|
|
76
|
+
*/
|
|
77
|
+
async function vibeProxyRequest<T>(
|
|
78
|
+
endpoint: string,
|
|
79
|
+
method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE',
|
|
80
|
+
accessToken: string,
|
|
81
|
+
body?: unknown
|
|
82
|
+
): Promise<T | null> {
|
|
83
|
+
const idpUrl = getEnv('IDP_URL');
|
|
84
|
+
const clientId = getEnv('VIBE_CLIENT_ID');
|
|
85
|
+
const signingKey = getEnv('VIBE_HMAC_KEY');
|
|
86
|
+
|
|
87
|
+
if (!idpUrl || !clientId || !signingKey) {
|
|
88
|
+
console.error('[vibe-sessions] Missing env vars: IDP_URL, VIBE_CLIENT_ID, or VIBE_HMAC_KEY');
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const proxyUrl = `${idpUrl}/api/vibe/proxy`;
|
|
93
|
+
const timestamp = Math.floor(Date.now() / 1000);
|
|
94
|
+
const signature = generateHmacSignature(signingKey, timestamp, method, endpoint);
|
|
95
|
+
|
|
96
|
+
const headers: Record<string, string> = {
|
|
97
|
+
'Content-Type': 'application/json',
|
|
98
|
+
'Authorization': `Bearer ${accessToken}`,
|
|
99
|
+
'X-Vibe-Client-Id': clientId,
|
|
100
|
+
'X-Vibe-Timestamp': String(timestamp),
|
|
101
|
+
'X-Vibe-Signature': signature,
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
try {
|
|
105
|
+
const response = await fetch(proxyUrl, {
|
|
106
|
+
method: 'POST',
|
|
107
|
+
headers,
|
|
108
|
+
body: JSON.stringify({
|
|
109
|
+
endpoint,
|
|
110
|
+
method,
|
|
111
|
+
data: body ?? null,
|
|
112
|
+
}),
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
if (response.status === 204) {
|
|
116
|
+
return null;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if (!response.ok) {
|
|
120
|
+
const errorText = await response.text();
|
|
121
|
+
// PAY-1427: never the raw proxy body - the request carried a Bearer token and an error page can echo it.
|
|
122
|
+
console.error(`[vibe-sessions] Proxy error ${response.status}:`, { bodyLength: errorText.length });
|
|
123
|
+
return null;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const data = await response.json();
|
|
127
|
+
// Unwrap IDP envelope if present
|
|
128
|
+
return (data?.data ?? data) as T;
|
|
129
|
+
} catch (error) {
|
|
130
|
+
console.error('[vibe-sessions] Proxy request failed:', error);
|
|
131
|
+
return null;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// -----------------------------------------------------------------------------
|
|
136
|
+
// TYPES
|
|
137
|
+
// -----------------------------------------------------------------------------
|
|
138
|
+
|
|
139
|
+
export interface CreateSessionInput {
|
|
140
|
+
/** IDP user ID */
|
|
141
|
+
idpUserId: number;
|
|
142
|
+
/** User email */
|
|
143
|
+
email: string;
|
|
144
|
+
/** User's display name */
|
|
145
|
+
name?: string;
|
|
146
|
+
/** Client IP address */
|
|
147
|
+
ipAddress: string;
|
|
148
|
+
/** User agent string */
|
|
149
|
+
userAgent: string;
|
|
150
|
+
/** Location info from geolocation lookup */
|
|
151
|
+
location?: LocationInfo | null;
|
|
152
|
+
/** Parsed device info */
|
|
153
|
+
device?: DeviceInfo;
|
|
154
|
+
/** OAuth provider used for login */
|
|
155
|
+
oauthProvider?: string;
|
|
156
|
+
/** Hashed session token for lookup */
|
|
157
|
+
sessionTokenHash?: string;
|
|
158
|
+
/**
|
|
159
|
+
* User's access token - REQUIRED for member-to-vibe calls.
|
|
160
|
+
* Without this, the request will fail with 401.
|
|
161
|
+
*/
|
|
162
|
+
accessToken: string;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export interface SessionQueryOptions {
|
|
166
|
+
status?: SessionStatus;
|
|
167
|
+
page?: number;
|
|
168
|
+
pageSize?: number;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// -----------------------------------------------------------------------------
|
|
172
|
+
// CREATE SESSION
|
|
173
|
+
// -----------------------------------------------------------------------------
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Create a new login session record.
|
|
177
|
+
* Called when a user successfully logs in.
|
|
178
|
+
*
|
|
179
|
+
* @param input - Session creation parameters including accessToken
|
|
180
|
+
* @returns The created session, or null on failure
|
|
181
|
+
*/
|
|
182
|
+
export async function createLoginSession(
|
|
183
|
+
input: CreateSessionInput
|
|
184
|
+
): Promise<IVibeLoginSession | null> {
|
|
185
|
+
if (!input.accessToken) {
|
|
186
|
+
console.error('[vibe-sessions] accessToken is required for createLoginSession');
|
|
187
|
+
return null;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const now = new Date();
|
|
191
|
+
const expiresAt = new Date(now.getTime() + SESSION_EXPIRY_DAYS * 24 * 60 * 60 * 1000);
|
|
192
|
+
|
|
193
|
+
// PAY-2362 (write side): ONE row per session hash. A second record-login for the same session (another tab, a cleared
|
|
194
|
+
// sessionStorage, every open session when an app first runs a version that changes its login-recording key) used to INSERT a second
|
|
195
|
+
// row with the SAME hash, and a revoke PATCHes ONE row by id. If a row for this hash already exists, PATCH last_activity on it (that
|
|
196
|
+
// one field only: it never touches status, so it cannot un-revoke) and return it. A failed lookup must never block a login: fall
|
|
197
|
+
// through to the insert, as before. The read side (findRevokingSession: revoked if ANY row for the hash is) covers duplicates that
|
|
198
|
+
// already exist.
|
|
199
|
+
if (input.sessionTokenHash) {
|
|
200
|
+
try {
|
|
201
|
+
const existingRows = (await getUserSessions(input.idpUserId, input.accessToken))
|
|
202
|
+
.filter(s => s.session_token_hash === input.sessionTokenHash);
|
|
203
|
+
if (existingRows.length > 0) {
|
|
204
|
+
const existing = existingRows.find(s => s.status === 'revoked') ?? existingRows[0];
|
|
205
|
+
await updateSessionActivity(existing.id, input.accessToken);
|
|
206
|
+
return existing;
|
|
207
|
+
}
|
|
208
|
+
} catch {
|
|
209
|
+
/* lookup failed: insert as before */
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const sessionData = {
|
|
214
|
+
idp_user_id: input.idpUserId,
|
|
215
|
+
email: input.email,
|
|
216
|
+
name: input.name || null,
|
|
217
|
+
|
|
218
|
+
// Location
|
|
219
|
+
ip_address: input.ipAddress,
|
|
220
|
+
city: input.location?.city || null,
|
|
221
|
+
region: input.location?.region || null,
|
|
222
|
+
country: input.location?.country || null,
|
|
223
|
+
country_code: input.location?.countryCode || null,
|
|
224
|
+
latitude: input.location?.latitude || null,
|
|
225
|
+
longitude: input.location?.longitude || null,
|
|
226
|
+
timezone: input.location?.timezone || null,
|
|
227
|
+
|
|
228
|
+
// Device
|
|
229
|
+
user_agent: input.userAgent,
|
|
230
|
+
device_type: input.device?.deviceType || 'unknown',
|
|
231
|
+
browser: input.device?.browser || 'Unknown',
|
|
232
|
+
browser_version: input.device?.browserVersion || null,
|
|
233
|
+
os: input.device?.os || 'Unknown',
|
|
234
|
+
os_version: input.device?.osVersion || null,
|
|
235
|
+
|
|
236
|
+
// Auth
|
|
237
|
+
oauth_provider: input.oauthProvider || null,
|
|
238
|
+
session_token_hash: input.sessionTokenHash || null,
|
|
239
|
+
|
|
240
|
+
// Status
|
|
241
|
+
status: 'active' as SessionStatus,
|
|
242
|
+
revoked_by: null,
|
|
243
|
+
revoked_at: null,
|
|
244
|
+
revoke_reason: null,
|
|
245
|
+
|
|
246
|
+
// Timestamps
|
|
247
|
+
last_activity: now.toISOString(),
|
|
248
|
+
expires_at: expiresAt.toISOString(),
|
|
249
|
+
};
|
|
250
|
+
|
|
251
|
+
const endpoint = `/v1/collections/${COLLECTION}/tables/${TABLE}`;
|
|
252
|
+
const session = await vibeProxyRequest<IVibeLoginSession>(
|
|
253
|
+
endpoint,
|
|
254
|
+
'POST',
|
|
255
|
+
input.accessToken,
|
|
256
|
+
sessionData
|
|
257
|
+
);
|
|
258
|
+
|
|
259
|
+
if (session) {
|
|
260
|
+
console.log(`[vibe-sessions] Created session for ${input.email} from ${input.ipAddress}`);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
return session;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// -----------------------------------------------------------------------------
|
|
267
|
+
// GET SESSIONS
|
|
268
|
+
// -----------------------------------------------------------------------------
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* Get all sessions for a specific user.
|
|
272
|
+
*
|
|
273
|
+
* @param idpUserId - The user's IDP ID
|
|
274
|
+
* @param accessToken - User's access token for auth
|
|
275
|
+
* @returns Array of sessions
|
|
276
|
+
*/
|
|
277
|
+
export async function getUserSessions(
|
|
278
|
+
idpUserId: number,
|
|
279
|
+
accessToken: string
|
|
280
|
+
): Promise<IVibeLoginSession[]> {
|
|
281
|
+
const endpoint = `/v1/collections/${COLLECTION}/tables/${TABLE}/query`;
|
|
282
|
+
const result = await vibeProxyRequest<{ data?: IVibeLoginSession[]; items?: IVibeLoginSession[] }>(
|
|
283
|
+
endpoint,
|
|
284
|
+
'POST',
|
|
285
|
+
accessToken,
|
|
286
|
+
{
|
|
287
|
+
// PAY-2037: a flat VibeFilterNode OBJECT, as idealresume sends it (vibe-sessions.ts:220). Core binds
|
|
288
|
+
// QueryRequest.Filter as an object (VibeQueryBuilder.cs IsFlatFilterFormat); an ARRAY does not bind.
|
|
289
|
+
filter: { field: 'idp_user_id', operator: 'eq', value: idpUserId },
|
|
290
|
+
pageSize: 100,
|
|
291
|
+
}
|
|
292
|
+
);
|
|
293
|
+
|
|
294
|
+
return result?.data || result?.items || [];
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* Get all sessions (admin only).
|
|
299
|
+
* Returns sessions from all users, sorted by most recent first.
|
|
300
|
+
*
|
|
301
|
+
* @param options - Query options
|
|
302
|
+
* @param accessToken - Admin's access token
|
|
303
|
+
* @returns Array of sessions with total count
|
|
304
|
+
*/
|
|
305
|
+
export async function getAllSessions(
|
|
306
|
+
options: SessionQueryOptions = {},
|
|
307
|
+
accessToken: string
|
|
308
|
+
): Promise<{ sessions: IVibeLoginSession[]; total: number }> {
|
|
309
|
+
const page = options.page || 1;
|
|
310
|
+
const pageSize = options.pageSize || 50;
|
|
311
|
+
|
|
312
|
+
const query: Record<string, unknown> = {
|
|
313
|
+
page,
|
|
314
|
+
pageSize,
|
|
315
|
+
// PAY-2037: Vibe's QueryRequest.OrderBy is an OBJECT; a string is a 400 and vibeProxyRequest
|
|
316
|
+
// turned that into an empty list. There is no `orderDir` (QAPert 69149).
|
|
317
|
+
orderBy: { created_at: 'desc' },
|
|
318
|
+
};
|
|
319
|
+
|
|
320
|
+
if (options.status) {
|
|
321
|
+
query.filter = { field: 'status', operator: 'eq', value: options.status };
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
const endpoint = `/v1/collections/${COLLECTION}/tables/${TABLE}/query`;
|
|
325
|
+
const result = await vibeProxyRequest<{
|
|
326
|
+
data?: IVibeLoginSession[];
|
|
327
|
+
items?: IVibeLoginSession[];
|
|
328
|
+
meta?: { total?: number; totalCount?: number };
|
|
329
|
+
totalCount?: number;
|
|
330
|
+
}>(endpoint, 'POST', accessToken, query);
|
|
331
|
+
|
|
332
|
+
const sessions = result?.data || result?.items || [];
|
|
333
|
+
const total = result?.meta?.total ?? result?.meta?.totalCount ?? result?.totalCount ?? sessions.length;
|
|
334
|
+
|
|
335
|
+
return { sessions, total };
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/**
|
|
339
|
+
* Get a single session by ID.
|
|
340
|
+
*
|
|
341
|
+
* @param sessionId - The session document ID
|
|
342
|
+
* @param accessToken - Access token for auth
|
|
343
|
+
*/
|
|
344
|
+
export async function getSessionById(
|
|
345
|
+
sessionId: number,
|
|
346
|
+
accessToken: string
|
|
347
|
+
): Promise<IVibeLoginSession | null> {
|
|
348
|
+
const endpoint = `/v1/collections/${COLLECTION}/tables/${TABLE}/${sessionId}`;
|
|
349
|
+
return vibeProxyRequest<IVibeLoginSession>(endpoint, 'GET', accessToken);
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
// -----------------------------------------------------------------------------
|
|
353
|
+
// REVOKE SESSION
|
|
354
|
+
// -----------------------------------------------------------------------------
|
|
355
|
+
|
|
356
|
+
/**
|
|
357
|
+
* Revoke a specific session.
|
|
358
|
+
* The user will be logged out on their next request.
|
|
359
|
+
*
|
|
360
|
+
* @param sessionId - The session document ID
|
|
361
|
+
* @param adminEmail - Email of the admin performing the revocation
|
|
362
|
+
* @param accessToken - Admin's access token
|
|
363
|
+
* @param reason - Optional reason for revocation
|
|
364
|
+
* @returns The updated session, or null on failure
|
|
365
|
+
*/
|
|
366
|
+
export async function revokeSession(
|
|
367
|
+
sessionId: number,
|
|
368
|
+
adminEmail: string,
|
|
369
|
+
accessToken: string,
|
|
370
|
+
reason?: string
|
|
371
|
+
): Promise<IVibeLoginSession | null> {
|
|
372
|
+
const now = new Date().toISOString();
|
|
373
|
+
// PAY-2037: PATCH, not PUT - a PUT replaces the row and this body carries 4 of its fields.
|
|
374
|
+
const endpoint = `/v1/collections/${COLLECTION}/tables/${TABLE}/${sessionId}`;
|
|
375
|
+
const session = await vibeProxyRequest<IVibeLoginSession>(
|
|
376
|
+
endpoint,
|
|
377
|
+
'PATCH',
|
|
378
|
+
accessToken,
|
|
379
|
+
{
|
|
380
|
+
status: 'revoked',
|
|
381
|
+
revoked_by: adminEmail,
|
|
382
|
+
revoked_at: now,
|
|
383
|
+
revoke_reason: reason || 'Revoked by administrator',
|
|
384
|
+
}
|
|
385
|
+
);
|
|
386
|
+
|
|
387
|
+
if (session) {
|
|
388
|
+
console.log(`[vibe-sessions] Session ${sessionId} revoked by ${adminEmail}`);
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
return session;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
/**
|
|
395
|
+
* Revoke all sessions for a specific user.
|
|
396
|
+
* Useful when an account is compromised.
|
|
397
|
+
*
|
|
398
|
+
* @param idpUserId - The user's IDP ID
|
|
399
|
+
* @param adminEmail - Email of the admin performing the revocation
|
|
400
|
+
* @param accessToken - Admin's access token
|
|
401
|
+
* @param reason - Optional reason for revocation
|
|
402
|
+
* @returns Number of sessions revoked
|
|
403
|
+
*/
|
|
404
|
+
export async function revokeAllUserSessions(
|
|
405
|
+
idpUserId: number,
|
|
406
|
+
adminEmail: string,
|
|
407
|
+
accessToken: string,
|
|
408
|
+
reason?: string
|
|
409
|
+
): Promise<number> {
|
|
410
|
+
const sessions = await getUserSessions(idpUserId, accessToken);
|
|
411
|
+
const activeSessions = sessions.filter(s => s.status === 'active');
|
|
412
|
+
|
|
413
|
+
let revokedCount = 0;
|
|
414
|
+
for (const session of activeSessions) {
|
|
415
|
+
const result = await revokeSession(session.id, adminEmail, accessToken, reason);
|
|
416
|
+
if (result) revokedCount++;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
console.log(`[vibe-sessions] Revoked ${revokedCount} sessions for user ${idpUserId}`);
|
|
420
|
+
return revokedCount;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
// -----------------------------------------------------------------------------
|
|
424
|
+
// UPDATE ACTIVITY
|
|
425
|
+
// -----------------------------------------------------------------------------
|
|
426
|
+
|
|
427
|
+
/**
|
|
428
|
+
* Update the last_activity timestamp for a session.
|
|
429
|
+
* Call this periodically during user activity.
|
|
430
|
+
*
|
|
431
|
+
* @param sessionId - The session document ID
|
|
432
|
+
* @param accessToken - Access token for auth
|
|
433
|
+
* @returns true if updated successfully
|
|
434
|
+
*/
|
|
435
|
+
export async function updateSessionActivity(
|
|
436
|
+
sessionId: number,
|
|
437
|
+
accessToken: string
|
|
438
|
+
): Promise<boolean> {
|
|
439
|
+
const endpoint = `/v1/collections/${COLLECTION}/tables/${TABLE}/${sessionId}`;
|
|
440
|
+
const result = await vibeProxyRequest<IVibeLoginSession>(
|
|
441
|
+
endpoint,
|
|
442
|
+
'PATCH',
|
|
443
|
+
accessToken,
|
|
444
|
+
{ last_activity: new Date().toISOString() }
|
|
445
|
+
);
|
|
446
|
+
|
|
447
|
+
return result !== null;
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
// -----------------------------------------------------------------------------
|
|
451
|
+
// CHECK SESSION STATUS
|
|
452
|
+
// -----------------------------------------------------------------------------
|
|
453
|
+
|
|
454
|
+
/**
|
|
455
|
+
* Check if a session is still valid (not revoked/expired).
|
|
456
|
+
* Use this in middleware to enforce session revocation.
|
|
457
|
+
*
|
|
458
|
+
* @param sessionId - The session document ID
|
|
459
|
+
* @param accessToken - Access token for auth
|
|
460
|
+
* @returns true if session is active and not expired
|
|
461
|
+
*/
|
|
462
|
+
export async function isSessionValid(
|
|
463
|
+
sessionId: number,
|
|
464
|
+
accessToken: string
|
|
465
|
+
): Promise<boolean> {
|
|
466
|
+
const session = await getSessionById(sessionId, accessToken);
|
|
467
|
+
|
|
468
|
+
if (!session) return false;
|
|
469
|
+
if (session.status !== 'active') return false;
|
|
470
|
+
|
|
471
|
+
// Check expiry
|
|
472
|
+
const expiresAt = new Date(session.expires_at);
|
|
473
|
+
if (expiresAt < new Date()) {
|
|
474
|
+
return false;
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
return true;
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
/**
|
|
481
|
+
* Check if a user has any revoked sessions that need enforcement.
|
|
482
|
+
* Returns the most recently revoked session if found.
|
|
483
|
+
*
|
|
484
|
+
* @param idpUserId - The user's IDP ID
|
|
485
|
+
* @param accessToken - Access token for auth
|
|
486
|
+
* @param sessionTokenHash - Optional token hash to check specific session
|
|
487
|
+
* @returns Revoked session info if enforcement needed, null otherwise
|
|
488
|
+
*
|
|
489
|
+
* @deprecated PAY-2037 step 1 (BAPert 69153): kept so no consumer breaks. It is a thin call into the
|
|
490
|
+
* same hash-first decision as getRevocationStatus; prefer getRevocationStatus, which distinguishes
|
|
491
|
+
* a failed read ('unknown') from 'active' instead of throwing.
|
|
492
|
+
*/
|
|
493
|
+
export async function checkSessionRevocation(
|
|
494
|
+
idpUserId: number,
|
|
495
|
+
_accessToken: string,
|
|
496
|
+
sessionTokenHash?: string
|
|
497
|
+
): Promise<IVibeLoginSession | null> {
|
|
498
|
+
// PAY-2037 step 1: same decision as getRevocationStatus, same HMAC-only read. The bearer is
|
|
499
|
+
// accepted for signature compatibility and never sent. A FAILED read throws - it must not read
|
|
500
|
+
// as "no revoked session" (that is the PAY-1880 fail-open this replaces).
|
|
501
|
+
const { ok, sessions } = await readSessionRevocationHmac(idpUserId);
|
|
502
|
+
if (!ok) {
|
|
503
|
+
throw new Error('[vibe-sessions] session revocation read failed (fail closed)');
|
|
504
|
+
}
|
|
505
|
+
return findRevokingSession(sessions, sessionTokenHash);
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
// -----------------------------------------------------------------------------
|
|
509
|
+
// REVOCATION STATUS (PAY-1882 step 4 producer half)
|
|
510
|
+
// -----------------------------------------------------------------------------
|
|
511
|
+
|
|
512
|
+
/** The four states a viability producer can report (BAPert 66320). */
|
|
513
|
+
export type RevocationStatus = 'active' | 'revoked' | 'unknown' | 'not_configured';
|
|
514
|
+
|
|
515
|
+
// BAPert 66320: log 'not_configured' ONCE, not per request - a Vibe-less
|
|
516
|
+
// deployment would otherwise emit a warn line on every viability check.
|
|
517
|
+
let warnedNotConfigured = false;
|
|
518
|
+
|
|
519
|
+
/**
|
|
520
|
+
* Hash a session token the same way record-login stores it.
|
|
521
|
+
* SHA256 hex, 64 chars. This must stay byte-identical to
|
|
522
|
+
* `hashSessionToken` in the consumers (idealresume.online/lib/session-token-hash.ts)
|
|
523
|
+
* or per-session matching silently falls back to the user-wide path.
|
|
524
|
+
*/
|
|
525
|
+
export function hashSessionToken(token: string): string {
|
|
526
|
+
return crypto.createHash('sha256').update(token).digest('hex');
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
/**
|
|
530
|
+
* BAPert 66480 condition 2 (ruling (ii), 66700; corrected per QAPert 66729):
|
|
531
|
+
* resolve the NUMERIC IdP user id the Vibe query filters on.
|
|
532
|
+
*
|
|
533
|
+
* *** DO NOT READ `sub` FOR THE USER ID. *** In the IdP's bearer token `sub` is the
|
|
534
|
+
* USERNAME, not the id: AESGCM_Encryption.cs:815 writes `{ "sub", payload.Username }`
|
|
535
|
+
* and :822 writes `{ "user_id", payload.UserId.ToString() }`. A username is commonly
|
|
536
|
+
* an email, so reading `sub` yields 0 for the common case - and for a username that
|
|
537
|
+
* happens to be all digits it yields the WRONG id, which getRevocationStatus answers
|
|
538
|
+
* 'active' for a user with no rows: a fail-OPEN.
|
|
539
|
+
*
|
|
540
|
+
* Resolution order:
|
|
541
|
+
* 1. the token's `user_id` claim, when it is all digits;
|
|
542
|
+
* 2. a digits-only session.userId / session.user.id;
|
|
543
|
+
* 3. otherwise 0 (unresolvable), which the caller reports as 'unknown' (fail closed).
|
|
544
|
+
*
|
|
545
|
+
* session.userId is not a safe source on its own either: session-store.ts:143 falls
|
|
546
|
+
* back to user.email, and better-auth.ts stringifies whichever id it finds.
|
|
547
|
+
*/
|
|
548
|
+
export function resolveNumericIdpUserId(session: unknown, accessToken?: string): number {
|
|
549
|
+
const isDigits = (v: unknown) => /^[0-9]+$/.test(String(v ?? ''));
|
|
550
|
+
if (accessToken) {
|
|
551
|
+
// The raw payload (jwtDecode returns every claim, typed or not); user_id, never sub.
|
|
552
|
+
const payload = decodeIdpAccessToken(accessToken) as Record<string, unknown> | null;
|
|
553
|
+
const claim = payload?.user_id;
|
|
554
|
+
if (isDigits(claim)) return Number(claim);
|
|
555
|
+
}
|
|
556
|
+
const s = (session ?? {}) as Record<string, any>;
|
|
557
|
+
const fromSession = s.userId ?? s.user?.id;
|
|
558
|
+
return isDigits(fromSession) ? Number(fromSession) : 0;
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
/**
|
|
562
|
+
* BAPert 66480 condition 1: WARN once per session when the numeric IdP user id
|
|
563
|
+
* could not be resolved (userId is email-shaped, empty, or otherwise not all
|
|
564
|
+
* digits). A non-numeric id must show up as a log line, not as an unexplained
|
|
565
|
+
* sign-out. Shape only - NEVER the value.
|
|
566
|
+
*
|
|
567
|
+
* Keyed on a hash of the session token, so one WARN per session; the set is
|
|
568
|
+
* bounded so a long-lived process cannot grow it without limit.
|
|
569
|
+
*/
|
|
570
|
+
const warnedNonNumericUserIds = new Set<string>();
|
|
571
|
+
|
|
572
|
+
function shapeOfUserId(v: unknown): string {
|
|
573
|
+
const str = String(v ?? '');
|
|
574
|
+
if (!str) return 'empty:0';
|
|
575
|
+
const kind = /^[0-9]+$/.test(str) ? 'digits' : str.includes('@') ? 'email' : 'other';
|
|
576
|
+
return `${kind}:${str.length}`;
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
function warnNonNumericUserIdOnce(sessionToken: string | undefined, rawUserId: unknown): void {
|
|
580
|
+
const key = sessionToken ? hashSessionToken(sessionToken).slice(0, 16) : `nosession:${shapeOfUserId(rawUserId)}`;
|
|
581
|
+
if (warnedNonNumericUserIds.has(key)) return;
|
|
582
|
+
if (warnedNonNumericUserIds.size > 5000) warnedNonNumericUserIds.clear();
|
|
583
|
+
warnedNonNumericUserIds.add(key);
|
|
584
|
+
console.warn('[REVOCATION] non-numeric userId, failing closed', { shape: shapeOfUserId(rawUserId) });
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
/**
|
|
588
|
+
* Public entry point for the twins: call this when a session exists but its
|
|
589
|
+
* userId is not a usable numeric IdP id. Returns 'unknown' (fail closed) and
|
|
590
|
+
* emits the once-per-session WARN. Shape only, never the value.
|
|
591
|
+
*/
|
|
592
|
+
export function warnNonNumericUserId(sessionToken: string | undefined, rawUserId: unknown): 'unknown' {
|
|
593
|
+
warnNonNumericUserIdOnce(sessionToken, rawUserId);
|
|
594
|
+
return 'unknown';
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
/**
|
|
598
|
+
* HMAC-ONLY session read for the revocation check (PAY-1882 step 4).
|
|
599
|
+
*
|
|
600
|
+
* *** DO NOT "SIMPLIFY" THIS ONTO vibeProxyRequest/getUserSessions. ***
|
|
601
|
+
* It is deliberately separate and deliberately sends NO Authorization header.
|
|
602
|
+
*
|
|
603
|
+
* WHY: the twin this must match is idealresume.online's checkSessionRevocation
|
|
604
|
+
* read. That repo's vibe-client adds `Authorization: Bearer` ONLY for member
|
|
605
|
+
* paths (`path.startsWith('/v1/my/')`) - see idealresume.online/lib/vibe-client.ts
|
|
606
|
+
* :207-210 - so its /v1/collections/.../query for this check goes out HMAC-only.
|
|
607
|
+
* The comment at vibe-client.ts:188-206 there records why: a bearer on the
|
|
608
|
+
* collections path caused USER_REQUIRED and broke signups. getUserSessions()
|
|
609
|
+
* here ALWAYS sends the bearer, so it cannot stand in for this read without
|
|
610
|
+
* changing the credential the two twins are supposed to share.
|
|
611
|
+
*
|
|
612
|
+
* BAPert 66344 (option a): match the twin, HMAC-only.
|
|
613
|
+
*
|
|
614
|
+
* Also distinguishes a FAILED read from an EMPTY one - getUserSessions()
|
|
615
|
+
* collapses a proxy outage and "user has no sessions" to `[]`, which is
|
|
616
|
+
* fail-OPEN for a revocation decision. Returns `ok:false` on an outage so the
|
|
617
|
+
* caller fails closed, and `ok:true, sessions:[]` only for a healthy empty.
|
|
618
|
+
*/
|
|
619
|
+
export async function readSessionRevocationHmac(
|
|
620
|
+
idpUserId: number
|
|
621
|
+
): Promise<{ ok: boolean; sessions: IVibeLoginSession[]; failure?: string }> {
|
|
622
|
+
const idpUrl = getEnv('IDP_URL');
|
|
623
|
+
const clientId = getEnv('VIBE_CLIENT_ID');
|
|
624
|
+
const signingKey = getEnv('VIBE_HMAC_KEY');
|
|
625
|
+
|
|
626
|
+
if (!idpUrl || !clientId || !signingKey) {
|
|
627
|
+
// Caller distinguishes this from a read failure; report ok:false, not [].
|
|
628
|
+
return { ok: false, sessions: [], failure: 'misconfigured' };
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
const endpoint = `/v1/collections/${COLLECTION}/tables/${TABLE}/query`;
|
|
632
|
+
const proxyUrl = `${idpUrl}/api/vibe/proxy`;
|
|
633
|
+
|
|
634
|
+
// Signed per ATTEMPT: the retry below must not reuse a timestamp that has aged.
|
|
635
|
+
const send = (): Promise<Response> => {
|
|
636
|
+
const timestamp = Math.floor(Date.now() / 1000);
|
|
637
|
+
const signature = generateHmacSignature(signingKey, timestamp, 'POST', endpoint);
|
|
638
|
+
// NOTE the absence of Authorization. This is the twin's credential.
|
|
639
|
+
const headers: Record<string, string> = {
|
|
640
|
+
'Content-Type': 'application/json',
|
|
641
|
+
'X-Vibe-Client-Id': clientId,
|
|
642
|
+
'X-Vibe-Timestamp': String(timestamp),
|
|
643
|
+
'X-Vibe-Signature': signature,
|
|
644
|
+
};
|
|
645
|
+
return fetch(proxyUrl, {
|
|
646
|
+
method: 'POST',
|
|
647
|
+
headers,
|
|
648
|
+
body: JSON.stringify({
|
|
649
|
+
endpoint,
|
|
650
|
+
method: 'POST',
|
|
651
|
+
data: {
|
|
652
|
+
// PAY-2037: a flat VibeFilterNode OBJECT, as idealresume sends it (vibe-sessions.ts:220). Core binds
|
|
653
|
+
// QueryRequest.Filter as an object (VibeQueryBuilder.cs IsFlatFilterFormat); an ARRAY does not bind.
|
|
654
|
+
filter: { field: 'idp_user_id', operator: 'eq', value: idpUserId },
|
|
655
|
+
pageSize: 100,
|
|
656
|
+
},
|
|
657
|
+
}),
|
|
658
|
+
});
|
|
659
|
+
};
|
|
660
|
+
|
|
661
|
+
try {
|
|
662
|
+
let response = await send();
|
|
663
|
+
|
|
664
|
+
// PAY-2136: ONE retry, and only for the transient class. The portal's own prefetch burst exceeds the free
|
|
665
|
+
// tier's concurrency (CONCURRENT_LIMIT, HTTP 429). Keyed on the BODY code, never on Retry-After: the IdP proxy
|
|
666
|
+
// relays body and status but drops that header (PAY-2143). AUTH_BLOCKED / other 429s and every 5xx are NOT
|
|
667
|
+
// retried (a block is not transient; a retry only adds a counted failure).
|
|
668
|
+
if (response.status === 429 && isConcurrentLimitBody(await safeBodyText(response))) {
|
|
669
|
+
await new Promise((resolve) => setTimeout(resolve, revocationRetryDelayMs(response)));
|
|
670
|
+
response = await send();
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
if (!response.ok) {
|
|
674
|
+
// PAY-1427: never the raw proxy body - an error page can echo the request.
|
|
675
|
+
console.error(`[vibe-sessions] Revocation read proxy error ${response.status}`);
|
|
676
|
+
return { ok: false, sessions: [], failure: String(response.status) }; // PAY-2136: status only, for the reason log
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
const data = await response.json();
|
|
680
|
+
const unwrapped = (data?.data ?? data) as { data?: IVibeLoginSession[]; items?: IVibeLoginSession[] };
|
|
681
|
+
return { ok: true, sessions: unwrapped?.data || unwrapped?.items || [] };
|
|
682
|
+
} catch (error) {
|
|
683
|
+
console.error('[vibe-sessions] Revocation read failed:', error);
|
|
684
|
+
return { ok: false, sessions: [], failure: 'exception' };
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
/** Body text of a response WITHOUT consuming the caller's copy; '' on any failure. */
|
|
689
|
+
async function safeBodyText(response: Response): Promise<string> {
|
|
690
|
+
try {
|
|
691
|
+
return await response.clone().text();
|
|
692
|
+
} catch {
|
|
693
|
+
return '';
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
/** True only for the exact code CONCURRENT_LIMIT (nested error.code, top-level code/errorCode); a non-JSON body falls back to the literal. */
|
|
698
|
+
export function isConcurrentLimitBody(bodyText: string): boolean {
|
|
699
|
+
try {
|
|
700
|
+
const b = JSON.parse(bodyText) as Record<string, unknown>;
|
|
701
|
+
const nested = (b?.error as Record<string, unknown> | undefined)?.code;
|
|
702
|
+
const code = [nested, b?.code, b?.errorCode].find((c) => typeof c === 'string');
|
|
703
|
+
if (typeof code === 'string') return code === 'CONCURRENT_LIMIT';
|
|
704
|
+
return false;
|
|
705
|
+
} catch {
|
|
706
|
+
return /CONCURRENT_LIMIT/.test(bodyText);
|
|
707
|
+
}
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
/** Retry-After seconds when the proxy kept it (capped at 2 s: this runs in the request path), else 200-400 ms jitter. */
|
|
711
|
+
export function revocationRetryDelayMs(response: Response): number {
|
|
712
|
+
const ra = Number(response.headers?.get?.('retry-after'));
|
|
713
|
+
if (Number.isFinite(ra) && ra > 0) return Math.min(ra * 1000, 2000);
|
|
714
|
+
return 200 + Math.floor(Math.random() * 200);
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
/**
|
|
718
|
+
* THE one revocation decision (PAY-2037 step 1, BAPert 69150): hash-first, per session.
|
|
719
|
+
* Returns the row that revokes THIS session, or null when it is not revoked.
|
|
720
|
+
*
|
|
721
|
+
* A session whose own token hash matches a row is decided by that row ALONE. A matched ACTIVE
|
|
722
|
+
* session is never logged out because another device was revoked (idealresume PAY-1882 F3).
|
|
723
|
+
* Only when no row carries the hash (a legacy row minted before hashing) does the user-wide
|
|
724
|
+
* rule apply: no active row -> the most recently revoked (none revoked: not revoked); else any revoked inside 2h.
|
|
725
|
+
*/
|
|
726
|
+
function findRevokingSession(
|
|
727
|
+
sessions: IVibeLoginSession[],
|
|
728
|
+
sessionTokenHash?: string
|
|
729
|
+
): IVibeLoginSession | null {
|
|
730
|
+
return findRevokingSessionWithRule(sessions, sessionTokenHash).row;
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
/** Which branch of the revocation decision produced the verdict (PAY-2136 reason log; never a value). */
|
|
734
|
+
export type RevocationRule = 'own_row_revoked' | 'legacy_no_active' | 'legacy_recent_2h' | 'none';
|
|
735
|
+
|
|
736
|
+
function findRevokingSessionWithRule(
|
|
737
|
+
sessions: IVibeLoginSession[],
|
|
738
|
+
sessionTokenHash?: string
|
|
739
|
+
): { row: IVibeLoginSession | null; rule: RevocationRule } {
|
|
740
|
+
if (sessions.length === 0) return { row: null, rule: 'none' };
|
|
741
|
+
|
|
742
|
+
if (sessionTokenHash) {
|
|
743
|
+
// PAY-2362: EVERY row carrying this hash, not the first. A second record-login for the same session (another tab, a cleared
|
|
744
|
+
// sessionStorage, every open session at a package roll) writes a second row with the SAME hash, and a revoke PATCHes ONE row by id.
|
|
745
|
+
// `find` took the first row, so an active duplicate listed before the revoked one hid the revoke. Revoked if ANY of them is.
|
|
746
|
+
const matching = sessions.filter(s => s.session_token_hash === sessionTokenHash);
|
|
747
|
+
if (matching.length > 0) {
|
|
748
|
+
const revoked = matching.find(s => s.status === 'revoked');
|
|
749
|
+
return revoked ? { row: revoked, rule: 'own_row_revoked' } : { row: null, rule: 'none' };
|
|
750
|
+
}
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
// Hash not found (a legacy row minted before hashing) or no hash given: idealresume's user-wide
|
|
754
|
+
// rules, lib/vibe-sessions.ts:594-651 (BAPert 69189).
|
|
755
|
+
const revokedAt = (s: IVibeLoginSession) => (s.revoked_at ? new Date(s.revoked_at).getTime() : 0);
|
|
756
|
+
const newestFirst = (x: IVibeLoginSession, y: IVibeLoginSession) => revokedAt(y) - revokedAt(x);
|
|
757
|
+
|
|
758
|
+
// Check 1: no active row. The most recently revoked row (whenever it was revoked) is the verdict.
|
|
759
|
+
// All rows expired and none revoked is NOT revoked: expiry is not revocation.
|
|
760
|
+
if (!sessions.some(s => s.status === 'active')) {
|
|
761
|
+
const revoked = sessions.filter(s => s.status === 'revoked' && s.revoked_at).sort(newestFirst);
|
|
762
|
+
if (revoked.length > 0) return { row: revoked[0], rule: 'legacy_no_active' };
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
// Check 2: any row revoked in the last TWO hours (idealresume's window; 5-minute polling needs the time).
|
|
766
|
+
const twoHoursAgo = Date.now() - 2 * 60 * 60 * 1000;
|
|
767
|
+
const recent = sessions.filter(s => s.status === 'revoked' && revokedAt(s) > twoHoursAgo).sort(newestFirst)[0] ?? null;
|
|
768
|
+
return recent ? { row: recent, rule: 'legacy_recent_2h' } : { row: null, rule: 'none' };
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
/**
|
|
772
|
+
* PAY-2136: one shape-only line per NON-viable revocation verdict, naming WHICH rule fired. Never a token, userId,
|
|
773
|
+
* hash, or row content: the rule label, a read-failure status code, a row COUNT and the session's tokenLogId.
|
|
774
|
+
* Must never throw into the viability path.
|
|
775
|
+
*/
|
|
776
|
+
function logRevocationReason(
|
|
777
|
+
verdict: 'revoked' | 'unknown',
|
|
778
|
+
reason: string,
|
|
779
|
+
sessionToken: string | undefined,
|
|
780
|
+
extra: Record<string, number> = {}
|
|
781
|
+
): void {
|
|
782
|
+
try {
|
|
783
|
+
console.warn('[REVOCATION] verdict', {
|
|
784
|
+
verdict,
|
|
785
|
+
reason,
|
|
786
|
+
sessionTokenId: sessionToken ? tokenLogId(sessionToken) : null,
|
|
787
|
+
...extra,
|
|
788
|
+
});
|
|
789
|
+
} catch {
|
|
790
|
+
/* diagnostic only */
|
|
791
|
+
}
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
/**
|
|
795
|
+
* Determine whether a session is revoked, with an explicit UNKNOWN state
|
|
796
|
+
* (PAY-1882 step 4 producer half).
|
|
797
|
+
*
|
|
798
|
+
* States:
|
|
799
|
+
* 'not_configured' - this deployment has no revocation source (IDP_URL /
|
|
800
|
+
* VIBE_CLIENT_ID / VIBE_HMAC_KEY absent). Viable; today's
|
|
801
|
+
* behaviour. Logged once, not per request.
|
|
802
|
+
* 'unknown' - revocation IS configured but the read FAILED (non-OK,
|
|
803
|
+
* timeout, throw). Enforcing middleware fails this CLOSED
|
|
804
|
+
* by default (revocationFailMode 'closed'); a Vibe-less
|
|
805
|
+
* consumer is 'not_configured', not 'unknown', so it is not
|
|
806
|
+
* signed out.
|
|
807
|
+
* 'revoked' - the session's own token hash matches a revoked row, or
|
|
808
|
+
* the user has no active session at all.
|
|
809
|
+
* 'active' - read OK and the session is not revoked.
|
|
810
|
+
*
|
|
811
|
+
* @param idpUserId - The user's IDP ID
|
|
812
|
+
* @param sessionToken - The live session token; hashed to match the stored row.
|
|
813
|
+
* When absent, the user-wide check applies (legacy path).
|
|
814
|
+
*/
|
|
815
|
+
async function readRevocationStatus(
|
|
816
|
+
idpUserId: number,
|
|
817
|
+
sessionToken?: string
|
|
818
|
+
): Promise<RevocationStatus> {
|
|
819
|
+
const idpUrl = getEnv('IDP_URL');
|
|
820
|
+
const clientId = getEnv('VIBE_CLIENT_ID');
|
|
821
|
+
const signingKey = getEnv('VIBE_HMAC_KEY');
|
|
822
|
+
|
|
823
|
+
// 'not_configured' is reserved for an app with NO revocation source at all:
|
|
824
|
+
// no VIBE_CLIENT_ID. BAPert 66320 - "the app has no revocation source
|
|
825
|
+
// (VIBE_CLIENT_ID absent / revocation not enabled)". Such a consumer stays
|
|
826
|
+
// viable, or a Vibe-less app would sign everyone out on every request.
|
|
827
|
+
if (!clientId) {
|
|
828
|
+
if (!warnedNotConfigured) {
|
|
829
|
+
warnedNotConfigured = true;
|
|
830
|
+
console.warn(
|
|
831
|
+
'[vibe-sessions] Revocation check not configured (VIBE_CLIENT_ID absent) - reporting not_configured (logged once)'
|
|
832
|
+
);
|
|
833
|
+
}
|
|
834
|
+
return 'not_configured';
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
// A client id IS present, so this app is a Vibe consumer that intends the
|
|
838
|
+
// revocation check - but the read is MISCONFIGURED (IDP_URL or VIBE_HMAC_KEY
|
|
839
|
+
// missing). BAPert 66344 condition 2: FAIL CLOSED. Do NOT fall back to bearer,
|
|
840
|
+
// do NOT report "no sessions". 'unknown' is the fail-closed state.
|
|
841
|
+
if (!idpUrl || !signingKey) {
|
|
842
|
+
console.warn(
|
|
843
|
+
'[vibe-sessions] Revocation check MISCONFIGURED (VIBE_CLIENT_ID set but IDP_URL/VIBE_HMAC_KEY missing) - reporting unknown (fail closed)'
|
|
844
|
+
);
|
|
845
|
+
logRevocationReason('unknown', 'misconfigured', sessionToken);
|
|
846
|
+
return 'unknown';
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
const { ok, sessions, failure } = await readSessionRevocationHmac(idpUserId);
|
|
850
|
+
|
|
851
|
+
if (!ok) {
|
|
852
|
+
console.warn('[vibe-sessions] Revocation read failed - reporting unknown');
|
|
853
|
+
logRevocationReason('unknown', `read_failed:${failure ?? 'unknown'}`, sessionToken);
|
|
854
|
+
return 'unknown';
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
const { row, rule } = findRevokingSessionWithRule(sessions, sessionToken ? hashSessionToken(sessionToken) : undefined);
|
|
858
|
+
if (row) {
|
|
859
|
+
logRevocationReason('revoked', rule, sessionToken, { rows: sessions.length });
|
|
860
|
+
return 'revoked';
|
|
861
|
+
}
|
|
862
|
+
return 'active';
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
// -----------------------------------------------------------------------------
|
|
866
|
+
// STATISTICS
|
|
867
|
+
// -----------------------------------------------------------------------------
|
|
868
|
+
|
|
869
|
+
/**
|
|
870
|
+
* Get session statistics for admin dashboard.
|
|
871
|
+
*
|
|
872
|
+
* @param accessToken - Admin's access token
|
|
873
|
+
*/
|
|
874
|
+
export async function getSessionStats(accessToken: string): Promise<{
|
|
875
|
+
totalActive: number;
|
|
876
|
+
totalRevoked: number;
|
|
877
|
+
uniqueUsers: number;
|
|
878
|
+
byCountry: Record<string, number>;
|
|
879
|
+
byDevice: Record<string, number>;
|
|
880
|
+
recentLogins: number; // Last 24 hours
|
|
881
|
+
}> {
|
|
882
|
+
const { sessions } = await getAllSessions({ pageSize: 1000 }, accessToken);
|
|
883
|
+
|
|
884
|
+
const stats = {
|
|
885
|
+
totalActive: 0,
|
|
886
|
+
totalRevoked: 0,
|
|
887
|
+
uniqueUsers: new Set<number>(),
|
|
888
|
+
byCountry: {} as Record<string, number>,
|
|
889
|
+
byDevice: {} as Record<string, number>,
|
|
890
|
+
recentLogins: 0,
|
|
891
|
+
};
|
|
892
|
+
|
|
893
|
+
const oneDayAgo = new Date(Date.now() - 24 * 60 * 60 * 1000);
|
|
894
|
+
|
|
895
|
+
for (const session of sessions) {
|
|
896
|
+
// Status counts
|
|
897
|
+
if (session.status === 'active') stats.totalActive++;
|
|
898
|
+
if (session.status === 'revoked') stats.totalRevoked++;
|
|
899
|
+
|
|
900
|
+
// Unique users
|
|
901
|
+
stats.uniqueUsers.add(session.idp_user_id);
|
|
902
|
+
|
|
903
|
+
// By country
|
|
904
|
+
const country = session.country_code || 'Unknown';
|
|
905
|
+
stats.byCountry[country] = (stats.byCountry[country] || 0) + 1;
|
|
906
|
+
|
|
907
|
+
// By device type
|
|
908
|
+
const device = session.device_type || 'unknown';
|
|
909
|
+
stats.byDevice[device] = (stats.byDevice[device] || 0) + 1;
|
|
910
|
+
|
|
911
|
+
// Recent logins
|
|
912
|
+
if (new Date(session.created_at) > oneDayAgo) {
|
|
913
|
+
stats.recentLogins++;
|
|
914
|
+
}
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
return {
|
|
918
|
+
totalActive: stats.totalActive,
|
|
919
|
+
totalRevoked: stats.totalRevoked,
|
|
920
|
+
uniqueUsers: stats.uniqueUsers.size,
|
|
921
|
+
byCountry: stats.byCountry,
|
|
922
|
+
byDevice: stats.byDevice,
|
|
923
|
+
recentLogins: stats.recentLogins,
|
|
924
|
+
};
|
|
925
|
+
}
|
|
900
926
|
|
|
901
927
|
// -----------------------------------------------------------------------------
|
|
902
928
|
// PAY-2136: SINGLE-FLIGHT + SHORT RESULT CACHE for the revocation read
|