@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,590 +1,590 @@
1
- /**
2
- * =============================================================================
3
- * VIBE CLIENT - PRISMA-STYLE QUERY BUILDER
4
- * =============================================================================
5
- *
6
- * A typed query builder for the Vibe App API.
7
- * Provides Prisma-like DX for CRUD operations on Vibe tables.
8
- *
9
- * Usage:
10
- * import { vibe } from '@payez/next-mvp/vibe'
11
- *
12
- * // Find many with filters
13
- * const users = await vibe.users.findMany({
14
- * where: { status: 'active' },
15
- * take: 10,
16
- * skip: 0,
17
- * orderBy: { created_at: 'desc' }
18
- * })
19
- *
20
- * // Find one by ID
21
- * const user = await vibe.users.findUnique({ where: { id: 123 } })
22
- *
23
- * // Create
24
- * const newUser = await vibe.users.create({
25
- * data: { email: 'test@example.com', name: 'Test' }
26
- * })
27
- *
28
- * // Update
29
- * const updated = await vibe.users.update({
30
- * where: { id: 123 },
31
- * data: { name: 'Updated' }
32
- * })
33
- *
34
- * // Delete (soft delete)
35
- * await vibe.users.delete({ where: { id: 123 } })
36
- *
37
- * =============================================================================
38
- */
39
-
40
- import {
41
- VibeError,
42
- VibeNotFoundError,
43
- VibeServiceError,
44
- } from './errors';
45
- import type {
46
- VibeMeta,
47
- VibeResponse,
48
- VibeErrorResponse,
49
- VibeTableName,
50
- VibeTableType,
51
- } from './types';
52
-
53
- // Re-export from types for convenience
54
- export type { VibeTableName, VibeTableType } from './types';
55
-
56
- // -----------------------------------------------------------------------------
57
- // CONFIGURATION
58
- // -----------------------------------------------------------------------------
59
-
60
- export interface VibeRetryConfig {
61
- /** Max retries for 5xx errors (default: 3) */
62
- maxRetries?: number;
63
- /** Base delay in ms for exponential backoff (default: 1000) */
64
- baseDelay?: number;
65
- /** Max delay in ms (default: 10000) */
66
- maxDelay?: number;
67
- /** Whether to retry on 429 with Retry-After header (default: true) */
68
- retryOnRateLimit?: boolean;
69
- }
70
-
71
- export interface VibeClientConfig {
72
- /** Base URL for the Vibe API (defaults to VIBE_API_URL env var) */
73
- baseUrl?: string;
74
- /** Vibe Client ID (defaults to VIBE_CLIENT_ID env var) */
75
- clientId?: string;
76
- /**
77
- * IDP URL for proxy requests (defaults to IDP_URL env var)
78
- * Used with appSigningKey for secure proxy-based Vibe access.
79
- */
80
- idpUrl?: string;
81
- /**
82
- * Vibe HMAC key for proxy authentication (defaults to VIBE_HMAC_KEY env var)
83
- * Base64-encoded signing key from Vibe Credentials tab.
84
- * When set, requests are routed through the IDP proxy instead of directly to Vibe.
85
- */
86
- appSigningKey?: string;
87
- /** Custom fetch function (for testing or custom auth) */
88
- fetch?: typeof fetch;
89
- /** Retry configuration for transient errors */
90
- retry?: VibeRetryConfig;
91
- }
92
-
93
- // -----------------------------------------------------------------------------
94
- // QUERY TYPES
95
- // -----------------------------------------------------------------------------
96
-
97
- /** Comparison operators for filters */
98
- export type FilterOperator = 'eq' | 'neq' | 'gt' | 'gte' | 'lt' | 'lte' | 'like' | 'in' | 'nin';
99
-
100
- /** Filter value - can be simple value or operator object */
101
- export type FilterValue<T> = T | { [K in FilterOperator]?: T };
102
-
103
- /** Where clause for queries */
104
- export type WhereClause<T> = {
105
- [K in keyof T]?: FilterValue<T[K]>;
106
- };
107
-
108
- /** Order direction */
109
- export type OrderDirection = 'asc' | 'desc';
110
-
111
- /** Order by clause */
112
- export type OrderByClause<T> = {
113
- [K in keyof T]?: OrderDirection;
114
- };
115
-
116
- /** Options for findMany */
117
- export interface FindManyOptions<T> {
118
- where?: WhereClause<T>;
119
- orderBy?: OrderByClause<T>;
120
- take?: number;
121
- skip?: number;
122
- }
123
-
124
- /** Options for findUnique */
125
- export interface FindUniqueOptions {
126
- where: { id: number };
127
- }
128
-
129
- /** Options for create */
130
- export interface CreateOptions<T> {
131
- data: Omit<T, 'id' | 'created_at' | 'updated_at'>;
132
- }
133
-
134
- /** Options for update */
135
- export interface UpdateOptions<T> {
136
- where: { id: number };
137
- data: Partial<Omit<T, 'id' | 'created_at' | 'updated_at'>>;
138
- }
139
-
140
- /** Options for delete */
141
- export interface DeleteOptions {
142
- where: { id: number };
143
- }
144
-
145
- /** Options for count */
146
- export interface CountOptions<T> {
147
- where?: WhereClause<T>;
148
- }
149
-
150
- /** Result of findMany with metadata */
151
- export interface FindManyResult<T> {
152
- data: T[];
153
- meta: VibeMeta;
154
- }
155
-
156
- // -----------------------------------------------------------------------------
157
- // RETRY HELPERS
158
- // -----------------------------------------------------------------------------
159
-
160
- const DEFAULT_RETRY_CONFIG: Required<VibeRetryConfig> = {
161
- maxRetries: 3,
162
- baseDelay: 1000,
163
- maxDelay: 10000,
164
- retryOnRateLimit: true,
165
- };
166
-
167
- /** Sleep for a given number of milliseconds */
168
- function sleep(ms: number): Promise<void> {
169
- return new Promise(resolve => setTimeout(resolve, ms));
170
- }
171
-
172
- /** Calculate exponential backoff delay with jitter */
173
- function getBackoffDelay(attempt: number, baseDelay: number, maxDelay: number): number {
174
- // Exponential backoff: baseDelay * 2^attempt
175
- const exponentialDelay = baseDelay * Math.pow(2, attempt);
176
- // Add jitter (±25%) to prevent thundering herd
177
- const jitter = exponentialDelay * 0.25 * (Math.random() * 2 - 1);
178
- return Math.min(exponentialDelay + jitter, maxDelay);
179
- }
180
-
181
- // -----------------------------------------------------------------------------
182
- // TABLE DELEGATE
183
- // -----------------------------------------------------------------------------
184
-
185
- /**
186
- * Delegate class for table operations.
187
- * Provides Prisma-style methods for a specific table.
188
- */
189
- export class VibeTableDelegate<T extends VibeTableName> {
190
- private tableName: T;
191
- private client: VibeClient;
192
-
193
- constructor(tableName: T, client: VibeClient) {
194
- this.tableName = tableName;
195
- this.client = client;
196
- }
197
-
198
- /**
199
- * Find multiple records with optional filtering and pagination.
200
- */
201
- async findMany(options?: FindManyOptions<VibeTableType<T>>): Promise<FindManyResult<VibeTableType<T>>> {
202
- const params = new URLSearchParams();
203
-
204
- // Build filter params
205
- if (options?.where) {
206
- for (const [key, value] of Object.entries(options.where)) {
207
- if (value !== undefined) {
208
- if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
209
- // Handle operator objects (e.g., { gt: 5 }, { in: [1,2,3] })
210
- for (const [op, val] of Object.entries(value)) {
211
- if (Array.isArray(val)) {
212
- // Handle array values for in/nin operators
213
- val.forEach(v => params.append(`filter[${key}][${op}][]`, String(v)));
214
- } else {
215
- params.append(`filter[${key}][${op}]`, String(val));
216
- }
217
- }
218
- } else if (Array.isArray(value)) {
219
- // Handle direct array values
220
- value.forEach(v => params.append(`filter[${key}][]`, String(v)));
221
- } else {
222
- params.append(`filter[${key}]`, String(value));
223
- }
224
- }
225
- }
226
- }
227
-
228
- // Pagination
229
- if (options?.take !== undefined) {
230
- params.append('limit', String(options.take));
231
- }
232
- if (options?.skip !== undefined) {
233
- params.append('offset', String(options.skip));
234
- }
235
-
236
- // Sorting
237
- if (options?.orderBy) {
238
- const sortParts: string[] = [];
239
- for (const [key, dir] of Object.entries(options.orderBy)) {
240
- sortParts.push(dir === 'desc' ? `-${key}` : key);
241
- }
242
- if (sortParts.length > 0) {
243
- params.append('sort', sortParts.join(','));
244
- }
245
- }
246
-
247
- const queryString = params.toString();
248
- const url = `${this.client.getBaseUrl()}/api/v1/vibe_app/${this.tableName}${queryString ? `?${queryString}` : ''}`;
249
-
250
- const response = await this.client.request<VibeTableType<T>[]>(url, 'GET');
251
-
252
- return {
253
- data: response.data,
254
- meta: response.meta || { total: response.data.length, limit: options?.take || 50, offset: options?.skip || 0 },
255
- };
256
- }
257
-
258
- /**
259
- * Find a single record by ID.
260
- * Throws VibeNotFoundError if not found.
261
- */
262
- async findUnique(options: FindUniqueOptions): Promise<VibeTableType<T>> {
263
- const url = `${this.client.getBaseUrl()}/api/v1/vibe_app/${this.tableName}/${options.where.id}`;
264
- const response = await this.client.request<VibeTableType<T>>(url, 'GET');
265
- return response.data;
266
- }
267
-
268
- /**
269
- * Find a single record by ID.
270
- * Returns null if not found (instead of throwing).
271
- */
272
- async findUniqueOrNull(options: FindUniqueOptions): Promise<VibeTableType<T> | null> {
273
- try {
274
- return await this.findUnique(options);
275
- } catch (error) {
276
- if (error instanceof VibeNotFoundError) {
277
- return null;
278
- }
279
- throw error;
280
- }
281
- }
282
-
283
- /**
284
- * Find the first record matching the filter.
285
- * Returns null if none found.
286
- */
287
- async findFirst(options?: FindManyOptions<VibeTableType<T>>): Promise<VibeTableType<T> | null> {
288
- const result = await this.findMany({ ...options, take: 1 });
289
- return result.data[0] || null;
290
- }
291
-
292
- /**
293
- * Create a new record.
294
- */
295
- async create(options: CreateOptions<VibeTableType<T>>): Promise<VibeTableType<T>> {
296
- const url = `${this.client.getBaseUrl()}/api/v1/vibe_app/${this.tableName}`;
297
- const response = await this.client.request<VibeTableType<T>>(url, 'POST', options.data);
298
- return response.data;
299
- }
300
-
301
- /**
302
- * Update an existing record by ID.
303
- * Throws VibeNotFoundError if not found.
304
- */
305
- async update(options: UpdateOptions<VibeTableType<T>>): Promise<VibeTableType<T>> {
306
- const url = `${this.client.getBaseUrl()}/api/v1/vibe_app/${this.tableName}/${options.where.id}`;
307
- const response = await this.client.request<VibeTableType<T>>(url, 'PUT', options.data);
308
- return response.data;
309
- }
310
-
311
- /**
312
- * Delete a record by ID (soft delete).
313
- * Throws VibeNotFoundError if not found.
314
- */
315
- async delete(options: DeleteOptions): Promise<VibeTableType<T>> {
316
- const url = `${this.client.getBaseUrl()}/api/v1/vibe_app/${this.tableName}/${options.where.id}`;
317
- const response = await this.client.request<VibeTableType<T>>(url, 'DELETE');
318
- return response.data;
319
- }
320
-
321
- /**
322
- * Count records matching the filter.
323
- */
324
- async count(options?: CountOptions<VibeTableType<T>>): Promise<number> {
325
- // Use findMany with limit 0 to get just the count
326
- const result = await this.findMany({ ...options, take: 0 });
327
- return result.meta.total;
328
- }
329
-
330
- /**
331
- * Create multiple records in a batch.
332
- */
333
- async createMany(options: { data: CreateOptions<VibeTableType<T>>['data'][] }): Promise<{ count: number }> {
334
- const url = `${this.client.getBaseUrl()}/api/v1/vibe_app/batch`;
335
- const response = await this.client.request<{ count: number }>(url, 'POST', {
336
- operations: options.data.map(item => ({
337
- table: this.tableName,
338
- operation: 'create',
339
- data: item,
340
- })),
341
- });
342
- return response.data;
343
- }
344
- }
345
-
346
- // -----------------------------------------------------------------------------
347
- // VIBE CLIENT
348
- // -----------------------------------------------------------------------------
349
-
350
- /**
351
- * Main Vibe client class.
352
- * Provides access to all Vibe App tables via table delegates.
353
- */
354
- export class VibeClient {
355
- private config: {
356
- baseUrl: string;
357
- clientId: string;
358
- idpUrl: string;
359
- appSigningKey: string;
360
- fetch: typeof fetch;
361
- retry: Required<VibeRetryConfig>;
362
- };
363
-
364
- // Table delegates
365
- public users: VibeTableDelegate<'users'>;
366
- public login_sessions: VibeTableDelegate<'login_sessions'>;
367
- public profiles: VibeTableDelegate<'profiles'>;
368
- public settings: VibeTableDelegate<'settings'>;
369
- public files: VibeTableDelegate<'files'>;
370
- public notifications: VibeTableDelegate<'notifications'>;
371
- public activity_log: VibeTableDelegate<'activity_log'>;
372
- public tags: VibeTableDelegate<'tags'>;
373
- public comments: VibeTableDelegate<'comments'>;
374
- public site_logs: VibeTableDelegate<'site_logs'>;
375
-
376
- constructor(config: VibeClientConfig = {}) {
377
- this.config = {
378
- baseUrl: config.baseUrl || process.env.VIBE_API_URL || '',
379
- clientId: config.clientId || process.env.VIBE_CLIENT_ID || '',
380
- idpUrl: config.idpUrl || process.env.IDP_URL || '',
381
- appSigningKey: config.appSigningKey || process.env.VIBE_HMAC_KEY || '',
382
- fetch: config.fetch || globalThis.fetch,
383
- retry: { ...DEFAULT_RETRY_CONFIG, ...config.retry },
384
- };
385
-
386
- // Initialize table delegates
387
- this.users = new VibeTableDelegate('users', this);
388
- this.login_sessions = new VibeTableDelegate('login_sessions', this);
389
- this.profiles = new VibeTableDelegate('profiles', this);
390
- this.settings = new VibeTableDelegate('settings', this);
391
- this.files = new VibeTableDelegate('files', this);
392
- this.notifications = new VibeTableDelegate('notifications', this);
393
- this.activity_log = new VibeTableDelegate('activity_log', this);
394
- this.tags = new VibeTableDelegate('tags', this);
395
- this.comments = new VibeTableDelegate('comments', this);
396
- this.site_logs = new VibeTableDelegate('site_logs', this);
397
- }
398
-
399
- /**
400
- * Check if the client is configured to use proxy mode (IDP proxy).
401
- * MVP always uses proxy mode - direct Vibe access is not supported.
402
- */
403
- private useProxyMode(): boolean {
404
- return !!this.config.appSigningKey && !!this.config.idpUrl;
405
- }
406
-
407
- /**
408
- * Generate HMAC-SHA256 signature for IDP proxy authentication.
409
- * Message format: {timestamp}|{method}|{endpoint}
410
- */
411
- private async generateProxySignature(timestamp: number, method: string, endpoint: string): Promise<string> {
412
- const message = `${timestamp}|${method}|${endpoint}`;
413
- const keyBuffer = Buffer.from(this.config.appSigningKey, 'base64');
414
-
415
- // Use Web Crypto API for browser/edge compatibility, fall back to Node crypto
416
- if (typeof globalThis.crypto !== 'undefined' && globalThis.crypto.subtle) {
417
- const encoder = new TextEncoder();
418
- const key = await globalThis.crypto.subtle.importKey(
419
- 'raw',
420
- keyBuffer,
421
- { name: 'HMAC', hash: 'SHA-256' },
422
- false,
423
- ['sign']
424
- );
425
- const signature = await globalThis.crypto.subtle.sign('HMAC', key, encoder.encode(message));
426
- return Buffer.from(signature).toString('base64');
427
- } else {
428
- // Node.js fallback
429
- const crypto = await import('crypto');
430
- return crypto.createHmac('sha256', keyBuffer).update(message).digest('base64');
431
- }
432
- }
433
-
434
- /**
435
- * Make a request through the IDP Vibe proxy.
436
- * The proxy handles credential injection securely.
437
- */
438
- private async requestViaProxy<T>(
439
- url: string,
440
- method: 'GET' | 'POST' | 'PUT' | 'DELETE',
441
- body?: unknown,
442
- authToken?: string
443
- ): Promise<VibeResponse<T>> {
444
- // Extract the endpoint path from the full URL
445
- const urlObj = new URL(url);
446
- const endpoint = urlObj.pathname + urlObj.search;
447
-
448
- const timestamp = Math.floor(Date.now() / 1000);
449
- const signature = await this.generateProxySignature(timestamp, method, endpoint);
450
-
451
- const proxyUrl = `${this.config.idpUrl}/api/vibe/proxy`;
452
-
453
- const headers: HeadersInit = {
454
- 'Content-Type': 'application/json',
455
- 'X-Vibe-Client-Id': this.config.clientId,
456
- 'X-Vibe-Timestamp': String(timestamp),
457
- 'X-Vibe-Signature': signature,
458
- };
459
-
460
- // Pass through auth token if provided (for user context)
461
- if (authToken) {
462
- headers['Authorization'] = `Bearer ${authToken}`;
463
- }
464
-
465
- const proxyBody = {
466
- method,
467
- endpoint,
468
- ...(body !== undefined && { body }),
469
- };
470
-
471
- const response = await this.config.fetch(proxyUrl, {
472
- method: 'POST',
473
- headers,
474
- body: JSON.stringify(proxyBody),
475
- });
476
-
477
- if (!response.ok) {
478
- const errorBody = await this.parseErrorResponse(response);
479
- throw VibeError.fromResponse(errorBody, response.status);
480
- }
481
-
482
- const responseData = await response.json();
483
-
484
- if (!responseData.success) {
485
- throw VibeError.fromResponse(responseData as VibeErrorResponse, response.status);
486
- }
487
-
488
- return responseData as VibeResponse<T>;
489
- }
490
-
491
- /**
492
- * Get the configured base URL.
493
- */
494
- getBaseUrl(): string {
495
- return this.config.baseUrl;
496
- }
497
-
498
- /**
499
- * Make an authenticated request to the Vibe API with smart retry logic.
500
- *
501
- * Routing:
502
- * - All requests route through the IDP proxy (requires VIBE_HMAC_KEY + IDP_URL)
503
- *
504
- * Retry behavior:
505
- * - 5xx errors: Retry up to maxRetries times with exponential backoff
506
- * - 429 errors: Retry once after Retry-After delay (if retryOnRateLimit enabled)
507
- * - 401/403 errors: No retry (auth errors)
508
- * - Network errors: Retry with backoff
509
- */
510
- async request<T>(
511
- url: string,
512
- method: 'GET' | 'POST' | 'PUT' | 'DELETE',
513
- body?: unknown,
514
- authToken?: string
515
- ): Promise<VibeResponse<T>> {
516
- if (!this.config.clientId) {
517
- throw new VibeServiceError('VIBE_CLIENT_ID is not configured');
518
- }
519
-
520
- // Check if we should use proxy mode
521
- if (this.useProxyMode()) {
522
- if (!this.config.idpUrl) {
523
- throw new VibeServiceError('IDP_URL is not configured (required for proxy mode)');
524
- }
525
- if (!this.config.appSigningKey) {
526
- throw new VibeServiceError('VIBE_HMAC_KEY is not configured (required for proxy mode)');
527
- }
528
- // Route through IDP proxy
529
- return this.requestViaProxy<T>(url, method, body, authToken);
530
- }
531
-
532
- // MVP requires proxy mode - direct Vibe access is not supported
533
- throw new VibeServiceError(
534
- 'Vibe proxy not configured. Set VIBE_HMAC_KEY + IDP_URL for proxy mode.'
535
- );
536
- }
537
-
538
- /**
539
- * Parse error response body, handling non-JSON responses gracefully.
540
- */
541
- private async parseErrorResponse(response: Response): Promise<VibeErrorResponse> {
542
- try {
543
- return await response.json();
544
- } catch {
545
- // Non-JSON error response
546
- const text = await response.text().catch(() => 'Unknown error');
547
- return {
548
- success: false,
549
- error: {
550
- code: `HTTP_${response.status}`,
551
- message: text || response.statusText,
552
- },
553
- };
554
- }
555
- }
556
-
557
- /**
558
- * Fetch the schema for type generation.
559
- */
560
- async getSchema(): Promise<Record<string, { columns: Array<{ name: string; type: string; nullable: boolean }> }>> {
561
- const url = `${this.config.baseUrl}/api/v1/vibe_app/schema`;
562
- const response = await this.request<Record<string, { columns: Array<{ name: string; type: string; nullable: boolean }> }>>(url, 'GET');
563
- return response.data;
564
- }
565
- }
566
-
567
- // -----------------------------------------------------------------------------
568
- // SINGLETON INSTANCE
569
- // -----------------------------------------------------------------------------
570
-
571
- /**
572
- * Default Vibe client instance.
573
- * Uses environment variables for configuration.
574
- *
575
- * Usage:
576
- * import { vibe } from '@payez/next-mvp/vibe'
577
- * const users = await vibe.users.findMany()
578
- */
579
- export const vibe = new VibeClient();
580
-
581
- /**
582
- * Create a new Vibe client with custom configuration.
583
- *
584
- * Usage:
585
- * import { createVibeClient } from '@payez/next-mvp/vibe'
586
- * const customVibe = createVibeClient({ baseUrl: 'https://custom-api.example.com' })
587
- */
588
- export function createVibeClient(config: VibeClientConfig): VibeClient {
589
- return new VibeClient(config);
590
- }
1
+ /**
2
+ * =============================================================================
3
+ * VIBE CLIENT - PRISMA-STYLE QUERY BUILDER
4
+ * =============================================================================
5
+ *
6
+ * A typed query builder for the Vibe App API.
7
+ * Provides Prisma-like DX for CRUD operations on Vibe tables.
8
+ *
9
+ * Usage:
10
+ * import { vibe } from '@payez/next-mvp/vibe'
11
+ *
12
+ * // Find many with filters
13
+ * const users = await vibe.users.findMany({
14
+ * where: { status: 'active' },
15
+ * take: 10,
16
+ * skip: 0,
17
+ * orderBy: { created_at: 'desc' }
18
+ * })
19
+ *
20
+ * // Find one by ID
21
+ * const user = await vibe.users.findUnique({ where: { id: 123 } })
22
+ *
23
+ * // Create
24
+ * const newUser = await vibe.users.create({
25
+ * data: { email: 'test@example.com', name: 'Test' }
26
+ * })
27
+ *
28
+ * // Update
29
+ * const updated = await vibe.users.update({
30
+ * where: { id: 123 },
31
+ * data: { name: 'Updated' }
32
+ * })
33
+ *
34
+ * // Delete (soft delete)
35
+ * await vibe.users.delete({ where: { id: 123 } })
36
+ *
37
+ * =============================================================================
38
+ */
39
+
40
+ import {
41
+ VibeError,
42
+ VibeNotFoundError,
43
+ VibeServiceError,
44
+ } from './errors';
45
+ import type {
46
+ VibeMeta,
47
+ VibeResponse,
48
+ VibeErrorResponse,
49
+ VibeTableName,
50
+ VibeTableType,
51
+ } from './types';
52
+
53
+ // Re-export from types for convenience
54
+ export type { VibeTableName, VibeTableType } from './types';
55
+
56
+ // -----------------------------------------------------------------------------
57
+ // CONFIGURATION
58
+ // -----------------------------------------------------------------------------
59
+
60
+ export interface VibeRetryConfig {
61
+ /** Max retries for 5xx errors (default: 3) */
62
+ maxRetries?: number;
63
+ /** Base delay in ms for exponential backoff (default: 1000) */
64
+ baseDelay?: number;
65
+ /** Max delay in ms (default: 10000) */
66
+ maxDelay?: number;
67
+ /** Whether to retry on 429 with Retry-After header (default: true) */
68
+ retryOnRateLimit?: boolean;
69
+ }
70
+
71
+ export interface VibeClientConfig {
72
+ /** Base URL for the Vibe API (defaults to VIBE_API_URL env var) */
73
+ baseUrl?: string;
74
+ /** Vibe Client ID (defaults to VIBE_CLIENT_ID env var) */
75
+ clientId?: string;
76
+ /**
77
+ * IDP URL for proxy requests (defaults to IDP_URL env var)
78
+ * Used with appSigningKey for secure proxy-based Vibe access.
79
+ */
80
+ idpUrl?: string;
81
+ /**
82
+ * Vibe HMAC key for proxy authentication (defaults to VIBE_HMAC_KEY env var)
83
+ * Base64-encoded signing key from Vibe Credentials tab.
84
+ * When set, requests are routed through the IDP proxy instead of directly to Vibe.
85
+ */
86
+ appSigningKey?: string;
87
+ /** Custom fetch function (for testing or custom auth) */
88
+ fetch?: typeof fetch;
89
+ /** Retry configuration for transient errors */
90
+ retry?: VibeRetryConfig;
91
+ }
92
+
93
+ // -----------------------------------------------------------------------------
94
+ // QUERY TYPES
95
+ // -----------------------------------------------------------------------------
96
+
97
+ /** Comparison operators for filters */
98
+ export type FilterOperator = 'eq' | 'neq' | 'gt' | 'gte' | 'lt' | 'lte' | 'like' | 'in' | 'nin';
99
+
100
+ /** Filter value - can be simple value or operator object */
101
+ export type FilterValue<T> = T | { [K in FilterOperator]?: T };
102
+
103
+ /** Where clause for queries */
104
+ export type WhereClause<T> = {
105
+ [K in keyof T]?: FilterValue<T[K]>;
106
+ };
107
+
108
+ /** Order direction */
109
+ export type OrderDirection = 'asc' | 'desc';
110
+
111
+ /** Order by clause */
112
+ export type OrderByClause<T> = {
113
+ [K in keyof T]?: OrderDirection;
114
+ };
115
+
116
+ /** Options for findMany */
117
+ export interface FindManyOptions<T> {
118
+ where?: WhereClause<T>;
119
+ orderBy?: OrderByClause<T>;
120
+ take?: number;
121
+ skip?: number;
122
+ }
123
+
124
+ /** Options for findUnique */
125
+ export interface FindUniqueOptions {
126
+ where: { id: number };
127
+ }
128
+
129
+ /** Options for create */
130
+ export interface CreateOptions<T> {
131
+ data: Omit<T, 'id' | 'created_at' | 'updated_at'>;
132
+ }
133
+
134
+ /** Options for update */
135
+ export interface UpdateOptions<T> {
136
+ where: { id: number };
137
+ data: Partial<Omit<T, 'id' | 'created_at' | 'updated_at'>>;
138
+ }
139
+
140
+ /** Options for delete */
141
+ export interface DeleteOptions {
142
+ where: { id: number };
143
+ }
144
+
145
+ /** Options for count */
146
+ export interface CountOptions<T> {
147
+ where?: WhereClause<T>;
148
+ }
149
+
150
+ /** Result of findMany with metadata */
151
+ export interface FindManyResult<T> {
152
+ data: T[];
153
+ meta: VibeMeta;
154
+ }
155
+
156
+ // -----------------------------------------------------------------------------
157
+ // RETRY HELPERS
158
+ // -----------------------------------------------------------------------------
159
+
160
+ const DEFAULT_RETRY_CONFIG: Required<VibeRetryConfig> = {
161
+ maxRetries: 3,
162
+ baseDelay: 1000,
163
+ maxDelay: 10000,
164
+ retryOnRateLimit: true,
165
+ };
166
+
167
+ /** Sleep for a given number of milliseconds */
168
+ function sleep(ms: number): Promise<void> {
169
+ return new Promise(resolve => setTimeout(resolve, ms));
170
+ }
171
+
172
+ /** Calculate exponential backoff delay with jitter */
173
+ function getBackoffDelay(attempt: number, baseDelay: number, maxDelay: number): number {
174
+ // Exponential backoff: baseDelay * 2^attempt
175
+ const exponentialDelay = baseDelay * Math.pow(2, attempt);
176
+ // Add jitter (±25%) to prevent thundering herd
177
+ const jitter = exponentialDelay * 0.25 * (Math.random() * 2 - 1);
178
+ return Math.min(exponentialDelay + jitter, maxDelay);
179
+ }
180
+
181
+ // -----------------------------------------------------------------------------
182
+ // TABLE DELEGATE
183
+ // -----------------------------------------------------------------------------
184
+
185
+ /**
186
+ * Delegate class for table operations.
187
+ * Provides Prisma-style methods for a specific table.
188
+ */
189
+ export class VibeTableDelegate<T extends VibeTableName> {
190
+ private tableName: T;
191
+ private client: VibeClient;
192
+
193
+ constructor(tableName: T, client: VibeClient) {
194
+ this.tableName = tableName;
195
+ this.client = client;
196
+ }
197
+
198
+ /**
199
+ * Find multiple records with optional filtering and pagination.
200
+ */
201
+ async findMany(options?: FindManyOptions<VibeTableType<T>>): Promise<FindManyResult<VibeTableType<T>>> {
202
+ const params = new URLSearchParams();
203
+
204
+ // Build filter params
205
+ if (options?.where) {
206
+ for (const [key, value] of Object.entries(options.where)) {
207
+ if (value !== undefined) {
208
+ if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
209
+ // Handle operator objects (e.g., { gt: 5 }, { in: [1,2,3] })
210
+ for (const [op, val] of Object.entries(value)) {
211
+ if (Array.isArray(val)) {
212
+ // Handle array values for in/nin operators
213
+ val.forEach(v => params.append(`filter[${key}][${op}][]`, String(v)));
214
+ } else {
215
+ params.append(`filter[${key}][${op}]`, String(val));
216
+ }
217
+ }
218
+ } else if (Array.isArray(value)) {
219
+ // Handle direct array values
220
+ value.forEach(v => params.append(`filter[${key}][]`, String(v)));
221
+ } else {
222
+ params.append(`filter[${key}]`, String(value));
223
+ }
224
+ }
225
+ }
226
+ }
227
+
228
+ // Pagination
229
+ if (options?.take !== undefined) {
230
+ params.append('limit', String(options.take));
231
+ }
232
+ if (options?.skip !== undefined) {
233
+ params.append('offset', String(options.skip));
234
+ }
235
+
236
+ // Sorting
237
+ if (options?.orderBy) {
238
+ const sortParts: string[] = [];
239
+ for (const [key, dir] of Object.entries(options.orderBy)) {
240
+ sortParts.push(dir === 'desc' ? `-${key}` : key);
241
+ }
242
+ if (sortParts.length > 0) {
243
+ params.append('sort', sortParts.join(','));
244
+ }
245
+ }
246
+
247
+ const queryString = params.toString();
248
+ const url = `${this.client.getBaseUrl()}/api/v1/vibe_app/${this.tableName}${queryString ? `?${queryString}` : ''}`;
249
+
250
+ const response = await this.client.request<VibeTableType<T>[]>(url, 'GET');
251
+
252
+ return {
253
+ data: response.data,
254
+ meta: response.meta || { total: response.data.length, limit: options?.take || 50, offset: options?.skip || 0 },
255
+ };
256
+ }
257
+
258
+ /**
259
+ * Find a single record by ID.
260
+ * Throws VibeNotFoundError if not found.
261
+ */
262
+ async findUnique(options: FindUniqueOptions): Promise<VibeTableType<T>> {
263
+ const url = `${this.client.getBaseUrl()}/api/v1/vibe_app/${this.tableName}/${options.where.id}`;
264
+ const response = await this.client.request<VibeTableType<T>>(url, 'GET');
265
+ return response.data;
266
+ }
267
+
268
+ /**
269
+ * Find a single record by ID.
270
+ * Returns null if not found (instead of throwing).
271
+ */
272
+ async findUniqueOrNull(options: FindUniqueOptions): Promise<VibeTableType<T> | null> {
273
+ try {
274
+ return await this.findUnique(options);
275
+ } catch (error) {
276
+ if (error instanceof VibeNotFoundError) {
277
+ return null;
278
+ }
279
+ throw error;
280
+ }
281
+ }
282
+
283
+ /**
284
+ * Find the first record matching the filter.
285
+ * Returns null if none found.
286
+ */
287
+ async findFirst(options?: FindManyOptions<VibeTableType<T>>): Promise<VibeTableType<T> | null> {
288
+ const result = await this.findMany({ ...options, take: 1 });
289
+ return result.data[0] || null;
290
+ }
291
+
292
+ /**
293
+ * Create a new record.
294
+ */
295
+ async create(options: CreateOptions<VibeTableType<T>>): Promise<VibeTableType<T>> {
296
+ const url = `${this.client.getBaseUrl()}/api/v1/vibe_app/${this.tableName}`;
297
+ const response = await this.client.request<VibeTableType<T>>(url, 'POST', options.data);
298
+ return response.data;
299
+ }
300
+
301
+ /**
302
+ * Update an existing record by ID.
303
+ * Throws VibeNotFoundError if not found.
304
+ */
305
+ async update(options: UpdateOptions<VibeTableType<T>>): Promise<VibeTableType<T>> {
306
+ const url = `${this.client.getBaseUrl()}/api/v1/vibe_app/${this.tableName}/${options.where.id}`;
307
+ const response = await this.client.request<VibeTableType<T>>(url, 'PUT', options.data);
308
+ return response.data;
309
+ }
310
+
311
+ /**
312
+ * Delete a record by ID (soft delete).
313
+ * Throws VibeNotFoundError if not found.
314
+ */
315
+ async delete(options: DeleteOptions): Promise<VibeTableType<T>> {
316
+ const url = `${this.client.getBaseUrl()}/api/v1/vibe_app/${this.tableName}/${options.where.id}`;
317
+ const response = await this.client.request<VibeTableType<T>>(url, 'DELETE');
318
+ return response.data;
319
+ }
320
+
321
+ /**
322
+ * Count records matching the filter.
323
+ */
324
+ async count(options?: CountOptions<VibeTableType<T>>): Promise<number> {
325
+ // Use findMany with limit 0 to get just the count
326
+ const result = await this.findMany({ ...options, take: 0 });
327
+ return result.meta.total;
328
+ }
329
+
330
+ /**
331
+ * Create multiple records in a batch.
332
+ */
333
+ async createMany(options: { data: CreateOptions<VibeTableType<T>>['data'][] }): Promise<{ count: number }> {
334
+ const url = `${this.client.getBaseUrl()}/api/v1/vibe_app/batch`;
335
+ const response = await this.client.request<{ count: number }>(url, 'POST', {
336
+ operations: options.data.map(item => ({
337
+ table: this.tableName,
338
+ operation: 'create',
339
+ data: item,
340
+ })),
341
+ });
342
+ return response.data;
343
+ }
344
+ }
345
+
346
+ // -----------------------------------------------------------------------------
347
+ // VIBE CLIENT
348
+ // -----------------------------------------------------------------------------
349
+
350
+ /**
351
+ * Main Vibe client class.
352
+ * Provides access to all Vibe App tables via table delegates.
353
+ */
354
+ export class VibeClient {
355
+ private config: {
356
+ baseUrl: string;
357
+ clientId: string;
358
+ idpUrl: string;
359
+ appSigningKey: string;
360
+ fetch: typeof fetch;
361
+ retry: Required<VibeRetryConfig>;
362
+ };
363
+
364
+ // Table delegates
365
+ public users: VibeTableDelegate<'users'>;
366
+ public login_sessions: VibeTableDelegate<'login_sessions'>;
367
+ public profiles: VibeTableDelegate<'profiles'>;
368
+ public settings: VibeTableDelegate<'settings'>;
369
+ public files: VibeTableDelegate<'files'>;
370
+ public notifications: VibeTableDelegate<'notifications'>;
371
+ public activity_log: VibeTableDelegate<'activity_log'>;
372
+ public tags: VibeTableDelegate<'tags'>;
373
+ public comments: VibeTableDelegate<'comments'>;
374
+ public site_logs: VibeTableDelegate<'site_logs'>;
375
+
376
+ constructor(config: VibeClientConfig = {}) {
377
+ this.config = {
378
+ baseUrl: config.baseUrl || process.env.VIBE_API_URL || '',
379
+ clientId: config.clientId || process.env.VIBE_CLIENT_ID || '',
380
+ idpUrl: config.idpUrl || process.env.IDP_URL || '',
381
+ appSigningKey: config.appSigningKey || process.env.VIBE_HMAC_KEY || '',
382
+ fetch: config.fetch || globalThis.fetch,
383
+ retry: { ...DEFAULT_RETRY_CONFIG, ...config.retry },
384
+ };
385
+
386
+ // Initialize table delegates
387
+ this.users = new VibeTableDelegate('users', this);
388
+ this.login_sessions = new VibeTableDelegate('login_sessions', this);
389
+ this.profiles = new VibeTableDelegate('profiles', this);
390
+ this.settings = new VibeTableDelegate('settings', this);
391
+ this.files = new VibeTableDelegate('files', this);
392
+ this.notifications = new VibeTableDelegate('notifications', this);
393
+ this.activity_log = new VibeTableDelegate('activity_log', this);
394
+ this.tags = new VibeTableDelegate('tags', this);
395
+ this.comments = new VibeTableDelegate('comments', this);
396
+ this.site_logs = new VibeTableDelegate('site_logs', this);
397
+ }
398
+
399
+ /**
400
+ * Check if the client is configured to use proxy mode (IDP proxy).
401
+ * MVP always uses proxy mode - direct Vibe access is not supported.
402
+ */
403
+ private useProxyMode(): boolean {
404
+ return !!this.config.appSigningKey && !!this.config.idpUrl;
405
+ }
406
+
407
+ /**
408
+ * Generate HMAC-SHA256 signature for IDP proxy authentication.
409
+ * Message format: {timestamp}|{method}|{endpoint}
410
+ */
411
+ private async generateProxySignature(timestamp: number, method: string, endpoint: string): Promise<string> {
412
+ const message = `${timestamp}|${method}|${endpoint}`;
413
+ const keyBuffer = Buffer.from(this.config.appSigningKey, 'base64');
414
+
415
+ // Use Web Crypto API for browser/edge compatibility, fall back to Node crypto
416
+ if (typeof globalThis.crypto !== 'undefined' && globalThis.crypto.subtle) {
417
+ const encoder = new TextEncoder();
418
+ const key = await globalThis.crypto.subtle.importKey(
419
+ 'raw',
420
+ keyBuffer,
421
+ { name: 'HMAC', hash: 'SHA-256' },
422
+ false,
423
+ ['sign']
424
+ );
425
+ const signature = await globalThis.crypto.subtle.sign('HMAC', key, encoder.encode(message));
426
+ return Buffer.from(signature).toString('base64');
427
+ } else {
428
+ // Node.js fallback
429
+ const crypto = await import('crypto');
430
+ return crypto.createHmac('sha256', keyBuffer).update(message).digest('base64');
431
+ }
432
+ }
433
+
434
+ /**
435
+ * Make a request through the IDP Vibe proxy.
436
+ * The proxy handles credential injection securely.
437
+ */
438
+ private async requestViaProxy<T>(
439
+ url: string,
440
+ method: 'GET' | 'POST' | 'PUT' | 'DELETE',
441
+ body?: unknown,
442
+ authToken?: string
443
+ ): Promise<VibeResponse<T>> {
444
+ // Extract the endpoint path from the full URL
445
+ const urlObj = new URL(url);
446
+ const endpoint = urlObj.pathname + urlObj.search;
447
+
448
+ const timestamp = Math.floor(Date.now() / 1000);
449
+ const signature = await this.generateProxySignature(timestamp, method, endpoint);
450
+
451
+ const proxyUrl = `${this.config.idpUrl}/api/vibe/proxy`;
452
+
453
+ const headers: HeadersInit = {
454
+ 'Content-Type': 'application/json',
455
+ 'X-Vibe-Client-Id': this.config.clientId,
456
+ 'X-Vibe-Timestamp': String(timestamp),
457
+ 'X-Vibe-Signature': signature,
458
+ };
459
+
460
+ // Pass through auth token if provided (for user context)
461
+ if (authToken) {
462
+ headers['Authorization'] = `Bearer ${authToken}`;
463
+ }
464
+
465
+ const proxyBody = {
466
+ method,
467
+ endpoint,
468
+ ...(body !== undefined && { body }),
469
+ };
470
+
471
+ const response = await this.config.fetch(proxyUrl, {
472
+ method: 'POST',
473
+ headers,
474
+ body: JSON.stringify(proxyBody),
475
+ });
476
+
477
+ if (!response.ok) {
478
+ const errorBody = await this.parseErrorResponse(response);
479
+ throw VibeError.fromResponse(errorBody, response.status);
480
+ }
481
+
482
+ const responseData = await response.json();
483
+
484
+ if (!responseData.success) {
485
+ throw VibeError.fromResponse(responseData as VibeErrorResponse, response.status);
486
+ }
487
+
488
+ return responseData as VibeResponse<T>;
489
+ }
490
+
491
+ /**
492
+ * Get the configured base URL.
493
+ */
494
+ getBaseUrl(): string {
495
+ return this.config.baseUrl;
496
+ }
497
+
498
+ /**
499
+ * Make an authenticated request to the Vibe API with smart retry logic.
500
+ *
501
+ * Routing:
502
+ * - All requests route through the IDP proxy (requires VIBE_HMAC_KEY + IDP_URL)
503
+ *
504
+ * Retry behavior:
505
+ * - 5xx errors: Retry up to maxRetries times with exponential backoff
506
+ * - 429 errors: Retry once after Retry-After delay (if retryOnRateLimit enabled)
507
+ * - 401/403 errors: No retry (auth errors)
508
+ * - Network errors: Retry with backoff
509
+ */
510
+ async request<T>(
511
+ url: string,
512
+ method: 'GET' | 'POST' | 'PUT' | 'DELETE',
513
+ body?: unknown,
514
+ authToken?: string
515
+ ): Promise<VibeResponse<T>> {
516
+ if (!this.config.clientId) {
517
+ throw new VibeServiceError('VIBE_CLIENT_ID is not configured');
518
+ }
519
+
520
+ // Check if we should use proxy mode
521
+ if (this.useProxyMode()) {
522
+ if (!this.config.idpUrl) {
523
+ throw new VibeServiceError('IDP_URL is not configured (required for proxy mode)');
524
+ }
525
+ if (!this.config.appSigningKey) {
526
+ throw new VibeServiceError('VIBE_HMAC_KEY is not configured (required for proxy mode)');
527
+ }
528
+ // Route through IDP proxy
529
+ return this.requestViaProxy<T>(url, method, body, authToken);
530
+ }
531
+
532
+ // MVP requires proxy mode - direct Vibe access is not supported
533
+ throw new VibeServiceError(
534
+ 'Vibe proxy not configured. Set VIBE_HMAC_KEY + IDP_URL for proxy mode.'
535
+ );
536
+ }
537
+
538
+ /**
539
+ * Parse error response body, handling non-JSON responses gracefully.
540
+ */
541
+ private async parseErrorResponse(response: Response): Promise<VibeErrorResponse> {
542
+ try {
543
+ return await response.json();
544
+ } catch {
545
+ // Non-JSON error response
546
+ const text = await response.text().catch(() => 'Unknown error');
547
+ return {
548
+ success: false,
549
+ error: {
550
+ code: `HTTP_${response.status}`,
551
+ message: text || response.statusText,
552
+ },
553
+ };
554
+ }
555
+ }
556
+
557
+ /**
558
+ * Fetch the schema for type generation.
559
+ */
560
+ async getSchema(): Promise<Record<string, { columns: Array<{ name: string; type: string; nullable: boolean }> }>> {
561
+ const url = `${this.config.baseUrl}/api/v1/vibe_app/schema`;
562
+ const response = await this.request<Record<string, { columns: Array<{ name: string; type: string; nullable: boolean }> }>>(url, 'GET');
563
+ return response.data;
564
+ }
565
+ }
566
+
567
+ // -----------------------------------------------------------------------------
568
+ // SINGLETON INSTANCE
569
+ // -----------------------------------------------------------------------------
570
+
571
+ /**
572
+ * Default Vibe client instance.
573
+ * Uses environment variables for configuration.
574
+ *
575
+ * Usage:
576
+ * import { vibe } from '@payez/next-mvp/vibe'
577
+ * const users = await vibe.users.findMany()
578
+ */
579
+ export const vibe = new VibeClient();
580
+
581
+ /**
582
+ * Create a new Vibe client with custom configuration.
583
+ *
584
+ * Usage:
585
+ * import { createVibeClient } from '@payez/next-mvp/vibe'
586
+ * const customVibe = createVibeClient({ baseUrl: 'https://custom-api.example.com' })
587
+ */
588
+ export function createVibeClient(config: VibeClientConfig): VibeClient {
589
+ return new VibeClient(config);
590
+ }