@sudobility/ratelimit_service 1.0.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.
- package/CLAUDE.md +160 -0
- package/dist/helpers/EntitlementHelper.cjs +75 -0
- package/dist/helpers/EntitlementHelper.d.ts +52 -0
- package/dist/helpers/EntitlementHelper.d.ts.map +1 -0
- package/dist/helpers/EntitlementHelper.js +75 -0
- package/dist/helpers/EntitlementHelper.js.map +1 -0
- package/dist/helpers/RateLimitChecker.cjs +264 -0
- package/dist/helpers/RateLimitChecker.d.ts +90 -0
- package/dist/helpers/RateLimitChecker.d.ts.map +1 -0
- package/dist/helpers/RateLimitChecker.js +264 -0
- package/dist/helpers/RateLimitChecker.js.map +1 -0
- package/dist/helpers/RateLimitRouteHandler.cjs +191 -0
- package/dist/helpers/RateLimitRouteHandler.d.ts +70 -0
- package/dist/helpers/RateLimitRouteHandler.d.ts.map +1 -0
- package/dist/helpers/RateLimitRouteHandler.js +191 -0
- package/dist/helpers/RateLimitRouteHandler.js.map +1 -0
- package/dist/helpers/RevenueCatHelper.cjs +96 -0
- package/dist/helpers/RevenueCatHelper.d.ts +51 -0
- package/dist/helpers/RevenueCatHelper.d.ts.map +1 -0
- package/dist/helpers/RevenueCatHelper.js +96 -0
- package/dist/helpers/RevenueCatHelper.js.map +1 -0
- package/dist/helpers/index.cjs +10 -0
- package/dist/helpers/index.d.ts +4 -0
- package/dist/helpers/index.d.ts.map +1 -0
- package/dist/helpers/index.js +10 -0
- package/dist/helpers/index.js.map +1 -0
- package/dist/index.cjs +36 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +36 -0
- package/dist/index.js.map +1 -0
- package/dist/middleware/hono.cjs +94 -0
- package/dist/middleware/hono.d.ts +63 -0
- package/dist/middleware/hono.d.ts.map +1 -0
- package/dist/middleware/hono.js +94 -0
- package/dist/middleware/hono.js.map +1 -0
- package/dist/schema/rate-limits.cjs +136 -0
- package/dist/schema/rate-limits.d.ts +333 -0
- package/dist/schema/rate-limits.d.ts.map +1 -0
- package/dist/schema/rate-limits.js +136 -0
- package/dist/schema/rate-limits.js.map +1 -0
- package/dist/types/entitlements.cjs +9 -0
- package/dist/types/entitlements.d.ts +29 -0
- package/dist/types/entitlements.d.ts.map +1 -0
- package/dist/types/entitlements.js +9 -0
- package/dist/types/entitlements.js.map +1 -0
- package/dist/types/index.cjs +20 -0
- package/dist/types/index.d.ts +4 -0
- package/dist/types/index.d.ts.map +1 -0
- package/dist/types/index.js +20 -0
- package/dist/types/index.js.map +1 -0
- package/dist/types/rate-limits.cjs +3 -0
- package/dist/types/rate-limits.d.ts +34 -0
- package/dist/types/rate-limits.d.ts.map +1 -0
- package/dist/types/rate-limits.js +3 -0
- package/dist/types/rate-limits.js.map +1 -0
- package/dist/types/responses.cjs +13 -0
- package/dist/types/responses.d.ts +85 -0
- package/dist/types/responses.d.ts.map +1 -0
- package/dist/types/responses.js +13 -0
- package/dist/types/responses.js.map +1 -0
- package/dist/utils/time.cjs +180 -0
- package/dist/utils/time.d.ts +80 -0
- package/dist/utils/time.d.ts.map +1 -0
- package/dist/utils/time.js +180 -0
- package/dist/utils/time.js.map +1 -0
- package/package.json +79 -0
package/CLAUDE.md
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
# Subscription Service
|
|
2
|
+
|
|
3
|
+
Shared rate limiting library based on RevenueCat entitlements.
|
|
4
|
+
|
|
5
|
+
**npm**: `@sudobility/ratelimit_service` (public)
|
|
6
|
+
|
|
7
|
+
## Tech Stack
|
|
8
|
+
|
|
9
|
+
- **Language**: TypeScript (strict mode)
|
|
10
|
+
- **Build**: Dual ESM/CJS output
|
|
11
|
+
- **Runtime**: Bun
|
|
12
|
+
- **Testing**: bun:test
|
|
13
|
+
- **Peer Dependencies**: drizzle-orm, hono
|
|
14
|
+
|
|
15
|
+
## Commands
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
bun run verify # All checks + build (use before commit)
|
|
19
|
+
bun test # Run tests
|
|
20
|
+
bun run lint # ESLint
|
|
21
|
+
bun run typecheck # TypeScript check
|
|
22
|
+
bun run build # Build ESM + CJS
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
## Project Structure
|
|
26
|
+
|
|
27
|
+
```
|
|
28
|
+
src/
|
|
29
|
+
├── index.ts # Main exports (re-exports from all modules)
|
|
30
|
+
├── types/
|
|
31
|
+
│ ├── index.ts # Type re-exports
|
|
32
|
+
│ ├── rate-limits.ts # RateLimits, RateLimitsConfig
|
|
33
|
+
│ ├── entitlements.ts # RevenueCat types, NONE_ENTITLEMENT constant
|
|
34
|
+
│ └── responses.ts # RateLimitCheckResult, PeriodType enum
|
|
35
|
+
├── schema/
|
|
36
|
+
│ └── rate-limits.ts # Drizzle schema template (consumers copy this)
|
|
37
|
+
├── helpers/
|
|
38
|
+
│ ├── index.ts # Helper re-exports
|
|
39
|
+
│ ├── RevenueCatHelper.ts # Fetches entitlements from RevenueCat API
|
|
40
|
+
│ ├── EntitlementHelper.ts # Resolves rate limits from entitlements
|
|
41
|
+
│ └── RateLimitChecker.ts # Checks/increments counters in database
|
|
42
|
+
├── middleware/
|
|
43
|
+
│ └── hono.ts # createRateLimitMiddleware factory
|
|
44
|
+
└── utils/
|
|
45
|
+
└── time.ts # Period calculation utilities
|
|
46
|
+
tests/
|
|
47
|
+
├── EntitlementHelper.test.ts # Unit tests for EntitlementHelper
|
|
48
|
+
└── time.test.ts # Unit tests for time utilities
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
## Architecture
|
|
52
|
+
|
|
53
|
+
### Data Flow
|
|
54
|
+
```
|
|
55
|
+
Request → Middleware → RevenueCatHelper → EntitlementHelper → RateLimitChecker → Response
|
|
56
|
+
(fetch entitlements) (resolve limits) (check/increment DB)
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
### Key Classes
|
|
60
|
+
|
|
61
|
+
| Class | Purpose | Key Method |
|
|
62
|
+
|-------|---------|------------|
|
|
63
|
+
| `RevenueCatHelper` | Fetch user entitlements from RevenueCat API | `getSubscriptionInfo(userId)` |
|
|
64
|
+
| `EntitlementHelper` | Map entitlements to rate limits | `getRateLimits(entitlements)` |
|
|
65
|
+
| `RateLimitChecker` | Check limits and increment counters | `checkAndIncrement(userId, limits, subscriptionStartedAt)` |
|
|
66
|
+
|
|
67
|
+
### Export Structure (package.json exports)
|
|
68
|
+
```typescript
|
|
69
|
+
// Main entry: "@sudobility/ratelimit_service"
|
|
70
|
+
export { RevenueCatHelper, EntitlementHelper, RateLimitChecker, createRateLimitMiddleware }
|
|
71
|
+
export type { RateLimits, RateLimitsConfig, RateLimitCheckResult, ... }
|
|
72
|
+
|
|
73
|
+
// Middleware: "@sudobility/ratelimit_service/middleware/hono"
|
|
74
|
+
export { createRateLimitMiddleware }
|
|
75
|
+
|
|
76
|
+
// Schema: "@sudobility/ratelimit_service/schema"
|
|
77
|
+
export { rateLimitCounters }
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
## Business Logic
|
|
81
|
+
|
|
82
|
+
### Rate Limits
|
|
83
|
+
- `undefined` = unlimited (no limit for that period)
|
|
84
|
+
- Limits checked in order: hourly → daily → monthly
|
|
85
|
+
- Multiple entitlements: upper-bound (most permissive) wins
|
|
86
|
+
|
|
87
|
+
### Entitlement Resolution
|
|
88
|
+
```typescript
|
|
89
|
+
const config: RateLimitsConfig = {
|
|
90
|
+
none: { hourly: 5, daily: 20, monthly: 100 }, // Required fallback
|
|
91
|
+
starter: { hourly: 10, daily: 50, monthly: 500 },
|
|
92
|
+
pro: { hourly: undefined, daily: undefined, monthly: undefined }, // unlimited
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
// Multiple entitlements → upper bound
|
|
96
|
+
getRateLimits(["starter", "pro"]) // → { hourly: undefined, daily: undefined, monthly: undefined }
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
### Subscription Month Calculation
|
|
100
|
+
Monthly periods are based on subscription start date, not calendar months:
|
|
101
|
+
- Subscription started March 5 → months are 3/5-4/4, 4/5-5/4, etc.
|
|
102
|
+
- Day overflow handled (Jan 31 → Feb 28 in non-leap year)
|
|
103
|
+
- No subscription → falls back to calendar month (1st of month)
|
|
104
|
+
|
|
105
|
+
### Database Schema
|
|
106
|
+
Uses period-based counters with history preservation:
|
|
107
|
+
- One row per (user_id, period_type, period_start)
|
|
108
|
+
- period_type: 'hourly' | 'daily' | 'monthly'
|
|
109
|
+
- Old periods NOT deleted (enables usage history UI)
|
|
110
|
+
|
|
111
|
+
## Coding Patterns
|
|
112
|
+
|
|
113
|
+
### Type Imports
|
|
114
|
+
```typescript
|
|
115
|
+
import type { RateLimits, RateLimitsConfig } from "../types/rate-limits";
|
|
116
|
+
import { NONE_ENTITLEMENT } from "../types/entitlements";
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
### Error Handling in Helpers
|
|
120
|
+
- RevenueCat 404 → return `["none"]` (user not found = no subscription)
|
|
121
|
+
- RevenueCat other errors → throw (let middleware handle)
|
|
122
|
+
- Unknown entitlement → fall back to "none" limits
|
|
123
|
+
|
|
124
|
+
### Testing Pattern
|
|
125
|
+
```typescript
|
|
126
|
+
import { describe, it, expect } from "bun:test";
|
|
127
|
+
|
|
128
|
+
describe("ClassName", () => {
|
|
129
|
+
describe("methodName", () => {
|
|
130
|
+
it("should do something", () => {
|
|
131
|
+
// Arrange
|
|
132
|
+
const helper = new ClassName(config);
|
|
133
|
+
// Act
|
|
134
|
+
const result = helper.method(input);
|
|
135
|
+
// Assert
|
|
136
|
+
expect(result).toEqual(expected);
|
|
137
|
+
});
|
|
138
|
+
});
|
|
139
|
+
});
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
### Time Utilities
|
|
143
|
+
All periods calculated in UTC:
|
|
144
|
+
```typescript
|
|
145
|
+
getCurrentHourStart(now) // 14:35:22Z → 14:00:00Z
|
|
146
|
+
getCurrentDayStart(now) // 2025-01-15T14:35Z → 2025-01-15T00:00Z
|
|
147
|
+
getSubscriptionMonthStart(subStart, now) // Based on subscription day
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
## Consuming APIs
|
|
151
|
+
|
|
152
|
+
This library is used by:
|
|
153
|
+
- sudojo_api
|
|
154
|
+
- shapeshyft_api
|
|
155
|
+
- whisperly_api
|
|
156
|
+
|
|
157
|
+
Consumers must:
|
|
158
|
+
1. Copy schema from `@sudobility/ratelimit_service/schema` to their db/schema.ts
|
|
159
|
+
2. Run migrations to create the `rate_limit_counters` table
|
|
160
|
+
3. Configure middleware with their RevenueCat API key and rate limits config
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.EntitlementHelper = void 0;
|
|
4
|
+
const entitlements_1 = require("../types/entitlements");
|
|
5
|
+
/**
|
|
6
|
+
* Helper class for resolving rate limits from entitlements.
|
|
7
|
+
*
|
|
8
|
+
* @example
|
|
9
|
+
* ```typescript
|
|
10
|
+
* const config: RateLimitsConfig = {
|
|
11
|
+
* none: { hourly: 2, daily: 5, monthly: 20 },
|
|
12
|
+
* starter: { hourly: 10, daily: 50, monthly: 500 },
|
|
13
|
+
* pro: { hourly: 100, daily: undefined, monthly: undefined },
|
|
14
|
+
* };
|
|
15
|
+
*
|
|
16
|
+
* const helper = new EntitlementHelper(config);
|
|
17
|
+
*
|
|
18
|
+
* // Single entitlement
|
|
19
|
+
* helper.getRateLimits("pro");
|
|
20
|
+
* // Returns: { hourly: 100, daily: undefined, monthly: undefined }
|
|
21
|
+
*
|
|
22
|
+
* // Multiple entitlements - returns upper bound
|
|
23
|
+
* helper.getRateLimits(["starter", "pro"]);
|
|
24
|
+
* // Returns: { hourly: 100, daily: undefined, monthly: undefined }
|
|
25
|
+
* ```
|
|
26
|
+
*/
|
|
27
|
+
class EntitlementHelper {
|
|
28
|
+
constructor(config) {
|
|
29
|
+
this.config = config;
|
|
30
|
+
}
|
|
31
|
+
getRateLimits(entitlementOrArray) {
|
|
32
|
+
const entitlements = Array.isArray(entitlementOrArray)
|
|
33
|
+
? entitlementOrArray
|
|
34
|
+
: [entitlementOrArray];
|
|
35
|
+
if (entitlements.length === 0) {
|
|
36
|
+
return this.config[entitlements_1.NONE_ENTITLEMENT];
|
|
37
|
+
}
|
|
38
|
+
if (entitlements.length === 1) {
|
|
39
|
+
const ent = entitlements[0];
|
|
40
|
+
return this.config[ent] ?? this.config[entitlements_1.NONE_ENTITLEMENT];
|
|
41
|
+
}
|
|
42
|
+
// Multiple entitlements - compute upper bound
|
|
43
|
+
return this.computeUpperBound(entitlements);
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Compute the upper bound (most permissive) rate limits from multiple entitlements.
|
|
47
|
+
*
|
|
48
|
+
* Rules:
|
|
49
|
+
* - undefined (unlimited) beats any number
|
|
50
|
+
* - Higher numbers beat lower numbers
|
|
51
|
+
*/
|
|
52
|
+
computeUpperBound(entitlements) {
|
|
53
|
+
const limits = entitlements.map(ent => this.config[ent] ?? this.config[entitlements_1.NONE_ENTITLEMENT]);
|
|
54
|
+
return {
|
|
55
|
+
hourly: this.maxLimit(limits.map(l => l.hourly)),
|
|
56
|
+
daily: this.maxLimit(limits.map(l => l.daily)),
|
|
57
|
+
monthly: this.maxLimit(limits.map(l => l.monthly)),
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Get the maximum (most permissive) limit from an array.
|
|
62
|
+
* undefined (unlimited) always wins.
|
|
63
|
+
*/
|
|
64
|
+
maxLimit(values) {
|
|
65
|
+
// If any value is undefined, result is unlimited
|
|
66
|
+
if (values.some(v => v === undefined)) {
|
|
67
|
+
return undefined;
|
|
68
|
+
}
|
|
69
|
+
// All values are defined, return the maximum
|
|
70
|
+
const definedValues = values;
|
|
71
|
+
return Math.max(...definedValues);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
exports.EntitlementHelper = EntitlementHelper;
|
|
75
|
+
//# sourceMappingURL=EntitlementHelper.js.map
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import type { RateLimits, RateLimitsConfig } from "../types/rate-limits";
|
|
2
|
+
/**
|
|
3
|
+
* Helper class for resolving rate limits from entitlements.
|
|
4
|
+
*
|
|
5
|
+
* @example
|
|
6
|
+
* ```typescript
|
|
7
|
+
* const config: RateLimitsConfig = {
|
|
8
|
+
* none: { hourly: 2, daily: 5, monthly: 20 },
|
|
9
|
+
* starter: { hourly: 10, daily: 50, monthly: 500 },
|
|
10
|
+
* pro: { hourly: 100, daily: undefined, monthly: undefined },
|
|
11
|
+
* };
|
|
12
|
+
*
|
|
13
|
+
* const helper = new EntitlementHelper(config);
|
|
14
|
+
*
|
|
15
|
+
* // Single entitlement
|
|
16
|
+
* helper.getRateLimits("pro");
|
|
17
|
+
* // Returns: { hourly: 100, daily: undefined, monthly: undefined }
|
|
18
|
+
*
|
|
19
|
+
* // Multiple entitlements - returns upper bound
|
|
20
|
+
* helper.getRateLimits(["starter", "pro"]);
|
|
21
|
+
* // Returns: { hourly: 100, daily: undefined, monthly: undefined }
|
|
22
|
+
* ```
|
|
23
|
+
*/
|
|
24
|
+
export declare class EntitlementHelper {
|
|
25
|
+
private readonly config;
|
|
26
|
+
constructor(config: RateLimitsConfig);
|
|
27
|
+
/**
|
|
28
|
+
* Get rate limits for a single entitlement.
|
|
29
|
+
* Falls back to "none" limits if entitlement not found in config.
|
|
30
|
+
*/
|
|
31
|
+
getRateLimits(entitlement: string): RateLimits;
|
|
32
|
+
/**
|
|
33
|
+
* Get rate limits for multiple entitlements.
|
|
34
|
+
* Returns the upper bound (most permissive) of all entitlements.
|
|
35
|
+
* undefined (unlimited) always wins over any number.
|
|
36
|
+
*/
|
|
37
|
+
getRateLimits(entitlements: string[]): RateLimits;
|
|
38
|
+
/**
|
|
39
|
+
* Compute the upper bound (most permissive) rate limits from multiple entitlements.
|
|
40
|
+
*
|
|
41
|
+
* Rules:
|
|
42
|
+
* - undefined (unlimited) beats any number
|
|
43
|
+
* - Higher numbers beat lower numbers
|
|
44
|
+
*/
|
|
45
|
+
private computeUpperBound;
|
|
46
|
+
/**
|
|
47
|
+
* Get the maximum (most permissive) limit from an array.
|
|
48
|
+
* undefined (unlimited) always wins.
|
|
49
|
+
*/
|
|
50
|
+
private maxLimit;
|
|
51
|
+
}
|
|
52
|
+
//# sourceMappingURL=EntitlementHelper.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"EntitlementHelper.d.ts","sourceRoot":"","sources":["../../src/helpers/EntitlementHelper.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,UAAU,EAAE,gBAAgB,EAAE,MAAM,sBAAsB,CAAC;AAGzE;;;;;;;;;;;;;;;;;;;;;GAqBG;AACH,qBAAa,iBAAiB;IAChB,OAAO,CAAC,QAAQ,CAAC,MAAM;gBAAN,MAAM,EAAE,gBAAgB;IAErD;;;OAGG;IACH,aAAa,CAAC,WAAW,EAAE,MAAM,GAAG,UAAU;IAE9C;;;;OAIG;IACH,aAAa,CAAC,YAAY,EAAE,MAAM,EAAE,GAAG,UAAU;IAoBjD;;;;;;OAMG;IACH,OAAO,CAAC,iBAAiB;IAYzB;;;OAGG;IACH,OAAO,CAAC,QAAQ;CAUjB"}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.EntitlementHelper = void 0;
|
|
4
|
+
const entitlements_1 = require("../types/entitlements");
|
|
5
|
+
/**
|
|
6
|
+
* Helper class for resolving rate limits from entitlements.
|
|
7
|
+
*
|
|
8
|
+
* @example
|
|
9
|
+
* ```typescript
|
|
10
|
+
* const config: RateLimitsConfig = {
|
|
11
|
+
* none: { hourly: 2, daily: 5, monthly: 20 },
|
|
12
|
+
* starter: { hourly: 10, daily: 50, monthly: 500 },
|
|
13
|
+
* pro: { hourly: 100, daily: undefined, monthly: undefined },
|
|
14
|
+
* };
|
|
15
|
+
*
|
|
16
|
+
* const helper = new EntitlementHelper(config);
|
|
17
|
+
*
|
|
18
|
+
* // Single entitlement
|
|
19
|
+
* helper.getRateLimits("pro");
|
|
20
|
+
* // Returns: { hourly: 100, daily: undefined, monthly: undefined }
|
|
21
|
+
*
|
|
22
|
+
* // Multiple entitlements - returns upper bound
|
|
23
|
+
* helper.getRateLimits(["starter", "pro"]);
|
|
24
|
+
* // Returns: { hourly: 100, daily: undefined, monthly: undefined }
|
|
25
|
+
* ```
|
|
26
|
+
*/
|
|
27
|
+
class EntitlementHelper {
|
|
28
|
+
constructor(config) {
|
|
29
|
+
this.config = config;
|
|
30
|
+
}
|
|
31
|
+
getRateLimits(entitlementOrArray) {
|
|
32
|
+
const entitlements = Array.isArray(entitlementOrArray)
|
|
33
|
+
? entitlementOrArray
|
|
34
|
+
: [entitlementOrArray];
|
|
35
|
+
if (entitlements.length === 0) {
|
|
36
|
+
return this.config[entitlements_1.NONE_ENTITLEMENT];
|
|
37
|
+
}
|
|
38
|
+
if (entitlements.length === 1) {
|
|
39
|
+
const ent = entitlements[0];
|
|
40
|
+
return this.config[ent] ?? this.config[entitlements_1.NONE_ENTITLEMENT];
|
|
41
|
+
}
|
|
42
|
+
// Multiple entitlements - compute upper bound
|
|
43
|
+
return this.computeUpperBound(entitlements);
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Compute the upper bound (most permissive) rate limits from multiple entitlements.
|
|
47
|
+
*
|
|
48
|
+
* Rules:
|
|
49
|
+
* - undefined (unlimited) beats any number
|
|
50
|
+
* - Higher numbers beat lower numbers
|
|
51
|
+
*/
|
|
52
|
+
computeUpperBound(entitlements) {
|
|
53
|
+
const limits = entitlements.map(ent => this.config[ent] ?? this.config[entitlements_1.NONE_ENTITLEMENT]);
|
|
54
|
+
return {
|
|
55
|
+
hourly: this.maxLimit(limits.map(l => l.hourly)),
|
|
56
|
+
daily: this.maxLimit(limits.map(l => l.daily)),
|
|
57
|
+
monthly: this.maxLimit(limits.map(l => l.monthly)),
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Get the maximum (most permissive) limit from an array.
|
|
62
|
+
* undefined (unlimited) always wins.
|
|
63
|
+
*/
|
|
64
|
+
maxLimit(values) {
|
|
65
|
+
// If any value is undefined, result is unlimited
|
|
66
|
+
if (values.some(v => v === undefined)) {
|
|
67
|
+
return undefined;
|
|
68
|
+
}
|
|
69
|
+
// All values are defined, return the maximum
|
|
70
|
+
const definedValues = values;
|
|
71
|
+
return Math.max(...definedValues);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
exports.EntitlementHelper = EntitlementHelper;
|
|
75
|
+
//# sourceMappingURL=EntitlementHelper.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"EntitlementHelper.js","sourceRoot":"","sources":["../../src/helpers/EntitlementHelper.ts"],"names":[],"mappings":";;;AACA,wDAAyD;AAEzD;;;;;;;;;;;;;;;;;;;;;GAqBG;AACH,MAAa,iBAAiB;IAC5B,YAA6B,MAAwB;QAAxB,WAAM,GAAN,MAAM,CAAkB;IAAG,CAAC;IAezD,aAAa,CAAC,kBAAqC;QACjD,MAAM,YAAY,GAAG,KAAK,CAAC,OAAO,CAAC,kBAAkB,CAAC;YACpD,CAAC,CAAC,kBAAkB;YACpB,CAAC,CAAC,CAAC,kBAAkB,CAAC,CAAC;QAEzB,IAAI,YAAY,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;YAC9B,OAAO,IAAI,CAAC,MAAM,CAAC,+BAAgB,CAAC,CAAC;QACvC,CAAC;QAED,IAAI,YAAY,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;YAC9B,MAAM,GAAG,GAAG,YAAY,CAAC,CAAC,CAAE,CAAC;YAC7B,OAAO,IAAI,CAAC,MAAM,CAAC,GAAG,CAAC,IAAI,IAAI,CAAC,MAAM,CAAC,+BAAgB,CAAC,CAAC;QAC3D,CAAC;QAED,8CAA8C;QAC9C,OAAO,IAAI,CAAC,iBAAiB,CAAC,YAAY,CAAC,CAAC;IAC9C,CAAC;IAED;;;;;;OAMG;IACK,iBAAiB,CAAC,YAAsB;QAC9C,MAAM,MAAM,GAAG,YAAY,CAAC,GAAG,CAC7B,GAAG,CAAC,EAAE,CAAC,IAAI,CAAC,MAAM,CAAC,GAAG,CAAC,IAAI,IAAI,CAAC,MAAM,CAAC,+BAAgB,CAAC,CACzD,CAAC;QAEF,OAAO;YACL,MAAM,EAAE,IAAI,CAAC,QAAQ,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC;YAChD,KAAK,EAAE,IAAI,CAAC,QAAQ,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC;YAC9C,OAAO,EAAE,IAAI,CAAC,QAAQ,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,OAAO,CAAC,CAAC;SACnD,CAAC;IACJ,CAAC;IAED;;;OAGG;IACK,QAAQ,CAAC,MAA8B;QAC7C,iDAAiD;QACjD,IAAI,MAAM,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,KAAK,SAAS,CAAC,EAAE,CAAC;YACtC,OAAO,SAAS,CAAC;QACnB,CAAC;QAED,6CAA6C;QAC7C,MAAM,aAAa,GAAG,MAAkB,CAAC;QACzC,OAAO,IAAI,CAAC,GAAG,CAAC,GAAG,aAAa,CAAC,CAAC;IACpC,CAAC;CACF;AAnED,8CAmEC"}
|
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.RateLimitChecker = void 0;
|
|
4
|
+
const drizzle_orm_1 = require("drizzle-orm");
|
|
5
|
+
const types_1 = require("../types");
|
|
6
|
+
const time_1 = require("../utils/time");
|
|
7
|
+
/**
|
|
8
|
+
* Checks and updates rate limits for a user.
|
|
9
|
+
* Uses period-based counters with history preservation.
|
|
10
|
+
*
|
|
11
|
+
* @example
|
|
12
|
+
* ```typescript
|
|
13
|
+
* import { db, rateLimitCounters } from "./db";
|
|
14
|
+
*
|
|
15
|
+
* const checker = new RateLimitChecker({ db, table: rateLimitCounters });
|
|
16
|
+
*
|
|
17
|
+
* const result = await checker.checkAndIncrement(
|
|
18
|
+
* userId,
|
|
19
|
+
* { hourly: 10, daily: 100, monthly: undefined },
|
|
20
|
+
* subscriptionStartedAt // from RevenueCat
|
|
21
|
+
* );
|
|
22
|
+
*
|
|
23
|
+
* if (!result.allowed) {
|
|
24
|
+
* return c.json({ error: "Rate limit exceeded" }, 429);
|
|
25
|
+
* }
|
|
26
|
+
* ```
|
|
27
|
+
*/
|
|
28
|
+
class RateLimitChecker {
|
|
29
|
+
constructor(config) {
|
|
30
|
+
this.db = config.db;
|
|
31
|
+
this.table = config.table;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Check if request is within rate limits and increment counters.
|
|
35
|
+
*
|
|
36
|
+
* @param userId - The user's ID
|
|
37
|
+
* @param limits - The rate limits to apply
|
|
38
|
+
* @param subscriptionStartedAt - When the subscription started (for monthly calculation)
|
|
39
|
+
* @returns Result indicating if request is allowed and remaining limits
|
|
40
|
+
*/
|
|
41
|
+
async checkAndIncrement(userId, limits, subscriptionStartedAt = null) {
|
|
42
|
+
const now = new Date();
|
|
43
|
+
// Get current counts for each period type
|
|
44
|
+
const counts = await this.getCurrentCounts(userId, subscriptionStartedAt, now);
|
|
45
|
+
// Check limits before incrementing
|
|
46
|
+
const checkResult = this.checkLimits(counts, limits);
|
|
47
|
+
if (!checkResult.allowed) {
|
|
48
|
+
return checkResult;
|
|
49
|
+
}
|
|
50
|
+
// Increment counters for enabled limit types
|
|
51
|
+
await this.incrementCounters(userId, limits, subscriptionStartedAt, now);
|
|
52
|
+
// Calculate remaining after increment
|
|
53
|
+
const remaining = this.calculateRemaining({
|
|
54
|
+
hourly: counts.hourly + (limits.hourly !== undefined ? 1 : 0),
|
|
55
|
+
daily: counts.daily + (limits.daily !== undefined ? 1 : 0),
|
|
56
|
+
monthly: counts.monthly + (limits.monthly !== undefined ? 1 : 0),
|
|
57
|
+
}, limits);
|
|
58
|
+
return {
|
|
59
|
+
allowed: true,
|
|
60
|
+
statusCode: 200,
|
|
61
|
+
remaining,
|
|
62
|
+
limits,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Get current usage without incrementing (for status queries).
|
|
67
|
+
*/
|
|
68
|
+
async checkOnly(userId, limits, subscriptionStartedAt = null) {
|
|
69
|
+
const now = new Date();
|
|
70
|
+
const counts = await this.getCurrentCounts(userId, subscriptionStartedAt, now);
|
|
71
|
+
const checkResult = this.checkLimits(counts, limits);
|
|
72
|
+
const remaining = this.calculateRemaining(counts, limits);
|
|
73
|
+
return {
|
|
74
|
+
...checkResult,
|
|
75
|
+
remaining,
|
|
76
|
+
limits,
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Get usage history for a user.
|
|
81
|
+
*
|
|
82
|
+
* @param userId - The user's ID
|
|
83
|
+
* @param periodType - The period type to get history for
|
|
84
|
+
* @param subscriptionStartedAt - When the subscription started (for calculating period_end)
|
|
85
|
+
* @param limit - Maximum number of entries to return (default: 100)
|
|
86
|
+
* @returns Usage history with period start/end and counts
|
|
87
|
+
*/
|
|
88
|
+
async getHistory(userId, periodType, subscriptionStartedAt = null, limit = 100) {
|
|
89
|
+
const tableAny = this.table;
|
|
90
|
+
const rows = await this.db
|
|
91
|
+
.select()
|
|
92
|
+
.from(this.table)
|
|
93
|
+
.where((0, drizzle_orm_1.and)((0, drizzle_orm_1.eq)(tableAny.user_id, userId), (0, drizzle_orm_1.eq)(tableAny.period_type, periodType)))
|
|
94
|
+
.orderBy((0, drizzle_orm_1.desc)(tableAny.period_start))
|
|
95
|
+
.limit(limit);
|
|
96
|
+
const entries = rows.map(row => {
|
|
97
|
+
const counter = row;
|
|
98
|
+
return {
|
|
99
|
+
period_start: counter.period_start,
|
|
100
|
+
period_end: this.getPeriodEnd(periodType, counter.period_start, subscriptionStartedAt),
|
|
101
|
+
request_count: counter.request_count,
|
|
102
|
+
};
|
|
103
|
+
});
|
|
104
|
+
return {
|
|
105
|
+
user_id: userId,
|
|
106
|
+
period_type: periodType,
|
|
107
|
+
entries,
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* Get current counts for each period type.
|
|
112
|
+
*/
|
|
113
|
+
async getCurrentCounts(userId, subscriptionStartedAt, now) {
|
|
114
|
+
const [hourlyCount, dailyCount, monthlyCount] = await Promise.all([
|
|
115
|
+
this.getCountForPeriod(userId, types_1.PeriodType.HOURLY, (0, time_1.getCurrentHourStart)(now)),
|
|
116
|
+
this.getCountForPeriod(userId, types_1.PeriodType.DAILY, (0, time_1.getCurrentDayStart)(now)),
|
|
117
|
+
this.getCountForPeriod(userId, types_1.PeriodType.MONTHLY, (0, time_1.getSubscriptionMonthStart)(subscriptionStartedAt, now)),
|
|
118
|
+
]);
|
|
119
|
+
return {
|
|
120
|
+
hourly: hourlyCount,
|
|
121
|
+
daily: dailyCount,
|
|
122
|
+
monthly: monthlyCount,
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* Get the counter value for a specific period.
|
|
127
|
+
*/
|
|
128
|
+
async getCountForPeriod(userId, periodType, periodStart) {
|
|
129
|
+
const tableAny = this.table;
|
|
130
|
+
const rows = await this.db
|
|
131
|
+
.select()
|
|
132
|
+
.from(this.table)
|
|
133
|
+
.where((0, drizzle_orm_1.and)((0, drizzle_orm_1.eq)(tableAny.user_id, userId), (0, drizzle_orm_1.eq)(tableAny.period_type, periodType), (0, drizzle_orm_1.eq)(tableAny.period_start, periodStart)))
|
|
134
|
+
.limit(1);
|
|
135
|
+
if (rows.length === 0) {
|
|
136
|
+
return 0;
|
|
137
|
+
}
|
|
138
|
+
const counter = rows[0];
|
|
139
|
+
return counter.request_count;
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
142
|
+
* Increment counters for enabled limit types.
|
|
143
|
+
*/
|
|
144
|
+
async incrementCounters(userId, limits, subscriptionStartedAt, now) {
|
|
145
|
+
const updates = [];
|
|
146
|
+
if (limits.hourly !== undefined) {
|
|
147
|
+
updates.push(this.incrementPeriodCounter(userId, types_1.PeriodType.HOURLY, (0, time_1.getCurrentHourStart)(now), now));
|
|
148
|
+
}
|
|
149
|
+
if (limits.daily !== undefined) {
|
|
150
|
+
updates.push(this.incrementPeriodCounter(userId, types_1.PeriodType.DAILY, (0, time_1.getCurrentDayStart)(now), now));
|
|
151
|
+
}
|
|
152
|
+
if (limits.monthly !== undefined) {
|
|
153
|
+
updates.push(this.incrementPeriodCounter(userId, types_1.PeriodType.MONTHLY, (0, time_1.getSubscriptionMonthStart)(subscriptionStartedAt, now), now));
|
|
154
|
+
}
|
|
155
|
+
await Promise.all(updates);
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* Increment a specific period counter (upsert).
|
|
159
|
+
*/
|
|
160
|
+
async incrementPeriodCounter(userId, periodType, periodStart, now) {
|
|
161
|
+
const tableAny = this.table;
|
|
162
|
+
// Try to find existing counter
|
|
163
|
+
const existing = await this.db
|
|
164
|
+
.select()
|
|
165
|
+
.from(this.table)
|
|
166
|
+
.where((0, drizzle_orm_1.and)((0, drizzle_orm_1.eq)(tableAny.user_id, userId), (0, drizzle_orm_1.eq)(tableAny.period_type, periodType), (0, drizzle_orm_1.eq)(tableAny.period_start, periodStart)))
|
|
167
|
+
.limit(1);
|
|
168
|
+
if (existing.length > 0) {
|
|
169
|
+
// Update existing counter
|
|
170
|
+
const counter = existing[0];
|
|
171
|
+
await this.db
|
|
172
|
+
.update(this.table)
|
|
173
|
+
.set({
|
|
174
|
+
request_count: counter.request_count + 1,
|
|
175
|
+
updated_at: now,
|
|
176
|
+
})
|
|
177
|
+
.where((0, drizzle_orm_1.eq)(tableAny.id, counter.id));
|
|
178
|
+
}
|
|
179
|
+
else {
|
|
180
|
+
// Insert new counter
|
|
181
|
+
await this.db.insert(this.table).values({
|
|
182
|
+
user_id: userId,
|
|
183
|
+
period_type: periodType,
|
|
184
|
+
period_start: periodStart,
|
|
185
|
+
request_count: 1,
|
|
186
|
+
created_at: now,
|
|
187
|
+
updated_at: now,
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
/**
|
|
192
|
+
* Check limits and return result.
|
|
193
|
+
*/
|
|
194
|
+
checkLimits(counts, limits) {
|
|
195
|
+
const remaining = this.calculateRemaining(counts, limits);
|
|
196
|
+
// Check hourly limit
|
|
197
|
+
if (limits.hourly !== undefined && counts.hourly >= limits.hourly) {
|
|
198
|
+
return {
|
|
199
|
+
allowed: false,
|
|
200
|
+
statusCode: 429,
|
|
201
|
+
remaining,
|
|
202
|
+
exceededLimit: "hourly",
|
|
203
|
+
limits,
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
// Check daily limit
|
|
207
|
+
if (limits.daily !== undefined && counts.daily >= limits.daily) {
|
|
208
|
+
return {
|
|
209
|
+
allowed: false,
|
|
210
|
+
statusCode: 429,
|
|
211
|
+
remaining,
|
|
212
|
+
exceededLimit: "daily",
|
|
213
|
+
limits,
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
// Check monthly limit
|
|
217
|
+
if (limits.monthly !== undefined && counts.monthly >= limits.monthly) {
|
|
218
|
+
return {
|
|
219
|
+
allowed: false,
|
|
220
|
+
statusCode: 429,
|
|
221
|
+
remaining,
|
|
222
|
+
exceededLimit: "monthly",
|
|
223
|
+
limits,
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
return {
|
|
227
|
+
allowed: true,
|
|
228
|
+
statusCode: 200,
|
|
229
|
+
remaining,
|
|
230
|
+
limits,
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
/**
|
|
234
|
+
* Calculate remaining requests for each period.
|
|
235
|
+
*/
|
|
236
|
+
calculateRemaining(counts, limits) {
|
|
237
|
+
return {
|
|
238
|
+
hourly: limits.hourly !== undefined
|
|
239
|
+
? Math.max(0, limits.hourly - counts.hourly)
|
|
240
|
+
: undefined,
|
|
241
|
+
daily: limits.daily !== undefined
|
|
242
|
+
? Math.max(0, limits.daily - counts.daily)
|
|
243
|
+
: undefined,
|
|
244
|
+
monthly: limits.monthly !== undefined
|
|
245
|
+
? Math.max(0, limits.monthly - counts.monthly)
|
|
246
|
+
: undefined,
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
/**
|
|
250
|
+
* Get the end of a period for history entries.
|
|
251
|
+
*/
|
|
252
|
+
getPeriodEnd(periodType, periodStart, subscriptionStartedAt) {
|
|
253
|
+
switch (periodType) {
|
|
254
|
+
case types_1.PeriodType.HOURLY:
|
|
255
|
+
return (0, time_1.getNextHourStart)(periodStart);
|
|
256
|
+
case types_1.PeriodType.DAILY:
|
|
257
|
+
return (0, time_1.getNextDayStart)(periodStart);
|
|
258
|
+
case types_1.PeriodType.MONTHLY:
|
|
259
|
+
return (0, time_1.getNextSubscriptionMonthStart)(subscriptionStartedAt, periodStart);
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
exports.RateLimitChecker = RateLimitChecker;
|
|
264
|
+
//# sourceMappingURL=RateLimitChecker.js.map
|