convex-feedback 0.1.0 → 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.
Files changed (36) hide show
  1. package/README.md +351 -92
  2. package/dist/.tsbuildinfo +1 -1
  3. package/dist/client/api.d.ts +267 -68
  4. package/dist/client/api.d.ts.map +1 -1
  5. package/dist/client/config.d.ts +168 -29
  6. package/dist/client/config.d.ts.map +1 -1
  7. package/dist/client/config.js.map +1 -1
  8. package/dist/client/index.d.ts +167 -172
  9. package/dist/client/index.d.ts.map +1 -1
  10. package/dist/client/index.js +75 -14
  11. package/dist/client/index.js.map +1 -1
  12. package/dist/component/_generated/component.d.ts +2 -2
  13. package/dist/component/_generated/component.d.ts.map +1 -1
  14. package/dist/component/comments.d.ts +12 -12
  15. package/dist/component/comments.d.ts.map +1 -1
  16. package/dist/component/comments.js +43 -60
  17. package/dist/component/comments.js.map +1 -1
  18. package/dist/component/entries.d.ts +30 -30
  19. package/dist/component/entries.d.ts.map +1 -1
  20. package/dist/component/entries.js +216 -97
  21. package/dist/component/entries.js.map +1 -1
  22. package/dist/component/model.d.ts +151 -0
  23. package/dist/component/model.d.ts.map +1 -1
  24. package/dist/react/index.d.ts +178 -56
  25. package/dist/react/index.d.ts.map +1 -1
  26. package/dist/react/index.js +70 -4
  27. package/dist/react/index.js.map +1 -1
  28. package/package.json +1 -1
  29. package/src/client/api.ts +326 -36
  30. package/src/client/config.ts +191 -29
  31. package/src/client/index.ts +437 -16
  32. package/src/component/_generated/component.ts +2 -2
  33. package/src/component/comments.ts +43 -61
  34. package/src/component/entries.ts +300 -123
  35. package/src/component/model.ts +158 -0
  36. package/src/react/index.ts +217 -9
@@ -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 {
@@ -43,15 +56,254 @@ export {
43
56
  type FeedbackConfigOverrides,
44
57
  } from "./config.js";
45
58
 
59
+ /**
60
+ * Minimal host context exposed to the actor resolver.
61
+ *
62
+ * The feedback component itself does not read host authentication. Use
63
+ * `auth` to resolve the current host identity into a `FeedbackActor`.
64
+ */
46
65
  export interface FeedbackAuthContext {
66
+ /** Host Convex authentication interface for the current request. */
47
67
  auth: Auth;
48
68
  }
49
69
 
50
- export interface ExposeFeedbackOptions {
70
+ /**
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.
138
+ */
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 {
232
+ /**
233
+ * Resolves the current request into a stable feedback actor.
234
+ *
235
+ * Return `null` for an unauthenticated request. Read-only queries may still
236
+ * execute, but mutations requiring an actor will reject unauthenticated
237
+ * callers.
238
+ */
51
239
  actor: (ctx: FeedbackAuthContext) => Promise<FeedbackActor | null>;
240
+
241
+ /**
242
+ * Optional component behavior overrides.
243
+ *
244
+ * Unspecified values use `defaultFeedbackConfig`.
245
+ */
52
246
  config?: FeedbackConfigOverrides;
53
247
  }
54
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
+
55
307
  function requireActor(actor: FeedbackActor | null): FeedbackActor {
56
308
  if (actor === null) {
57
309
  throw new ConvexError("Authentication is required.");
@@ -59,6 +311,62 @@ function requireActor(actor: FeedbackActor | null): FeedbackActor {
59
311
  return actor;
60
312
  }
61
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
+
62
370
  function clampPositive(
63
371
  value: number | undefined,
64
372
  fallback: number,
@@ -85,29 +393,50 @@ function actorIdFields(actor: FeedbackActor | null): {
85
393
  return actor === null ? {} : { viewerActorId: actor.id };
86
394
  }
87
395
 
88
- export function exposeFeedbackApi<Name extends string | undefined>(
396
+ function buildFeedbackApi<
397
+ Name extends string | undefined,
398
+ ReturnsValidator extends FeedbackRateLimitReturnValidator | undefined =
399
+ undefined,
400
+ >(
89
401
  component: ComponentApi<Name>,
90
- options: ExposeFeedbackOptions,
402
+ options: ExposeFeedbackOptions<ReturnsValidator>,
91
403
  ) {
92
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
+ );
93
421
 
94
422
  return {
95
423
  listEntries: queryGeneric({
96
424
  args: {
97
425
  paginationOpts: paginationOptsValidator,
98
- kind: v.optional(entryKindValidator),
426
+ kinds: v.optional(v.array(entryKindValidator)),
99
427
  status: v.optional(entryStatusValidator),
100
428
  sort: v.optional(entrySortValidator),
101
429
  },
102
430
  returns: paginationResultValidator(publicEntryValidator),
103
431
  handler: async (ctx, args) => {
104
432
  const actor = await options.actor(ctx);
433
+
105
434
  return await ctx.runQuery(component.entries.list, {
106
435
  paginationOpts: clampPagination(
107
436
  args.paginationOpts,
108
437
  config.entries.maxPageSize,
109
438
  ),
110
- ...(args.kind === undefined ? {} : { kind: args.kind }),
439
+ ...(args.kinds === undefined ? {} : { kinds: args.kinds }),
111
440
  ...(args.status === undefined ? {} : { status: args.status }),
112
441
  sort: args.sort ?? config.entries.defaultSort,
113
442
  ...actorIdFields(actor),
@@ -130,16 +459,17 @@ export function exposeFeedbackApi<Name extends string | undefined>(
130
459
  searchEntries: queryGeneric({
131
460
  args: {
132
461
  searchQuery: v.string(),
133
- kind: v.optional(entryKindValidator),
462
+ kinds: v.optional(v.array(entryKindValidator)),
134
463
  status: v.optional(entryStatusValidator),
135
464
  limit: v.optional(v.number()),
136
465
  },
137
466
  returns: v.array(publicEntryValidator),
138
467
  handler: async (ctx, args) => {
139
468
  const actor = await options.actor(ctx);
469
+
140
470
  return await ctx.runQuery(component.entries.search, {
141
471
  searchQuery: args.searchQuery,
142
- ...(args.kind === undefined ? {} : { kind: args.kind }),
472
+ ...(args.kinds === undefined ? {} : { kinds: args.kinds }),
143
473
  ...(args.status === undefined ? {} : { status: args.status }),
144
474
  limit: clampPositive(
145
475
  args.limit,
@@ -163,7 +493,9 @@ export function exposeFeedbackApi<Name extends string | undefined>(
163
493
  if (!config.search.duplicateSuggestions) {
164
494
  return { exact: [], similar: [] };
165
495
  }
496
+
166
497
  const actor = await options.actor(ctx);
498
+
167
499
  return await ctx.runQuery(component.entries.similar, {
168
500
  title: args.title,
169
501
  body: args.body,
@@ -184,9 +516,16 @@ export function exposeFeedbackApi<Name extends string | undefined>(
184
516
  title: v.string(),
185
517
  body: v.string(),
186
518
  },
187
- returns: v.string(),
519
+ returns: idReturns,
188
520
  handler: async (ctx, args) => {
189
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;
190
529
  return await ctx.runMutation(component.entries.create, {
191
530
  actorId: actor.id,
192
531
  kind: args.kind,
@@ -206,9 +545,16 @@ export function exposeFeedbackApi<Name extends string | undefined>(
206
545
  title: v.string(),
207
546
  body: v.string(),
208
547
  },
209
- returns: v.null(),
548
+ returns: nullReturns,
210
549
  handler: async (ctx, args) => {
211
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;
212
558
  return await ctx.runMutation(component.entries.update, {
213
559
  actor,
214
560
  entryId: args.entryId,
@@ -223,9 +569,17 @@ export function exposeFeedbackApi<Name extends string | undefined>(
223
569
 
224
570
  setEntryStatus: mutationGeneric({
225
571
  args: { entryId: v.string(), status: entryStatusValidator },
226
- returns: v.null(),
572
+ returns: nullReturns,
227
573
  handler: async (ctx, args) => {
228
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;
229
583
  return await ctx.runMutation(component.entries.setStatus, {
230
584
  actor,
231
585
  entryId: args.entryId,
@@ -236,9 +590,16 @@ export function exposeFeedbackApi<Name extends string | undefined>(
236
590
 
237
591
  setEntryUpvote: mutationGeneric({
238
592
  args: { entryId: v.string(), desiredState: v.boolean() },
239
- returns: v.object({ active: v.boolean(), upvoteCount: v.number() }),
593
+ returns: entryUpvoteReturns,
240
594
  handler: async (ctx, args) => {
241
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;
242
603
  return await ctx.runMutation(component.entries.setUpvote, {
243
604
  actorId: actor.id,
244
605
  entryId: args.entryId,
@@ -278,9 +639,16 @@ export function exposeFeedbackApi<Name extends string | undefined>(
278
639
  parentCommentId: v.optional(v.string()),
279
640
  body: v.string(),
280
641
  },
281
- returns: v.string(),
642
+ returns: idReturns,
282
643
  handler: async (ctx, args) => {
283
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;
284
652
  return await ctx.runMutation(component.comments.create, {
285
653
  actorId: actor.id,
286
654
  entryId: args.entryId,
@@ -296,9 +664,16 @@ export function exposeFeedbackApi<Name extends string | undefined>(
296
664
 
297
665
  updateComment: mutationGeneric({
298
666
  args: { commentId: v.string(), body: v.string() },
299
- returns: v.null(),
667
+ returns: nullReturns,
300
668
  handler: async (ctx, args) => {
301
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;
302
677
  return await ctx.runMutation(component.comments.update, {
303
678
  actor,
304
679
  commentId: args.commentId,
@@ -311,9 +686,16 @@ export function exposeFeedbackApi<Name extends string | undefined>(
311
686
 
312
687
  deleteComment: mutationGeneric({
313
688
  args: { commentId: v.string() },
314
- returns: v.null(),
689
+ returns: nullReturns,
315
690
  handler: async (ctx, args) => {
316
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;
317
699
  return await ctx.runMutation(component.comments.remove, {
318
700
  actor,
319
701
  commentId: args.commentId,
@@ -324,9 +706,16 @@ export function exposeFeedbackApi<Name extends string | undefined>(
324
706
 
325
707
  setCommentLike: mutationGeneric({
326
708
  args: { commentId: v.string(), desiredState: v.boolean() },
327
- returns: v.object({ active: v.boolean(), likeCount: v.number() }),
709
+ returns: commentLikeReturns,
328
710
  handler: async (ctx, args) => {
329
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;
330
719
  return await ctx.runMutation(component.comments.setLike, {
331
720
  actorId: actor.id,
332
721
  commentId: args.commentId,
@@ -336,3 +725,35 @@ export function exposeFeedbackApi<Name extends string | undefined>(
336
725
  }),
337
726
  } satisfies Record<keyof FeedbackPublicApi, unknown>;
338
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
+ }
@@ -161,7 +161,7 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
161
161
  "query",
162
162
  "internal",
163
163
  {
164
- kind?: "feedback" | "feature_request" | "bug_report";
164
+ kinds?: Array<"feedback" | "feature_request" | "bug_report">;
165
165
  paginationOpts: {
166
166
  cursor: string | null;
167
167
  endCursor?: string | null;
@@ -211,7 +211,7 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
211
211
  "query",
212
212
  "internal",
213
213
  {
214
- kind?: "feedback" | "feature_request" | "bug_report";
214
+ kinds?: Array<"feedback" | "feature_request" | "bug_report">;
215
215
  limit: number;
216
216
  searchQuery: string;
217
217
  status?: