@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,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
|
+
}
|