@devwithbobby/loops 0.1.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/.changeset/README.md +8 -0
- package/.changeset/config.json +14 -0
- package/.config/commitlint.config.ts +11 -0
- package/.config/lefthook.yml +11 -0
- package/.github/workflows/release.yml +52 -0
- package/.github/workflows/test-and-lint.yml +39 -0
- package/README.md +517 -0
- package/biome.json +45 -0
- package/bun.lock +1166 -0
- package/bunfig.toml +7 -0
- package/convex.json +3 -0
- package/example/CLAUDE.md +106 -0
- package/example/README.md +21 -0
- package/example/bun-env.d.ts +17 -0
- package/example/convex/_generated/api.d.ts +53 -0
- package/example/convex/_generated/api.js +23 -0
- package/example/convex/_generated/dataModel.d.ts +60 -0
- package/example/convex/_generated/server.d.ts +149 -0
- package/example/convex/_generated/server.js +90 -0
- package/example/convex/convex.config.ts +7 -0
- package/example/convex/example.ts +76 -0
- package/example/convex/schema.ts +3 -0
- package/example/convex/tsconfig.json +34 -0
- package/example/src/App.tsx +185 -0
- package/example/src/frontend.tsx +39 -0
- package/example/src/index.css +15 -0
- package/example/src/index.html +12 -0
- package/example/src/index.tsx +19 -0
- package/example/tsconfig.json +28 -0
- package/package.json +95 -0
- package/prds/CHANGELOG.md +38 -0
- package/prds/CLAUDE.md +408 -0
- package/prds/CONTRIBUTING.md +274 -0
- package/prds/ENV_SETUP.md +222 -0
- package/prds/MONITORING.md +301 -0
- package/prds/RATE_LIMITING.md +412 -0
- package/prds/SECURITY.md +246 -0
- package/renovate.json +32 -0
- package/src/client/index.ts +530 -0
- package/src/client/types.ts +64 -0
- package/src/component/_generated/api.d.ts +55 -0
- package/src/component/_generated/api.js +23 -0
- package/src/component/_generated/dataModel.d.ts +60 -0
- package/src/component/_generated/server.d.ts +149 -0
- package/src/component/_generated/server.js +90 -0
- package/src/component/convex.config.ts +27 -0
- package/src/component/lib.ts +1125 -0
- package/src/component/schema.ts +17 -0
- package/src/component/tables/contacts.ts +16 -0
- package/src/component/tables/emailOperations.ts +22 -0
- package/src/component/validators.ts +39 -0
- package/src/utils.ts +6 -0
- package/test/client/_generated/_ignore.ts +1 -0
- package/test/client/index.test.ts +65 -0
- package/test/client/setup.test.ts +54 -0
- package/test/component/lib.test.ts +225 -0
- package/test/component/setup.test.ts +21 -0
- package/tsconfig.build.json +20 -0
- package/tsconfig.json +22 -0
|
@@ -0,0 +1,412 @@
|
|
|
1
|
+
# Rate Limiting for Email Sending
|
|
2
|
+
|
|
3
|
+
The Loops component provides built-in rate limiting capabilities to prevent email abuse and ensure compliance with email service provider limits.
|
|
4
|
+
|
|
5
|
+
## Features
|
|
6
|
+
|
|
7
|
+
1. **Recipient-based Rate Limiting** - Limit emails per recipient
|
|
8
|
+
2. **Actor-based Rate Limiting** - Limit emails per user/actor
|
|
9
|
+
3. **Global Rate Limiting** - Limit total emails across the system
|
|
10
|
+
4. **Automatic Rate Limit Checks** - Based on logged email operations
|
|
11
|
+
5. **Rate Limit Status Queries** - Check rate limit status before sending
|
|
12
|
+
|
|
13
|
+
## Rate Limiting Functions
|
|
14
|
+
|
|
15
|
+
### 1. Check Recipient Rate Limit
|
|
16
|
+
|
|
17
|
+
Check if an email can be sent to a specific recipient:
|
|
18
|
+
|
|
19
|
+
```typescript
|
|
20
|
+
const rateLimitCheck = await ctx.runQuery(
|
|
21
|
+
components.loopsComponent.lib.checkRecipientRateLimit,
|
|
22
|
+
{
|
|
23
|
+
email: "user@example.com",
|
|
24
|
+
timeWindowMs: 3600000, // 1 hour
|
|
25
|
+
maxEmails: 10, // Max 10 emails per hour to this recipient
|
|
26
|
+
}
|
|
27
|
+
);
|
|
28
|
+
|
|
29
|
+
if (!rateLimitCheck.allowed) {
|
|
30
|
+
throw new Error(
|
|
31
|
+
`Rate limit exceeded. ${rateLimitCheck.count}/${rateLimitCheck.limit} emails sent in the last hour. Retry after ${Math.ceil(rateLimitCheck.retryAfter! / 1000)} seconds.`
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
**Response:**
|
|
37
|
+
```typescript
|
|
38
|
+
{
|
|
39
|
+
allowed: boolean; // Whether sending is allowed
|
|
40
|
+
count: number; // Current count in time window
|
|
41
|
+
limit: number; // Maximum allowed
|
|
42
|
+
timeWindowMs: number; // Time window size
|
|
43
|
+
retryAfter?: number; // Milliseconds until oldest email expires (if limited)
|
|
44
|
+
}
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
### 2. Check Actor Rate Limit
|
|
48
|
+
|
|
49
|
+
Check if a specific user/actor can send more emails:
|
|
50
|
+
|
|
51
|
+
```typescript
|
|
52
|
+
const rateLimitCheck = await ctx.runQuery(
|
|
53
|
+
components.loopsComponent.lib.checkActorRateLimit,
|
|
54
|
+
{
|
|
55
|
+
actorId: "user-123", // User ID or actor identifier
|
|
56
|
+
timeWindowMs: 3600000, // 1 hour
|
|
57
|
+
maxEmails: 100, // Max 100 emails per hour from this actor
|
|
58
|
+
}
|
|
59
|
+
);
|
|
60
|
+
|
|
61
|
+
if (!rateLimitCheck.allowed) {
|
|
62
|
+
throw new Error(`Rate limit exceeded for actor.`);
|
|
63
|
+
}
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
**Note:** Actor ID must be set when logging email operations. The app layer should pass it through (see below).
|
|
67
|
+
|
|
68
|
+
### 3. Check Global Rate Limit
|
|
69
|
+
|
|
70
|
+
Check system-wide email sending rate:
|
|
71
|
+
|
|
72
|
+
```typescript
|
|
73
|
+
const rateLimitCheck = await ctx.runQuery(
|
|
74
|
+
components.loopsComponent.lib.checkGlobalRateLimit,
|
|
75
|
+
{
|
|
76
|
+
timeWindowMs: 3600000, // 1 hour
|
|
77
|
+
maxEmails: 10000, // Max 10,000 emails per hour system-wide
|
|
78
|
+
}
|
|
79
|
+
);
|
|
80
|
+
|
|
81
|
+
if (!rateLimitCheck.allowed) {
|
|
82
|
+
throw new Error(`Global rate limit exceeded.`);
|
|
83
|
+
}
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
## Usage Patterns
|
|
87
|
+
|
|
88
|
+
### Pattern 1: Rate Limit Check Before Sending
|
|
89
|
+
|
|
90
|
+
```typescript
|
|
91
|
+
export const sendTransactional = action({
|
|
92
|
+
args: {
|
|
93
|
+
email: v.string(),
|
|
94
|
+
transactionalId: v.string(),
|
|
95
|
+
},
|
|
96
|
+
handler: async (ctx, args) => {
|
|
97
|
+
const identity = await ctx.auth.getUserIdentity();
|
|
98
|
+
if (!identity) throw new Error("Unauthorized");
|
|
99
|
+
|
|
100
|
+
// Check recipient rate limit
|
|
101
|
+
const recipientCheck = await ctx.runQuery(
|
|
102
|
+
components.loopsComponent.lib.checkRecipientRateLimit,
|
|
103
|
+
{
|
|
104
|
+
email: args.email,
|
|
105
|
+
timeWindowMs: 3600000, // 1 hour
|
|
106
|
+
maxEmails: 10, // 10 emails per hour per recipient
|
|
107
|
+
}
|
|
108
|
+
);
|
|
109
|
+
|
|
110
|
+
if (!recipientCheck.allowed) {
|
|
111
|
+
const retrySeconds = Math.ceil((recipientCheck.retryAfter ?? 0) / 1000);
|
|
112
|
+
throw new Error(
|
|
113
|
+
`Rate limit exceeded. You can send emails to ${args.email} again in ${retrySeconds} seconds.`
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Check actor rate limit
|
|
118
|
+
const actorCheck = await ctx.runQuery(
|
|
119
|
+
components.loopsComponent.lib.checkActorRateLimit,
|
|
120
|
+
{
|
|
121
|
+
actorId: identity.subject,
|
|
122
|
+
timeWindowMs: 3600000, // 1 hour
|
|
123
|
+
maxEmails: 100, // 100 emails per hour per user
|
|
124
|
+
}
|
|
125
|
+
);
|
|
126
|
+
|
|
127
|
+
if (!actorCheck.allowed) {
|
|
128
|
+
throw new Error(`You've reached your email sending limit. Please try again later.`);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Send email if rate limits pass
|
|
132
|
+
return await loops.sendTransactional(ctx, {
|
|
133
|
+
email: args.email,
|
|
134
|
+
transactionalId: args.transactionalId,
|
|
135
|
+
});
|
|
136
|
+
},
|
|
137
|
+
});
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
### Pattern 2: Helper Function for Rate Limiting
|
|
141
|
+
|
|
142
|
+
Create a reusable helper function:
|
|
143
|
+
|
|
144
|
+
```typescript
|
|
145
|
+
// In your app's convex/rateLimits.ts
|
|
146
|
+
import { components } from "./_generated/api";
|
|
147
|
+
|
|
148
|
+
export async function enforceEmailRateLimits(
|
|
149
|
+
ctx: ActionCtx,
|
|
150
|
+
options: {
|
|
151
|
+
email: string;
|
|
152
|
+
actorId: string;
|
|
153
|
+
}
|
|
154
|
+
) {
|
|
155
|
+
// Check recipient limit (10 per hour)
|
|
156
|
+
const recipientCheck = await ctx.runQuery(
|
|
157
|
+
components.loopsComponent.lib.checkRecipientRateLimit,
|
|
158
|
+
{
|
|
159
|
+
email: options.email,
|
|
160
|
+
timeWindowMs: 3600000,
|
|
161
|
+
maxEmails: 10,
|
|
162
|
+
}
|
|
163
|
+
);
|
|
164
|
+
|
|
165
|
+
if (!recipientCheck.allowed) {
|
|
166
|
+
const retrySeconds = Math.ceil((recipientCheck.retryAfter ?? 0) / 1000);
|
|
167
|
+
throw new Error(
|
|
168
|
+
`Rate limit exceeded. Retry after ${retrySeconds} seconds.`
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// Check actor limit (100 per hour)
|
|
173
|
+
const actorCheck = await ctx.runQuery(
|
|
174
|
+
components.loopsComponent.lib.checkActorRateLimit,
|
|
175
|
+
{
|
|
176
|
+
actorId: options.actorId,
|
|
177
|
+
timeWindowMs: 3600000,
|
|
178
|
+
maxEmails: 100,
|
|
179
|
+
}
|
|
180
|
+
);
|
|
181
|
+
|
|
182
|
+
if (!actorCheck.allowed) {
|
|
183
|
+
throw new Error(`Email sending limit reached. Please try again later.`);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// Optional: Check global limit
|
|
187
|
+
const globalCheck = await ctx.runQuery(
|
|
188
|
+
components.loopsComponent.lib.checkGlobalRateLimit,
|
|
189
|
+
{
|
|
190
|
+
timeWindowMs: 3600000,
|
|
191
|
+
maxEmails: 10000, // 10k per hour system-wide
|
|
192
|
+
}
|
|
193
|
+
);
|
|
194
|
+
|
|
195
|
+
if (!globalCheck.allowed) {
|
|
196
|
+
throw new Error(`System email limit reached. Please try again later.`);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// Then use it:
|
|
201
|
+
export const sendTransactional = action({
|
|
202
|
+
handler: async (ctx, args) => {
|
|
203
|
+
const identity = await ctx.auth.getUserIdentity();
|
|
204
|
+
if (!identity) throw new Error("Unauthorized");
|
|
205
|
+
|
|
206
|
+
await enforceEmailRateLimits(ctx, {
|
|
207
|
+
email: args.email,
|
|
208
|
+
actorId: identity.subject,
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
return await loops.sendTransactional(ctx, args);
|
|
212
|
+
},
|
|
213
|
+
});
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
### Pattern 3: Different Limits for Different Operations
|
|
217
|
+
|
|
218
|
+
```typescript
|
|
219
|
+
const RATE_LIMITS = {
|
|
220
|
+
transactional: {
|
|
221
|
+
recipient: { timeWindowMs: 3600000, maxEmails: 10 }, // 10/hour
|
|
222
|
+
actor: { timeWindowMs: 3600000, maxEmails: 100 }, // 100/hour
|
|
223
|
+
},
|
|
224
|
+
campaign: {
|
|
225
|
+
actor: { timeWindowMs: 3600000, maxEmails: 5 }, // 5/hour (more restrictive)
|
|
226
|
+
},
|
|
227
|
+
event: {
|
|
228
|
+
recipient: { timeWindowMs: 3600000, maxEmails: 20 }, // 20/hour
|
|
229
|
+
actor: { timeWindowMs: 3600000, maxEmails: 200 }, // 200/hour
|
|
230
|
+
},
|
|
231
|
+
};
|
|
232
|
+
|
|
233
|
+
export const sendTransactional = action({
|
|
234
|
+
handler: async (ctx, args) => {
|
|
235
|
+
const identity = await ctx.auth.getUserIdentity();
|
|
236
|
+
if (!identity) throw new Error("Unauthorized");
|
|
237
|
+
|
|
238
|
+
const limits = RATE_LIMITS.transactional;
|
|
239
|
+
|
|
240
|
+
// Check limits
|
|
241
|
+
const recipientCheck = await ctx.runQuery(
|
|
242
|
+
components.loopsComponent.lib.checkRecipientRateLimit,
|
|
243
|
+
{
|
|
244
|
+
email: args.email,
|
|
245
|
+
...limits.recipient,
|
|
246
|
+
}
|
|
247
|
+
);
|
|
248
|
+
|
|
249
|
+
if (!recipientCheck.allowed) {
|
|
250
|
+
throw new Error("Recipient rate limit exceeded");
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
const actorCheck = await ctx.runQuery(
|
|
254
|
+
components.loopsComponent.lib.checkActorRateLimit,
|
|
255
|
+
{
|
|
256
|
+
actorId: identity.subject,
|
|
257
|
+
...limits.actor,
|
|
258
|
+
}
|
|
259
|
+
);
|
|
260
|
+
|
|
261
|
+
if (!actorCheck.allowed) {
|
|
262
|
+
throw new Error("Actor rate limit exceeded");
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
return await loops.sendTransactional(ctx, args);
|
|
266
|
+
},
|
|
267
|
+
});
|
|
268
|
+
```
|
|
269
|
+
|
|
270
|
+
## Setting Actor IDs
|
|
271
|
+
|
|
272
|
+
To use actor-based rate limiting, you need to ensure actor IDs are logged with email operations. Currently, the component logs operations automatically, but actor IDs must be passed from the app layer.
|
|
273
|
+
|
|
274
|
+
**Future Enhancement:** The component could accept an optional `actorId` parameter in email-sending functions. For now, you can track actor IDs by:
|
|
275
|
+
|
|
276
|
+
1. Using the `userId` field in contacts (which gets logged in some operations)
|
|
277
|
+
2. Extending the component to accept actor IDs
|
|
278
|
+
3. Creating wrapper functions that include actor ID in metadata
|
|
279
|
+
|
|
280
|
+
## Using the Client Library
|
|
281
|
+
|
|
282
|
+
```typescript
|
|
283
|
+
import { Loops } from "robertalv/loops-component";
|
|
284
|
+
|
|
285
|
+
const loops = new Loops(components.loopsComponent);
|
|
286
|
+
|
|
287
|
+
// In an action
|
|
288
|
+
export const sendTransactional = action({
|
|
289
|
+
handler: async (ctx, args) => {
|
|
290
|
+
// Check rate limit
|
|
291
|
+
const check = await loops.checkRecipientRateLimit(ctx, {
|
|
292
|
+
email: args.email,
|
|
293
|
+
timeWindowMs: 3600000,
|
|
294
|
+
maxEmails: 10,
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
if (!check.allowed) {
|
|
298
|
+
throw new Error("Rate limit exceeded");
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// Send email
|
|
302
|
+
return await loops.sendTransactional(ctx, args);
|
|
303
|
+
},
|
|
304
|
+
});
|
|
305
|
+
```
|
|
306
|
+
|
|
307
|
+
Or export rate limit functions:
|
|
308
|
+
|
|
309
|
+
```typescript
|
|
310
|
+
export const {
|
|
311
|
+
checkRecipientRateLimit,
|
|
312
|
+
checkActorRateLimit,
|
|
313
|
+
checkGlobalRateLimit,
|
|
314
|
+
} = loops.api();
|
|
315
|
+
```
|
|
316
|
+
|
|
317
|
+
## Recommended Rate Limits
|
|
318
|
+
|
|
319
|
+
Based on email best practices:
|
|
320
|
+
|
|
321
|
+
### Per Recipient
|
|
322
|
+
- **Transactional emails**: 10-20 per hour
|
|
323
|
+
- **Event-triggered emails**: 5-10 per hour
|
|
324
|
+
- **Marketing emails**: 1-3 per day
|
|
325
|
+
|
|
326
|
+
### Per Actor/User
|
|
327
|
+
- **Regular users**: 50-100 emails per hour
|
|
328
|
+
- **Power users**: 200-500 emails per hour
|
|
329
|
+
- **System/automated**: Higher limits with monitoring
|
|
330
|
+
|
|
331
|
+
### Global
|
|
332
|
+
- Based on your email service provider limits
|
|
333
|
+
- Typical: 10,000-100,000 emails per hour
|
|
334
|
+
- Monitor and adjust based on your usage
|
|
335
|
+
|
|
336
|
+
## Rate Limiting with convex-helpers
|
|
337
|
+
|
|
338
|
+
You can also combine component rate limiting with `convex-helpers` rate limiting:
|
|
339
|
+
|
|
340
|
+
```typescript
|
|
341
|
+
import { rateLimit } from "convex-helpers/server/rateLimit";
|
|
342
|
+
|
|
343
|
+
export const sendTransactional = action({
|
|
344
|
+
handler: async (ctx, args) => {
|
|
345
|
+
// Generic rate limiting (per user)
|
|
346
|
+
await rateLimit(ctx, { maxOps: 10, period: 60000 }); // 10 per minute
|
|
347
|
+
|
|
348
|
+
// Email-specific rate limiting (per recipient)
|
|
349
|
+
const emailCheck = await ctx.runQuery(
|
|
350
|
+
components.loopsComponent.lib.checkRecipientRateLimit,
|
|
351
|
+
{
|
|
352
|
+
email: args.email,
|
|
353
|
+
timeWindowMs: 3600000,
|
|
354
|
+
maxEmails: 10,
|
|
355
|
+
}
|
|
356
|
+
);
|
|
357
|
+
|
|
358
|
+
if (!emailCheck.allowed) {
|
|
359
|
+
throw new Error("Email rate limit exceeded");
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
return await loops.sendTransactional(ctx, args);
|
|
363
|
+
},
|
|
364
|
+
});
|
|
365
|
+
```
|
|
366
|
+
|
|
367
|
+
## Best Practices
|
|
368
|
+
|
|
369
|
+
1. **Always check rate limits before sending** - Prevents unnecessary API calls
|
|
370
|
+
2. **Use multiple layers** - Combine recipient, actor, and global limits
|
|
371
|
+
3. **Provide clear error messages** - Tell users when they can retry
|
|
372
|
+
4. **Monitor rate limit hits** - Log when limits are exceeded for analysis
|
|
373
|
+
5. **Adjust limits based on usage** - Start conservative and increase as needed
|
|
374
|
+
6. **Consider different limits** - Transactional vs marketing emails may have different limits
|
|
375
|
+
7. **Use retryAfter** - Provide users with retry timing information
|
|
376
|
+
|
|
377
|
+
## Integration with Monitoring
|
|
378
|
+
|
|
379
|
+
Rate limiting works seamlessly with the monitoring system:
|
|
380
|
+
|
|
381
|
+
```typescript
|
|
382
|
+
export const sendTransactional = action({
|
|
383
|
+
handler: async (ctx, args) => {
|
|
384
|
+
// Check rate limit
|
|
385
|
+
const check = await ctx.runQuery(
|
|
386
|
+
components.loopsComponent.lib.checkRecipientRateLimit,
|
|
387
|
+
{ email: args.email, timeWindowMs: 3600000, maxEmails: 10 }
|
|
388
|
+
);
|
|
389
|
+
|
|
390
|
+
if (!check.allowed) {
|
|
391
|
+
// Log rate limit hit for monitoring
|
|
392
|
+
console.warn(`Rate limit exceeded for ${args.email}`, check);
|
|
393
|
+
|
|
394
|
+
// Check if it's a spam pattern
|
|
395
|
+
const spamCheck = await ctx.runQuery(
|
|
396
|
+
components.loopsComponent.lib.detectRecipientSpam,
|
|
397
|
+
{ maxEmailsPerRecipient: 10 }
|
|
398
|
+
);
|
|
399
|
+
|
|
400
|
+
if (spamCheck.some((s) => s.email === args.email)) {
|
|
401
|
+
// Alert security team
|
|
402
|
+
console.error(`Potential spam detected for ${args.email}`);
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
throw new Error("Rate limit exceeded");
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
return await loops.sendTransactional(ctx, args);
|
|
409
|
+
},
|
|
410
|
+
});
|
|
411
|
+
```
|
|
412
|
+
|
package/prds/SECURITY.md
ADDED
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
# Security Considerations for Loops Component
|
|
2
|
+
|
|
3
|
+
## Current Security Risks
|
|
4
|
+
|
|
5
|
+
### 1. ⚠️ **No Authentication/Authorization in Example**
|
|
6
|
+
**Risk Level: HIGH**
|
|
7
|
+
|
|
8
|
+
The example (`example/convex/example.ts`) exports all functions directly without authentication checks. This means:
|
|
9
|
+
- Any authenticated user (or anyone with access) can send emails
|
|
10
|
+
- Anyone can delete contacts
|
|
11
|
+
- Anyone can unsubscribe users
|
|
12
|
+
- No permission checks
|
|
13
|
+
|
|
14
|
+
**Mitigation Required:**
|
|
15
|
+
- Apps MUST wrap component functions with authentication
|
|
16
|
+
- Add authorization checks before exposing functions to clients
|
|
17
|
+
- Consider role-based access control
|
|
18
|
+
|
|
19
|
+
### 2. ⚠️ **API Key Exposure Risk**
|
|
20
|
+
**Risk Level: MEDIUM**
|
|
21
|
+
|
|
22
|
+
The API key is passed through function arguments to component actions:
|
|
23
|
+
- API keys appear in function arguments (could be logged)
|
|
24
|
+
- Error messages might leak API key information
|
|
25
|
+
- API key stored in memory in multiple places
|
|
26
|
+
|
|
27
|
+
**Current Protection:**
|
|
28
|
+
- API key is `private readonly` in client class
|
|
29
|
+
- Stored in environment variables (not in code)
|
|
30
|
+
- Only accessible from server-side code
|
|
31
|
+
|
|
32
|
+
**Recommendations:**
|
|
33
|
+
- Avoid logging function arguments
|
|
34
|
+
- Sanitize error messages
|
|
35
|
+
- Consider using Convex secrets management
|
|
36
|
+
|
|
37
|
+
### 3. ⚠️ **Error Message Information Leakage**
|
|
38
|
+
**Risk Level: MEDIUM**
|
|
39
|
+
|
|
40
|
+
Error messages could expose sensitive information:
|
|
41
|
+
```typescript
|
|
42
|
+
throw new Error(`Loops API error: ${response.status} ${error}`);
|
|
43
|
+
```
|
|
44
|
+
- Could leak API details
|
|
45
|
+
- Could expose internal structure
|
|
46
|
+
|
|
47
|
+
**Recommendation:**
|
|
48
|
+
- Sanitize error messages in production
|
|
49
|
+
- Log detailed errors server-side only
|
|
50
|
+
- Return generic error messages to clients
|
|
51
|
+
|
|
52
|
+
### 4. ⚠️ **No Rate Limiting**
|
|
53
|
+
**Risk Level: MEDIUM**
|
|
54
|
+
|
|
55
|
+
Functions can be called unlimited times:
|
|
56
|
+
- Could be abused to send spam
|
|
57
|
+
- Could exhaust API quotas
|
|
58
|
+
- Could cause DoS
|
|
59
|
+
|
|
60
|
+
**Recommendation:**
|
|
61
|
+
- Implement rate limiting in app wrapper functions
|
|
62
|
+
- Use Convex rate limiting features
|
|
63
|
+
- Consider per-user/per-IP limits
|
|
64
|
+
|
|
65
|
+
### 5. ⚠️ **countContacts Query Exposed**
|
|
66
|
+
**Risk Level: LOW-MEDIUM**
|
|
67
|
+
|
|
68
|
+
The `countContacts` query can reveal user counts without authentication:
|
|
69
|
+
- Anyone with app access can see audience sizes
|
|
70
|
+
- Could be used for reconnaissance
|
|
71
|
+
|
|
72
|
+
**Recommendation:**
|
|
73
|
+
- Wrap in app function with auth
|
|
74
|
+
- Consider if counts should be public information
|
|
75
|
+
|
|
76
|
+
### 6. ✅ **Input Validation**
|
|
77
|
+
**Status: GOOD**
|
|
78
|
+
|
|
79
|
+
- Zod validation on all inputs
|
|
80
|
+
- Email validation using `.email()` validator
|
|
81
|
+
- Type safety enforced
|
|
82
|
+
|
|
83
|
+
**Remaining Concerns:**
|
|
84
|
+
- Ensure no email injection attacks
|
|
85
|
+
- Validate event names and properties
|
|
86
|
+
|
|
87
|
+
### 7. ✅ **Component Isolation**
|
|
88
|
+
**Status: GOOD**
|
|
89
|
+
|
|
90
|
+
- Component functions are internal (not directly callable from clients)
|
|
91
|
+
- Apps must explicitly wrap functions
|
|
92
|
+
- Follows Convex security model
|
|
93
|
+
|
|
94
|
+
## Security Best Practices
|
|
95
|
+
|
|
96
|
+
### ✅ What to Do:
|
|
97
|
+
|
|
98
|
+
1. **Always Add Authentication in App Wrapper:**
|
|
99
|
+
```typescript
|
|
100
|
+
// ✅ GOOD - Add auth check
|
|
101
|
+
export const addContact = action({
|
|
102
|
+
args: { email: v.string(), ... },
|
|
103
|
+
handler: async (ctx, args) => {
|
|
104
|
+
const identity = await ctx.auth.getUserIdentity();
|
|
105
|
+
if (!identity) throw new Error("Unauthorized");
|
|
106
|
+
|
|
107
|
+
// Check permissions
|
|
108
|
+
if (!hasPermission(identity, "manageContacts")) {
|
|
109
|
+
throw new Error("Forbidden");
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return await loops.addContact(ctx, { email: args.email, ... });
|
|
113
|
+
},
|
|
114
|
+
});
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
2. **Use Environment Variables for API Key:**
|
|
118
|
+
```typescript
|
|
119
|
+
// ✅ GOOD - API key from environment
|
|
120
|
+
// Set LOOPS_API_KEY in Convex environment variables first:
|
|
121
|
+
// npx convex env set LOOPS_API_KEY "your-api-key"
|
|
122
|
+
const loops = new Loops(components.loopsComponent);
|
|
123
|
+
// Uses process.env.LOOPS_API_KEY automatically
|
|
124
|
+
|
|
125
|
+
// ❌ BAD - Never pass API key directly in production
|
|
126
|
+
// const loops = new Loops(components.loopsComponent, { apiKey: "..." });
|
|
127
|
+
```
|
|
128
|
+
**📖 See [ENV_SETUP.md](./ENV_SETUP.md) for detailed setup instructions and security best practices.**
|
|
129
|
+
|
|
130
|
+
3. **Sanitize Error Messages:**
|
|
131
|
+
```typescript
|
|
132
|
+
// ✅ GOOD - Generic error to client
|
|
133
|
+
try {
|
|
134
|
+
await loops.addContact(ctx, contact);
|
|
135
|
+
} catch (error) {
|
|
136
|
+
// Log detailed error server-side
|
|
137
|
+
console.error("Failed to add contact:", error);
|
|
138
|
+
// Return generic error to client
|
|
139
|
+
throw new Error("Failed to add contact. Please try again.");
|
|
140
|
+
}
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
4. **Implement Rate Limiting:**
|
|
144
|
+
```typescript
|
|
145
|
+
import { rateLimit } from "convex-helpers/server/rateLimit";
|
|
146
|
+
|
|
147
|
+
export const sendTransactional = action({
|
|
148
|
+
args: { ... },
|
|
149
|
+
handler: async (ctx, args) => {
|
|
150
|
+
await rateLimit(ctx, { maxOps: 10, period: 60000 }); // 10 per minute
|
|
151
|
+
return await loops.sendTransactional(ctx, args);
|
|
152
|
+
},
|
|
153
|
+
});
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
5. **Validate User Permissions:**
|
|
157
|
+
```typescript
|
|
158
|
+
export const deleteContact = action({
|
|
159
|
+
args: { email: v.string() },
|
|
160
|
+
handler: async (ctx, args) => {
|
|
161
|
+
const identity = await ctx.auth.getUserIdentity();
|
|
162
|
+
if (!identity) throw new Error("Unauthorized");
|
|
163
|
+
|
|
164
|
+
// Only admins can delete contacts
|
|
165
|
+
if (!isAdmin(identity)) {
|
|
166
|
+
throw new Error("Only admins can delete contacts");
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
return await loops.deleteContact(ctx, args.email);
|
|
170
|
+
},
|
|
171
|
+
});
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
### ❌ What NOT to Do:
|
|
175
|
+
|
|
176
|
+
1. **Don't Export Functions Directly Without Auth:**
|
|
177
|
+
```typescript
|
|
178
|
+
// ❌ BAD - No authentication
|
|
179
|
+
export const { addContact, deleteContact } = loops.api();
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
2. **Don't Pass API Key from Client:**
|
|
183
|
+
```typescript
|
|
184
|
+
// ❌ BAD - Never do this
|
|
185
|
+
export const addContact = action({
|
|
186
|
+
args: { apiKey: v.string(), ... }, // Never accept API key from client
|
|
187
|
+
handler: async (ctx, args) => { ... }
|
|
188
|
+
});
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
3. **Don't Expose Internal Errors:**
|
|
192
|
+
```typescript
|
|
193
|
+
// ❌ BAD - Exposes internal details
|
|
194
|
+
catch (error) {
|
|
195
|
+
throw new Error(`API call failed: ${error.message} with key ${apiKey}`);
|
|
196
|
+
}
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
4. **Don't Skip Rate Limiting for Email Sending:**
|
|
200
|
+
```typescript
|
|
201
|
+
// ❌ BAD - No rate limiting
|
|
202
|
+
export const sendCampaign = action({
|
|
203
|
+
handler: async (ctx, args) => {
|
|
204
|
+
// Anyone could spam unlimited emails!
|
|
205
|
+
return await loops.sendCampaign(ctx, args);
|
|
206
|
+
},
|
|
207
|
+
});
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
## Recommended Security Architecture
|
|
211
|
+
|
|
212
|
+
```
|
|
213
|
+
Client (React)
|
|
214
|
+
↓
|
|
215
|
+
App Function (with auth, rate limiting, validation)
|
|
216
|
+
↓
|
|
217
|
+
Loops Client (with API key from env)
|
|
218
|
+
↓
|
|
219
|
+
Component Function (validates input)
|
|
220
|
+
↓
|
|
221
|
+
Loops.so API
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
## Security Checklist for Apps Using This Component
|
|
225
|
+
|
|
226
|
+
- [ ] Wrap all functions with authentication checks
|
|
227
|
+
- [ ] Add authorization checks (role-based if needed)
|
|
228
|
+
- [x] Implement rate limiting for email-sending functions - ✅ **IMPLEMENTED** See RATE_LIMITING.md
|
|
229
|
+
- [ ] Sanitize error messages before returning to clients
|
|
230
|
+
- [x] Store API key in Convex environment variables only - ✅ **IMPLEMENTED** See ENV_SETUP.md
|
|
231
|
+
- [ ] Audit who can access contact management functions
|
|
232
|
+
- [ ] Consider logging all email operations for audit trail
|
|
233
|
+
- [ ] Validate user permissions for sensitive operations (delete, unsubscribe)
|
|
234
|
+
- [x] Monitor for unusual activity (spam patterns) - ✅ **IMPLEMENTED** See MONITORING.md
|
|
235
|
+
|
|
236
|
+
## Component-Level Security
|
|
237
|
+
|
|
238
|
+
The component itself is secure because:
|
|
239
|
+
- ✅ Functions are internal (not directly callable from clients)
|
|
240
|
+
- ✅ Input validation with Zod
|
|
241
|
+
- ✅ Type safety enforced
|
|
242
|
+
- ✅ Component isolation prevents unauthorized access
|
|
243
|
+
- ✅ API key never exposed to client code
|
|
244
|
+
|
|
245
|
+
However, **apps using this component MUST implement their own security layer** when exposing functions to clients.
|
|
246
|
+
|
package/renovate.json
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
|
|
3
|
+
"extends": ["config:best-practices"],
|
|
4
|
+
"schedule": ["* 0-4 * * 1"],
|
|
5
|
+
"timezone": "America/Los_Angeles",
|
|
6
|
+
"prConcurrentLimit": 1,
|
|
7
|
+
"packageRules": [
|
|
8
|
+
{
|
|
9
|
+
"groupName": "Convex packages",
|
|
10
|
+
"matchPackagePatterns": ["^convex"],
|
|
11
|
+
"automerge": false,
|
|
12
|
+
"description": "Keep Convex packages together but require manual review"
|
|
13
|
+
},
|
|
14
|
+
{
|
|
15
|
+
"groupName": "Routine updates",
|
|
16
|
+
"matchUpdateTypes": ["minor", "patch", "pin", "digest"],
|
|
17
|
+
"excludePackagePatterns": ["^convex"],
|
|
18
|
+
"automerge": true
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
"groupName": "Major updates",
|
|
22
|
+
"matchUpdateTypes": ["major"],
|
|
23
|
+
"excludePackagePatterns": ["^convex"],
|
|
24
|
+
"automerge": false
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
"matchDepTypes": ["devDependencies"],
|
|
28
|
+
"excludePackagePatterns": ["^convex"],
|
|
29
|
+
"automerge": true
|
|
30
|
+
}
|
|
31
|
+
]
|
|
32
|
+
}
|