@autumnsgrove/groveengine 0.4.12 → 0.5.0

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.
@@ -0,0 +1,143 @@
1
+ /**
2
+ * GroveAuth Client
3
+ *
4
+ * Client library for integrating with GroveAuth authentication service.
5
+ * Use this to handle OAuth flows, token management, and subscription checks.
6
+ */
7
+ import type { GroveAuthConfig, TokenResponse, TokenInfo, UserInfo, LoginUrlResult, SubscriptionResponse, CanPostResponse, SubscriptionTier } from "./types.js";
8
+ /**
9
+ * Generate a code verifier for PKCE
10
+ */
11
+ export declare function generateCodeVerifier(): string;
12
+ /**
13
+ * Generate a code challenge from a code verifier using SHA-256
14
+ */
15
+ export declare function generateCodeChallenge(verifier: string): Promise<string>;
16
+ /**
17
+ * Generate a random state parameter for CSRF protection
18
+ */
19
+ export declare function generateState(): string;
20
+ export declare class GroveAuthClient {
21
+ private config;
22
+ /**
23
+ * In-memory cache for subscription data.
24
+ * Reduces API calls for frequently accessed subscription info.
25
+ * Cache entries expire after 5 minutes (configurable via cacheTTL).
26
+ */
27
+ private subscriptionCache;
28
+ private cacheTTL;
29
+ /**
30
+ * Track in-flight subscription requests to prevent duplicate API calls.
31
+ * When multiple concurrent requests come in for the same user,
32
+ * they all wait on the same promise instead of making redundant API calls.
33
+ */
34
+ private subscriptionPromises;
35
+ constructor(config: GroveAuthConfig & {
36
+ cacheTTL?: number;
37
+ });
38
+ /**
39
+ * Clear subscription cache for a specific user or all users
40
+ */
41
+ clearSubscriptionCache(userId?: string): void;
42
+ /**
43
+ * Clean up expired cache entries to prevent memory leaks.
44
+ * Call periodically in long-running processes.
45
+ *
46
+ * @returns Number of entries removed
47
+ */
48
+ cleanupExpiredCache(): number;
49
+ /**
50
+ * Helper for exponential backoff retry logic
51
+ */
52
+ private withRetry;
53
+ /**
54
+ * Generate a login URL with PKCE
55
+ * Store the state and codeVerifier in a secure cookie for verification
56
+ */
57
+ getLoginUrl(): Promise<LoginUrlResult>;
58
+ /**
59
+ * Exchange an authorization code for tokens
60
+ */
61
+ exchangeCode(code: string, codeVerifier: string): Promise<TokenResponse>;
62
+ /**
63
+ * Refresh an access token using a refresh token.
64
+ * Includes automatic retry with exponential backoff for transient failures.
65
+ *
66
+ * @param refreshToken - The refresh token to use
67
+ * @param options.maxRetries - Maximum retry attempts (default: 3)
68
+ * @returns New token response with fresh access token
69
+ * @throws GroveAuthError if refresh fails after all retries
70
+ */
71
+ refreshToken(refreshToken: string, options?: {
72
+ maxRetries?: number;
73
+ }): Promise<TokenResponse>;
74
+ /**
75
+ * Check if a token is expired or about to expire.
76
+ * Returns true if token expires within the buffer period.
77
+ *
78
+ * @param expiresAt - ISO timestamp of token expiration
79
+ * @param bufferSeconds - Refresh this many seconds before expiry (default: 60)
80
+ */
81
+ isTokenExpiringSoon(expiresAt: string | Date, bufferSeconds?: number): boolean;
82
+ /**
83
+ * Revoke a refresh token
84
+ */
85
+ revokeToken(refreshToken: string): Promise<void>;
86
+ /**
87
+ * Verify an access token
88
+ */
89
+ verifyToken(accessToken: string): Promise<TokenInfo | null>;
90
+ /**
91
+ * Get user info using an access token
92
+ */
93
+ getUserInfo(accessToken: string): Promise<UserInfo>;
94
+ /**
95
+ * Get the current user's subscription
96
+ */
97
+ getSubscription(accessToken: string): Promise<SubscriptionResponse>;
98
+ /**
99
+ * Get a specific user's subscription (with caching and deduplication)
100
+ *
101
+ * Features:
102
+ * - In-memory caching with configurable TTL
103
+ * - Request deduplication: concurrent requests share the same API call
104
+ * - Automatic cache invalidation on mutations
105
+ *
106
+ * @param accessToken - Valid access token
107
+ * @param userId - User ID to get subscription for
108
+ * @param skipCache - If true, bypasses cache and fetches fresh data
109
+ */
110
+ getUserSubscription(accessToken: string, userId: string, skipCache?: boolean): Promise<SubscriptionResponse>;
111
+ /**
112
+ * Internal method to fetch user subscription from API
113
+ */
114
+ private _fetchUserSubscription;
115
+ /**
116
+ * Check if a user can create a new post
117
+ */
118
+ canUserCreatePost(accessToken: string, userId: string): Promise<CanPostResponse>;
119
+ /**
120
+ * Increment post count after creating a post
121
+ * Automatically invalidates subscription cache for this user
122
+ */
123
+ incrementPostCount(accessToken: string, userId: string): Promise<SubscriptionResponse>;
124
+ /**
125
+ * Decrement post count after deleting a post
126
+ * Automatically updates subscription cache for this user
127
+ */
128
+ decrementPostCount(accessToken: string, userId: string): Promise<SubscriptionResponse>;
129
+ /**
130
+ * Update post count to a specific value
131
+ * Automatically updates subscription cache for this user
132
+ */
133
+ setPostCount(accessToken: string, userId: string, count: number): Promise<SubscriptionResponse>;
134
+ /**
135
+ * Update a user's subscription tier
136
+ * Automatically updates subscription cache for this user
137
+ */
138
+ updateTier(accessToken: string, userId: string, tier: SubscriptionTier): Promise<SubscriptionResponse>;
139
+ }
140
+ /**
141
+ * Create a GroveAuth client instance
142
+ */
143
+ export declare function createGroveAuthClient(config: GroveAuthConfig): GroveAuthClient;
@@ -0,0 +1,502 @@
1
+ /**
2
+ * GroveAuth Client
3
+ *
4
+ * Client library for integrating with GroveAuth authentication service.
5
+ * Use this to handle OAuth flows, token management, and subscription checks.
6
+ */
7
+ import { GroveAuthError } from "./types.js";
8
+ const DEFAULT_AUTH_URL = "https://auth-api.grove.place";
9
+ // =============================================================================
10
+ // PKCE HELPERS
11
+ // =============================================================================
12
+ /**
13
+ * Generate a cryptographically secure random string
14
+ */
15
+ function generateRandomString(length) {
16
+ const charset = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~";
17
+ const randomValues = new Uint8Array(length);
18
+ crypto.getRandomValues(randomValues);
19
+ return Array.from(randomValues, (byte) => charset[byte % charset.length]).join("");
20
+ }
21
+ /**
22
+ * Generate a code verifier for PKCE
23
+ */
24
+ export function generateCodeVerifier() {
25
+ return generateRandomString(64);
26
+ }
27
+ /**
28
+ * Generate a code challenge from a code verifier using SHA-256
29
+ */
30
+ export async function generateCodeChallenge(verifier) {
31
+ const encoder = new TextEncoder();
32
+ const data = encoder.encode(verifier);
33
+ const hash = await crypto.subtle.digest("SHA-256", data);
34
+ const base64 = btoa(String.fromCharCode(...new Uint8Array(hash)));
35
+ // URL-safe base64
36
+ return base64.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
37
+ }
38
+ /**
39
+ * Generate a random state parameter for CSRF protection
40
+ */
41
+ export function generateState() {
42
+ return crypto.randomUUID();
43
+ }
44
+ // =============================================================================
45
+ // INPUT VALIDATION
46
+ // =============================================================================
47
+ /**
48
+ * Validate a user ID to prevent injection attacks.
49
+ * User IDs should only contain safe characters.
50
+ *
51
+ * Valid characters: alphanumeric, underscore, hyphen
52
+ * Max length: 128 characters (UUIDs are 36 chars)
53
+ */
54
+ const USER_ID_PATTERN = /^[a-zA-Z0-9_-]{1,128}$/;
55
+ function validateUserId(userId) {
56
+ if (!userId || typeof userId !== "string") {
57
+ throw new GroveAuthError("invalid_user_id", "User ID is required", 400);
58
+ }
59
+ if (!USER_ID_PATTERN.test(userId)) {
60
+ throw new GroveAuthError("invalid_user_id", "User ID contains invalid characters", 400);
61
+ }
62
+ }
63
+ // =============================================================================
64
+ // GROVEAUTH CLIENT CLASS
65
+ // =============================================================================
66
+ export class GroveAuthClient {
67
+ config;
68
+ /**
69
+ * In-memory cache for subscription data.
70
+ * Reduces API calls for frequently accessed subscription info.
71
+ * Cache entries expire after 5 minutes (configurable via cacheTTL).
72
+ */
73
+ subscriptionCache = new Map();
74
+ cacheTTL;
75
+ /**
76
+ * Track in-flight subscription requests to prevent duplicate API calls.
77
+ * When multiple concurrent requests come in for the same user,
78
+ * they all wait on the same promise instead of making redundant API calls.
79
+ */
80
+ subscriptionPromises = new Map();
81
+ constructor(config) {
82
+ this.config = {
83
+ ...config,
84
+ authBaseUrl: config.authBaseUrl || DEFAULT_AUTH_URL,
85
+ };
86
+ // Default cache TTL: 5 minutes (300000ms)
87
+ this.cacheTTL = config.cacheTTL ?? 300000;
88
+ }
89
+ /**
90
+ * Clear subscription cache for a specific user or all users
91
+ */
92
+ clearSubscriptionCache(userId) {
93
+ if (userId) {
94
+ // Clear specific user's cache entries
95
+ for (const key of this.subscriptionCache.keys()) {
96
+ if (key.includes(userId)) {
97
+ this.subscriptionCache.delete(key);
98
+ }
99
+ }
100
+ }
101
+ else {
102
+ this.subscriptionCache.clear();
103
+ }
104
+ }
105
+ /**
106
+ * Clean up expired cache entries to prevent memory leaks.
107
+ * Call periodically in long-running processes.
108
+ *
109
+ * @returns Number of entries removed
110
+ */
111
+ cleanupExpiredCache() {
112
+ const now = Date.now();
113
+ let removed = 0;
114
+ for (const [key, entry] of this.subscriptionCache.entries()) {
115
+ if (now >= entry.expires) {
116
+ this.subscriptionCache.delete(key);
117
+ removed++;
118
+ }
119
+ }
120
+ return removed;
121
+ }
122
+ /**
123
+ * Helper for exponential backoff retry logic
124
+ */
125
+ async withRetry(operation, options = {}) {
126
+ const { maxRetries = 3, baseDelayMs = 1000 } = options;
127
+ let lastError;
128
+ for (let attempt = 0; attempt <= maxRetries; attempt++) {
129
+ try {
130
+ return await operation();
131
+ }
132
+ catch (error) {
133
+ lastError = error instanceof Error ? error : new Error(String(error));
134
+ // Don't retry on 4xx errors (client errors)
135
+ if (error instanceof GroveAuthError &&
136
+ error.statusCode >= 400 &&
137
+ error.statusCode < 500) {
138
+ throw error;
139
+ }
140
+ // Don't retry if we've exhausted attempts
141
+ if (attempt === maxRetries) {
142
+ break;
143
+ }
144
+ // Exponential backoff: 1s, 2s, 4s
145
+ const delay = baseDelayMs * Math.pow(2, attempt);
146
+ await new Promise((resolve) => setTimeout(resolve, delay));
147
+ }
148
+ }
149
+ throw lastError ?? new Error("Operation failed after retries");
150
+ }
151
+ // ===========================================================================
152
+ // AUTHENTICATION FLOW
153
+ // ===========================================================================
154
+ /**
155
+ * Generate a login URL with PKCE
156
+ * Store the state and codeVerifier in a secure cookie for verification
157
+ */
158
+ async getLoginUrl() {
159
+ const state = generateState();
160
+ const codeVerifier = generateCodeVerifier();
161
+ const codeChallenge = await generateCodeChallenge(codeVerifier);
162
+ const params = new URLSearchParams({
163
+ client_id: this.config.clientId,
164
+ redirect_uri: this.config.redirectUri,
165
+ state,
166
+ code_challenge: codeChallenge,
167
+ code_challenge_method: "S256",
168
+ });
169
+ return {
170
+ url: `${this.config.authBaseUrl}/login?${params}`,
171
+ state,
172
+ codeVerifier,
173
+ };
174
+ }
175
+ /**
176
+ * Exchange an authorization code for tokens
177
+ */
178
+ async exchangeCode(code, codeVerifier) {
179
+ const response = await fetch(`${this.config.authBaseUrl}/token`, {
180
+ method: "POST",
181
+ headers: {
182
+ "Content-Type": "application/x-www-form-urlencoded",
183
+ },
184
+ body: new URLSearchParams({
185
+ grant_type: "authorization_code",
186
+ code,
187
+ redirect_uri: this.config.redirectUri,
188
+ client_id: this.config.clientId,
189
+ client_secret: this.config.clientSecret,
190
+ code_verifier: codeVerifier,
191
+ }),
192
+ });
193
+ const data = await response.json();
194
+ if (!response.ok) {
195
+ throw new GroveAuthError(data.error || "token_error", data.error_description || data.message || "Failed to exchange code", response.status);
196
+ }
197
+ return data;
198
+ }
199
+ /**
200
+ * Refresh an access token using a refresh token.
201
+ * Includes automatic retry with exponential backoff for transient failures.
202
+ *
203
+ * @param refreshToken - The refresh token to use
204
+ * @param options.maxRetries - Maximum retry attempts (default: 3)
205
+ * @returns New token response with fresh access token
206
+ * @throws GroveAuthError if refresh fails after all retries
207
+ */
208
+ async refreshToken(refreshToken, options = {}) {
209
+ return this.withRetry(async () => {
210
+ const response = await fetch(`${this.config.authBaseUrl}/token/refresh`, {
211
+ method: "POST",
212
+ headers: {
213
+ "Content-Type": "application/x-www-form-urlencoded",
214
+ },
215
+ body: new URLSearchParams({
216
+ grant_type: "refresh_token",
217
+ refresh_token: refreshToken,
218
+ client_id: this.config.clientId,
219
+ client_secret: this.config.clientSecret,
220
+ }),
221
+ });
222
+ const data = await response.json();
223
+ if (!response.ok) {
224
+ throw new GroveAuthError(data.error || "refresh_error", data.error_description || data.message || "Failed to refresh token", response.status);
225
+ }
226
+ return data;
227
+ }, { maxRetries: options.maxRetries ?? 3 });
228
+ }
229
+ /**
230
+ * Check if a token is expired or about to expire.
231
+ * Returns true if token expires within the buffer period.
232
+ *
233
+ * @param expiresAt - ISO timestamp of token expiration
234
+ * @param bufferSeconds - Refresh this many seconds before expiry (default: 60)
235
+ */
236
+ isTokenExpiringSoon(expiresAt, bufferSeconds = 60) {
237
+ const expiresTime = new Date(expiresAt).getTime();
238
+ const bufferTime = bufferSeconds * 1000;
239
+ return Date.now() >= expiresTime - bufferTime;
240
+ }
241
+ /**
242
+ * Revoke a refresh token
243
+ */
244
+ async revokeToken(refreshToken) {
245
+ const response = await fetch(`${this.config.authBaseUrl}/token/revoke`, {
246
+ method: "POST",
247
+ headers: {
248
+ "Content-Type": "application/x-www-form-urlencoded",
249
+ },
250
+ body: new URLSearchParams({
251
+ token: refreshToken,
252
+ token_type_hint: "refresh_token",
253
+ client_id: this.config.clientId,
254
+ client_secret: this.config.clientSecret,
255
+ }),
256
+ });
257
+ if (!response.ok) {
258
+ const data = await response.json();
259
+ throw new GroveAuthError(data.error || "revoke_error", data.error_description || data.message || "Failed to revoke token", response.status);
260
+ }
261
+ }
262
+ // ===========================================================================
263
+ // TOKEN VERIFICATION
264
+ // ===========================================================================
265
+ /**
266
+ * Verify an access token
267
+ */
268
+ async verifyToken(accessToken) {
269
+ const response = await fetch(`${this.config.authBaseUrl}/verify`, {
270
+ headers: {
271
+ Authorization: `Bearer ${accessToken}`,
272
+ },
273
+ });
274
+ const data = (await response.json());
275
+ if (!data.active) {
276
+ return null;
277
+ }
278
+ return data;
279
+ }
280
+ /**
281
+ * Get user info using an access token
282
+ */
283
+ async getUserInfo(accessToken) {
284
+ const response = await fetch(`${this.config.authBaseUrl}/userinfo`, {
285
+ headers: {
286
+ Authorization: `Bearer ${accessToken}`,
287
+ },
288
+ });
289
+ if (!response.ok) {
290
+ const data = await response.json();
291
+ throw new GroveAuthError(data.error || "userinfo_error", data.error_description || data.message || "Failed to get user info", response.status);
292
+ }
293
+ return response.json();
294
+ }
295
+ // ===========================================================================
296
+ // SUBSCRIPTION MANAGEMENT
297
+ // ===========================================================================
298
+ /**
299
+ * Get the current user's subscription
300
+ */
301
+ async getSubscription(accessToken) {
302
+ const response = await fetch(`${this.config.authBaseUrl}/subscription`, {
303
+ headers: {
304
+ Authorization: `Bearer ${accessToken}`,
305
+ },
306
+ });
307
+ if (!response.ok) {
308
+ const data = await response.json();
309
+ throw new GroveAuthError(data.error || "subscription_error", data.message || "Failed to get subscription", response.status);
310
+ }
311
+ return response.json();
312
+ }
313
+ /**
314
+ * Get a specific user's subscription (with caching and deduplication)
315
+ *
316
+ * Features:
317
+ * - In-memory caching with configurable TTL
318
+ * - Request deduplication: concurrent requests share the same API call
319
+ * - Automatic cache invalidation on mutations
320
+ *
321
+ * @param accessToken - Valid access token
322
+ * @param userId - User ID to get subscription for
323
+ * @param skipCache - If true, bypasses cache and fetches fresh data
324
+ */
325
+ async getUserSubscription(accessToken, userId, skipCache = false) {
326
+ validateUserId(userId);
327
+ const cacheKey = `sub:${userId}`;
328
+ // Check cache first (unless explicitly skipped)
329
+ if (!skipCache) {
330
+ const cached = this.subscriptionCache.get(cacheKey);
331
+ if (cached && cached.expires > Date.now()) {
332
+ return cached.data;
333
+ }
334
+ // Check for in-flight request to prevent redundant API calls
335
+ const inFlight = this.subscriptionPromises.get(cacheKey);
336
+ if (inFlight) {
337
+ return inFlight;
338
+ }
339
+ }
340
+ // Create the fetch promise
341
+ const fetchPromise = this._fetchUserSubscription(accessToken, userId, cacheKey);
342
+ // Store the promise so concurrent requests can share it
343
+ this.subscriptionPromises.set(cacheKey, fetchPromise);
344
+ try {
345
+ return await fetchPromise;
346
+ }
347
+ finally {
348
+ // Clean up the in-flight tracking
349
+ this.subscriptionPromises.delete(cacheKey);
350
+ }
351
+ }
352
+ /**
353
+ * Internal method to fetch user subscription from API
354
+ */
355
+ async _fetchUserSubscription(accessToken, userId, cacheKey) {
356
+ const response = await fetch(`${this.config.authBaseUrl}/subscription/${encodeURIComponent(userId)}`, {
357
+ headers: {
358
+ Authorization: `Bearer ${accessToken}`,
359
+ },
360
+ });
361
+ if (!response.ok) {
362
+ const data = await response.json();
363
+ throw new GroveAuthError(data.error || "subscription_error", data.message || "Failed to get subscription", response.status);
364
+ }
365
+ const data = (await response.json());
366
+ // Cache the result
367
+ this.subscriptionCache.set(cacheKey, {
368
+ data,
369
+ expires: Date.now() + this.cacheTTL,
370
+ });
371
+ return data;
372
+ }
373
+ /**
374
+ * Check if a user can create a new post
375
+ */
376
+ async canUserCreatePost(accessToken, userId) {
377
+ validateUserId(userId);
378
+ const response = await fetch(`${this.config.authBaseUrl}/subscription/${encodeURIComponent(userId)}/can-post`, {
379
+ headers: {
380
+ Authorization: `Bearer ${accessToken}`,
381
+ },
382
+ });
383
+ if (!response.ok) {
384
+ const data = await response.json();
385
+ throw new GroveAuthError(data.error || "limit_check_error", data.message || "Failed to check post limit", response.status);
386
+ }
387
+ return response.json();
388
+ }
389
+ /**
390
+ * Increment post count after creating a post
391
+ * Automatically invalidates subscription cache for this user
392
+ */
393
+ async incrementPostCount(accessToken, userId) {
394
+ validateUserId(userId);
395
+ const response = await fetch(`${this.config.authBaseUrl}/subscription/${encodeURIComponent(userId)}/post-count`, {
396
+ method: "POST",
397
+ headers: {
398
+ Authorization: `Bearer ${accessToken}`,
399
+ "Content-Type": "application/json",
400
+ },
401
+ body: JSON.stringify({ action: "increment" }),
402
+ });
403
+ if (!response.ok) {
404
+ const data = await response.json();
405
+ throw new GroveAuthError(data.error || "count_error", data.message || "Failed to update post count", response.status);
406
+ }
407
+ const data = (await response.json());
408
+ // Update cache with fresh data
409
+ this.subscriptionCache.set(`sub:${userId}`, {
410
+ data,
411
+ expires: Date.now() + this.cacheTTL,
412
+ });
413
+ return data;
414
+ }
415
+ /**
416
+ * Decrement post count after deleting a post
417
+ * Automatically updates subscription cache for this user
418
+ */
419
+ async decrementPostCount(accessToken, userId) {
420
+ validateUserId(userId);
421
+ const response = await fetch(`${this.config.authBaseUrl}/subscription/${encodeURIComponent(userId)}/post-count`, {
422
+ method: "POST",
423
+ headers: {
424
+ Authorization: `Bearer ${accessToken}`,
425
+ "Content-Type": "application/json",
426
+ },
427
+ body: JSON.stringify({ action: "decrement" }),
428
+ });
429
+ if (!response.ok) {
430
+ const data = await response.json();
431
+ throw new GroveAuthError(data.error || "count_error", data.message || "Failed to update post count", response.status);
432
+ }
433
+ const data = (await response.json());
434
+ // Update cache with fresh data
435
+ this.subscriptionCache.set(`sub:${userId}`, {
436
+ data,
437
+ expires: Date.now() + this.cacheTTL,
438
+ });
439
+ return data;
440
+ }
441
+ /**
442
+ * Update post count to a specific value
443
+ * Automatically updates subscription cache for this user
444
+ */
445
+ async setPostCount(accessToken, userId, count) {
446
+ validateUserId(userId);
447
+ const response = await fetch(`${this.config.authBaseUrl}/subscription/${encodeURIComponent(userId)}/post-count`, {
448
+ method: "POST",
449
+ headers: {
450
+ Authorization: `Bearer ${accessToken}`,
451
+ "Content-Type": "application/json",
452
+ },
453
+ body: JSON.stringify({ count }),
454
+ });
455
+ if (!response.ok) {
456
+ const data = await response.json();
457
+ throw new GroveAuthError(data.error || "count_error", data.message || "Failed to update post count", response.status);
458
+ }
459
+ const data = (await response.json());
460
+ // Update cache with fresh data
461
+ this.subscriptionCache.set(`sub:${userId}`, {
462
+ data,
463
+ expires: Date.now() + this.cacheTTL,
464
+ });
465
+ return data;
466
+ }
467
+ /**
468
+ * Update a user's subscription tier
469
+ * Automatically updates subscription cache for this user
470
+ */
471
+ async updateTier(accessToken, userId, tier) {
472
+ validateUserId(userId);
473
+ const response = await fetch(`${this.config.authBaseUrl}/subscription/${encodeURIComponent(userId)}/tier`, {
474
+ method: "PUT",
475
+ headers: {
476
+ Authorization: `Bearer ${accessToken}`,
477
+ "Content-Type": "application/json",
478
+ },
479
+ body: JSON.stringify({ tier }),
480
+ });
481
+ if (!response.ok) {
482
+ const data = await response.json();
483
+ throw new GroveAuthError(data.error || "tier_error", data.message || "Failed to update tier", response.status);
484
+ }
485
+ const data = (await response.json());
486
+ // Update cache with fresh data
487
+ this.subscriptionCache.set(`sub:${userId}`, {
488
+ data,
489
+ expires: Date.now() + this.cacheTTL,
490
+ });
491
+ return data;
492
+ }
493
+ }
494
+ // =============================================================================
495
+ // FACTORY FUNCTION
496
+ // =============================================================================
497
+ /**
498
+ * Create a GroveAuth client instance
499
+ */
500
+ export function createGroveAuthClient(config) {
501
+ return new GroveAuthClient(config);
502
+ }
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Shared Color Utilities for Quota Components
3
+ *
4
+ * Centralizes color class definitions to ensure consistency
5
+ * across QuotaWidget, QuotaWarning, and UpgradePrompt components.
6
+ */
7
+ export type StatusColor = 'green' | 'yellow' | 'red' | 'gray';
8
+ export type AlertVariant = 'success' | 'warning' | 'error' | 'info';
9
+ /**
10
+ * Color classes for status indicators (progress bars, badges)
11
+ */
12
+ export declare const STATUS_COLORS: Record<StatusColor, {
13
+ bg: string;
14
+ fill: string;
15
+ badge: string;
16
+ text: string;
17
+ }>;
18
+ /**
19
+ * Color classes for alert/banner variants
20
+ */
21
+ export declare const ALERT_VARIANTS: Record<AlertVariant, {
22
+ container: string;
23
+ icon: string;
24
+ title: string;
25
+ text: string;
26
+ button: string;
27
+ }>;
28
+ /**
29
+ * Get status color based on percentage used
30
+ */
31
+ export declare function getStatusColorFromPercentage(percentage: number | null): StatusColor;
32
+ /**
33
+ * Get alert variant from status color
34
+ */
35
+ export declare function getAlertVariantFromColor(color: StatusColor): AlertVariant;