kitcn 0.23.0 → 0.25.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.
Files changed (85) hide show
  1. package/dist/aggregate/index.d.ts +11 -1
  2. package/dist/aggregate/index.js +1 -1
  3. package/dist/auth/generated/index.d.ts +1 -1
  4. package/dist/auth/generated/index.js +1 -1
  5. package/dist/auth/http/index.d.ts +1 -1
  6. package/dist/auth/index.d.ts +4 -4
  7. package/dist/auth/index.js +10 -9
  8. package/dist/auth/nextjs/index.d.ts +2 -2
  9. package/dist/auth/nextjs/index.js +3 -3
  10. package/dist/auth/start/server/index.d.ts +1 -1
  11. package/dist/auth/start/server/index.js +1 -1
  12. package/dist/{builder-CsxVc5xC.js → builder-1Vx-tEnS.js} +3 -3
  13. package/dist/{caller-factory-CWa0ELLD.js → caller-factory-Dd3H7j3V.js} +3 -3
  14. package/dist/{where-clause-compiler-B_H3oio5.d.ts → capabilities-Bem7xvGK.d.ts} +2958 -4145
  15. package/dist/cli.mjs +155 -60
  16. package/dist/{convex-plugin-D8B0oCFq.js → convex-plugin-DfOhBU9g.js} +1 -1
  17. package/dist/{create-schema-orm-B3f2Kc8O.js → create-schema-orm-Bu2JCiBS.js} +1 -1
  18. package/dist/crpc/index.d.ts +3 -3
  19. package/dist/crpc/index.js +4 -4
  20. package/dist/{customFunctions-DxEEO4Dq.js → customFunctions-BbhgdRGl.js} +2 -2
  21. package/dist/definitions-D9vhJ6OV.js +215 -0
  22. package/dist/extensions-DzjCJXYl.js +55 -0
  23. package/dist/filter-expression-Dydt8wS0.js +402 -0
  24. package/dist/{generated-contract-disabled-B-nmd7Ne.d.ts → generated-contract-disabled-BEc4d98x.d.ts} +2 -2
  25. package/dist/{http-types-BoSDAh4Y.d.ts → http-types-90mapMP9.d.ts} +33 -3
  26. package/dist/id-Bibg34Yb.js +37 -0
  27. package/dist/{local-env-yFKub75x.mjs → local-env-Du24tdbp.mjs} +2243 -2113
  28. package/dist/{middleware-Br5zubu7.d.ts → middleware-CE_MC6Gi.d.ts} +1 -1
  29. package/dist/{middleware-DUd1Sj39.js → middleware-cJDRHTRY.js} +1 -1
  30. package/dist/orm/aggregate-index/index.d.ts +20 -0
  31. package/dist/orm/aggregate-index/index.js +2136 -0
  32. package/dist/orm/index.d.ts +5 -4
  33. package/dist/orm/index.js +126 -4292
  34. package/dist/orm/migrations/index.d.ts +3 -0
  35. package/dist/orm/migrations/index.js +506 -0
  36. package/dist/plugins/index.d.ts +1 -1
  37. package/dist/plugins/index.js +1 -1
  38. package/dist/{procedure-caller-DQxnLBS_.js → procedure-caller-DIzXfX2E.js} +2 -2
  39. package/dist/{procedure-name-C55TynK3.d.ts → procedure-name-B09-cf1-.d.ts} +3 -3
  40. package/dist/{query-context-BzihIpnM.js → query-context-DOm5Xm3H.js} +1 -402
  41. package/dist/{query-options-C_eBSIXG.js → query-options-DUiOj01a.js} +33 -1
  42. package/dist/ratelimit/index.d.ts +47 -11
  43. package/dist/ratelimit/index.js +256 -102
  44. package/dist/react/index.d.ts +9 -6
  45. package/dist/react/index.js +36 -17
  46. package/dist/rsc/index.d.ts +6 -9
  47. package/dist/rsc/index.js +7 -11
  48. package/dist/runtime-DfGsZtx3.js +1074 -0
  49. package/dist/schema-Besl-rpv.js +1411 -0
  50. package/dist/server/index.d.ts +4 -4
  51. package/dist/server/index.js +4 -4
  52. package/dist/solid/index.d.ts +9 -6
  53. package/dist/solid/index.js +36 -17
  54. package/dist/{runtime-6DJxpDfb.js → table-rmJm5Qio.js} +3 -1095
  55. package/dist/{token-xlpENMVn.js → token-DcV_0fkF.js} +1 -1
  56. package/dist/{types-C0Xl7P8K.d.ts → types-In-ZZOsd.d.ts} +1 -1
  57. package/dist/{validators-C7LelqTN.js → validators-Dmbhsd3U.js} +1 -1
  58. package/dist/watcher.mjs +1 -1
  59. package/dist/where-clause-compiler-mohWFiG0.d.ts +2042 -0
  60. package/package.json +3 -2
  61. package/skills/kitcn/SKILL.md +19 -18
  62. package/skills/kitcn/references/features/aggregates.md +21 -0
  63. package/skills/kitcn/references/features/auth-organizations.md +3 -3
  64. package/skills/kitcn/references/features/auth-polar.md +2 -1
  65. package/skills/kitcn/references/features/auth.md +8 -1
  66. package/skills/kitcn/references/features/http.md +26 -1
  67. package/skills/kitcn/references/features/ratelimit.md +11 -0
  68. package/skills/kitcn/references/features/react.md +1 -1
  69. package/skills/kitcn/references/setup/auth.md +52 -42
  70. package/skills/kitcn/references/setup/index.md +1 -1
  71. package/skills/kitcn/references/setup/server.md +37 -6
  72. /package/dist/{api-entry-N3nBOlI2.js → api-entry-CkDpGYVg.js} +0 -0
  73. /package/dist/{context-utils-BBUtBqjN.d.ts → context-utils-DwZ3Cam1.d.ts} +0 -0
  74. /package/dist/{create-schema-BXrKE2YY.js → create-schema-BRFjdgRr.js} +0 -0
  75. /package/dist/{error-Bvo7YEhk.js → error-CMLeCadS.js} +0 -0
  76. /package/dist/{generated-contract-disabled-BXaz7JCE.js → generated-contract-disabled-CZa0iyV0.js} +0 -0
  77. /package/dist/{meta-utils-D9K4fICl.js → meta-utils-D2lqN2Lg.js} +0 -0
  78. /package/dist/{query-context-CNo9ffvI.d.ts → query-context-DJONf8X5.d.ts} +0 -0
  79. /package/dist/{token-B9Bjcqug.d.ts → token-kQaqFby4.d.ts} +0 -0
  80. /package/dist/{transformer-yZuBWo8v.js → transformer-D8wO-kEj.js} +0 -0
  81. /package/dist/{types-BCl8gfGy.d.ts → types-Bf3XQex5.d.ts} +0 -0
  82. /package/dist/{types-DF2cg_w0.d.ts → types-MHYXboyy.d.ts} +0 -0
  83. /package/dist/{types-CnTpHR1F.d.ts → types-eNzQmbqM.d.ts} +0 -0
  84. /package/dist/{upstream-BR6sBLg3.js → upstream-BCgGZX6q.js} +0 -0
  85. /package/dist/{validators-BhsByJeg.d.ts → validators-V4eM2-Jg.d.ts} +0 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kitcn",
3
- "version": "0.23.0",
3
+ "version": "0.25.0",
4
4
  "description": "kitcn - React Query integration and CLI tools for Convex",
5
5
  "keywords": [
6
6
  "convex",
@@ -31,6 +31,8 @@
31
31
  "./auth/start/server": "./dist/auth/start/server/index.js",
32
32
  "./crpc": "./dist/crpc/index.js",
33
33
  "./orm": "./dist/orm/index.js",
34
+ "./orm/aggregate-index": "./dist/orm/aggregate-index/index.js",
35
+ "./orm/migrations": "./dist/orm/migrations/index.js",
34
36
  "./plugins": "./dist/plugins/index.js",
35
37
  "./ratelimit": "./dist/ratelimit/index.js",
36
38
  "./ratelimit/react": "./dist/ratelimit/react/index.js",
@@ -73,7 +75,6 @@
73
75
  "jose": "^6.1.3",
74
76
  "picocolors": "^1.1.1",
75
77
  "remeda": "^2.33.6",
76
- "svix": "^1.84.1",
77
78
  "typescript": "5.9.3",
78
79
  "type-fest": "^5.4.4"
79
80
  },
@@ -171,10 +171,9 @@ Schema rules that matter:
171
171
  ### 2) Procedure Builders + Middleware
172
172
 
173
173
  ```ts
174
- import { getHeaders } from "kitcn/auth";
174
+ import { getSession } from "kitcn/auth";
175
175
  import { CRPCError } from "kitcn/server";
176
- import { getAuth } from "../functions/generated/auth";
177
- import { initCRPC } from "../functions/generated/server";
176
+ import { initCRPC, type QueryCtx } from "../functions/generated/server";
178
177
 
179
178
  const c = initCRPC
180
179
  .meta<{
@@ -190,40 +189,42 @@ function requireAuth<T>(user: T | null): T {
190
189
  }
191
190
  return user;
192
191
  }
192
+ async function getSessionUser(ctx: QueryCtx) {
193
+ const session = await getSession(ctx);
194
+ if (!session) return null;
195
+ return await ctx.orm.query.user.findFirst({
196
+ where: { id: { eq: session.userId } },
197
+ });
198
+ }
199
+
193
200
  export const publicQuery = c.query.meta({ auth: "optional" });
194
201
  export const authQuery = c.query
195
202
  .meta({ auth: "required" })
196
203
  .use(async ({ ctx, next }) => {
197
- const auth = getAuth(ctx);
198
- const session = await auth.api.getSession({
199
- headers: await getHeaders(ctx),
200
- });
201
- const user = requireAuth(session?.user ?? null);
204
+ const user = requireAuth(await getSessionUser(ctx));
202
205
  return next({ ctx: { ...ctx, user, userId: user.id } });
203
206
  });
204
207
  export const authMutation = c.mutation
205
208
  .meta({ auth: "optional" })
206
209
  .use(async ({ ctx, next }) => {
207
- const auth = getAuth(ctx);
208
- const session = await auth.api.getSession({
209
- headers: await getHeaders(ctx),
210
- });
210
+ const user = await getSessionUser(ctx);
211
211
  return next({
212
- ctx: {
213
- ...ctx,
214
- user: session?.user ?? null,
215
- userId: session?.user?.id ?? null,
216
- },
212
+ ctx: { ...ctx, user, userId: user?.id ?? null },
217
213
  });
218
214
  });
219
215
  ```
220
216
 
221
217
  Builder rules that matter:
222
218
 
223
- 1. Build `public`, `optional`, `auth`, and `private` procedure families once in `convex/lib/crpc.ts`.
219
+ 1. Build `public`, `optional`, `auth`, and `private` procedure families once in `convex/lib/crpc.ts`. Authenticated action builders live in `convex/lib/crpc-action.ts`, the only builder module that imports `getAuth`.
224
220
  2. `.meta(...)` is client-visible via generated API metadata. Never put secrets there.
225
221
  3. Middleware receives server-only `procedure` info. When procedures are built from your app `generated/server` helper, standard `export const` queries, mutations, and actions infer `module:function` automatically from file path + export name. Use `.name("module:function")` only to override or cover unusual export shapes.
226
222
  4. Resolve session/user once in middleware. Do not re-fetch auth state in every procedure.
223
+ Query/mutation middleware uses `getSession(ctx)` from `kitcn/auth`, which reads the session row
224
+ directly. Keep `getAuth(ctx)` out of `convex/lib/crpc.ts`: it pulls the whole Better Auth
225
+ definition and every auth plugin into the static import closure of every procedure module, and
226
+ Convex has no dynamic `import()` to escape it. Import `getAuth` only in the modules that call
227
+ `auth.api.*` — `convex/lib/crpc-action.ts`, HTTP routes, and organization/admin mutations.
227
228
  5. Shared `c.middleware()` chains preserve mutation writer types on mutation procedures. If the middleware itself performs writes, type it as mutation-only with `c.middleware<MutationCtx>(...)`.
228
229
  6. Keep deeper auth/runtime edge cases in `references/setup/server.md` and `references/features/auth*.md`.
229
230
 
@@ -9,6 +9,27 @@ Canonical runtime rules:
9
9
  - Use `rankIndex` + `rank()` for rankings, random access, sorted pagination
10
10
  - `aggregateIndex` and `rankIndex` backfill automatically via `kitcn dev` — no manual trigger wiring needed
11
11
 
12
+ ## Setup
13
+
14
+ Aggregate and rank indexes are an optional ORM subsystem. Register it where the ORM is created:
15
+
16
+ ```ts
17
+ import { createOrm } from "kitcn/orm";
18
+ import { aggregateCapability } from "kitcn/orm/aggregate-index";
19
+
20
+ export const orm = createOrm({ schema, capabilities: [aggregateCapability()] });
21
+ ```
22
+
23
+ `kitcn codegen` writes this into `<functionsDir>/generated/server.ts` for schemas that declare an
24
+ `aggregateIndex` or `rankIndex`, and leaves it out otherwise. Convex bundles everything a module
25
+ statically imports and has no dynamic `import()`, and every procedure module imports
26
+ `generated/server.ts`, so an unused registration would put the aggregate btree in every Convex
27
+ function of the app. Declare the index first, then rerun `kitcn codegen`.
28
+
29
+ Without the capability, `aggregateIndex`/`rankIndex` schemas and filtered `count()` / `aggregate()`
30
+ / `groupBy()` / `rank()` / relation `_count` throw a setup error. Unfiltered `count()` and
31
+ `aggregate({ _count: true })` are served by the native Convex count syscall and need nothing.
32
+
12
33
  ## ORM Scalar Metrics
13
34
 
14
35
  ### `aggregateIndex` Schema Declaration
@@ -438,9 +438,9 @@ export const updateOrganization = authMutation
438
438
  });
439
439
  ```
440
440
 
441
- Use an `authAction` instead of an `authMutation` for any Better Auth endpoint
442
- that can run external plugin work such as Stripe, Polar, or email delivery.
443
- Convex mutations cannot call those SDKs.
441
+ Use an `authAction` (from `../lib/crpc-action`) instead of an `authMutation` for
442
+ any Better Auth endpoint that can run external plugin work such as Stripe,
443
+ Polar, or email delivery. Convex mutations cannot call those SDKs.
444
444
 
445
445
  ### Delete Organization
446
446
 
@@ -435,7 +435,8 @@ export const updateUserPolarCustomerId = privateMutation
435
435
  import '../lib/polar-polyfills';
436
436
  import { CRPCError } from 'kitcn/server';
437
437
  import { z } from 'zod';
438
- import { authAction, privateMutation, privateQuery } from '../lib/crpc';
438
+ import { privateMutation, privateQuery } from '../lib/crpc';
439
+ import { authAction } from '../lib/crpc-action';
439
440
  import { getPolarClient } from '../lib/polar-client';
440
441
  import { createPolarSubscriptionCaller } from './generated/polarSubscription.runtime';
441
442
 
@@ -15,7 +15,14 @@ Covers Better Auth integration with Convex: server setup, client hooks, triggers
15
15
  | Query/Mutation (`ctx.db`) | Direct DB | No `runQuery`/`runMutation` wrapper |
16
16
  | Action/HTTP | HTTP adapter | Uses `ctx.run*` APIs |
17
17
 
18
- **Entrypoint**: `getAuth(ctx)` everywhere (query, mutation, action, HTTP).
18
+ **Entrypoints**:
19
+
20
+ - `getSession(ctx)` from `kitcn/auth` in queries and mutations, including the shared cRPC auth
21
+ middleware. It reads the session row directly and keeps the Better Auth definition out of the
22
+ importing module's static closure.
23
+ - `getAuth(ctx)` only where `auth.api.*` is actually called: `convex/lib/crpc-action.ts`, HTTP
24
+ routes, and organization/admin mutations. Convex has no dynamic `import()`, so every module that
25
+ names `getAuth` carries the whole auth definition and its plugins into its evaluation closure.
19
26
 
20
27
  ## Auth Flow
21
28
 
@@ -495,8 +495,18 @@ crpc.http.todos.list.queryOptions({
495
495
  searchParams: { limit: "10" },
496
496
  headers: { "X-Custom": "value" },
497
497
  });
498
+
499
+ // Override the default 30s staleTime
500
+ crpc.http.todos.list.queryOptions(
501
+ { searchParams: { limit: "10" } },
502
+ { staleTime: 0 }
503
+ );
498
504
  ```
499
505
 
506
+ `queryOptions` defaults to a 30 second `staleTime` so RSC-prefetched entries hydrate
507
+ without an immediate refetch. `refetchOnMount` keeps TanStack's default, so a route
508
+ invalidated while unmounted still refetches.
509
+
500
510
  ### One-Time Fetch
501
511
 
502
512
  ```ts
@@ -558,6 +568,20 @@ const updateTodo = useMutation(
558
568
  );
559
569
  ```
560
570
 
571
+ ### Cache Keys
572
+
573
+ ```ts
574
+ const queryKey = crpc.http.todos.list.queryKey();
575
+ // => ['httpQuery', 'todos.list', {}]
576
+ queryClient.getQueryData(queryKey);
577
+
578
+ crpc.http.todos.get.queryKey({ params: { id: '123' } });
579
+ // => ['httpQuery', 'todos.get', { params: { id: '123' } }]
580
+ ```
581
+
582
+ `queryKey()` is the exact key `queryOptions()` and RSC prefetching store under, so
583
+ `getQueryData`/`setQueryData` hit. Use `queryFilter()` to match every args variant.
584
+
561
585
  See [Client Methods](#client-methods) in API Reference.
562
586
 
563
587
  ## RSC Prefetching
@@ -645,4 +669,5 @@ Client args can include `params`, `searchParams`, `form`, custom `fetch`,
645
669
  ### Client Methods
646
670
 
647
671
  Generated clients expose `queryOptions`, `staticQueryOptions`,
648
- `mutationOptions`, `queryKey`, and `queryFilter`.
672
+ `mutationOptions`, `queryKey` (exact cache key), and `queryFilter` (matches every
673
+ args variant when called without args).
@@ -81,6 +81,17 @@ For denied reserved fixed-window and token-bucket requests, `reset` is when the
81
81
 
82
82
  A request larger than every shard's capacity plus finite reservation headroom can never succeed. It returns `reason: 'requestTooLarge'` and `reset: 0` without reading shard state. When only smaller shards cannot serve a request, they are excluded from retry calculations. Fresh, full, and partial snapshot projections preserve permanent denial as `retryAfter: Infinity`, and the React hook reports `ok: false` without scheduling a retry timer. Reduce `count`, reduce `shards`, or raise the configured capacity.
83
83
 
84
+ ## Protection / deny list — delta from parity
85
+
86
+ With `enableProtection: true`, failures are counted per identifier, ip, userAgent, and country.
87
+
88
+ - A value's block is cached for up to 24 h once it reaches `denyListThreshold` **within a rolling 10 minute window**. Failures paced wider than that window decay, so a shared NAT/carrier IP is not blocked by unrelated failures accumulated over days.
89
+ - Protection state is a bounded in-memory LRU. Failure histories are evicted before blocks, active blocks refresh on use, and the coldest block can be evicted above 4,096 simultaneous blocked values per prefix. Evicted values fall through to the database-backed limiter.
90
+ - A success clears the **identifier** counter only. ip/userAgent counters are attacker-supplied; clearing them on success would let a caller loop `threshold - 1` failures plus one success indefinitely, or reset a victim's counter by forging their user-agent.
91
+ - State is module-scope memory: at most 4096 tracked values per prefix with least-recently-hit eviction, and values over 128 characters stored truncated. It is per-isolate, so it does not survive a cold start and is not shared across function entries.
92
+ - Increments are not rolled back when Convex retries a mutation after a write conflict, so a value can count slightly more failures than it was served.
93
+ - `pickDeniedValue` runs before any database read, so a blocked value is rejected without touching `ratelimitState`.
94
+
84
95
  ## Convex constraints
85
96
 
86
97
  - `blockUntilReady()` needs `setTimeout`, so it only runs in actions or non-Convex runtimes. It throws with that guidance inside queries and mutations. `limit()` and `check()` never touch timers.
@@ -28,7 +28,7 @@ export const { CRPCProvider, useCRPC, useCRPCClient } = createCRPCContext({
28
28
 
29
29
  ### QueryClient
30
30
 
31
- cRPC auto-sets `staleTime: Infinity`, `refetch*: false` per query (Convex pushes via WebSocket — never stale).
31
+ cRPC auto-sets `staleTime: Infinity`, `refetch*: false` per query (Convex pushes via WebSocket — never stale). `crpc.http.*` routes are pull-model instead: `staleTime: 30_000` with `refetchOnMount` left at TanStack's default, so RSC-prefetched data hydrates but an invalidated route still refetches.
32
32
 
33
33
  ```ts
34
34
  // src/lib/convex/query-client.ts
@@ -331,11 +331,10 @@ Again: printed payload only. You still need to set the env manually.
331
331
  After non-auth baseline is green, replace `convex/lib/crpc.ts` with this auth-aware variant:
332
332
 
333
333
  ```ts
334
- import { getHeaders } from "kitcn/auth";
334
+ import { getSession } from "kitcn/auth";
335
335
  import { CRPCError } from "kitcn/server";
336
336
 
337
- import { getAuth } from "../functions/generated/auth";
338
- import { initCRPC } from "../functions/generated/server";
337
+ import { initCRPC, type QueryCtx } from "../functions/generated/server";
339
338
 
340
339
  const c = initCRPC
341
340
  .meta<{
@@ -366,6 +365,14 @@ function requireAuth<T>(user: T | null): T {
366
365
  return user;
367
366
  }
368
367
 
368
+ async function getSessionUser(ctx: QueryCtx) {
369
+ const session = await getSession(ctx);
370
+ if (!session) return null;
371
+ return await ctx.orm.query.user.findFirst({
372
+ where: { id: { eq: session.userId } },
373
+ });
374
+ }
375
+
369
376
  export const publicQuery = c.query.meta({ auth: "optional" });
370
377
  export const publicAction = c.action;
371
378
  export const publicMutation = c.mutation;
@@ -377,28 +384,17 @@ export const privateAction = c.action.internal();
377
384
  export const optionalAuthQuery = c.query
378
385
  .meta({ auth: "optional" })
379
386
  .use(async ({ ctx, next }) => {
380
- const auth = getAuth(ctx);
381
- const session = await auth.api.getSession({
382
- headers: await getHeaders(ctx),
383
- });
387
+ const user = await getSessionUser(ctx);
384
388
 
385
389
  return next({
386
- ctx: {
387
- ...ctx,
388
- user: session?.user ?? null,
389
- userId: session?.user?.id ?? null,
390
- },
390
+ ctx: { ...ctx, user, userId: user?.id ?? null },
391
391
  });
392
392
  });
393
393
 
394
394
  export const authQuery = c.query
395
395
  .meta({ auth: "required" })
396
396
  .use(async ({ ctx, next }) => {
397
- const auth = getAuth(ctx);
398
- const session = await auth.api.getSession({
399
- headers: await getHeaders(ctx),
400
- });
401
- const user = requireAuth(session?.user ?? null);
397
+ const user = requireAuth(await getSessionUser(ctx));
402
398
  return next({ ctx: { ...ctx, user, userId: user.id } });
403
399
  })
404
400
  .use(roleMiddleware);
@@ -406,43 +402,21 @@ export const authQuery = c.query
406
402
  export const optionalAuthMutation = c.mutation
407
403
  .meta({ auth: "optional" })
408
404
  .use(async ({ ctx, next }) => {
409
- const auth = getAuth(ctx);
410
- const session = await auth.api.getSession({
411
- headers: await getHeaders(ctx),
412
- });
405
+ const user = await getSessionUser(ctx);
413
406
 
414
407
  return next({
415
- ctx: {
416
- ...ctx,
417
- user: session?.user ?? null,
418
- userId: session?.user?.id ?? null,
419
- },
408
+ ctx: { ...ctx, user, userId: user?.id ?? null },
420
409
  });
421
410
  });
422
411
 
423
412
  export const authMutation = c.mutation
424
413
  .meta({ auth: "required" })
425
414
  .use(async ({ ctx, next }) => {
426
- const auth = getAuth(ctx);
427
- const session = await auth.api.getSession({
428
- headers: await getHeaders(ctx),
429
- });
430
- const user = requireAuth(session?.user ?? null);
415
+ const user = requireAuth(await getSessionUser(ctx));
431
416
  return next({ ctx: { ...ctx, user, userId: user.id } });
432
417
  })
433
418
  .use(roleMiddleware);
434
419
 
435
- export const authAction = c.action
436
- .meta({ auth: "required" })
437
- .use(async ({ ctx, next }) => {
438
- const auth = getAuth(ctx);
439
- const session = await auth.api.getSession({
440
- headers: await getHeaders(ctx),
441
- });
442
- const user = requireAuth(session?.user ?? null);
443
- return next({ ctx: { ...ctx, user, userId: user.id } });
444
- });
445
-
446
420
  export const publicRoute = c.httpAction;
447
421
  export const authRoute = c.httpAction.use(async ({ ctx, next }) => {
448
422
  const identity = await ctx.auth.getUserIdentity();
@@ -480,6 +454,42 @@ export const optionalAuthRoute = c.httpAction.use(async ({ ctx, next }) => {
480
454
  export const router = c.router;
481
455
  ```
482
456
 
457
+ ### 6.9.1 Authenticated action builders
458
+
459
+ Authenticated actions live in their own module. Actions have no `ctx.db`, so they resolve the
460
+ session through `auth.api.getSession` instead of `getSession(ctx)`. `getAuth` carries the whole
461
+ Better Auth definition and every plugin into the static import closure of whatever imports it, and
462
+ Convex has no dynamic `import()`, so it stays out of `convex/lib/crpc.ts` — the module every
463
+ procedure imports.
464
+
465
+ Skip this file if the app has no authenticated actions.
466
+
467
+ **Create:** `convex/lib/crpc-action.ts`
468
+
469
+ ```ts
470
+ import { getHeaders } from "kitcn/auth";
471
+ import { CRPCError } from "kitcn/server";
472
+
473
+ import { getAuth } from "../functions/generated/auth";
474
+ import { publicAction } from "./crpc";
475
+
476
+ export const authAction = publicAction
477
+ .meta({ auth: "required" })
478
+ .use(async ({ ctx, next }) => {
479
+ const auth = getAuth(ctx);
480
+ const session = await auth.api.getSession({
481
+ headers: await getHeaders(ctx),
482
+ });
483
+ const user = session?.user;
484
+ if (!user) {
485
+ throw new CRPCError({ code: "UNAUTHORIZED", message: "Not authenticated" });
486
+ }
487
+ return next({ ctx: { ...ctx, user, userId: user.id } });
488
+ });
489
+ ```
490
+
491
+ Import `authAction` from `../lib/crpc-action`. Every other builder comes from `../lib/crpc`.
492
+
483
493
  ### 6.10 Auth sign-in gate (required before Section 7+ and all optional modules/plugins)
484
494
 
485
495
  Do not continue until all checks below pass:
@@ -755,7 +755,7 @@ domain depth: `orm.md`, `http.md`, `react.md`, `aggregates.md`,
755
755
  | HTTP calls fail but queries work | `.site` URL missing or wrong | Set `NEXT_PUBLIC_CONVEX_SITE_URL` correctly |
756
756
  | Auth works locally but fails in prod | JWKS not pushed | Run `bunx kitcn env push --prod` |
757
757
  | Sign-in fails on `/auth` (loop, no session, or immediate sign-out) | Auth route/env/provider wiring mismatch | Recheck Sections 6.5-6.7 (`authMiddleware`, route registration, env push), verify provider credentials/URLs, then rerun Section 11.3 |
758
- | `UNAUTHORIZED` on protected procedures | auth middleware not attaching `userId` | Ensure `getAuth(ctx)` + `getHeaders(ctx)` session lookup is in middleware |
758
+ | `UNAUTHORIZED` on protected procedures | auth middleware not attaching `userId` | Ensure the `getSession(ctx)` session lookup is in middleware |
759
759
  | `ctx.orm` missing in handlers | Generated `initCRPC` not used | Use `initCRPC` from `../functions/generated/server` — ORM context is pre-wired |
760
760
  | `Property 'insert'/'update' does not exist on type 'OrmReader'` | Using query context for mutations | Ensure mutation handlers use `publicMutation` / `protectedMutation` builders |
761
761
  | `useCRPC must be used within CRPCProvider` | Provider chain not mounted around route tree | Wrap app with `AppConvexProvider` and verify `CRPCProvider` is inside QueryClientProvider (Section 7.4 / 8.A.4) |
@@ -175,7 +175,8 @@ export const router = c.router;
175
175
  Phase ordering rule:
176
176
 
177
177
  1. Keep this non-auth baseline until Section 11.2 fully passes.
178
- 2. Only then replace this file with the auth-aware variant in Section 6.9.
178
+ 2. Only then replace this file with the auth-aware variant in Section 6.9, and add
179
+ `convex/lib/crpc-action.ts` from Section 6.9.1 if the app has authenticated actions.
179
180
 
180
181
  ### 5.4 Shared API/type helpers (generated)
181
182
 
@@ -356,7 +357,7 @@ Rate limiting is opt-in: scaffold the full starter once.
356
357
  bunx kitcn add ratelimit
357
358
  ```
358
359
 
359
- This creates `convex/lib/plugins/ratelimit/schema.ts`, `convex/lib/plugins/ratelimit/plugin.ts`, and registers `ratelimitExtension()` in `convex/functions/schema.ts`.
360
+ This creates `convex/lib/plugins/ratelimit/schema.ts`, `convex/lib/plugins/ratelimit/plugin.ts`, `convex/functions/plugins/ratelimit.ts`, and registers `ratelimitExtension()` in `convex/functions/schema.ts`.
360
361
 
361
362
  ```ts
362
363
  import { defineSchema } from "kitcn/orm";
@@ -365,12 +366,18 @@ import { ratelimitExtension } from "../lib/plugins/ratelimit/schema";
365
366
  export default defineSchema(tables).extend(ratelimitExtension());
366
367
  ```
367
368
 
368
- Create `convex/lib/plugins/ratelimit/plugin.ts` and call `ratelimit.middleware()` from mutation builders. Use the default bucket for normal writes and reserve `.meta({ ratelimit: ... })` for named overrides. Runtime accounting (`shards`, `check()`, snapshots, read accuracy) lives in `features/ratelimit.md`.
369
+ Create `convex/lib/plugins/ratelimit/plugin.ts` and call `ratelimit.middleware()` from mutation builders. Use the default bucket for normal writes; the starter's `interactive` bucket demonstrates a named `.meta({ ratelimit: ... })` override. Rename or remove it to match the application. Runtime accounting (`shards`, `check()`, snapshots, read accuracy) lives in `features/ratelimit.md`.
369
370
 
370
371
  Use `RatelimitPlugin` from `kitcn/ratelimit`:
371
372
 
372
373
  ```ts
373
- import { MINUTE, Ratelimit, RatelimitPlugin } from "kitcn/ratelimit";
374
+ import {
375
+ type LimitRequest,
376
+ MINUTE,
377
+ Ratelimit,
378
+ RatelimitPlugin,
379
+ SECOND,
380
+ } from "kitcn/ratelimit";
374
381
  import type { MutationCtx } from "../../../functions/generated/server";
375
382
 
376
383
  const fixed = (rate: number) => Ratelimit.fixedWindow(rate, MINUTE);
@@ -381,6 +388,11 @@ export const ratelimitBuckets = {
381
388
  free: fixed(60),
382
389
  premium: fixed(200),
383
390
  },
391
+ interactive: {
392
+ public: Ratelimit.fixedWindow(3, 30 * SECOND),
393
+ free: Ratelimit.fixedWindow(3, 30 * SECOND),
394
+ premium: Ratelimit.fixedWindow(3, 30 * SECOND),
395
+ },
384
396
  } as const;
385
397
 
386
398
  type RatelimitTier = keyof (typeof ratelimitBuckets)["default"];
@@ -415,14 +427,27 @@ async function getRequestSignals(ctx: RatelimitCtx) {
415
427
  };
416
428
  }
417
429
 
430
+ function getRequestIdentifier(
431
+ user: RatelimitUser | null,
432
+ signals: LimitRequest | undefined
433
+ ) {
434
+ if (user) return user.id;
435
+ return signals?.ip ? `ip:${signals.ip}` : "ip:unknown";
436
+ }
437
+
418
438
  export const ratelimit = RatelimitPlugin.configure({
419
439
  buckets: ratelimitBuckets,
420
440
  getBucket: ({ meta }: { meta: RatelimitMeta }) => meta.ratelimit ?? "default",
421
441
  getUser: ({ ctx }: { ctx: RatelimitCtx }) => ctx.user ?? null,
422
- getIdentifier: ({ user }: { user: RatelimitUser | null }) =>
423
- user?.id ?? "anonymous",
424
442
  getTier: getUserTier,
425
443
  getSignals: ({ ctx }: { ctx: RatelimitCtx }) => getRequestSignals(ctx),
444
+ getIdentifier: ({
445
+ user,
446
+ signals,
447
+ }: {
448
+ user: RatelimitUser | null;
449
+ signals: LimitRequest | undefined;
450
+ }) => getRequestIdentifier(user, signals),
426
451
  prefix: ({ bucket, tier }) => `ratelimit:${bucket}:${tier}`,
427
452
  failureMode: "closed",
428
453
  enableProtection: true,
@@ -432,6 +457,12 @@ export const ratelimit = RatelimitPlugin.configure({
432
457
 
433
458
  Use `ctx.meta.getRequestMetadata()` on Convex 1.38.0+ for IP/user-agent signals in mutation rate limits. Convex also exposes this metadata in actions; route action-side enforcement through a mutation when database-backed ratelimit state is required.
434
459
 
460
+ The identifier is the partition key: every request resolving to the same string shares one budget and one `ratelimitState` document. Never key unauthenticated traffic to a constant — `fixedWindow(30, MINUTE)` under a constant identifier is 30 req/min for the whole deployment, and 30 consecutive denials arm a 24 h deny-list block against that constant. `getSignals` runs once per request, before `getIdentifier`, so keying on `signals.ip` costs no extra syscall.
461
+
462
+ Plan for the two consequences: shared NAT/CGNAT addresses share a budget, and calls without request metadata all land on `ip:unknown`. One `ratelimitState` row exists per identifier per `bucket:tier`; run the scaffolded `plugins/ratelimit:cleanup` private mutation on demand, with `olderThanMs` longer than every configured rate-limit window, and repeat while it returns `hasMore: true`.
463
+
464
+ `RatelimitPlugin.configure` also forwards `failureMode`, `timeout`, `enableProtection`, `denyListThreshold`, `denyList`, `dynamicLimits`, and `ephemeralCache` to the limiter.
465
+
435
466
  ### 9.5 Scheduling gate
436
467
 
437
468
  Create `convex/functions/crons.ts` with `cronJobs()` and use `caller.schedule.now/after/at` in mutations/actions for delayed procedure jobs (`ctx.scheduler.*` only for raw `internal.*` functions).
File without changes