@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.
- package/dist/components/quota/QuotaWarning.svelte +125 -0
- package/dist/components/quota/QuotaWarning.svelte.d.ts +16 -0
- package/dist/components/quota/QuotaWidget.svelte +120 -0
- package/dist/components/quota/QuotaWidget.svelte.d.ts +15 -0
- package/dist/components/quota/UpgradePrompt.svelte +287 -0
- package/dist/components/quota/UpgradePrompt.svelte.d.ts +13 -0
- package/dist/components/quota/index.d.ts +8 -0
- package/dist/components/quota/index.js +8 -0
- package/dist/groveauth/client.d.ts +143 -0
- package/dist/groveauth/client.js +502 -0
- package/dist/groveauth/colors.d.ts +35 -0
- package/dist/groveauth/colors.js +91 -0
- package/dist/groveauth/index.d.ts +34 -0
- package/dist/groveauth/index.js +35 -0
- package/dist/groveauth/limits.d.ts +70 -0
- package/dist/groveauth/limits.js +194 -0
- package/dist/groveauth/rate-limit.d.ts +95 -0
- package/dist/groveauth/rate-limit.js +172 -0
- package/dist/groveauth/types.d.ts +137 -0
- package/dist/groveauth/types.js +57 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +4 -0
- package/package.json +1 -1
|
@@ -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;
|