convex-feedback 0.1.1 → 0.1.2

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.
@@ -4,9 +4,22 @@ import {
4
4
  paginationResultValidator,
5
5
  queryGeneric,
6
6
  type Auth,
7
+ type DefaultFunctionArgs,
8
+ type FunctionReference,
9
+ type GenericDataModel,
10
+ type GenericMutationCtx,
7
11
  type PaginationOptions,
12
+ type RegisteredMutation,
13
+ type RegisteredQuery,
8
14
  } from "convex/server";
9
- import { ConvexError, v } from "convex/values";
15
+ import {
16
+ ConvexError,
17
+ v,
18
+ type Infer,
19
+ type Validator,
20
+ type Value,
21
+ type VUnion,
22
+ } from "convex/values";
10
23
 
11
24
  import type { ComponentApi } from "../component/_generated/component.js";
12
25
  import {
@@ -55,10 +68,167 @@ export interface FeedbackAuthContext {
55
68
  }
56
69
 
57
70
  /**
58
- * Configuration used when exposing feedback functions from the host Convex
59
- * application.
71
+ * Minimal host mutation context passed to a feedback rate limiter.
72
+ *
73
+ * This provides the nested function calls needed by Convex rate-limiter
74
+ * helpers without coupling limiters to the host application's data model.
75
+ */
76
+ export type FeedbackRateLimitContext = Pick<
77
+ GenericMutationCtx<GenericDataModel>,
78
+ "runQuery" | "runMutation"
79
+ >;
80
+
81
+ /**
82
+ * A host-defined rate limiter keyed by the resolved feedback actor ID.
83
+ *
84
+ * In the default `"throw"` mode, return `undefined` when allowed and throw when
85
+ * rejected. In `"return"` mode, return `undefined` when allowed or a value
86
+ * matching the configured `returns` validator when rejected. `null` is never
87
+ * a valid return-mode result.
88
+ *
89
+ * @typeParam Result Rejection value used only by non-throwing limiters.
90
+ */
91
+ export type FeedbackRateLimiter<Result = void> = (
92
+ ctx: FeedbackRateLimitContext,
93
+ key: string,
94
+ ) => Promise<Result | undefined>;
95
+
96
+ /**
97
+ * Rate limiter hooks shared by related feedback mutations.
98
+ *
99
+ * @typeParam Result Rejection value used only in `"return"` mode.
100
+ */
101
+ export interface FeedbackRateLimiters<Result = void> {
102
+ /** Applied when creating an entry. */
103
+ createEntry?: FeedbackRateLimiter<Result>;
104
+
105
+ /** Applied when creating a comment or reply. */
106
+ createComment?: FeedbackRateLimiter<Result>;
107
+
108
+ /** Applied to entry edits, status changes, comment edits, and deletion. */
109
+ editContent?: FeedbackRateLimiter<Result>;
110
+
111
+ /** Applied to entry upvotes and comment likes. */
112
+ reactions?: FeedbackRateLimiter<Result>;
113
+ }
114
+
115
+ /** Configuration for the default behavior, where limiters reject by throwing. */
116
+ export interface ThrowingFeedbackRateLimitConfig {
117
+ /**
118
+ * Configures limiter functions to reject requests by throwing.
119
+ *
120
+ * This is the default when `behavior` is omitted.
121
+ */
122
+ behavior?: "throw";
123
+
124
+ /**
125
+ * Whether all configured limiter groups apply to moderators.
126
+ *
127
+ * @default false
128
+ */
129
+ limitModerators?: boolean;
130
+
131
+ /** Not accepted in throwing mode; select `"return"` to provide a validator. */
132
+ returns?: never;
133
+ }
134
+
135
+ /**
136
+ * A required, non-optional Convex validator for a non-null rate-limit
137
+ * rejection.
60
138
  */
61
- export interface ExposeFeedbackOptions {
139
+ export type FeedbackRateLimitReturnValidator = Validator<
140
+ Exclude<Value, null>,
141
+ "required",
142
+ string
143
+ >;
144
+
145
+ type FeedbackFunctionReturnValidator = Validator<Value, "required", string>;
146
+
147
+ /**
148
+ * Return behavior: a defined limiter result short-circuits the mutation.
149
+ *
150
+ * @typeParam ReturnsValidator Validator for the value returned on rejection.
151
+ */
152
+ export interface ReturningFeedbackRateLimitConfig<
153
+ ReturnsValidator extends FeedbackRateLimitReturnValidator,
154
+ > {
155
+ /** Configures limiter functions to return rejected requests to the client. */
156
+ behavior: "return";
157
+
158
+ /**
159
+ * Convex validator for the non-null rejection value returned by a limiter.
160
+ *
161
+ * This field is required when `behavior` is `"return"` and may not accept
162
+ * `null`. Its inferred value type is added to the result type of every
163
+ * exposed feedback mutation.
164
+ */
165
+ returns: ReturnsValidator;
166
+
167
+ /**
168
+ * Whether all configured limiter groups apply to moderators.
169
+ *
170
+ * @default false
171
+ */
172
+ limitModerators?: boolean;
173
+ }
174
+
175
+ /**
176
+ * Configuration for feedback rate-limit rejection behavior.
177
+ *
178
+ * Without a validator, this resolves to throwing behavior. Supplying a
179
+ * validator requires `behavior: "return"` and the same validator in `returns`.
180
+ *
181
+ * @typeParam ReturnsValidator Validator for a non-throwing rejection value.
182
+ */
183
+ export type FeedbackRateLimitConfig<
184
+ ReturnsValidator extends FeedbackRateLimitReturnValidator | undefined =
185
+ undefined,
186
+ > = ReturnsValidator extends FeedbackRateLimitReturnValidator
187
+ ? ReturningFeedbackRateLimitConfig<ReturnsValidator>
188
+ : ThrowingFeedbackRateLimitConfig;
189
+
190
+ type RateLimitResult<
191
+ ReturnsValidator extends FeedbackRateLimitReturnValidator | undefined,
192
+ > = ReturnsValidator extends FeedbackRateLimitReturnValidator
193
+ ? Infer<ReturnsValidator>
194
+ : never;
195
+
196
+ type RateLimiterResult<
197
+ ReturnsValidator extends FeedbackRateLimitReturnValidator | undefined,
198
+ > = ReturnsValidator extends FeedbackRateLimitReturnValidator
199
+ ? Infer<ReturnsValidator>
200
+ : void;
201
+
202
+ type RegisteredFeedbackFunction<Function> =
203
+ Function extends FunctionReference<
204
+ "mutation",
205
+ "public",
206
+ infer Args,
207
+ infer Result
208
+ >
209
+ ? Args extends DefaultFunctionArgs
210
+ ? RegisteredMutation<"public", Args, Promise<Result>>
211
+ : never
212
+ : Function extends FunctionReference<
213
+ "query",
214
+ "public",
215
+ infer Args,
216
+ infer Result
217
+ >
218
+ ? Args extends DefaultFunctionArgs
219
+ ? RegisteredQuery<"public", Args, Promise<Result>>
220
+ : never
221
+ : never;
222
+
223
+ type ExposedFeedbackApi<RateLimitResult> = {
224
+ [
225
+ FunctionName in keyof FeedbackPublicApi<string | undefined, RateLimitResult>
226
+ ]: RegisteredFeedbackFunction<
227
+ FeedbackPublicApi<string | undefined, RateLimitResult>[FunctionName]
228
+ >;
229
+ };
230
+
231
+ interface ExposeFeedbackOptionsBase {
62
232
  /**
63
233
  * Resolves the current request into a stable feedback actor.
64
234
  *
@@ -76,6 +246,64 @@ export interface ExposeFeedbackOptions {
76
246
  config?: FeedbackConfigOverrides;
77
247
  }
78
248
 
249
+ /** Options for the default mode, where limiter functions reject by throwing. */
250
+ export type ThrowingExposeFeedbackOptions = Omit<
251
+ ExposeFeedbackOptionsBase,
252
+ "config"
253
+ > & {
254
+ /** Optional throwing rate limiters for the component's mutation groups. */
255
+ rateLimiters?: FeedbackRateLimiters;
256
+
257
+ /** Optional component behavior and throwing rate-limit overrides. */
258
+ config?: FeedbackConfigOverrides & {
259
+ /**
260
+ * Controls throwing behavior and the moderator exemption.
261
+ *
262
+ * Omit this object to use `behavior: "throw"` and
263
+ * `limitModerators: false`.
264
+ */
265
+ rateLimiting?: FeedbackRateLimitConfig;
266
+ };
267
+ };
268
+
269
+ /** Options for returning a validated rejection value instead of throwing. */
270
+ export type ReturningExposeFeedbackOptions<
271
+ ReturnsValidator extends FeedbackRateLimitReturnValidator,
272
+ > = Omit<ExposeFeedbackOptionsBase, "config"> & {
273
+ /**
274
+ * Optional returning rate limiters for the component's mutation groups.
275
+ *
276
+ * A limiter returns `undefined` to allow the request. Any other returned
277
+ * value must match `config.rateLimiting.returns` and prevents the component
278
+ * mutation from running.
279
+ */
280
+ rateLimiters?: FeedbackRateLimiters<NoInfer<Infer<ReturnsValidator>>>;
281
+
282
+ /** Component behavior plus the required non-throwing limiter configuration. */
283
+ config: FeedbackConfigOverrides & {
284
+ /**
285
+ * Configures non-throwing rejection behavior.
286
+ *
287
+ * Both `behavior: "return"` and a `returns` Convex validator are required.
288
+ */
289
+ rateLimiting: FeedbackRateLimitConfig<ReturnsValidator>;
290
+ };
291
+ };
292
+
293
+ /**
294
+ * Configuration used when exposing feedback functions from the host app.
295
+ *
296
+ * When `ReturnsValidator` is omitted, limiter functions use throwing behavior.
297
+ * Supplying a validator selects return behavior and adds its inferred value to
298
+ * the exposed mutation result types.
299
+ */
300
+ export type ExposeFeedbackOptions<
301
+ ReturnsValidator extends FeedbackRateLimitReturnValidator | undefined =
302
+ undefined,
303
+ > = ReturnsValidator extends FeedbackRateLimitReturnValidator
304
+ ? ReturningExposeFeedbackOptions<ReturnsValidator>
305
+ : ThrowingExposeFeedbackOptions;
306
+
79
307
  function requireActor(actor: FeedbackActor | null): FeedbackActor {
80
308
  if (actor === null) {
81
309
  throw new ConvexError("Authentication is required.");
@@ -83,6 +311,62 @@ function requireActor(actor: FeedbackActor | null): FeedbackActor {
83
311
  return actor;
84
312
  }
85
313
 
314
+ function requireModerator(actor: FeedbackActor): void {
315
+ if (!actor.isModerator) {
316
+ throw new ConvexError("Moderator permissions are required.");
317
+ }
318
+ }
319
+
320
+ type RateLimitedReturnsValidator<
321
+ Base extends FeedbackFunctionReturnValidator,
322
+ Limit extends FeedbackRateLimitReturnValidator | undefined,
323
+ > = Limit extends FeedbackRateLimitReturnValidator
324
+ ? VUnion<Infer<Base> | Infer<Limit>, [Base, Limit]>
325
+ : Base;
326
+
327
+ function rateLimitedReturns<
328
+ Base extends FeedbackFunctionReturnValidator,
329
+ Limit extends FeedbackRateLimitReturnValidator | undefined,
330
+ >(base: Base, limit: Limit): RateLimitedReturnsValidator<Base, Limit> {
331
+ return (
332
+ limit === undefined ? base : v.union(base, limit)
333
+ ) as RateLimitedReturnsValidator<Base, Limit>;
334
+ }
335
+
336
+ async function applyRateLimiter<
337
+ ReturnsValidator extends FeedbackRateLimitReturnValidator | undefined,
338
+ >(
339
+ ctx: FeedbackRateLimitContext,
340
+ actor: FeedbackActor,
341
+ limiter: FeedbackRateLimiter<RateLimiterResult<ReturnsValidator>> | undefined,
342
+ rateLimitConfig:
343
+ | ThrowingFeedbackRateLimitConfig
344
+ | ReturningFeedbackRateLimitConfig<FeedbackRateLimitReturnValidator>
345
+ | undefined,
346
+ ): Promise<RateLimitResult<ReturnsValidator> | undefined> {
347
+ if (
348
+ limiter === undefined ||
349
+ (actor.isModerator && rateLimitConfig?.limitModerators !== true)
350
+ ) {
351
+ return undefined;
352
+ }
353
+
354
+ const result = await limiter(ctx, actor.id);
355
+ if (rateLimitConfig?.behavior !== "return" || result === undefined) {
356
+ return undefined;
357
+ }
358
+ if (result === null) {
359
+ throw new ConvexError(
360
+ "A return-mode feedback rate limiter returned null. Return undefined to allow the request or a non-null value matching config.rateLimiting.returns to reject it.",
361
+ );
362
+ }
363
+ return result as RateLimitResult<ReturnsValidator>;
364
+ }
365
+
366
+ function asRateLimitContext(ctx: unknown): FeedbackRateLimitContext {
367
+ return ctx as FeedbackRateLimitContext;
368
+ }
369
+
86
370
  function clampPositive(
87
371
  value: number | undefined,
88
372
  fallback: number,
@@ -109,11 +393,31 @@ function actorIdFields(actor: FeedbackActor | null): {
109
393
  return actor === null ? {} : { viewerActorId: actor.id };
110
394
  }
111
395
 
112
- export function exposeFeedbackApi<Name extends string | undefined>(
396
+ function buildFeedbackApi<
397
+ Name extends string | undefined,
398
+ ReturnsValidator extends FeedbackRateLimitReturnValidator | undefined =
399
+ undefined,
400
+ >(
113
401
  component: ComponentApi<Name>,
114
- options: ExposeFeedbackOptions,
402
+ options: ExposeFeedbackOptions<ReturnsValidator>,
115
403
  ) {
116
404
  const config = createFeedbackConfig(options.config);
405
+ const rateLimitConfig = options.config?.rateLimiting;
406
+ const rateLimitReturnValidator =
407
+ rateLimitConfig?.behavior === "return"
408
+ ? rateLimitConfig.returns
409
+ : undefined;
410
+
411
+ const idReturns = rateLimitedReturns(v.string(), rateLimitReturnValidator);
412
+ const nullReturns = rateLimitedReturns(v.null(), rateLimitReturnValidator);
413
+ const entryUpvoteReturns = rateLimitedReturns(
414
+ v.object({ active: v.boolean(), upvoteCount: v.number() }),
415
+ rateLimitReturnValidator,
416
+ );
417
+ const commentLikeReturns = rateLimitedReturns(
418
+ v.object({ active: v.boolean(), likeCount: v.number() }),
419
+ rateLimitReturnValidator,
420
+ );
117
421
 
118
422
  return {
119
423
  listEntries: queryGeneric({
@@ -212,9 +516,16 @@ export function exposeFeedbackApi<Name extends string | undefined>(
212
516
  title: v.string(),
213
517
  body: v.string(),
214
518
  },
215
- returns: v.string(),
519
+ returns: idReturns,
216
520
  handler: async (ctx, args) => {
217
521
  const actor = requireActor(await options.actor(ctx));
522
+ const limited = await applyRateLimiter(
523
+ asRateLimitContext(ctx),
524
+ actor,
525
+ options.rateLimiters?.createEntry,
526
+ rateLimitConfig,
527
+ );
528
+ if (limited !== undefined) return limited;
218
529
  return await ctx.runMutation(component.entries.create, {
219
530
  actorId: actor.id,
220
531
  kind: args.kind,
@@ -234,9 +545,16 @@ export function exposeFeedbackApi<Name extends string | undefined>(
234
545
  title: v.string(),
235
546
  body: v.string(),
236
547
  },
237
- returns: v.null(),
548
+ returns: nullReturns,
238
549
  handler: async (ctx, args) => {
239
550
  const actor = requireActor(await options.actor(ctx));
551
+ const limited = await applyRateLimiter(
552
+ asRateLimitContext(ctx),
553
+ actor,
554
+ options.rateLimiters?.editContent,
555
+ rateLimitConfig,
556
+ );
557
+ if (limited !== undefined) return limited;
240
558
  return await ctx.runMutation(component.entries.update, {
241
559
  actor,
242
560
  entryId: args.entryId,
@@ -251,9 +569,17 @@ export function exposeFeedbackApi<Name extends string | undefined>(
251
569
 
252
570
  setEntryStatus: mutationGeneric({
253
571
  args: { entryId: v.string(), status: entryStatusValidator },
254
- returns: v.null(),
572
+ returns: nullReturns,
255
573
  handler: async (ctx, args) => {
256
574
  const actor = requireActor(await options.actor(ctx));
575
+ requireModerator(actor);
576
+ const limited = await applyRateLimiter(
577
+ asRateLimitContext(ctx),
578
+ actor,
579
+ options.rateLimiters?.editContent,
580
+ rateLimitConfig,
581
+ );
582
+ if (limited !== undefined) return limited;
257
583
  return await ctx.runMutation(component.entries.setStatus, {
258
584
  actor,
259
585
  entryId: args.entryId,
@@ -264,9 +590,16 @@ export function exposeFeedbackApi<Name extends string | undefined>(
264
590
 
265
591
  setEntryUpvote: mutationGeneric({
266
592
  args: { entryId: v.string(), desiredState: v.boolean() },
267
- returns: v.object({ active: v.boolean(), upvoteCount: v.number() }),
593
+ returns: entryUpvoteReturns,
268
594
  handler: async (ctx, args) => {
269
595
  const actor = requireActor(await options.actor(ctx));
596
+ const limited = await applyRateLimiter(
597
+ asRateLimitContext(ctx),
598
+ actor,
599
+ options.rateLimiters?.reactions,
600
+ rateLimitConfig,
601
+ );
602
+ if (limited !== undefined) return limited;
270
603
  return await ctx.runMutation(component.entries.setUpvote, {
271
604
  actorId: actor.id,
272
605
  entryId: args.entryId,
@@ -306,9 +639,16 @@ export function exposeFeedbackApi<Name extends string | undefined>(
306
639
  parentCommentId: v.optional(v.string()),
307
640
  body: v.string(),
308
641
  },
309
- returns: v.string(),
642
+ returns: idReturns,
310
643
  handler: async (ctx, args) => {
311
644
  const actor = requireActor(await options.actor(ctx));
645
+ const limited = await applyRateLimiter(
646
+ asRateLimitContext(ctx),
647
+ actor,
648
+ options.rateLimiters?.createComment,
649
+ rateLimitConfig,
650
+ );
651
+ if (limited !== undefined) return limited;
312
652
  return await ctx.runMutation(component.comments.create, {
313
653
  actorId: actor.id,
314
654
  entryId: args.entryId,
@@ -324,9 +664,16 @@ export function exposeFeedbackApi<Name extends string | undefined>(
324
664
 
325
665
  updateComment: mutationGeneric({
326
666
  args: { commentId: v.string(), body: v.string() },
327
- returns: v.null(),
667
+ returns: nullReturns,
328
668
  handler: async (ctx, args) => {
329
669
  const actor = requireActor(await options.actor(ctx));
670
+ const limited = await applyRateLimiter(
671
+ asRateLimitContext(ctx),
672
+ actor,
673
+ options.rateLimiters?.editContent,
674
+ rateLimitConfig,
675
+ );
676
+ if (limited !== undefined) return limited;
330
677
  return await ctx.runMutation(component.comments.update, {
331
678
  actor,
332
679
  commentId: args.commentId,
@@ -339,9 +686,16 @@ export function exposeFeedbackApi<Name extends string | undefined>(
339
686
 
340
687
  deleteComment: mutationGeneric({
341
688
  args: { commentId: v.string() },
342
- returns: v.null(),
689
+ returns: nullReturns,
343
690
  handler: async (ctx, args) => {
344
691
  const actor = requireActor(await options.actor(ctx));
692
+ const limited = await applyRateLimiter(
693
+ asRateLimitContext(ctx),
694
+ actor,
695
+ options.rateLimiters?.editContent,
696
+ rateLimitConfig,
697
+ );
698
+ if (limited !== undefined) return limited;
345
699
  return await ctx.runMutation(component.comments.remove, {
346
700
  actor,
347
701
  commentId: args.commentId,
@@ -352,9 +706,16 @@ export function exposeFeedbackApi<Name extends string | undefined>(
352
706
 
353
707
  setCommentLike: mutationGeneric({
354
708
  args: { commentId: v.string(), desiredState: v.boolean() },
355
- returns: v.object({ active: v.boolean(), likeCount: v.number() }),
709
+ returns: commentLikeReturns,
356
710
  handler: async (ctx, args) => {
357
711
  const actor = requireActor(await options.actor(ctx));
712
+ const limited = await applyRateLimiter(
713
+ asRateLimitContext(ctx),
714
+ actor,
715
+ options.rateLimiters?.reactions,
716
+ rateLimitConfig,
717
+ );
718
+ if (limited !== undefined) return limited;
358
719
  return await ctx.runMutation(component.comments.setLike, {
359
720
  actorId: actor.id,
360
721
  commentId: args.commentId,
@@ -364,3 +725,35 @@ export function exposeFeedbackApi<Name extends string | undefined>(
364
725
  }),
365
726
  } satisfies Record<keyof FeedbackPublicApi, unknown>;
366
727
  }
728
+
729
+ /**
730
+ * Exposes the feedback component through host queries and mutations.
731
+ *
732
+ * Rate limiters use throwing behavior unless
733
+ * `config.rateLimiting.behavior` is `"return"`. Return behavior requires a
734
+ * `returns` validator and adds that validator's inferred type to every
735
+ * mutation result.
736
+ */
737
+ export function exposeFeedbackApi<
738
+ Name extends string | undefined,
739
+ ReturnsValidator extends FeedbackRateLimitReturnValidator,
740
+ >(
741
+ component: ComponentApi<Name>,
742
+ options: ReturningExposeFeedbackOptions<ReturnsValidator>,
743
+ ): ExposedFeedbackApi<Infer<ReturnsValidator>>;
744
+
745
+ /** Exposes feedback using optional throwing rate limiters. */
746
+ export function exposeFeedbackApi<Name extends string | undefined>(
747
+ component: ComponentApi<Name>,
748
+ options: ThrowingExposeFeedbackOptions,
749
+ ): ExposedFeedbackApi<never>;
750
+
751
+ export function exposeFeedbackApi<
752
+ Name extends string | undefined,
753
+ ReturnsValidator extends FeedbackRateLimitReturnValidator | undefined,
754
+ >(
755
+ component: ComponentApi<Name>,
756
+ options: ExposeFeedbackOptions<ReturnsValidator>,
757
+ ): ExposedFeedbackApi<RateLimitResult<ReturnsValidator>> {
758
+ return buildFeedbackApi(component, options);
759
+ }