@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,279 +1,279 @@
1
- /**
2
- * Page RBAC Check Module
3
- *
4
- * Checks page-level permissions via Vibe API through the IDP Proxy.
5
- * Uses in-memory cache to reduce API calls.
6
- * Fails closed (DENY) on errors or timeout.
7
- *
8
- * All requests route through the IDP Vibe Proxy ({IDP_URL}/api/vibe/proxy)
9
- * which injects proper HMAC credentials for the Vibe API.
10
- *
11
- * @version 2.0.0
12
- * @since page-rbac-2026-01
13
- */
14
-
15
- // ============================================================================
16
- // WEB CRYPTO HELPERS (Edge Runtime compatible)
17
- // ============================================================================
18
-
19
- const encoder = new TextEncoder();
20
-
21
- async function sha256Hex(input: string): Promise<string> {
22
- const data = encoder.encode(input);
23
- const hash = await crypto.subtle.digest('SHA-256', data);
24
- return Array.from(new Uint8Array(hash))
25
- .map(b => b.toString(16).padStart(2, '0'))
26
- .join('');
27
- }
28
-
29
- async function hmacSha256Base64(key: Uint8Array, message: string): Promise<string> {
30
- const cryptoKey = await crypto.subtle.importKey(
31
- 'raw',
32
- key as BufferSource,
33
- { name: 'HMAC', hash: 'SHA-256' },
34
- false,
35
- ['sign']
36
- );
37
- const signature = await crypto.subtle.sign('HMAC', cryptoKey, encoder.encode(message));
38
- return btoa(String.fromCharCode(...new Uint8Array(signature)));
39
- }
40
-
41
- function base64ToUint8Array(base64: string): Uint8Array {
42
- const binary = atob(base64);
43
- const bytes = new Uint8Array(binary.length);
44
- for (let i = 0; i < binary.length; i++) {
45
- bytes[i] = binary.charCodeAt(i);
46
- }
47
- return bytes;
48
- }
49
-
50
- // ============================================================================
51
- // TYPES
52
- // ============================================================================
53
-
54
- export interface RBACResult {
55
- allowed: boolean;
56
- requires_2fa?: boolean;
57
- requires_elevated_auth?: boolean;
58
- reason?: string;
59
- required_roles?: string[];
60
- required_claims?: Array<{ type: string; value: string }>;
61
- matched_rule?: string;
62
- redirect?: string;
63
- cache_ttl?: number;
64
- }
65
-
66
- interface CacheEntry {
67
- result: RBACResult;
68
- expires: number;
69
- }
70
-
71
- // ============================================================================
72
- // CACHE
73
- // ============================================================================
74
-
75
- const rbacCache = new Map<string, CacheEntry>();
76
- const DEFAULT_CACHE_TTL = 60; // 60 seconds
77
- const MAX_CACHE_TTL = 300; // 5 minutes max - prevents cache poisoning
78
- const MAX_CACHE_SIZE = 1000;
79
-
80
- /**
81
- * Generate cache key for RBAC result.
82
- * Uses SHA-256 hash to avoid key collisions and limit key size.
83
- */
84
- async function getCacheKey(clientId: string, path: string, roles: string[]): Promise<string> {
85
- const sortedRoles = [...roles].sort().join(',');
86
- const input = JSON.stringify({ clientId, path, roles: sortedRoles });
87
- const hash = await sha256Hex(input);
88
- return hash.substring(0, 32);
89
- }
90
-
91
- /**
92
- * Get cached RBAC result if valid.
93
- */
94
- function getCachedResult(key: string): RBACResult | null {
95
- const cached = rbacCache.get(key);
96
- if (cached && cached.expires > Date.now()) {
97
- return cached.result;
98
- }
99
- // Clean up expired entry
100
- if (cached) {
101
- rbacCache.delete(key);
102
- }
103
- return null;
104
- }
105
-
106
- /**
107
- * Cache an RBAC result.
108
- */
109
- function setCachedResult(key: string, result: RBACResult): void {
110
- // Prevent unbounded cache growth
111
- if (rbacCache.size >= MAX_CACHE_SIZE) {
112
- // Remove oldest entries (first 100)
113
- const keysToDelete = Array.from(rbacCache.keys()).slice(0, 100);
114
- keysToDelete.forEach(k => rbacCache.delete(k));
115
- }
116
-
117
- // SECURITY: Clamp TTL to prevent cache poisoning attacks
118
- const ttl = Math.min(result.cache_ttl ?? DEFAULT_CACHE_TTL, MAX_CACHE_TTL);
119
- rbacCache.set(key, {
120
- result,
121
- expires: Date.now() + (ttl * 1000),
122
- });
123
- }
124
-
125
- /**
126
- * Clear cache (for testing or config changes).
127
- */
128
- export function clearRBACCache(): void {
129
- rbacCache.clear();
130
- }
131
-
132
- // ============================================================================
133
- // RBAC CHECK (via IDP Proxy)
134
- // ============================================================================
135
-
136
- /**
137
- * Check if user has permission to access a page.
138
- *
139
- * Routes through IDP Vibe Proxy ({IDP_URL}/api/vibe/proxy) which injects
140
- * proper HMAC credentials. The Vibe RBAC endpoint requires client context
141
- * that only the proxy can provide.
142
- *
143
- * FAIL CLOSED: If proxy is unreachable or times out, access is DENIED.
144
- *
145
- * @param path - The route path to check
146
- * @param userRoles - User's roles from session
147
- * @param clientId - Client slug for multi-tenancy
148
- * @param userClaims - Optional claims for claim-based authorization
149
- * @returns RBAC result with allowed/denied status
150
- */
151
- export async function checkPagePermission(
152
- path: string,
153
- userRoles: string[],
154
- clientId: string,
155
- userClaims?: Record<string, string>
156
- ): Promise<RBACResult> {
157
- // Check cache first
158
- const cacheKey = await getCacheKey(clientId, path, userRoles);
159
- const cached = getCachedResult(cacheKey);
160
- if (cached) {
161
- return cached;
162
- }
163
-
164
- const idpUrl = process.env.NEXT_PUBLIC_IDP_URL || process.env.IDP_URL;
165
- const vibeClientId = process.env.VIBE_CLIENT_ID;
166
- const hmacKey = process.env.VIBE_HMAC_KEY || process.env.IDP_SIGNING_KEY;
167
-
168
- if (!idpUrl) {
169
- console.error('[RBAC] IDP_URL not configured');
170
- return {
171
- allowed: false,
172
- reason: 'rbac_not_configured',
173
- redirect: '/error?code=rbac_not_configured',
174
- };
175
- }
176
-
177
- // Build RBAC endpoint with query params
178
- // Vibe route is /v1/rbac/check (no /api/ prefix)
179
- const params = new URLSearchParams();
180
- params.set('path', path);
181
- params.set('roles', userRoles.join(','));
182
-
183
- if (userClaims && Object.keys(userClaims).length > 0) {
184
- const claimsParam = Object.entries(userClaims)
185
- .map(([type, value]) => `${type}:${value}`)
186
- .join(',');
187
- params.set('claims', claimsParam);
188
- }
189
-
190
- const rbacEndpoint = `/v1/rbac/check?${params.toString()}`;
191
-
192
- // Build proxy request
193
- const proxyUrl = `${idpUrl}/api/vibe/proxy`;
194
- const timestamp = Math.floor(Date.now() / 1000);
195
-
196
- const headers: Record<string, string> = {
197
- 'Content-Type': 'application/json',
198
- 'Accept': 'application/json',
199
- };
200
-
201
- if (vibeClientId) {
202
- headers['X-Vibe-Client-Id'] = vibeClientId;
203
- }
204
-
205
- // Sign with HMAC (same format as vibe-client: timestamp|method|endpoint)
206
- if (hmacKey && vibeClientId) {
207
- const stringToSign = `${timestamp}|GET|${rbacEndpoint}`;
208
- const keyBuffer = base64ToUint8Array(hmacKey);
209
- const signature = await hmacSha256Base64(keyBuffer, stringToSign);
210
- headers['X-Vibe-Timestamp'] = String(timestamp);
211
- headers['X-Vibe-Signature'] = signature;
212
- }
213
-
214
- // Proxy body format: { endpoint, method, data }
215
- const proxyBody = {
216
- endpoint: rbacEndpoint,
217
- method: 'GET',
218
- data: null,
219
- };
220
-
221
- try {
222
- // 2 second timeout - fail closed
223
- const controller = new AbortController();
224
- const timeoutId = setTimeout(() => controller.abort(), 2000);
225
-
226
- const response = await fetch(proxyUrl, {
227
- method: 'POST',
228
- headers,
229
- body: JSON.stringify(proxyBody),
230
- signal: controller.signal,
231
- });
232
-
233
- clearTimeout(timeoutId);
234
-
235
- if (!response.ok) {
236
- console.error('[RBAC] Proxy error:', response.status, response.statusText);
237
- return {
238
- allowed: false,
239
- reason: 'rbac_api_error',
240
- redirect: '/error?code=rbac_error',
241
- };
242
- }
243
-
244
- const body = await response.json();
245
-
246
- // Vibe API wraps responses: { success: true, data: { allowed, reason, ... } }
247
- // Unwrap the .data property if present, otherwise use body directly
248
- const result: RBACResult = body?.data ?? body;
249
-
250
- // Cache the result
251
- setCachedResult(cacheKey, result);
252
-
253
- return result;
254
- } catch (error: any) {
255
- // Fail closed on any error
256
- if (error.name === 'AbortError') {
257
- console.error('[RBAC] Proxy timeout (2s exceeded)');
258
- return {
259
- allowed: false,
260
- reason: 'rbac_timeout',
261
- redirect: '/error?code=rbac_timeout',
262
- };
263
- }
264
-
265
- console.error('[RBAC] Proxy error:', error);
266
- return {
267
- allowed: false,
268
- reason: 'rbac_service_unavailable',
269
- redirect: '/error?code=rbac_unavailable',
270
- };
271
- }
272
- }
273
-
274
- /**
275
- * Check if RBAC is enabled for this deployment.
276
- */
277
- export function isRBACEnabled(): boolean {
278
- return process.env.VIBE_RBAC_ENABLED === 'true';
279
- }
1
+ /**
2
+ * Page RBAC Check Module
3
+ *
4
+ * Checks page-level permissions via Vibe API through the IDP Proxy.
5
+ * Uses in-memory cache to reduce API calls.
6
+ * Fails closed (DENY) on errors or timeout.
7
+ *
8
+ * All requests route through the IDP Vibe Proxy ({IDP_URL}/api/vibe/proxy)
9
+ * which injects proper HMAC credentials for the Vibe API.
10
+ *
11
+ * @version 2.0.0
12
+ * @since page-rbac-2026-01
13
+ */
14
+
15
+ // ============================================================================
16
+ // WEB CRYPTO HELPERS (Edge Runtime compatible)
17
+ // ============================================================================
18
+
19
+ const encoder = new TextEncoder();
20
+
21
+ async function sha256Hex(input: string): Promise<string> {
22
+ const data = encoder.encode(input);
23
+ const hash = await crypto.subtle.digest('SHA-256', data);
24
+ return Array.from(new Uint8Array(hash))
25
+ .map(b => b.toString(16).padStart(2, '0'))
26
+ .join('');
27
+ }
28
+
29
+ async function hmacSha256Base64(key: Uint8Array, message: string): Promise<string> {
30
+ const cryptoKey = await crypto.subtle.importKey(
31
+ 'raw',
32
+ key as BufferSource,
33
+ { name: 'HMAC', hash: 'SHA-256' },
34
+ false,
35
+ ['sign']
36
+ );
37
+ const signature = await crypto.subtle.sign('HMAC', cryptoKey, encoder.encode(message));
38
+ return btoa(String.fromCharCode(...new Uint8Array(signature)));
39
+ }
40
+
41
+ function base64ToUint8Array(base64: string): Uint8Array {
42
+ const binary = atob(base64);
43
+ const bytes = new Uint8Array(binary.length);
44
+ for (let i = 0; i < binary.length; i++) {
45
+ bytes[i] = binary.charCodeAt(i);
46
+ }
47
+ return bytes;
48
+ }
49
+
50
+ // ============================================================================
51
+ // TYPES
52
+ // ============================================================================
53
+
54
+ export interface RBACResult {
55
+ allowed: boolean;
56
+ requires_2fa?: boolean;
57
+ requires_elevated_auth?: boolean;
58
+ reason?: string;
59
+ required_roles?: string[];
60
+ required_claims?: Array<{ type: string; value: string }>;
61
+ matched_rule?: string;
62
+ redirect?: string;
63
+ cache_ttl?: number;
64
+ }
65
+
66
+ interface CacheEntry {
67
+ result: RBACResult;
68
+ expires: number;
69
+ }
70
+
71
+ // ============================================================================
72
+ // CACHE
73
+ // ============================================================================
74
+
75
+ const rbacCache = new Map<string, CacheEntry>();
76
+ const DEFAULT_CACHE_TTL = 60; // 60 seconds
77
+ const MAX_CACHE_TTL = 300; // 5 minutes max - prevents cache poisoning
78
+ const MAX_CACHE_SIZE = 1000;
79
+
80
+ /**
81
+ * Generate cache key for RBAC result.
82
+ * Uses SHA-256 hash to avoid key collisions and limit key size.
83
+ */
84
+ async function getCacheKey(clientId: string, path: string, roles: string[]): Promise<string> {
85
+ const sortedRoles = [...roles].sort().join(',');
86
+ const input = JSON.stringify({ clientId, path, roles: sortedRoles });
87
+ const hash = await sha256Hex(input);
88
+ return hash.substring(0, 32);
89
+ }
90
+
91
+ /**
92
+ * Get cached RBAC result if valid.
93
+ */
94
+ function getCachedResult(key: string): RBACResult | null {
95
+ const cached = rbacCache.get(key);
96
+ if (cached && cached.expires > Date.now()) {
97
+ return cached.result;
98
+ }
99
+ // Clean up expired entry
100
+ if (cached) {
101
+ rbacCache.delete(key);
102
+ }
103
+ return null;
104
+ }
105
+
106
+ /**
107
+ * Cache an RBAC result.
108
+ */
109
+ function setCachedResult(key: string, result: RBACResult): void {
110
+ // Prevent unbounded cache growth
111
+ if (rbacCache.size >= MAX_CACHE_SIZE) {
112
+ // Remove oldest entries (first 100)
113
+ const keysToDelete = Array.from(rbacCache.keys()).slice(0, 100);
114
+ keysToDelete.forEach(k => rbacCache.delete(k));
115
+ }
116
+
117
+ // SECURITY: Clamp TTL to prevent cache poisoning attacks
118
+ const ttl = Math.min(result.cache_ttl ?? DEFAULT_CACHE_TTL, MAX_CACHE_TTL);
119
+ rbacCache.set(key, {
120
+ result,
121
+ expires: Date.now() + (ttl * 1000),
122
+ });
123
+ }
124
+
125
+ /**
126
+ * Clear cache (for testing or config changes).
127
+ */
128
+ export function clearRBACCache(): void {
129
+ rbacCache.clear();
130
+ }
131
+
132
+ // ============================================================================
133
+ // RBAC CHECK (via IDP Proxy)
134
+ // ============================================================================
135
+
136
+ /**
137
+ * Check if user has permission to access a page.
138
+ *
139
+ * Routes through IDP Vibe Proxy ({IDP_URL}/api/vibe/proxy) which injects
140
+ * proper HMAC credentials. The Vibe RBAC endpoint requires client context
141
+ * that only the proxy can provide.
142
+ *
143
+ * FAIL CLOSED: If proxy is unreachable or times out, access is DENIED.
144
+ *
145
+ * @param path - The route path to check
146
+ * @param userRoles - User's roles from session
147
+ * @param clientId - Client slug for multi-tenancy
148
+ * @param userClaims - Optional claims for claim-based authorization
149
+ * @returns RBAC result with allowed/denied status
150
+ */
151
+ export async function checkPagePermission(
152
+ path: string,
153
+ userRoles: string[],
154
+ clientId: string,
155
+ userClaims?: Record<string, string>
156
+ ): Promise<RBACResult> {
157
+ // Check cache first
158
+ const cacheKey = await getCacheKey(clientId, path, userRoles);
159
+ const cached = getCachedResult(cacheKey);
160
+ if (cached) {
161
+ return cached;
162
+ }
163
+
164
+ const idpUrl = process.env.NEXT_PUBLIC_IDP_URL || process.env.IDP_URL;
165
+ const vibeClientId = process.env.VIBE_CLIENT_ID;
166
+ const hmacKey = process.env.VIBE_HMAC_KEY || process.env.IDP_SIGNING_KEY;
167
+
168
+ if (!idpUrl) {
169
+ console.error('[RBAC] IDP_URL not configured');
170
+ return {
171
+ allowed: false,
172
+ reason: 'rbac_not_configured',
173
+ redirect: '/error?code=rbac_not_configured',
174
+ };
175
+ }
176
+
177
+ // Build RBAC endpoint with query params
178
+ // Vibe route is /v1/rbac/check (no /api/ prefix)
179
+ const params = new URLSearchParams();
180
+ params.set('path', path);
181
+ params.set('roles', userRoles.join(','));
182
+
183
+ if (userClaims && Object.keys(userClaims).length > 0) {
184
+ const claimsParam = Object.entries(userClaims)
185
+ .map(([type, value]) => `${type}:${value}`)
186
+ .join(',');
187
+ params.set('claims', claimsParam);
188
+ }
189
+
190
+ const rbacEndpoint = `/v1/rbac/check?${params.toString()}`;
191
+
192
+ // Build proxy request
193
+ const proxyUrl = `${idpUrl}/api/vibe/proxy`;
194
+ const timestamp = Math.floor(Date.now() / 1000);
195
+
196
+ const headers: Record<string, string> = {
197
+ 'Content-Type': 'application/json',
198
+ 'Accept': 'application/json',
199
+ };
200
+
201
+ if (vibeClientId) {
202
+ headers['X-Vibe-Client-Id'] = vibeClientId;
203
+ }
204
+
205
+ // Sign with HMAC (same format as vibe-client: timestamp|method|endpoint)
206
+ if (hmacKey && vibeClientId) {
207
+ const stringToSign = `${timestamp}|GET|${rbacEndpoint}`;
208
+ const keyBuffer = base64ToUint8Array(hmacKey);
209
+ const signature = await hmacSha256Base64(keyBuffer, stringToSign);
210
+ headers['X-Vibe-Timestamp'] = String(timestamp);
211
+ headers['X-Vibe-Signature'] = signature;
212
+ }
213
+
214
+ // Proxy body format: { endpoint, method, data }
215
+ const proxyBody = {
216
+ endpoint: rbacEndpoint,
217
+ method: 'GET',
218
+ data: null,
219
+ };
220
+
221
+ try {
222
+ // 2 second timeout - fail closed
223
+ const controller = new AbortController();
224
+ const timeoutId = setTimeout(() => controller.abort(), 2000);
225
+
226
+ const response = await fetch(proxyUrl, {
227
+ method: 'POST',
228
+ headers,
229
+ body: JSON.stringify(proxyBody),
230
+ signal: controller.signal,
231
+ });
232
+
233
+ clearTimeout(timeoutId);
234
+
235
+ if (!response.ok) {
236
+ console.error('[RBAC] Proxy error:', response.status, response.statusText);
237
+ return {
238
+ allowed: false,
239
+ reason: 'rbac_api_error',
240
+ redirect: '/error?code=rbac_error',
241
+ };
242
+ }
243
+
244
+ const body = await response.json();
245
+
246
+ // Vibe API wraps responses: { success: true, data: { allowed, reason, ... } }
247
+ // Unwrap the .data property if present, otherwise use body directly
248
+ const result: RBACResult = body?.data ?? body;
249
+
250
+ // Cache the result
251
+ setCachedResult(cacheKey, result);
252
+
253
+ return result;
254
+ } catch (error: any) {
255
+ // Fail closed on any error
256
+ if (error.name === 'AbortError') {
257
+ console.error('[RBAC] Proxy timeout (2s exceeded)');
258
+ return {
259
+ allowed: false,
260
+ reason: 'rbac_timeout',
261
+ redirect: '/error?code=rbac_timeout',
262
+ };
263
+ }
264
+
265
+ console.error('[RBAC] Proxy error:', error);
266
+ return {
267
+ allowed: false,
268
+ reason: 'rbac_service_unavailable',
269
+ redirect: '/error?code=rbac_unavailable',
270
+ };
271
+ }
272
+ }
273
+
274
+ /**
275
+ * Check if RBAC is enabled for this deployment.
276
+ */
277
+ export function isRBACEnabled(): boolean {
278
+ return process.env.VIBE_RBAC_ENABLED === 'true';
279
+ }