@autumnsgrove/groveengine 0.4.12 → 0.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/README.md +5 -3
  2. package/dist/components/OnboardingChecklist.svelte +118 -0
  3. package/dist/components/OnboardingChecklist.svelte.d.ts +14 -0
  4. package/dist/components/quota/QuotaWarning.svelte +125 -0
  5. package/dist/components/quota/QuotaWarning.svelte.d.ts +16 -0
  6. package/dist/components/quota/QuotaWidget.svelte +120 -0
  7. package/dist/components/quota/QuotaWidget.svelte.d.ts +15 -0
  8. package/dist/components/quota/UpgradePrompt.svelte +288 -0
  9. package/dist/components/quota/UpgradePrompt.svelte.d.ts +13 -0
  10. package/dist/components/quota/index.d.ts +8 -0
  11. package/dist/components/quota/index.js +8 -0
  12. package/dist/groveauth/client.d.ts +143 -0
  13. package/dist/groveauth/client.js +502 -0
  14. package/dist/groveauth/colors.d.ts +35 -0
  15. package/dist/groveauth/colors.js +91 -0
  16. package/dist/groveauth/index.d.ts +34 -0
  17. package/dist/groveauth/index.js +35 -0
  18. package/dist/groveauth/limits.d.ts +70 -0
  19. package/dist/groveauth/limits.js +202 -0
  20. package/dist/groveauth/rate-limit.d.ts +95 -0
  21. package/dist/groveauth/rate-limit.js +172 -0
  22. package/dist/groveauth/types.d.ts +139 -0
  23. package/dist/groveauth/types.js +61 -0
  24. package/dist/index.d.ts +2 -0
  25. package/dist/index.js +4 -0
  26. package/dist/payments/types.d.ts +7 -2
  27. package/dist/server/services/__mocks__/cloudflare.d.ts +54 -0
  28. package/dist/server/services/__mocks__/cloudflare.js +470 -0
  29. package/dist/server/services/cache.d.ts +170 -0
  30. package/dist/server/services/cache.js +335 -0
  31. package/dist/server/services/database.d.ts +236 -0
  32. package/dist/server/services/database.js +450 -0
  33. package/dist/server/services/index.d.ts +34 -0
  34. package/dist/server/services/index.js +77 -0
  35. package/dist/server/services/storage.d.ts +221 -0
  36. package/dist/server/services/storage.js +485 -0
  37. package/package.json +11 -1
  38. package/static/fonts/Calistoga-Regular.ttf +1438 -0
  39. package/static/fonts/Caveat-Regular.ttf +0 -0
  40. package/static/fonts/EBGaramond-Regular.ttf +0 -0
  41. package/static/fonts/Fraunces-Regular.ttf +0 -0
  42. package/static/fonts/InstrumentSans-Regular.ttf +0 -0
  43. package/static/fonts/Lora-Regular.ttf +0 -0
  44. package/static/fonts/Luciole-Regular.ttf +1438 -0
  45. package/static/fonts/Manrope-Regular.ttf +0 -0
  46. package/static/fonts/Merriweather-Regular.ttf +1439 -0
  47. package/static/fonts/Nunito-Regular.ttf +0 -0
  48. package/static/fonts/PlusJakartaSans-Regular.ttf +0 -0
@@ -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,202 @@
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 === 'evergreen') {
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.tier === 'oak') {
87
+ return {
88
+ recommended: false,
89
+ fromTier: tierName,
90
+ toTier: 'Evergreen',
91
+ reason: 'You already have unlimited posts. Evergreen adds domain search and support hours.',
92
+ };
93
+ }
94
+ if (status.upgrade_required || status.is_at_limit) {
95
+ const toTier = status.tier === 'seedling' ? 'Sapling' : 'Oak';
96
+ return {
97
+ recommended: true,
98
+ fromTier: tierName,
99
+ toTier,
100
+ reason: 'You\'ve reached your post limit',
101
+ };
102
+ }
103
+ if (status.percentage_used !== null && status.percentage_used >= 80) {
104
+ const toTier = status.tier === 'seedling' ? 'Sapling' : 'Oak';
105
+ return {
106
+ recommended: true,
107
+ fromTier: tierName,
108
+ toTier,
109
+ reason: `You're using ${status.percentage_used.toFixed(0)}% of your quota`,
110
+ };
111
+ }
112
+ return {
113
+ recommended: false,
114
+ fromTier: tierName,
115
+ toTier: null,
116
+ reason: 'Your current plan is sufficient for your usage',
117
+ };
118
+ }
119
+ /**
120
+ * Get data for rendering a quota widget
121
+ */
122
+ export function getQuotaWidgetData(status) {
123
+ const urgency = getQuotaUrgency(status);
124
+ const colorMap = {
125
+ healthy: 'green',
126
+ warning: 'yellow',
127
+ critical: 'red',
128
+ blocked: 'red',
129
+ };
130
+ const statusTextMap = {
131
+ healthy: 'Healthy',
132
+ warning: 'Warning',
133
+ critical: 'Critical',
134
+ blocked: 'Blocked',
135
+ };
136
+ return {
137
+ count: status.post_count,
138
+ limit: status.post_limit,
139
+ percentage: status.percentage_used,
140
+ remaining: status.posts_remaining,
141
+ color: status.post_limit === null ? 'gray' : colorMap[urgency],
142
+ statusText: status.post_limit === null ? 'Unlimited' : statusTextMap[urgency],
143
+ description: getQuotaDescription(status),
144
+ showUpgrade: urgency === 'warning' || urgency === 'critical' || urgency === 'blocked',
145
+ tierName: TIER_NAMES[status.tier],
146
+ canPost: status.can_create_post,
147
+ };
148
+ }
149
+ /**
150
+ * Check if a post can be created and whether to show warnings
151
+ */
152
+ export function getPreSubmitCheck(response) {
153
+ const { allowed, status } = response;
154
+ // Upgrade required - can't post at all
155
+ if (status.upgrade_required) {
156
+ return {
157
+ allowed: false,
158
+ showWarning: true,
159
+ warningMessage: 'Your grace period has expired. Please upgrade your plan or delete posts to continue.',
160
+ upgradeRequired: true,
161
+ status,
162
+ };
163
+ }
164
+ // In grace period - show warning but allow
165
+ if (status.is_in_grace_period) {
166
+ return {
167
+ allowed: true,
168
+ showWarning: true,
169
+ warningMessage: `You're over your post limit. ${status.grace_period_days_remaining} days remaining before your account becomes read-only.`,
170
+ upgradeRequired: false,
171
+ status,
172
+ };
173
+ }
174
+ // At or near limit - show warning
175
+ if (status.is_at_limit) {
176
+ return {
177
+ allowed,
178
+ showWarning: true,
179
+ warningMessage: 'You\'ve reached your post limit. Creating a new post will start your grace period.',
180
+ upgradeRequired: false,
181
+ status,
182
+ };
183
+ }
184
+ // Near limit (90%+) - gentle warning
185
+ if (status.percentage_used !== null && status.percentage_used >= 90) {
186
+ return {
187
+ allowed: true,
188
+ showWarning: true,
189
+ warningMessage: `You're at ${status.percentage_used.toFixed(0)}% of your post limit. Consider upgrading soon.`,
190
+ upgradeRequired: false,
191
+ status,
192
+ };
193
+ }
194
+ // All good
195
+ return {
196
+ allowed: true,
197
+ showWarning: false,
198
+ warningMessage: null,
199
+ upgradeRequired: false,
200
+ status,
201
+ };
202
+ }
@@ -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
+ }