@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,91 @@
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
+ /**
8
+ * Color classes for status indicators (progress bars, badges)
9
+ */
10
+ export const STATUS_COLORS = {
11
+ green: {
12
+ bg: 'bg-green-100 dark:bg-green-900/30',
13
+ fill: 'bg-green-500',
14
+ badge: 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300',
15
+ text: 'text-green-600 dark:text-green-400',
16
+ },
17
+ yellow: {
18
+ bg: 'bg-yellow-100 dark:bg-yellow-900/30',
19
+ fill: 'bg-yellow-500',
20
+ badge: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-300',
21
+ text: 'text-yellow-600 dark:text-yellow-400',
22
+ },
23
+ red: {
24
+ bg: 'bg-red-100 dark:bg-red-900/30',
25
+ fill: 'bg-red-500',
26
+ badge: 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-300',
27
+ text: 'text-red-600 dark:text-red-400',
28
+ },
29
+ gray: {
30
+ bg: 'bg-gray-100 dark:bg-gray-800',
31
+ fill: 'bg-gray-400',
32
+ badge: 'bg-gray-100 text-gray-800 dark:bg-gray-800 dark:text-gray-300',
33
+ text: 'text-gray-600 dark:text-gray-400',
34
+ },
35
+ };
36
+ /**
37
+ * Color classes for alert/banner variants
38
+ */
39
+ export const ALERT_VARIANTS = {
40
+ success: {
41
+ container: 'bg-green-50 dark:bg-green-900/30 border-green-200 dark:border-green-800',
42
+ icon: 'text-green-500',
43
+ title: 'text-green-800 dark:text-green-200',
44
+ text: 'text-green-700 dark:text-green-300',
45
+ button: 'bg-green-600 hover:bg-green-700 text-white',
46
+ },
47
+ warning: {
48
+ container: 'bg-yellow-50 dark:bg-yellow-900/30 border-yellow-200 dark:border-yellow-800',
49
+ icon: 'text-yellow-500',
50
+ title: 'text-yellow-800 dark:text-yellow-200',
51
+ text: 'text-yellow-700 dark:text-yellow-300',
52
+ button: 'bg-yellow-600 hover:bg-yellow-700 text-white',
53
+ },
54
+ error: {
55
+ container: 'bg-red-50 dark:bg-red-900/30 border-red-200 dark:border-red-800',
56
+ icon: 'text-red-500',
57
+ title: 'text-red-800 dark:text-red-200',
58
+ text: 'text-red-700 dark:text-red-300',
59
+ button: 'bg-red-600 hover:bg-red-700 text-white',
60
+ },
61
+ info: {
62
+ container: 'bg-blue-50 dark:bg-blue-900/30 border-blue-200 dark:border-blue-800',
63
+ icon: 'text-blue-500',
64
+ title: 'text-blue-800 dark:text-blue-200',
65
+ text: 'text-blue-700 dark:text-blue-300',
66
+ button: 'bg-blue-600 hover:bg-blue-700 text-white',
67
+ },
68
+ };
69
+ /**
70
+ * Get status color based on percentage used
71
+ */
72
+ export function getStatusColorFromPercentage(percentage) {
73
+ if (percentage === null)
74
+ return 'gray';
75
+ if (percentage >= 100)
76
+ return 'red';
77
+ if (percentage >= 80)
78
+ return 'yellow';
79
+ return 'green';
80
+ }
81
+ /**
82
+ * Get alert variant from status color
83
+ */
84
+ export function getAlertVariantFromColor(color) {
85
+ switch (color) {
86
+ case 'green': return 'success';
87
+ case 'yellow': return 'warning';
88
+ case 'red': return 'error';
89
+ default: return 'info';
90
+ }
91
+ }
@@ -0,0 +1,34 @@
1
+ /**
2
+ * GroveAuth Client Module
3
+ *
4
+ * Client library for integrating with GroveAuth authentication service.
5
+ *
6
+ * @example
7
+ * ```typescript
8
+ * import { createGroveAuthClient } from '@autumnsgrove/groveengine/groveauth';
9
+ *
10
+ * const auth = createGroveAuthClient({
11
+ * clientId: 'your-client-id',
12
+ * clientSecret: env.GROVEAUTH_CLIENT_SECRET,
13
+ * redirectUri: 'https://yoursite.com/auth/callback',
14
+ * });
15
+ *
16
+ * // Generate login URL
17
+ * const { url, state, codeVerifier } = await auth.getLoginUrl();
18
+ *
19
+ * // Exchange code for tokens
20
+ * const tokens = await auth.exchangeCode(code, codeVerifier);
21
+ *
22
+ * // Check post limits
23
+ * const { allowed, status } = await auth.canUserCreatePost(tokens.access_token, userId);
24
+ * ```
25
+ */
26
+ export { GroveAuthClient, createGroveAuthClient } from './client.js';
27
+ export { generateCodeVerifier, generateCodeChallenge, generateState } from './client.js';
28
+ export type { GroveAuthConfig, TokenResponse, TokenInfo, UserInfo, LoginUrlResult, UserSubscription, SubscriptionStatus, SubscriptionResponse, CanPostResponse, SubscriptionTier, AuthError, } from './types.js';
29
+ export { GroveAuthError, TIER_POST_LIMITS, TIER_NAMES } from './types.js';
30
+ export { getQuotaDescription, getQuotaUrgency, getSuggestedActions, getUpgradeRecommendation, getQuotaWidgetData, getPreSubmitCheck, } from './limits.js';
31
+ export type { QuotaWidgetData, PreSubmitCheckResult } from './limits.js';
32
+ export { STATUS_COLORS, ALERT_VARIANTS, getStatusColorFromPercentage, getAlertVariantFromColor, } from './colors.js';
33
+ export type { StatusColor, AlertVariant } from './colors.js';
34
+ export { RateLimiter, RateLimitError, withRateLimit, DEFAULT_RATE_LIMITS, } from './rate-limit.js';
@@ -0,0 +1,35 @@
1
+ /**
2
+ * GroveAuth Client Module
3
+ *
4
+ * Client library for integrating with GroveAuth authentication service.
5
+ *
6
+ * @example
7
+ * ```typescript
8
+ * import { createGroveAuthClient } from '@autumnsgrove/groveengine/groveauth';
9
+ *
10
+ * const auth = createGroveAuthClient({
11
+ * clientId: 'your-client-id',
12
+ * clientSecret: env.GROVEAUTH_CLIENT_SECRET,
13
+ * redirectUri: 'https://yoursite.com/auth/callback',
14
+ * });
15
+ *
16
+ * // Generate login URL
17
+ * const { url, state, codeVerifier } = await auth.getLoginUrl();
18
+ *
19
+ * // Exchange code for tokens
20
+ * const tokens = await auth.exchangeCode(code, codeVerifier);
21
+ *
22
+ * // Check post limits
23
+ * const { allowed, status } = await auth.canUserCreatePost(tokens.access_token, userId);
24
+ * ```
25
+ */
26
+ // Client
27
+ export { GroveAuthClient, createGroveAuthClient } from './client.js';
28
+ export { generateCodeVerifier, generateCodeChallenge, generateState } from './client.js';
29
+ export { GroveAuthError, TIER_POST_LIMITS, TIER_NAMES } from './types.js';
30
+ // Post limit helpers
31
+ export { getQuotaDescription, getQuotaUrgency, getSuggestedActions, getUpgradeRecommendation, getQuotaWidgetData, getPreSubmitCheck, } from './limits.js';
32
+ // Color utilities
33
+ export { STATUS_COLORS, ALERT_VARIANTS, getStatusColorFromPercentage, getAlertVariantFromColor, } from './colors.js';
34
+ // Rate limiting
35
+ export { RateLimiter, RateLimitError, withRateLimit, DEFAULT_RATE_LIMITS, } from './rate-limit.js';
@@ -0,0 +1,70 @@
1
+ /**
2
+ * Post Limit Enforcement
3
+ *
4
+ * High-level utilities for enforcing post limits in GroveEngine.
5
+ * Uses GroveAuth subscription API to check and update limits.
6
+ */
7
+ import type { SubscriptionStatus, CanPostResponse } from './types.js';
8
+ /**
9
+ * Get a human-readable description of the quota status
10
+ */
11
+ export declare function getQuotaDescription(status: SubscriptionStatus): string;
12
+ /**
13
+ * Get the urgency level for the current quota status
14
+ */
15
+ export declare function getQuotaUrgency(status: SubscriptionStatus): 'healthy' | 'warning' | 'critical' | 'blocked';
16
+ /**
17
+ * Get suggested actions based on quota status
18
+ */
19
+ export declare function getSuggestedActions(status: SubscriptionStatus): string[];
20
+ /**
21
+ * Get upgrade recommendation based on current usage
22
+ */
23
+ export declare function getUpgradeRecommendation(status: SubscriptionStatus): {
24
+ recommended: boolean;
25
+ fromTier: string;
26
+ toTier: string | null;
27
+ reason: string;
28
+ };
29
+ export interface QuotaWidgetData {
30
+ /** Current post count */
31
+ count: number;
32
+ /** Post limit (null if unlimited) */
33
+ limit: number | null;
34
+ /** Percentage used (null if unlimited) */
35
+ percentage: number | null;
36
+ /** Posts remaining (null if unlimited) */
37
+ remaining: number | null;
38
+ /** Status color */
39
+ color: 'green' | 'yellow' | 'red' | 'gray';
40
+ /** Status text */
41
+ statusText: string;
42
+ /** Human-readable description */
43
+ description: string;
44
+ /** Whether to show upgrade prompt */
45
+ showUpgrade: boolean;
46
+ /** Tier name */
47
+ tierName: string;
48
+ /** Whether user can create posts */
49
+ canPost: boolean;
50
+ }
51
+ /**
52
+ * Get data for rendering a quota widget
53
+ */
54
+ export declare function getQuotaWidgetData(status: SubscriptionStatus): QuotaWidgetData;
55
+ export interface PreSubmitCheckResult {
56
+ /** Whether the post can be created */
57
+ allowed: boolean;
58
+ /** Whether to show a warning dialog */
59
+ showWarning: boolean;
60
+ /** Warning message if applicable */
61
+ warningMessage: string | null;
62
+ /** Whether upgrade is required */
63
+ upgradeRequired: boolean;
64
+ /** Full status for additional UI */
65
+ status: SubscriptionStatus;
66
+ }
67
+ /**
68
+ * Check if a post can be created and whether to show warnings
69
+ */
70
+ export declare function getPreSubmitCheck(response: CanPostResponse): PreSubmitCheckResult;
@@ -0,0 +1,194 @@
1
+ /**
2
+ * Post Limit Enforcement
3
+ *
4
+ * High-level utilities for enforcing post limits in GroveEngine.
5
+ * Uses GroveAuth subscription API to check and update limits.
6
+ */
7
+ import { TIER_POST_LIMITS, TIER_NAMES } from './types.js';
8
+ // =============================================================================
9
+ // LIMIT STATUS HELPERS
10
+ // =============================================================================
11
+ /**
12
+ * Get a human-readable description of the quota status
13
+ */
14
+ export function getQuotaDescription(status) {
15
+ if (status.post_limit === null) {
16
+ return 'Unlimited posts';
17
+ }
18
+ const remaining = status.posts_remaining ?? 0;
19
+ const limit = status.post_limit;
20
+ const used = status.post_count;
21
+ if (status.upgrade_required) {
22
+ return `Limit reached (${used}/${limit}). Upgrade required.`;
23
+ }
24
+ if (status.is_in_grace_period && status.grace_period_days_remaining !== null) {
25
+ return `Limit reached. ${status.grace_period_days_remaining} days remaining in grace period.`;
26
+ }
27
+ if (status.is_at_limit) {
28
+ return `At limit (${used}/${limit}). Delete posts or upgrade.`;
29
+ }
30
+ if (status.percentage_used !== null && status.percentage_used >= 90) {
31
+ return `${remaining} posts remaining (${status.percentage_used.toFixed(0)}% used)`;
32
+ }
33
+ return `${used}/${limit} posts used`;
34
+ }
35
+ /**
36
+ * Get the urgency level for the current quota status
37
+ */
38
+ export function getQuotaUrgency(status) {
39
+ if (status.upgrade_required) {
40
+ return 'blocked';
41
+ }
42
+ if (status.is_at_limit || (status.percentage_used !== null && status.percentage_used >= 100)) {
43
+ return 'critical';
44
+ }
45
+ if (status.percentage_used !== null && status.percentage_used >= 90) {
46
+ return 'warning';
47
+ }
48
+ return 'healthy';
49
+ }
50
+ /**
51
+ * Get suggested actions based on quota status
52
+ */
53
+ export function getSuggestedActions(status) {
54
+ const actions = [];
55
+ if (status.upgrade_required) {
56
+ actions.push('Upgrade your plan to continue posting');
57
+ actions.push('Delete older posts to free up space');
58
+ return actions;
59
+ }
60
+ if (status.is_in_grace_period) {
61
+ actions.push(`Upgrade within ${status.grace_period_days_remaining} days to avoid read-only mode`);
62
+ actions.push('Delete older posts to get under the limit');
63
+ }
64
+ if (status.is_at_limit) {
65
+ actions.push('Upgrade your plan for more posts');
66
+ actions.push('Delete older posts to create new ones');
67
+ }
68
+ if (status.percentage_used !== null && status.percentage_used >= 75 && status.percentage_used < 90) {
69
+ actions.push('Consider upgrading soon - you\'re using most of your quota');
70
+ }
71
+ return actions;
72
+ }
73
+ /**
74
+ * Get upgrade recommendation based on current usage
75
+ */
76
+ export function getUpgradeRecommendation(status) {
77
+ const tierName = TIER_NAMES[status.tier];
78
+ if (status.tier === 'business') {
79
+ return {
80
+ recommended: false,
81
+ fromTier: tierName,
82
+ toTier: null,
83
+ reason: 'You have the highest tier with unlimited posts',
84
+ };
85
+ }
86
+ if (status.upgrade_required || status.is_at_limit) {
87
+ const toTier = status.tier === 'starter' ? 'Professional' : 'Business';
88
+ return {
89
+ recommended: true,
90
+ fromTier: tierName,
91
+ toTier,
92
+ reason: 'You\'ve reached your post limit',
93
+ };
94
+ }
95
+ if (status.percentage_used !== null && status.percentage_used >= 80) {
96
+ const toTier = status.tier === 'starter' ? 'Professional' : 'Business';
97
+ return {
98
+ recommended: true,
99
+ fromTier: tierName,
100
+ toTier,
101
+ reason: `You're using ${status.percentage_used.toFixed(0)}% of your quota`,
102
+ };
103
+ }
104
+ return {
105
+ recommended: false,
106
+ fromTier: tierName,
107
+ toTier: null,
108
+ reason: 'Your current plan is sufficient for your usage',
109
+ };
110
+ }
111
+ /**
112
+ * Get data for rendering a quota widget
113
+ */
114
+ export function getQuotaWidgetData(status) {
115
+ const urgency = getQuotaUrgency(status);
116
+ const colorMap = {
117
+ healthy: 'green',
118
+ warning: 'yellow',
119
+ critical: 'red',
120
+ blocked: 'red',
121
+ };
122
+ const statusTextMap = {
123
+ healthy: 'Healthy',
124
+ warning: 'Warning',
125
+ critical: 'Critical',
126
+ blocked: 'Blocked',
127
+ };
128
+ return {
129
+ count: status.post_count,
130
+ limit: status.post_limit,
131
+ percentage: status.percentage_used,
132
+ remaining: status.posts_remaining,
133
+ color: status.post_limit === null ? 'gray' : colorMap[urgency],
134
+ statusText: status.post_limit === null ? 'Unlimited' : statusTextMap[urgency],
135
+ description: getQuotaDescription(status),
136
+ showUpgrade: urgency === 'warning' || urgency === 'critical' || urgency === 'blocked',
137
+ tierName: TIER_NAMES[status.tier],
138
+ canPost: status.can_create_post,
139
+ };
140
+ }
141
+ /**
142
+ * Check if a post can be created and whether to show warnings
143
+ */
144
+ export function getPreSubmitCheck(response) {
145
+ const { allowed, status } = response;
146
+ // Upgrade required - can't post at all
147
+ if (status.upgrade_required) {
148
+ return {
149
+ allowed: false,
150
+ showWarning: true,
151
+ warningMessage: 'Your grace period has expired. Please upgrade your plan or delete posts to continue.',
152
+ upgradeRequired: true,
153
+ status,
154
+ };
155
+ }
156
+ // In grace period - show warning but allow
157
+ if (status.is_in_grace_period) {
158
+ return {
159
+ allowed: true,
160
+ showWarning: true,
161
+ warningMessage: `You're over your post limit. ${status.grace_period_days_remaining} days remaining before your account becomes read-only.`,
162
+ upgradeRequired: false,
163
+ status,
164
+ };
165
+ }
166
+ // At or near limit - show warning
167
+ if (status.is_at_limit) {
168
+ return {
169
+ allowed,
170
+ showWarning: true,
171
+ warningMessage: 'You\'ve reached your post limit. Creating a new post will start your grace period.',
172
+ upgradeRequired: false,
173
+ status,
174
+ };
175
+ }
176
+ // Near limit (90%+) - gentle warning
177
+ if (status.percentage_used !== null && status.percentage_used >= 90) {
178
+ return {
179
+ allowed: true,
180
+ showWarning: true,
181
+ warningMessage: `You're at ${status.percentage_used.toFixed(0)}% of your post limit. Consider upgrading soon.`,
182
+ upgradeRequired: false,
183
+ status,
184
+ };
185
+ }
186
+ // All good
187
+ return {
188
+ allowed: true,
189
+ showWarning: false,
190
+ warningMessage: null,
191
+ upgradeRequired: false,
192
+ status,
193
+ };
194
+ }
@@ -0,0 +1,95 @@
1
+ /**
2
+ * Rate Limiting Utilities for GroveAuth API
3
+ *
4
+ * Provides CLIENT-SIDE rate limiting and request throttling as a first line
5
+ * of defense to prevent accidental API abuse and improve user experience.
6
+ *
7
+ * IMPORTANT: This is NOT a security measure. Client-side rate limiting can
8
+ * be bypassed. Server-side rate limiting should be the PRIMARY enforcement
9
+ * mechanism. This client-side limiter helps:
10
+ *
11
+ * 1. Reduce unnecessary API calls (fail fast before hitting server limits)
12
+ * 2. Improve UX by showing rate limit errors immediately
13
+ * 3. Prevent accidental DoS from buggy client code
14
+ *
15
+ * Server-side implementation should use Cloudflare Workers rate limiting:
16
+ * @see https://developers.cloudflare.com/workers/runtime-apis/rate-limit/
17
+ */
18
+ interface RateLimitConfig {
19
+ /** Maximum requests allowed in the window */
20
+ maxRequests: number;
21
+ /** Time window in milliseconds */
22
+ windowMs: number;
23
+ }
24
+ /**
25
+ * Default rate limits for different endpoint types.
26
+ * These should match server-side limits.
27
+ */
28
+ export declare const DEFAULT_RATE_LIMITS: Record<string, RateLimitConfig>;
29
+ /**
30
+ * Simple in-memory rate limiter for client-side request throttling.
31
+ *
32
+ * Usage:
33
+ * ```typescript
34
+ * const limiter = new RateLimiter();
35
+ *
36
+ * // Before making an API call
37
+ * if (!limiter.checkLimit('subscription', userId)) {
38
+ * throw new Error('Rate limit exceeded');
39
+ * }
40
+ * ```
41
+ */
42
+ export declare class RateLimiter {
43
+ private limits;
44
+ private config;
45
+ constructor(config?: Record<string, RateLimitConfig>);
46
+ /**
47
+ * Check if a request is allowed and increment the counter.
48
+ *
49
+ * @param type - The endpoint type (token, subscription, postCount, canPost)
50
+ * @param key - Unique key for the rate limit (e.g., userId, IP)
51
+ * @returns true if request is allowed, false if rate limited
52
+ */
53
+ checkLimit(type: string, key: string): boolean;
54
+ /**
55
+ * Get remaining requests for a given type and key.
56
+ *
57
+ * @returns Object with remaining count and reset time, or null if no limit tracked
58
+ */
59
+ getRemaining(type: string, key: string): {
60
+ remaining: number;
61
+ resetAt: number;
62
+ } | null;
63
+ /**
64
+ * Clear rate limit entries (useful for testing or manual reset)
65
+ */
66
+ clear(type?: string, key?: string): void;
67
+ /**
68
+ * Clean up expired entries to prevent memory leaks.
69
+ * Call periodically in long-running processes.
70
+ */
71
+ cleanup(): number;
72
+ }
73
+ /**
74
+ * Error thrown when rate limit is exceeded
75
+ */
76
+ export declare class RateLimitError extends Error {
77
+ readonly retryAfterMs: number;
78
+ constructor(type: string, retryAfterMs: number);
79
+ }
80
+ /**
81
+ * Create a rate-limited wrapper for async functions.
82
+ *
83
+ * Usage:
84
+ * ```typescript
85
+ * const limiter = new RateLimiter();
86
+ * const rateLimitedFetch = withRateLimit(
87
+ * limiter,
88
+ * 'subscription',
89
+ * () => userId,
90
+ * fetchSubscription
91
+ * );
92
+ * ```
93
+ */
94
+ export declare function withRateLimit<T extends (...args: unknown[]) => Promise<unknown>>(limiter: RateLimiter, type: string, getKey: (...args: Parameters<T>) => string, fn: T): T;
95
+ export {};
@@ -0,0 +1,172 @@
1
+ /**
2
+ * Rate Limiting Utilities for GroveAuth API
3
+ *
4
+ * Provides CLIENT-SIDE rate limiting and request throttling as a first line
5
+ * of defense to prevent accidental API abuse and improve user experience.
6
+ *
7
+ * IMPORTANT: This is NOT a security measure. Client-side rate limiting can
8
+ * be bypassed. Server-side rate limiting should be the PRIMARY enforcement
9
+ * mechanism. This client-side limiter helps:
10
+ *
11
+ * 1. Reduce unnecessary API calls (fail fast before hitting server limits)
12
+ * 2. Improve UX by showing rate limit errors immediately
13
+ * 3. Prevent accidental DoS from buggy client code
14
+ *
15
+ * Server-side implementation should use Cloudflare Workers rate limiting:
16
+ * @see https://developers.cloudflare.com/workers/runtime-apis/rate-limit/
17
+ */
18
+ /**
19
+ * Default rate limits for different endpoint types.
20
+ * These should match server-side limits.
21
+ */
22
+ export const DEFAULT_RATE_LIMITS = {
23
+ token: { maxRequests: 10, windowMs: 60000 }, // 10/min
24
+ subscription: { maxRequests: 60, windowMs: 60000 }, // 60/min
25
+ postCount: { maxRequests: 30, windowMs: 60000 }, // 30/min
26
+ canPost: { maxRequests: 120, windowMs: 60000 }, // 120/min
27
+ };
28
+ /**
29
+ * Simple in-memory rate limiter for client-side request throttling.
30
+ *
31
+ * Usage:
32
+ * ```typescript
33
+ * const limiter = new RateLimiter();
34
+ *
35
+ * // Before making an API call
36
+ * if (!limiter.checkLimit('subscription', userId)) {
37
+ * throw new Error('Rate limit exceeded');
38
+ * }
39
+ * ```
40
+ */
41
+ export class RateLimiter {
42
+ limits = new Map();
43
+ config;
44
+ constructor(config) {
45
+ this.config = { ...DEFAULT_RATE_LIMITS, ...config };
46
+ }
47
+ /**
48
+ * Check if a request is allowed and increment the counter.
49
+ *
50
+ * @param type - The endpoint type (token, subscription, postCount, canPost)
51
+ * @param key - Unique key for the rate limit (e.g., userId, IP)
52
+ * @returns true if request is allowed, false if rate limited
53
+ */
54
+ checkLimit(type, key) {
55
+ const limitConfig = this.config[type];
56
+ if (!limitConfig) {
57
+ // Unknown type, allow by default
58
+ return true;
59
+ }
60
+ const limitKey = `${type}:${key}`;
61
+ const now = Date.now();
62
+ const entry = this.limits.get(limitKey);
63
+ // Check if we need to reset the window
64
+ if (!entry || now >= entry.resetAt) {
65
+ this.limits.set(limitKey, {
66
+ count: 1,
67
+ resetAt: now + limitConfig.windowMs,
68
+ });
69
+ return true;
70
+ }
71
+ // Check if we're within limits
72
+ if (entry.count < limitConfig.maxRequests) {
73
+ entry.count++;
74
+ return true;
75
+ }
76
+ return false;
77
+ }
78
+ /**
79
+ * Get remaining requests for a given type and key.
80
+ *
81
+ * @returns Object with remaining count and reset time, or null if no limit tracked
82
+ */
83
+ getRemaining(type, key) {
84
+ const limitConfig = this.config[type];
85
+ if (!limitConfig)
86
+ return null;
87
+ const limitKey = `${type}:${key}`;
88
+ const now = Date.now();
89
+ const entry = this.limits.get(limitKey);
90
+ if (!entry || now >= entry.resetAt) {
91
+ return {
92
+ remaining: limitConfig.maxRequests,
93
+ resetAt: now + limitConfig.windowMs,
94
+ };
95
+ }
96
+ return {
97
+ remaining: Math.max(0, limitConfig.maxRequests - entry.count),
98
+ resetAt: entry.resetAt,
99
+ };
100
+ }
101
+ /**
102
+ * Clear rate limit entries (useful for testing or manual reset)
103
+ */
104
+ clear(type, key) {
105
+ if (type && key) {
106
+ this.limits.delete(`${type}:${key}`);
107
+ }
108
+ else if (type) {
109
+ for (const limitKey of this.limits.keys()) {
110
+ if (limitKey.startsWith(`${type}:`)) {
111
+ this.limits.delete(limitKey);
112
+ }
113
+ }
114
+ }
115
+ else {
116
+ this.limits.clear();
117
+ }
118
+ }
119
+ /**
120
+ * Clean up expired entries to prevent memory leaks.
121
+ * Call periodically in long-running processes.
122
+ */
123
+ cleanup() {
124
+ const now = Date.now();
125
+ let removed = 0;
126
+ for (const [key, entry] of this.limits.entries()) {
127
+ if (now >= entry.resetAt) {
128
+ this.limits.delete(key);
129
+ removed++;
130
+ }
131
+ }
132
+ return removed;
133
+ }
134
+ }
135
+ /**
136
+ * Error thrown when rate limit is exceeded
137
+ */
138
+ export class RateLimitError extends Error {
139
+ retryAfterMs;
140
+ constructor(type, retryAfterMs) {
141
+ super(`Rate limit exceeded for ${type}. Retry after ${Math.ceil(retryAfterMs / 1000)} seconds.`);
142
+ this.name = "RateLimitError";
143
+ this.retryAfterMs = retryAfterMs;
144
+ }
145
+ }
146
+ /**
147
+ * Create a rate-limited wrapper for async functions.
148
+ *
149
+ * Usage:
150
+ * ```typescript
151
+ * const limiter = new RateLimiter();
152
+ * const rateLimitedFetch = withRateLimit(
153
+ * limiter,
154
+ * 'subscription',
155
+ * () => userId,
156
+ * fetchSubscription
157
+ * );
158
+ * ```
159
+ */
160
+ export function withRateLimit(limiter, type, getKey, fn) {
161
+ return (async (...args) => {
162
+ const key = getKey(...args);
163
+ const remaining = limiter.getRemaining(type, key);
164
+ if (!limiter.checkLimit(type, key)) {
165
+ const retryAfterMs = remaining?.resetAt
166
+ ? remaining.resetAt - Date.now()
167
+ : 60000;
168
+ throw new RateLimitError(type, retryAfterMs);
169
+ }
170
+ return fn(...args);
171
+ });
172
+ }