@payez/next-mvp 3.1.1 → 3.2.1

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.
@@ -359,10 +359,16 @@ async function executeDecision(request, decision, pathname, sessionPointer, sess
359
359
  return handleRefresh(request, safeCallback, opts);
360
360
  }
361
361
  }
362
+ /** Paths that must never be RBAC-checked (they are RBAC redirect targets) */
363
+ const RBAC_EXEMPT_PATHS = ['/error', '/unauthorized', '/service-unavailable'];
362
364
  /** Handle 'allow' decision - run RBAC if enabled */
363
365
  async function handleAllow(request, pathname, sessionPointer, sessionStatus) {
364
366
  const isPublic = (0, route_config_1.isUnauthenticatedRoute)(pathname);
365
367
  if ((0, rbac_check_1.isRBACEnabled)() && !isPublic) {
368
+ // Skip RBAC for error/fallback pages to prevent redirect loops
369
+ if (RBAC_EXEMPT_PATHS.some(p => pathname.startsWith(p))) {
370
+ return server_1.NextResponse.next();
371
+ }
366
372
  if (!sessionPointer.clientId) {
367
373
  console.error('[MIDDLEWARE] RBAC: No clientId');
368
374
  return server_1.NextResponse.redirect(new URL('/error?code=no_client_id', request.url));
@@ -371,6 +377,11 @@ async function handleAllow(request, pathname, sessionPointer, sessionStatus) {
371
377
  const result = await (0, rbac_check_1.checkPagePermission)(pathname, sessionPointer.roles, sessionPointer.clientId);
372
378
  if (!result.allowed) {
373
379
  console.log('[MIDDLEWARE] RBAC denied:', { pathname, reason: result.reason });
380
+ // In development, fail open - RBAC API may not be fully configured
381
+ if (process.env.NODE_ENV !== 'production') {
382
+ console.warn('[MIDDLEWARE] RBAC: Allowing in development despite denial:', result.reason);
383
+ return server_1.NextResponse.next();
384
+ }
374
385
  return server_1.NextResponse.redirect(new URL(result.redirect || '/unauthorized', request.url));
375
386
  }
376
387
  if (result.requires_2fa && !sessionStatus.twoFactorComplete) {
@@ -379,6 +390,11 @@ async function handleAllow(request, pathname, sessionPointer, sessionStatus) {
379
390
  }
380
391
  catch (error) {
381
392
  console.error('[MIDDLEWARE] RBAC error:', error);
393
+ // In development, fail open
394
+ if (process.env.NODE_ENV !== 'production') {
395
+ console.warn('[MIDDLEWARE] RBAC: Allowing in development despite error');
396
+ return server_1.NextResponse.next();
397
+ }
382
398
  return server_1.NextResponse.redirect(new URL('/error?code=rbac_error', request.url));
383
399
  }
384
400
  }
@@ -1,11 +1,14 @@
1
1
  /**
2
2
  * Page RBAC Check Module
3
3
  *
4
- * Checks page-level permissions via Vibe API.
4
+ * Checks page-level permissions via Vibe API through the IDP Proxy.
5
5
  * Uses in-memory cache to reduce API calls.
6
6
  * Fails closed (DENY) on errors or timeout.
7
7
  *
8
- * @version 1.0.0
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
9
12
  * @since page-rbac-2026-01
10
13
  */
11
14
  export interface RBACResult {
@@ -29,11 +32,15 @@ export declare function clearRBACCache(): void;
29
32
  /**
30
33
  * Check if user has permission to access a page.
31
34
  *
32
- * FAIL CLOSED: If Vibe API is unreachable or times out, access is DENIED.
35
+ * Routes through IDP Vibe Proxy ({IDP_URL}/api/vibe/proxy) which injects
36
+ * proper HMAC credentials. The Vibe RBAC endpoint requires client context
37
+ * that only the proxy can provide.
38
+ *
39
+ * FAIL CLOSED: If proxy is unreachable or times out, access is DENIED.
33
40
  *
34
41
  * @param path - The route path to check
35
42
  * @param userRoles - User's roles from session
36
- * @param clientId - Client ID for multi-tenancy
43
+ * @param clientId - Client slug for multi-tenancy
37
44
  * @param userClaims - Optional claims for claim-based authorization
38
45
  * @returns RBAC result with allowed/denied status
39
46
  */
@@ -2,18 +2,44 @@
2
2
  /**
3
3
  * Page RBAC Check Module
4
4
  *
5
- * Checks page-level permissions via Vibe API.
5
+ * Checks page-level permissions via Vibe API through the IDP Proxy.
6
6
  * Uses in-memory cache to reduce API calls.
7
7
  * Fails closed (DENY) on errors or timeout.
8
8
  *
9
- * @version 1.0.0
9
+ * All requests route through the IDP Vibe Proxy ({IDP_URL}/api/vibe/proxy)
10
+ * which injects proper HMAC credentials for the Vibe API.
11
+ *
12
+ * @version 2.0.0
10
13
  * @since page-rbac-2026-01
11
14
  */
12
15
  Object.defineProperty(exports, "__esModule", { value: true });
13
16
  exports.clearRBACCache = clearRBACCache;
14
17
  exports.checkPagePermission = checkPagePermission;
15
18
  exports.isRBACEnabled = isRBACEnabled;
16
- const crypto_1 = require("crypto");
19
+ // ============================================================================
20
+ // WEB CRYPTO HELPERS (Edge Runtime compatible)
21
+ // ============================================================================
22
+ const encoder = new TextEncoder();
23
+ async function sha256Hex(input) {
24
+ const data = encoder.encode(input);
25
+ const hash = await crypto.subtle.digest('SHA-256', data);
26
+ return Array.from(new Uint8Array(hash))
27
+ .map(b => b.toString(16).padStart(2, '0'))
28
+ .join('');
29
+ }
30
+ async function hmacSha256Base64(key, message) {
31
+ const cryptoKey = await crypto.subtle.importKey('raw', key, { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']);
32
+ const signature = await crypto.subtle.sign('HMAC', cryptoKey, encoder.encode(message));
33
+ return btoa(String.fromCharCode(...new Uint8Array(signature)));
34
+ }
35
+ function base64ToUint8Array(base64) {
36
+ const binary = atob(base64);
37
+ const bytes = new Uint8Array(binary.length);
38
+ for (let i = 0; i < binary.length; i++) {
39
+ bytes[i] = binary.charCodeAt(i);
40
+ }
41
+ return bytes;
42
+ }
17
43
  // ============================================================================
18
44
  // CACHE
19
45
  // ============================================================================
@@ -25,10 +51,11 @@ const MAX_CACHE_SIZE = 1000;
25
51
  * Generate cache key for RBAC result.
26
52
  * Uses SHA-256 hash to avoid key collisions and limit key size.
27
53
  */
28
- function getCacheKey(clientId, path, roles) {
54
+ async function getCacheKey(clientId, path, roles) {
29
55
  const sortedRoles = [...roles].sort().join(',');
30
56
  const input = JSON.stringify({ clientId, path, roles: sortedRoles });
31
- return (0, crypto_1.createHash)('sha256').update(input).digest('hex').substring(0, 32);
57
+ const hash = await sha256Hex(input);
58
+ return hash.substring(0, 32);
32
59
  }
33
60
  /**
34
61
  * Get cached RBAC result if valid.
@@ -68,99 +95,100 @@ function clearRBACCache() {
68
95
  rbacCache.clear();
69
96
  }
70
97
  // ============================================================================
71
- // SIGNATURE
72
- // ============================================================================
73
- /**
74
- * Generate HMAC-SHA256 signature for Vibe API request.
75
- * SECURITY: Signing key is required in production.
76
- */
77
- function generateSignature(path, clientId, timestamp) {
78
- const signingKey = process.env.VIBE_SIGNING_KEY;
79
- // SECURITY: Require signing key in production
80
- if (!signingKey) {
81
- if (process.env.NODE_ENV === 'production') {
82
- throw new Error('[RBAC] VIBE_SIGNING_KEY is required in production');
83
- }
84
- return ''; // Signature optional in dev only
85
- }
86
- const stringToSign = `${timestamp}|GET|/api/v1/rbac/check|${path}|${clientId}`;
87
- return (0, crypto_1.createHmac)('sha256', Buffer.from(signingKey, 'base64'))
88
- .update(stringToSign)
89
- .digest('base64');
90
- }
91
- // ============================================================================
92
- // RBAC CHECK
98
+ // RBAC CHECK (via IDP Proxy)
93
99
  // ============================================================================
94
100
  /**
95
101
  * Check if user has permission to access a page.
96
102
  *
97
- * FAIL CLOSED: If Vibe API is unreachable or times out, access is DENIED.
103
+ * Routes through IDP Vibe Proxy ({IDP_URL}/api/vibe/proxy) which injects
104
+ * proper HMAC credentials. The Vibe RBAC endpoint requires client context
105
+ * that only the proxy can provide.
106
+ *
107
+ * FAIL CLOSED: If proxy is unreachable or times out, access is DENIED.
98
108
  *
99
109
  * @param path - The route path to check
100
110
  * @param userRoles - User's roles from session
101
- * @param clientId - Client ID for multi-tenancy
111
+ * @param clientId - Client slug for multi-tenancy
102
112
  * @param userClaims - Optional claims for claim-based authorization
103
113
  * @returns RBAC result with allowed/denied status
104
114
  */
105
115
  async function checkPagePermission(path, userRoles, clientId, userClaims) {
106
116
  // Check cache first
107
- const cacheKey = getCacheKey(clientId, path, userRoles);
117
+ const cacheKey = await getCacheKey(clientId, path, userRoles);
108
118
  const cached = getCachedResult(cacheKey);
109
119
  if (cached) {
110
120
  return cached;
111
121
  }
112
- const vibeApiUrl = process.env.VIBE_API_URL;
113
- if (!vibeApiUrl) {
114
- console.error('[RBAC] VIBE_API_URL not configured');
122
+ const idpUrl = process.env.NEXT_PUBLIC_IDP_URL || process.env.IDP_URL;
123
+ const vibeClientId = process.env.VIBE_CLIENT_ID;
124
+ const hmacKey = process.env.VIBE_HMAC_KEY || process.env.IDP_SIGNING_KEY;
125
+ if (!idpUrl) {
126
+ console.error('[RBAC] IDP_URL not configured');
115
127
  return {
116
128
  allowed: false,
117
129
  reason: 'rbac_not_configured',
118
130
  redirect: '/error?code=rbac_not_configured',
119
131
  };
120
132
  }
121
- // Build request URL
122
- const url = new URL('/api/v1/rbac/check', vibeApiUrl);
123
- url.searchParams.set('path', path);
124
- url.searchParams.set('roles', userRoles.join(','));
125
- // Add claims if provided
133
+ // Build RBAC endpoint with query params
134
+ // Vibe route is /v1/rbac/check (no /api/ prefix)
135
+ const params = new URLSearchParams();
136
+ params.set('path', path);
137
+ params.set('roles', userRoles.join(','));
126
138
  if (userClaims && Object.keys(userClaims).length > 0) {
127
139
  const claimsParam = Object.entries(userClaims)
128
140
  .map(([type, value]) => `${type}:${value}`)
129
141
  .join(',');
130
- url.searchParams.set('claims', claimsParam);
142
+ params.set('claims', claimsParam);
131
143
  }
132
- // Generate signature
144
+ const rbacEndpoint = `/v1/rbac/check?${params.toString()}`;
145
+ // Build proxy request
146
+ const proxyUrl = `${idpUrl}/api/vibe/proxy`;
133
147
  const timestamp = Math.floor(Date.now() / 1000);
134
- const signature = generateSignature(path, clientId, timestamp);
135
- // Build headers
136
148
  const headers = {
149
+ 'Content-Type': 'application/json',
137
150
  'Accept': 'application/json',
138
- 'X-Client-Id': clientId,
139
- 'X-Vibe-Client-Id': clientId,
140
151
  };
141
- if (signature) {
152
+ if (vibeClientId) {
153
+ headers['X-Vibe-Client-Id'] = vibeClientId;
154
+ }
155
+ // Sign with HMAC (same format as vibe-client: timestamp|method|endpoint)
156
+ if (hmacKey && vibeClientId) {
157
+ const stringToSign = `${timestamp}|GET|${rbacEndpoint}`;
158
+ const keyBuffer = base64ToUint8Array(hmacKey);
159
+ const signature = await hmacSha256Base64(keyBuffer, stringToSign);
142
160
  headers['X-Vibe-Timestamp'] = String(timestamp);
143
161
  headers['X-Vibe-Signature'] = signature;
144
162
  }
163
+ // Proxy body format: { endpoint, method, data }
164
+ const proxyBody = {
165
+ endpoint: rbacEndpoint,
166
+ method: 'GET',
167
+ data: null,
168
+ };
145
169
  try {
146
170
  // 2 second timeout - fail closed
147
171
  const controller = new AbortController();
148
172
  const timeoutId = setTimeout(() => controller.abort(), 2000);
149
- const response = await fetch(url.toString(), {
150
- method: 'GET',
173
+ const response = await fetch(proxyUrl, {
174
+ method: 'POST',
151
175
  headers,
176
+ body: JSON.stringify(proxyBody),
152
177
  signal: controller.signal,
153
178
  });
154
179
  clearTimeout(timeoutId);
155
180
  if (!response.ok) {
156
- console.error('[RBAC] Vibe API error:', response.status, response.statusText);
181
+ console.error('[RBAC] Proxy error:', response.status, response.statusText);
157
182
  return {
158
183
  allowed: false,
159
184
  reason: 'rbac_api_error',
160
185
  redirect: '/error?code=rbac_error',
161
186
  };
162
187
  }
163
- const result = await response.json();
188
+ const body = await response.json();
189
+ // Vibe API wraps responses: { success: true, data: { allowed, reason, ... } }
190
+ // Unwrap the .data property if present, otherwise use body directly
191
+ const result = body?.data ?? body;
164
192
  // Cache the result
165
193
  setCachedResult(cacheKey, result);
166
194
  return result;
@@ -168,14 +196,14 @@ async function checkPagePermission(path, userRoles, clientId, userClaims) {
168
196
  catch (error) {
169
197
  // Fail closed on any error
170
198
  if (error.name === 'AbortError') {
171
- console.error('[RBAC] Vibe API timeout (2s exceeded)');
199
+ console.error('[RBAC] Proxy timeout (2s exceeded)');
172
200
  return {
173
201
  allowed: false,
174
202
  reason: 'rbac_timeout',
175
203
  redirect: '/error?code=rbac_timeout',
176
204
  };
177
205
  }
178
- console.error('[RBAC] Vibe API error:', error);
206
+ console.error('[RBAC] Proxy error:', error);
179
207
  return {
180
208
  allowed: false,
181
209
  reason: 'rbac_service_unavailable',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@payez/next-mvp",
3
- "version": "3.1.1",
3
+ "version": "3.2.1",
4
4
  "sideEffects": false,
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -503,6 +503,9 @@ async function executeDecision(
503
503
  }
504
504
  }
505
505
 
506
+ /** Paths that must never be RBAC-checked (they are RBAC redirect targets) */
507
+ const RBAC_EXEMPT_PATHS = ['/error', '/unauthorized', '/service-unavailable'];
508
+
506
509
  /** Handle 'allow' decision - run RBAC if enabled */
507
510
  async function handleAllow(
508
511
  request: NextRequest,
@@ -513,6 +516,11 @@ async function handleAllow(
513
516
  const isPublic = isUnauthenticatedRoute(pathname);
514
517
 
515
518
  if (isRBACEnabled() && !isPublic) {
519
+ // Skip RBAC for error/fallback pages to prevent redirect loops
520
+ if (RBAC_EXEMPT_PATHS.some(p => pathname.startsWith(p))) {
521
+ return NextResponse.next();
522
+ }
523
+
516
524
  if (!sessionPointer.clientId) {
517
525
  console.error('[MIDDLEWARE] RBAC: No clientId');
518
526
  return NextResponse.redirect(new URL('/error?code=no_client_id', request.url));
@@ -523,6 +531,13 @@ async function handleAllow(
523
531
 
524
532
  if (!result.allowed) {
525
533
  console.log('[MIDDLEWARE] RBAC denied:', { pathname, reason: result.reason });
534
+
535
+ // In development, fail open - RBAC API may not be fully configured
536
+ if (process.env.NODE_ENV !== 'production') {
537
+ console.warn('[MIDDLEWARE] RBAC: Allowing in development despite denial:', result.reason);
538
+ return NextResponse.next();
539
+ }
540
+
526
541
  return NextResponse.redirect(new URL(result.redirect || '/unauthorized', request.url));
527
542
  }
528
543
 
@@ -533,6 +548,13 @@ async function handleAllow(
533
548
  }
534
549
  } catch (error) {
535
550
  console.error('[MIDDLEWARE] RBAC error:', error);
551
+
552
+ // In development, fail open
553
+ if (process.env.NODE_ENV !== 'production') {
554
+ console.warn('[MIDDLEWARE] RBAC: Allowing in development despite error');
555
+ return NextResponse.next();
556
+ }
557
+
536
558
  return NextResponse.redirect(new URL('/error?code=rbac_error', request.url));
537
559
  }
538
560
  }
@@ -1,15 +1,51 @@
1
1
  /**
2
2
  * Page RBAC Check Module
3
3
  *
4
- * Checks page-level permissions via Vibe API.
4
+ * Checks page-level permissions via Vibe API through the IDP Proxy.
5
5
  * Uses in-memory cache to reduce API calls.
6
6
  * Fails closed (DENY) on errors or timeout.
7
7
  *
8
- * @version 1.0.0
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
9
12
  * @since page-rbac-2026-01
10
13
  */
11
14
 
12
- import { createHmac, createHash } from 'crypto';
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
+ }
13
49
 
14
50
  // ============================================================================
15
51
  // TYPES
@@ -45,10 +81,11 @@ const MAX_CACHE_SIZE = 1000;
45
81
  * Generate cache key for RBAC result.
46
82
  * Uses SHA-256 hash to avoid key collisions and limit key size.
47
83
  */
48
- function getCacheKey(clientId: string, path: string, roles: string[]): string {
84
+ async function getCacheKey(clientId: string, path: string, roles: string[]): Promise<string> {
49
85
  const sortedRoles = [...roles].sort().join(',');
50
86
  const input = JSON.stringify({ clientId, path, roles: sortedRoles });
51
- return createHash('sha256').update(input).digest('hex').substring(0, 32);
87
+ const hash = await sha256Hex(input);
88
+ return hash.substring(0, 32);
52
89
  }
53
90
 
54
91
  /**
@@ -93,46 +130,21 @@ export function clearRBACCache(): void {
93
130
  }
94
131
 
95
132
  // ============================================================================
96
- // SIGNATURE
97
- // ============================================================================
98
-
99
- /**
100
- * Generate HMAC-SHA256 signature for Vibe API request.
101
- * SECURITY: Signing key is required in production.
102
- */
103
- function generateSignature(
104
- path: string,
105
- clientId: string,
106
- timestamp: number
107
- ): string {
108
- const signingKey = process.env.VIBE_SIGNING_KEY;
109
-
110
- // SECURITY: Require signing key in production
111
- if (!signingKey) {
112
- if (process.env.NODE_ENV === 'production') {
113
- throw new Error('[RBAC] VIBE_SIGNING_KEY is required in production');
114
- }
115
- return ''; // Signature optional in dev only
116
- }
117
-
118
- const stringToSign = `${timestamp}|GET|/api/v1/rbac/check|${path}|${clientId}`;
119
- return createHmac('sha256', Buffer.from(signingKey, 'base64'))
120
- .update(stringToSign)
121
- .digest('base64');
122
- }
123
-
124
- // ============================================================================
125
- // RBAC CHECK
133
+ // RBAC CHECK (via IDP Proxy)
126
134
  // ============================================================================
127
135
 
128
136
  /**
129
137
  * Check if user has permission to access a page.
130
138
  *
131
- * FAIL CLOSED: If Vibe API is unreachable or times out, access is DENIED.
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.
132
144
  *
133
145
  * @param path - The route path to check
134
146
  * @param userRoles - User's roles from session
135
- * @param clientId - Client ID for multi-tenancy
147
+ * @param clientId - Client slug for multi-tenancy
136
148
  * @param userClaims - Optional claims for claim-based authorization
137
149
  * @returns RBAC result with allowed/denied status
138
150
  */
@@ -143,15 +155,18 @@ export async function checkPagePermission(
143
155
  userClaims?: Record<string, string>
144
156
  ): Promise<RBACResult> {
145
157
  // Check cache first
146
- const cacheKey = getCacheKey(clientId, path, userRoles);
158
+ const cacheKey = await getCacheKey(clientId, path, userRoles);
147
159
  const cached = getCachedResult(cacheKey);
148
160
  if (cached) {
149
161
  return cached;
150
162
  }
151
163
 
152
- const vibeApiUrl = process.env.VIBE_API_URL;
153
- if (!vibeApiUrl) {
154
- console.error('[RBAC] VIBE_API_URL not configured');
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');
155
170
  return {
156
171
  allowed: false,
157
172
  reason: 'rbac_not_configured',
@@ -159,50 +174,66 @@ export async function checkPagePermission(
159
174
  };
160
175
  }
161
176
 
162
- // Build request URL
163
- const url = new URL('/api/v1/rbac/check', vibeApiUrl);
164
- url.searchParams.set('path', path);
165
- url.searchParams.set('roles', userRoles.join(','));
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(','));
166
182
 
167
- // Add claims if provided
168
183
  if (userClaims && Object.keys(userClaims).length > 0) {
169
184
  const claimsParam = Object.entries(userClaims)
170
185
  .map(([type, value]) => `${type}:${value}`)
171
186
  .join(',');
172
- url.searchParams.set('claims', claimsParam);
187
+ params.set('claims', claimsParam);
173
188
  }
174
189
 
175
- // Generate signature
190
+ const rbacEndpoint = `/v1/rbac/check?${params.toString()}`;
191
+
192
+ // Build proxy request
193
+ const proxyUrl = `${idpUrl}/api/vibe/proxy`;
176
194
  const timestamp = Math.floor(Date.now() / 1000);
177
- const signature = generateSignature(path, clientId, timestamp);
178
195
 
179
- // Build headers
180
196
  const headers: Record<string, string> = {
197
+ 'Content-Type': 'application/json',
181
198
  'Accept': 'application/json',
182
- 'X-Client-Id': clientId,
183
- 'X-Vibe-Client-Id': clientId,
184
199
  };
185
200
 
186
- if (signature) {
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);
187
210
  headers['X-Vibe-Timestamp'] = String(timestamp);
188
211
  headers['X-Vibe-Signature'] = signature;
189
212
  }
190
213
 
214
+ // Proxy body format: { endpoint, method, data }
215
+ const proxyBody = {
216
+ endpoint: rbacEndpoint,
217
+ method: 'GET',
218
+ data: null,
219
+ };
220
+
191
221
  try {
192
222
  // 2 second timeout - fail closed
193
223
  const controller = new AbortController();
194
224
  const timeoutId = setTimeout(() => controller.abort(), 2000);
195
225
 
196
- const response = await fetch(url.toString(), {
197
- method: 'GET',
226
+ const response = await fetch(proxyUrl, {
227
+ method: 'POST',
198
228
  headers,
229
+ body: JSON.stringify(proxyBody),
199
230
  signal: controller.signal,
200
231
  });
201
232
 
202
233
  clearTimeout(timeoutId);
203
234
 
204
235
  if (!response.ok) {
205
- console.error('[RBAC] Vibe API error:', response.status, response.statusText);
236
+ console.error('[RBAC] Proxy error:', response.status, response.statusText);
206
237
  return {
207
238
  allowed: false,
208
239
  reason: 'rbac_api_error',
@@ -210,7 +241,11 @@ export async function checkPagePermission(
210
241
  };
211
242
  }
212
243
 
213
- const result: RBACResult = await response.json();
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;
214
249
 
215
250
  // Cache the result
216
251
  setCachedResult(cacheKey, result);
@@ -219,7 +254,7 @@ export async function checkPagePermission(
219
254
  } catch (error: any) {
220
255
  // Fail closed on any error
221
256
  if (error.name === 'AbortError') {
222
- console.error('[RBAC] Vibe API timeout (2s exceeded)');
257
+ console.error('[RBAC] Proxy timeout (2s exceeded)');
223
258
  return {
224
259
  allowed: false,
225
260
  reason: 'rbac_timeout',
@@ -227,7 +262,7 @@ export async function checkPagePermission(
227
262
  };
228
263
  }
229
264
 
230
- console.error('[RBAC] Vibe API error:', error);
265
+ console.error('[RBAC] Proxy error:', error);
231
266
  return {
232
267
  allowed: false,
233
268
  reason: 'rbac_service_unavailable',