kitcn 0.0.1 → 0.12.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (93) hide show
  1. package/bin/intent.js +3 -0
  2. package/dist/aggregate/index.d.ts +388 -0
  3. package/dist/aggregate/index.js +37 -0
  4. package/dist/api-entry-BckXqaLb.js +66 -0
  5. package/dist/auth/client/index.d.ts +37 -0
  6. package/dist/auth/client/index.js +217 -0
  7. package/dist/auth/config/index.d.ts +45 -0
  8. package/dist/auth/config/index.js +24 -0
  9. package/dist/auth/generated/index.d.ts +2 -0
  10. package/dist/auth/generated/index.js +3 -0
  11. package/dist/auth/http/index.d.ts +64 -0
  12. package/dist/auth/http/index.js +461 -0
  13. package/dist/auth/index.d.ts +221 -0
  14. package/dist/auth/index.js +1398 -0
  15. package/dist/auth/nextjs/index.d.ts +50 -0
  16. package/dist/auth/nextjs/index.js +81 -0
  17. package/dist/auth-store-Cljlmdmi.js +197 -0
  18. package/dist/builder-CBdG5W6A.js +1974 -0
  19. package/dist/caller-factory-cTXNvYdz.js +216 -0
  20. package/dist/cli.mjs +13264 -0
  21. package/dist/codegen-lF80HSWu.mjs +3416 -0
  22. package/dist/context-utils-HPC5nXzx.d.ts +17 -0
  23. package/dist/create-schema-odyF4kCy.js +156 -0
  24. package/dist/create-schema-orm-DOyiNDCx.js +246 -0
  25. package/dist/crpc/index.d.ts +105 -0
  26. package/dist/crpc/index.js +169 -0
  27. package/dist/customFunctions-C0voKmtx.js +144 -0
  28. package/dist/error-BZEnI7Sq.js +41 -0
  29. package/dist/generated-contract-disabled-Cih4eITO.js +50 -0
  30. package/dist/generated-contract-disabled-D-sOFy92.d.ts +354 -0
  31. package/dist/http-types-DqJubRPJ.d.ts +292 -0
  32. package/dist/meta-utils-0Pu0Nrap.js +117 -0
  33. package/dist/middleware-BUybuv9n.d.ts +34 -0
  34. package/dist/middleware-C2qTZ3V7.js +84 -0
  35. package/dist/orm/index.d.ts +17 -0
  36. package/dist/orm/index.js +10713 -0
  37. package/dist/plugins/index.d.ts +2 -0
  38. package/dist/plugins/index.js +3 -0
  39. package/dist/procedure-caller-DtxLmGwA.d.ts +1467 -0
  40. package/dist/procedure-caller-MWcxhQDv.js +349 -0
  41. package/dist/query-context-B8o6-8kC.js +1518 -0
  42. package/dist/query-context-CFZqIvD7.d.ts +42 -0
  43. package/dist/query-options-Dw7cOyXl.js +121 -0
  44. package/dist/ratelimit/index.d.ts +269 -0
  45. package/dist/ratelimit/index.js +856 -0
  46. package/dist/ratelimit/react/index.d.ts +76 -0
  47. package/dist/ratelimit/react/index.js +183 -0
  48. package/dist/react/index.d.ts +1284 -0
  49. package/dist/react/index.js +2526 -0
  50. package/dist/rsc/index.d.ts +276 -0
  51. package/dist/rsc/index.js +233 -0
  52. package/dist/runtime-CtvJPkur.js +2453 -0
  53. package/dist/server/index.d.ts +5 -0
  54. package/dist/server/index.js +6 -0
  55. package/dist/solid/index.d.ts +1221 -0
  56. package/dist/solid/index.js +2940 -0
  57. package/dist/transformer-DtDhR3Lc.js +194 -0
  58. package/dist/types-BTb_4BaU.d.ts +42 -0
  59. package/dist/types-BiJE7qxR.d.ts +4 -0
  60. package/dist/types-DEJpkIhw.d.ts +88 -0
  61. package/dist/types-HhO_R6pd.d.ts +213 -0
  62. package/dist/validators-B7oIJCAp.js +279 -0
  63. package/dist/validators-vzRKjBJC.d.ts +88 -0
  64. package/dist/watcher.mjs +96 -0
  65. package/dist/where-clause-compiler-DdjN63Io.d.ts +4756 -0
  66. package/package.json +107 -34
  67. package/skills/convex/SKILL.md +486 -0
  68. package/skills/convex/references/features/aggregates.md +353 -0
  69. package/skills/convex/references/features/auth-admin.md +446 -0
  70. package/skills/convex/references/features/auth-organizations.md +1141 -0
  71. package/skills/convex/references/features/auth-polar.md +579 -0
  72. package/skills/convex/references/features/auth.md +470 -0
  73. package/skills/convex/references/features/create-plugins.md +153 -0
  74. package/skills/convex/references/features/http.md +676 -0
  75. package/skills/convex/references/features/migrations.md +162 -0
  76. package/skills/convex/references/features/orm.md +1166 -0
  77. package/skills/convex/references/features/react.md +657 -0
  78. package/skills/convex/references/features/scheduling.md +267 -0
  79. package/skills/convex/references/features/testing.md +209 -0
  80. package/skills/convex/references/setup/auth.md +501 -0
  81. package/skills/convex/references/setup/biome.md +190 -0
  82. package/skills/convex/references/setup/doc-guidelines.md +145 -0
  83. package/skills/convex/references/setup/index.md +761 -0
  84. package/skills/convex/references/setup/next.md +116 -0
  85. package/skills/convex/references/setup/react.md +175 -0
  86. package/skills/convex/references/setup/server.md +473 -0
  87. package/skills/convex/references/setup/start.md +67 -0
  88. package/LICENSE +0 -21
  89. package/README.md +0 -0
  90. package/dist/index.d.mts +0 -5
  91. package/dist/index.d.mts.map +0 -1
  92. package/dist/index.mjs +0 -6
  93. package/dist/index.mjs.map +0 -1
@@ -0,0 +1,1141 @@
1
+ # Auth Organizations Plugin
2
+
3
+ Multi-tenant organization features via Better Auth plugin. Organizations, members, invitations, teams, RBAC, lifecycle hooks.
4
+
5
+ Prerequisites: `setup/auth.md`, `setup/server.md`.
6
+
7
+ See [Better Auth Organization Plugin](https://www.better-auth.com/docs/plugins/organization) for full API reference.
8
+
9
+ ## Server Config
10
+
11
+ ```ts
12
+ // convex/functions/auth.ts
13
+ import { organization } from "better-auth/plugins";
14
+ import type { ActionCtx } from "./generated/server";
15
+ import { defineAuth } from "./generated/auth";
16
+
17
+ export default defineAuth((ctx) => ({
18
+ plugins: [
19
+ convex({ authConfig, jwks: process.env.JWKS }),
20
+ admin(),
21
+ organization({
22
+ ac,
23
+ roles,
24
+ allowUserToCreateOrganization: true,
25
+ organizationLimit: 5,
26
+ membershipLimit: 100,
27
+ creatorRole: "owner",
28
+ invitationExpiresIn: 48 * 60 * 60, // 48 hours
29
+ teams: { enabled: true, maximumTeams: 10 },
30
+ sendInvitationEmail: async (data) => {
31
+ const inviterName = data.inviter.user.name || "Team Admin";
32
+ const organizationName = data.organization.name;
33
+ const roleSuffix = data.role ? ` as ${data.role}` : "";
34
+ const acceptUrl = `${process.env.SITE_URL!}/w/${data.organization.slug}?invite=${data.id}`;
35
+
36
+ await (ctx as ActionCtx).scheduler.runAfter(
37
+ 0,
38
+ internal.plugins.email.sendTemplatedEmail,
39
+ {
40
+ to: data.email,
41
+ subject: `${inviterName} invited you to join ${organizationName}`,
42
+ title: `Invitation to join ${organizationName}`,
43
+ body: `${inviterName} (${data.inviter.user.email}) invited you to join ${organizationName}${roleSuffix}.`,
44
+ ctaLabel: "Accept invitation",
45
+ ctaUrl: acceptUrl,
46
+ }
47
+ );
48
+ },
49
+ }),
50
+ ],
51
+ }));
52
+ ```
53
+
54
+ ## Client Config
55
+
56
+ ```ts
57
+ // src/lib/convex/auth-client.ts
58
+ import { organizationClient } from "better-auth/client/plugins";
59
+ import { ac, roles } from "@convex/auth-shared";
60
+
61
+ export const authClient = createAuthClient({
62
+ plugins: [
63
+ inferAdditionalFields<Auth>(),
64
+ convexClient(),
65
+ organizationClient({ ac, roles, teams: { enabled: true } }),
66
+ ],
67
+ });
68
+ ```
69
+
70
+ ## Schema
71
+
72
+ ```ts
73
+ // convex/functions/schema.ts
74
+ import {
75
+ convexTable,
76
+ defineSchema,
77
+ id,
78
+ index,
79
+ integer,
80
+ json,
81
+ text,
82
+ timestamp,
83
+ } from "kitcn/orm";
84
+
85
+ export const organization = convexTable(
86
+ "organization",
87
+ {
88
+ name: text().notNull(),
89
+ slug: text().notNull(),
90
+ logo: text(),
91
+ createdAt: timestamp().notNull().defaultNow(),
92
+ metadata: json<Record<string, unknown>>(),
93
+ },
94
+ (t) => [index("slug").on(t.slug), index("name").on(t.name)]
95
+ );
96
+
97
+ export const member = convexTable(
98
+ "member",
99
+ {
100
+ organizationId: id("organization").notNull(),
101
+ userId: id("user").notNull(),
102
+ role: text().notNull(),
103
+ createdAt: timestamp().notNull().defaultNow(),
104
+ },
105
+ (t) => [
106
+ index("userId").on(t.userId),
107
+ index("organizationId_userId").on(t.organizationId, t.userId),
108
+ index("organizationId_role").on(t.organizationId, t.role),
109
+ ]
110
+ );
111
+
112
+ export const invitation = convexTable(
113
+ "invitation",
114
+ {
115
+ organizationId: id("organization").notNull(),
116
+ inviterId: id("user").notNull(),
117
+ email: text().notNull(),
118
+ role: text(),
119
+ status: text().notNull(),
120
+ expiresAt: integer().notNull(),
121
+ createdAt: timestamp().notNull().defaultNow(),
122
+ },
123
+ (t) => [
124
+ index("email").on(t.email),
125
+ index("status").on(t.status),
126
+ index("email_organizationId_status").on(
127
+ t.email,
128
+ t.organizationId,
129
+ t.status
130
+ ),
131
+ index("organizationId_status").on(t.organizationId, t.status),
132
+ ]
133
+ );
134
+
135
+ // Add to existing session table
136
+ export const session = convexTable("session", {
137
+ // ... existing session fields
138
+ activeOrganizationId: id("organization"),
139
+ activeTeamId: id("team"),
140
+ });
141
+ ```
142
+
143
+ ### Teams (Optional)
144
+
145
+ ```ts
146
+ export const team = convexTable(
147
+ "team",
148
+ {
149
+ name: text().notNull(),
150
+ organizationId: id("organization").notNull(),
151
+ createdAt: timestamp().notNull().defaultNow(),
152
+ updatedAt: integer(),
153
+ },
154
+ (t) => [index("organizationId").on(t.organizationId)]
155
+ );
156
+
157
+ export const teamMember = convexTable(
158
+ "teamMember",
159
+ {
160
+ teamId: id("team").notNull(),
161
+ userId: id("user").notNull(),
162
+ createdAt: timestamp(),
163
+ },
164
+ (t) => [index("teamId").on(t.teamId), index("userId").on(t.userId)]
165
+ );
166
+ ```
167
+
168
+ ### Additional Fields
169
+
170
+ Extend organization tables with custom fields in plugin config:
171
+
172
+ ```ts
173
+ organization({
174
+ schema: {
175
+ organization: { fields: { description: v.optional(v.string()), website: v.optional(v.string()) } },
176
+ member: { fields: { title: v.optional(v.string()), department: v.optional(v.string()) } },
177
+ invitation: { fields: { message: v.optional(v.string()) } },
178
+ },
179
+ }),
180
+ ```
181
+
182
+ Then add matching columns in your schema.
183
+
184
+ ## Access Control
185
+
186
+ ### Basic Setup
187
+
188
+ ```ts
189
+ // convex/shared/auth-shared.ts
190
+ import { createAccessControl } from "better-auth/plugins/access";
191
+ import {
192
+ defaultStatements,
193
+ memberAc,
194
+ ownerAc,
195
+ } from "better-auth/plugins/organization/access";
196
+
197
+ const statement = { ...defaultStatements } as const;
198
+ export const ac = createAccessControl(statement);
199
+
200
+ const member = ac.newRole({ ...memberAc.statements });
201
+ const owner = ac.newRole({ ...ownerAc.statements });
202
+ export const roles = { member, owner };
203
+ ```
204
+
205
+ ### Custom Permissions
206
+
207
+ ```ts
208
+ const statement = {
209
+ ...defaultStatements,
210
+ project: ["create", "read", "update", "delete"],
211
+ billing: ["read", "update"],
212
+ analytics: ["read"],
213
+ } as const;
214
+
215
+ export const ac = createAccessControl(statement);
216
+
217
+ const viewer = ac.newRole({ project: ["read"], analytics: ["read"] });
218
+ const editor = ac.newRole({
219
+ ...memberAc.statements,
220
+ project: ["create", "read", "update"],
221
+ analytics: ["read"],
222
+ });
223
+ const admin = ac.newRole({
224
+ ...ownerAc.statements,
225
+ project: ["create", "read", "update", "delete"],
226
+ billing: ["read", "update"],
227
+ analytics: ["read"],
228
+ });
229
+ export const roles = { viewer, editor, admin };
230
+ ```
231
+
232
+ ### Check Role Permission
233
+
234
+ ```ts
235
+ const canEdit = ac.checkRolePermission({
236
+ role: "editor",
237
+ permission: { project: ["update"] },
238
+ });
239
+ ```
240
+
241
+ ### Dynamic Access Control
242
+
243
+ ```ts
244
+ organization({
245
+ ac: {
246
+ ...ac,
247
+ resolveRole: async ({ role, organizationId }) => {
248
+ if (roles[role]) return roles[role];
249
+ const customRole = await ctx.orm.query.customRole.findFirst({ where: { name: role, organizationId } });
250
+ if (customRole) return ac.newRole(customRole.permissions);
251
+ return null;
252
+ },
253
+ },
254
+ }),
255
+ ```
256
+
257
+ ### Permission Helper
258
+
259
+ ```ts
260
+ // convex/lib/auth/auth-helpers.ts
261
+ import { CRPCError } from "kitcn/server";
262
+ import type { AuthCtx } from "../crpc";
263
+
264
+ export const hasPermission = async (
265
+ ctx: AuthCtx,
266
+ body: { permissions: Record<string, string[]> },
267
+ shouldThrow = true
268
+ ) => {
269
+ const result = await ctx.auth.api.hasPermission({
270
+ body,
271
+ headers: ctx.auth.headers,
272
+ });
273
+ if (shouldThrow && !result.success) {
274
+ throw new CRPCError({
275
+ code: "FORBIDDEN",
276
+ message: "Insufficient permissions",
277
+ });
278
+ }
279
+ return result.success;
280
+ };
281
+ ```
282
+
283
+ ## Organization Functions
284
+
285
+ **Pattern:** Better Auth API for multi-table ops (create, delete, invitations). `ctx.orm` for simple reads/updates.
286
+
287
+ Example-parity helper module:
288
+
289
+ - `convex/lib/organization-helpers.ts` for shared organization listing and personal-organization bootstrap logic.
290
+
291
+ ### Check Slug
292
+
293
+ ```ts
294
+ export const checkSlug = authQuery
295
+ .input(z.object({ slug: z.string() }))
296
+ .output(z.object({ available: z.boolean() }))
297
+ .query(async ({ ctx, input }) => {
298
+ const existing = await ctx.orm.query.organization.findFirst({
299
+ where: { slug: input.slug },
300
+ });
301
+ return { available: !existing };
302
+ });
303
+ ```
304
+
305
+ ### List Organizations
306
+
307
+ ```ts
308
+ export const listOrganizations = authQuery
309
+ .output(
310
+ z.object({
311
+ canCreateOrganization: z.boolean(),
312
+ organizations: z.array(
313
+ z.object({
314
+ id: z.string(),
315
+ createdAt: z.date(),
316
+ isPersonal: z.boolean(),
317
+ logo: z.string().nullish(),
318
+ name: z.string(),
319
+ plan: z.string(),
320
+ slug: z.string(),
321
+ })
322
+ ),
323
+ })
324
+ )
325
+ .query(async ({ ctx }) => {
326
+ const orgs = await listUserOrganizations(ctx, ctx.userId);
327
+ if (!orgs?.length)
328
+ return { canCreateOrganization: true, organizations: [] };
329
+
330
+ const activeOrgId = ctx.user.activeOrganization?.id;
331
+ const organizations = orgs
332
+ .filter((org) => org.id !== activeOrgId)
333
+ .map((org) => ({
334
+ id: org.id,
335
+ createdAt: org.createdAt,
336
+ isPersonal: org.id === ctx.user.personalOrganizationId,
337
+ logo: org.logo || null,
338
+ name: org.name,
339
+ plan: DEFAULT_PLAN,
340
+ slug: org.slug,
341
+ }));
342
+ return { canCreateOrganization: true, organizations };
343
+ });
344
+ ```
345
+
346
+ ### Create Organization
347
+
348
+ ```ts
349
+ export const createOrganization = authMutation
350
+ .meta({ ratelimit: "organization/create" })
351
+ .input(z.object({ name: z.string().min(1).max(100) }))
352
+ .output(z.object({ id: z.string(), slug: z.string() }))
353
+ .mutation(async ({ ctx, input }) => {
354
+ let slug = input.name;
355
+ let attempt = 0;
356
+ while (attempt < 10) {
357
+ const existing = await ctx.orm.query.organization.findFirst({
358
+ where: { slug },
359
+ });
360
+ if (!existing) break;
361
+ slug = `${slug}-${Math.random().toString(36).slice(2, 10)}`;
362
+ attempt++;
363
+ }
364
+ if (attempt >= 10)
365
+ throw new CRPCError({
366
+ code: "BAD_REQUEST",
367
+ message: "Could not generate unique slug",
368
+ });
369
+
370
+ const org = await ctx.auth.api.createOrganization({
371
+ body: { name: input.name, slug },
372
+ headers: ctx.auth.headers,
373
+ });
374
+ if (!org)
375
+ throw new CRPCError({
376
+ code: "INTERNAL_SERVER_ERROR",
377
+ message: "Failed to create organization",
378
+ });
379
+
380
+ await setActiveOrganizationHandler(ctx, { organizationId: org.id });
381
+ return { id: org.id, slug: org.slug };
382
+ });
383
+ ```
384
+
385
+ ### Update Organization
386
+
387
+ ```ts
388
+ export const updateOrganization = authMutation
389
+ .meta({ ratelimit: "organization/update" })
390
+ .input(
391
+ z.object({
392
+ organizationId: z.string(),
393
+ logo: z.string().url().optional(),
394
+ name: z.string().min(1).max(100).optional(),
395
+ slug: z.string().optional(),
396
+ })
397
+ )
398
+
399
+ .mutation(async ({ ctx, input }) => {
400
+ await hasPermission(ctx, {
401
+ organizationId: input.organizationId,
402
+ permissions: { organization: ["update"] },
403
+ });
404
+
405
+ let slug = input.slug;
406
+ if (input.slug) {
407
+ if (input.organizationId === ctx.user.personalOrganizationId) {
408
+ slug = undefined;
409
+ } else {
410
+ slugSchema.parse(input.slug);
411
+ const existing = await ctx.orm.query.organization.findFirst({
412
+ where: { slug: input.slug },
413
+ });
414
+ if (existing && existing.id !== input.organizationId) {
415
+ throw new CRPCError({
416
+ code: "BAD_REQUEST",
417
+ message: "This slug is already taken",
418
+ });
419
+ }
420
+ }
421
+ }
422
+
423
+ const data: { logo?: string; name?: string; slug?: string } = {};
424
+ if (input.logo !== undefined) data.logo = input.logo;
425
+ if (input.name !== undefined) data.name = input.name;
426
+ if (slug !== undefined) data.slug = slug;
427
+
428
+ await ctx.auth.api.updateOrganization({
429
+ body: { data, organizationId: input.organizationId },
430
+ headers: ctx.auth.headers,
431
+ });
432
+ return null;
433
+ });
434
+ ```
435
+
436
+ ### Delete Organization
437
+
438
+ ```ts
439
+ export const deleteOrganization = authMutation
440
+ .input(z.object({ organizationId: z.string() }))
441
+
442
+ .mutation(async ({ ctx, input }) => {
443
+ await hasPermission(ctx, {
444
+ organizationId: input.organizationId,
445
+ permissions: { organization: ["delete"] },
446
+ });
447
+
448
+ if (input.organizationId === ctx.user.personalOrganizationId) {
449
+ throw new CRPCError({
450
+ code: "FORBIDDEN",
451
+ message:
452
+ "Personal organizations can be deleted only by deleting your account.",
453
+ });
454
+ }
455
+ if (input.organizationId === ctx.user.activeOrganization?.id) {
456
+ await setActiveOrganizationHandler(ctx, {
457
+ organizationId: ctx.user.personalOrganizationId!,
458
+ });
459
+ }
460
+
461
+ await ctx.auth.api.deleteOrganization({
462
+ body: { organizationId: input.organizationId },
463
+ headers: ctx.auth.headers,
464
+ });
465
+ return null;
466
+ });
467
+ ```
468
+
469
+ ### Set Active Organization
470
+
471
+ ```ts
472
+ export const setActiveOrganization = authMutation
473
+ .meta({ ratelimit: "organization/setActive" })
474
+ .input(z.object({ organizationId: z.string() }))
475
+
476
+ .mutation(async ({ ctx, input }) => setActiveOrganizationHandler(ctx, input));
477
+ ```
478
+
479
+ ## Invitation Functions
480
+
481
+ ### Send Invitation
482
+
483
+ ```ts
484
+ export const inviteMember = authMutation
485
+ .meta({ ratelimit: "organization/invite" })
486
+ .input(
487
+ z.object({
488
+ email: z.string().email(),
489
+ organizationId: z.string(),
490
+ role: z.enum(["owner", "member"]),
491
+ })
492
+ )
493
+
494
+ .mutation(async ({ ctx, input }) => {
495
+ await hasPermission(ctx, {
496
+ organizationId: input.organizationId,
497
+ permissions: { invitation: ["create"] },
498
+ });
499
+
500
+ // Check member limit
501
+ const members = await ctx.orm.query.member.findMany({
502
+ where: { organizationId: input.organizationId },
503
+ limit: DEFAULT_LIST_LIMIT,
504
+ });
505
+ const pending = await ctx.orm.query.invitation.findMany({
506
+ where: { organizationId: input.organizationId, status: "pending" },
507
+ limit: DEFAULT_LIST_LIMIT,
508
+ });
509
+ if (members.length + pending.length >= MEMBER_LIMIT) {
510
+ throw new CRPCError({
511
+ code: "FORBIDDEN",
512
+ message: `Organization member limit reached. Maximum ${MEMBER_LIMIT} members allowed.`,
513
+ });
514
+ }
515
+
516
+ // Cancel existing pending invites for same email
517
+ const existing = await ctx.orm.query.invitation.findMany({
518
+ where: {
519
+ email: input.email,
520
+ organizationId: input.organizationId,
521
+ status: "pending",
522
+ },
523
+ limit: DEFAULT_LIST_LIMIT,
524
+ });
525
+ for (const inv of existing) {
526
+ await ctx.orm
527
+ .update(invitation)
528
+ .set({ status: "canceled" })
529
+ .where(eq(invitation.id, inv.id));
530
+ }
531
+
532
+ await ctx.auth.api.createInvitation({
533
+ body: {
534
+ email: input.email,
535
+ organizationId: input.organizationId,
536
+ role: input.role,
537
+ },
538
+ headers: ctx.auth.headers,
539
+ });
540
+ return null;
541
+ });
542
+ ```
543
+
544
+ ### Accept / Reject / Cancel
545
+
546
+ ```ts
547
+ export const acceptInvitation = authMutation
548
+ .input(z.object({ invitationId: z.string() }))
549
+
550
+ .mutation(async ({ ctx, input }) => {
551
+ const inv = await ctx.orm.query.invitation
552
+ .findFirstOrThrow({
553
+ where: { id: input.invitationId, email: ctx.user.email },
554
+ })
555
+ .catch(() => {
556
+ throw new CRPCError({
557
+ code: "FORBIDDEN",
558
+ message: "Invitation not found for your email",
559
+ });
560
+ });
561
+
562
+ if (inv.status !== "pending")
563
+ throw new CRPCError({
564
+ code: "BAD_REQUEST",
565
+ message: "Invitation already processed",
566
+ });
567
+
568
+ await ctx.auth.api.acceptInvitation({
569
+ body: { invitationId: input.invitationId },
570
+ headers: ctx.auth.headers,
571
+ });
572
+ return null;
573
+ });
574
+
575
+ export const rejectInvitation = authMutation
576
+ .meta({ ratelimit: "organization/rejectInvite" })
577
+ .input(z.object({ invitationId: z.string() }))
578
+
579
+ .mutation(async ({ ctx, input }) => {
580
+ const inv = await ctx.orm.query.invitation
581
+ .findFirstOrThrow({
582
+ where: { id: input.invitationId, email: ctx.user.email },
583
+ })
584
+ .catch(() => {
585
+ throw new CRPCError({
586
+ code: "FORBIDDEN",
587
+ message: "Invitation not found for your email",
588
+ });
589
+ });
590
+
591
+ if (inv.status !== "pending")
592
+ throw new CRPCError({
593
+ code: "BAD_REQUEST",
594
+ message: "Invitation already processed",
595
+ });
596
+
597
+ await ctx.auth.api.rejectInvitation({
598
+ body: { invitationId: input.invitationId },
599
+ headers: ctx.auth.headers,
600
+ });
601
+ return null;
602
+ });
603
+
604
+ export const cancelInvitation = authMutation
605
+ .meta({ ratelimit: "organization/cancelInvite" })
606
+ .input(z.object({ invitationId: z.string() }))
607
+
608
+ .mutation(async ({ ctx, input }) => {
609
+ const inv = await ctx.orm.query.invitation.findFirstOrThrow({
610
+ where: { id: input.invitationId },
611
+ });
612
+ await hasPermission(ctx, {
613
+ organizationId: inv.organizationId,
614
+ permissions: { invitation: ["cancel"] },
615
+ });
616
+
617
+ try {
618
+ await ctx.auth.api.cancelInvitation({
619
+ body: { invitationId: input.invitationId },
620
+ headers: ctx.auth.headers,
621
+ });
622
+ } catch (error) {
623
+ if (error instanceof Error && error.message?.includes("not found")) {
624
+ throw new CRPCError({
625
+ code: "NOT_FOUND",
626
+ message: "Invitation not found or already processed",
627
+ });
628
+ }
629
+ throw new CRPCError({
630
+ code: "BAD_REQUEST",
631
+ message: `Failed: ${error instanceof Error ? error.message : "Unknown"}`,
632
+ });
633
+ }
634
+ return null;
635
+ });
636
+ ```
637
+
638
+ ### List User Invitations
639
+
640
+ ```ts
641
+ export const listUserInvitations = authQuery
642
+ .output(
643
+ z.array(
644
+ z.object({
645
+ id: z.string(),
646
+ expiresAt: z.date(),
647
+ inviterName: z.string().nullable(),
648
+ organizationName: z.string(),
649
+ organizationSlug: z.string(),
650
+ role: z.string(),
651
+ })
652
+ )
653
+ )
654
+ .query(async ({ ctx }) => {
655
+ const invitations = await ctx.orm.query.invitation.findMany({
656
+ where: { email: ctx.user.email, status: "pending" },
657
+ limit: DEFAULT_LIST_LIMIT,
658
+ columns: {
659
+ id: true,
660
+ expiresAt: true,
661
+ organizationId: true,
662
+ inviterId: true,
663
+ role: true,
664
+ },
665
+ with: {
666
+ organization: { columns: { name: true, slug: true } },
667
+ inviter: { columns: { name: true } },
668
+ },
669
+ });
670
+
671
+ return invitations.map((inv) => {
672
+ const org = inv.organization;
673
+ if (!org)
674
+ throw new CRPCError({
675
+ code: "NOT_FOUND",
676
+ message: "Organization not found",
677
+ });
678
+ return {
679
+ id: inv.id,
680
+ expiresAt: inv.expiresAt,
681
+ inviterName: inv.inviter?.name ?? null,
682
+ organizationName: org.name,
683
+ organizationSlug: org.slug,
684
+ role: inv.role || "member",
685
+ };
686
+ });
687
+ });
688
+ ```
689
+
690
+ ### List Pending Invitations
691
+
692
+ ```ts
693
+ export const listPendingInvitations = authQuery
694
+ .input(z.object({ slug: z.string() }))
695
+ .output(
696
+ z.array(
697
+ z.object({
698
+ id: z.string(),
699
+ createdAt: z.date(),
700
+ email: z.string(),
701
+ expiresAt: z.date(),
702
+ organizationId: z.string(),
703
+ role: z.string(),
704
+ status: z.string(),
705
+ })
706
+ )
707
+ )
708
+ .query(async ({ ctx, input }) => {
709
+ const org = await ctx.orm.query.organization.findFirst({
710
+ where: { slug: input.slug },
711
+ });
712
+ if (!org) return [];
713
+
714
+ const canManage = await hasPermission(
715
+ ctx,
716
+ { organizationId: org.id, permissions: { invitation: ["create"] } },
717
+ false
718
+ );
719
+ if (!canManage) return [];
720
+
721
+ const invitations = await ctx.orm.query.invitation.findMany({
722
+ where: { organizationId: org.id, status: "pending" },
723
+ limit: DEFAULT_LIST_LIMIT,
724
+ columns: {
725
+ id: true,
726
+ createdAt: true,
727
+ email: true,
728
+ expiresAt: true,
729
+ organizationId: true,
730
+ role: true,
731
+ status: true,
732
+ },
733
+ });
734
+
735
+ return invitations.map((inv) => ({
736
+ id: inv.id,
737
+ createdAt: inv.createdAt,
738
+ email: inv.email,
739
+ expiresAt: inv.expiresAt,
740
+ organizationId: inv.organizationId,
741
+ role: inv.role || "member",
742
+ status: inv.status,
743
+ }));
744
+ });
745
+ ```
746
+
747
+ ## Member Functions
748
+
749
+ ### Get Active Member
750
+
751
+ ```ts
752
+ export const getActiveMember = authQuery
753
+ .output(
754
+ z
755
+ .object({ id: z.string(), createdAt: z.date(), role: z.string() })
756
+ .nullable()
757
+ )
758
+ .query(async ({ ctx }) => {
759
+ if (!ctx.user.activeOrganization) return null;
760
+ const m = await ctx.orm.query.member.findFirst({
761
+ where: {
762
+ organizationId: ctx.user.activeOrganization.id,
763
+ userId: ctx.userId,
764
+ },
765
+ });
766
+ if (!m) return null;
767
+ return { id: m.id, createdAt: m.createdAt, role: m.role };
768
+ });
769
+ ```
770
+
771
+ ### Add Member Directly
772
+
773
+ ```ts
774
+ export const addMember = authMutation
775
+ .meta({ ratelimit: "organization/addMember" })
776
+ .input(z.object({ role: z.enum(["owner", "member"]), userId: z.string() }))
777
+
778
+ .mutation(async ({ ctx, input }) => {
779
+ await hasPermission(ctx, { permissions: { member: ["create"] } });
780
+ await ctx.auth.api.addMember({
781
+ body: {
782
+ userId: input.userId,
783
+ organizationId: ctx.user.activeOrganization!.id,
784
+ role: input.role,
785
+ },
786
+ headers: ctx.auth.headers,
787
+ });
788
+ return null;
789
+ });
790
+ ```
791
+
792
+ ### List Members
793
+
794
+ ```ts
795
+ export const listMembers = authQuery
796
+ .input(z.object({ slug: z.string() }))
797
+ .output(
798
+ z.object({
799
+ currentUserRole: z.string().optional(),
800
+ isPersonal: z.boolean(),
801
+ members: z.array(
802
+ z.object({
803
+ id: z.string(),
804
+ createdAt: z.date(),
805
+ organizationId: z.string(),
806
+ role: z.string(),
807
+ user: z.object({
808
+ id: z.string(),
809
+ email: z.string(),
810
+ image: z.string().nullish(),
811
+ name: z.string().nullable(),
812
+ }),
813
+ userId: z.string(),
814
+ })
815
+ ),
816
+ })
817
+ )
818
+ .query(async ({ ctx, input }) => {
819
+ const org = await ctx.orm.query.organization.findFirst({
820
+ where: { slug: input.slug },
821
+ });
822
+ if (!org) return { isPersonal: false, members: [] };
823
+
824
+ const currentMember = await ctx.orm.query.member.findFirst({
825
+ where: { organizationId: org.id, userId: ctx.userId },
826
+ });
827
+ if (!currentMember)
828
+ return {
829
+ isPersonal: org.id === ctx.user.personalOrganizationId,
830
+ members: [],
831
+ };
832
+
833
+ const members = await ctx.orm.query.member.findMany({
834
+ where: { organizationId: org.id },
835
+ limit: DEFAULT_LIST_LIMIT,
836
+ with: { user: true },
837
+ });
838
+ if (!members?.length)
839
+ return {
840
+ isPersonal: org.id === ctx.user.personalOrganizationId,
841
+ members: [],
842
+ };
843
+
844
+ const enriched = members
845
+ .map((m) => {
846
+ if (!m.user) return null;
847
+ return {
848
+ id: m.id,
849
+ createdAt: m.createdAt,
850
+ organizationId: org.id,
851
+ role: m.role,
852
+ user: {
853
+ id: m.user.id,
854
+ email: m.user.email,
855
+ image: m.user.image,
856
+ name: m.user.name,
857
+ },
858
+ userId: m.userId,
859
+ };
860
+ })
861
+ .filter((row): row is NonNullable<typeof row> => row !== null);
862
+
863
+ return {
864
+ currentUserRole: currentMember.role,
865
+ isPersonal: org.id === ctx.user.personalOrganizationId,
866
+ members: enriched,
867
+ };
868
+ });
869
+ ```
870
+
871
+ ### Update Member Role
872
+
873
+ ```ts
874
+ export const updateMemberRole = authMutation
875
+ .meta({ ratelimit: "organization/updateRole" })
876
+ .input(z.object({ memberId: z.string(), role: z.enum(["owner", "member"]) }))
877
+
878
+ .mutation(async ({ ctx, input }) => {
879
+ const m = await ctx.orm.query.member.findFirstOrThrow({
880
+ where: { id: input.memberId },
881
+ });
882
+ await hasPermission(ctx, {
883
+ organizationId: m.organizationId,
884
+ permissions: { member: ["update"] },
885
+ });
886
+
887
+ await ctx.auth.api.updateMemberRole({
888
+ body: {
889
+ memberId: input.memberId,
890
+ organizationId: m.organizationId,
891
+ role: input.role,
892
+ },
893
+ headers: ctx.auth.headers,
894
+ });
895
+ return null;
896
+ });
897
+ ```
898
+
899
+ ### Remove Member
900
+
901
+ ```ts
902
+ export const removeMember = authMutation
903
+ .meta({ ratelimit: "organization/removeMember" })
904
+ .input(z.object({ memberId: z.string() }))
905
+
906
+ .mutation(async ({ ctx, input }) => {
907
+ const m = await ctx.orm.query.member.findFirstOrThrow({
908
+ where: { id: input.memberId },
909
+ });
910
+ await hasPermission(ctx, {
911
+ organizationId: m.organizationId,
912
+ permissions: { member: ["delete"] },
913
+ });
914
+
915
+ await ctx.auth.api.removeMember({
916
+ body: {
917
+ memberIdOrEmail: input.memberId,
918
+ organizationId: m.organizationId,
919
+ },
920
+ headers: ctx.auth.headers,
921
+ });
922
+ return null;
923
+ });
924
+ ```
925
+
926
+ ### Leave Organization
927
+
928
+ ```ts
929
+ export const leaveOrganization = authMutation
930
+ .meta({ ratelimit: "organization/leave" })
931
+ .input(z.object({ organizationId: z.string() }))
932
+
933
+ .mutation(async ({ ctx, input }) => {
934
+ if (input.organizationId === ctx.user.personalOrganizationId) {
935
+ throw new CRPCError({
936
+ code: "BAD_REQUEST",
937
+ message: "Cannot leave personal organization",
938
+ });
939
+ }
940
+
941
+ const currentMember = await ctx.orm.query.member
942
+ .findFirstOrThrow({
943
+ where: { organizationId: input.organizationId, userId: ctx.userId },
944
+ })
945
+ .catch(() => {
946
+ throw new CRPCError({ code: "FORBIDDEN", message: "Not a member" });
947
+ });
948
+
949
+ if (currentMember.role === "owner") {
950
+ const owners = await ctx.orm.query.member.findMany({
951
+ where: { organizationId: input.organizationId, role: "owner" },
952
+ limit: 2,
953
+ });
954
+ if (owners.length <= 1) {
955
+ throw new CRPCError({
956
+ code: "FORBIDDEN",
957
+ message: "Cannot leave as the only owner. Transfer ownership first.",
958
+ });
959
+ }
960
+ }
961
+
962
+ await ctx.auth.api.leaveOrganization({
963
+ body: { organizationId: input.organizationId },
964
+ headers: ctx.auth.headers,
965
+ });
966
+
967
+ if (input.organizationId === ctx.user.activeOrganization?.id) {
968
+ await setActiveOrganizationHandler(ctx, {
969
+ organizationId: ctx.user.personalOrganizationId!,
970
+ });
971
+ }
972
+ return null;
973
+ });
974
+ ```
975
+
976
+ ## Teams
977
+
978
+ Use Better Auth team APIs directly:
979
+
980
+ ```ts
981
+ // List teams
982
+ const teams = await ctx.auth.api.listTeams({
983
+ query: { organizationId: ctx.user.activeOrganization!.id },
984
+ headers: ctx.auth.headers,
985
+ });
986
+
987
+ // Add/remove member
988
+ await ctx.auth.api.addTeamMember({
989
+ body: { teamId, userId },
990
+ headers: ctx.auth.headers,
991
+ });
992
+ await ctx.auth.api.removeTeamMember({
993
+ body: { teamId, userId },
994
+ headers: ctx.auth.headers,
995
+ });
996
+
997
+ // List team members
998
+ const members = await ctx.auth.api.listTeamMembers({
999
+ body: { teamId },
1000
+ headers: ctx.auth.headers,
1001
+ });
1002
+ ```
1003
+
1004
+ ## Hooks
1005
+
1006
+ ### Organization Hooks
1007
+
1008
+ ```ts
1009
+ organization({
1010
+ organizationCreation: {
1011
+ beforeCreate: async ({ organization, user }) => { return { data: organization }; },
1012
+ afterCreate: async ({ organization, member, user }) => { /* setup defaults */ },
1013
+ },
1014
+ organizationDeletion: {
1015
+ beforeDelete: async (data) => { /* cleanup */ },
1016
+ afterDelete: async (data) => { /* post-cleanup */ },
1017
+ },
1018
+ }),
1019
+ ```
1020
+
1021
+ ### Member Hooks
1022
+
1023
+ ```ts
1024
+ organization({
1025
+ membershipManagement: {
1026
+ beforeAddMember: async ({ organization, member, user }) => { return { data: member }; },
1027
+ afterAddMember: async ({ organization, member, user }) => { /* notifications */ },
1028
+ beforeRemoveMember: async ({ organization, member, user }) => { /* cleanup */ },
1029
+ afterRemoveMember: async ({ organization, member, user }) => { /* post-removal */ },
1030
+ beforeUpdateRole: async ({ organization, member, role }) => { return { data: { role } }; },
1031
+ afterUpdateRole: async ({ organization, member, role }) => { /* notifications */ },
1032
+ },
1033
+ }),
1034
+ ```
1035
+
1036
+ ### Invitation Hooks
1037
+
1038
+ ```ts
1039
+ organization({
1040
+ invitationManagement: {
1041
+ beforeCreateInvitation: async ({ invitation, organization, inviter }) => { return { data: invitation }; },
1042
+ afterCreateInvitation: async ({ invitation, organization, inviter }) => { /* notify */ },
1043
+ beforeAcceptInvitation: async ({ invitation, user }) => { return { data: invitation }; },
1044
+ afterAcceptInvitation: async ({ invitation, member, user }) => { /* welcome */ },
1045
+ },
1046
+ }),
1047
+ ```
1048
+
1049
+ ### Team Hooks
1050
+
1051
+ ```ts
1052
+ organization({
1053
+ teamManagement: {
1054
+ beforeCreateTeam: async ({ team, organization }) => { return { data: team }; },
1055
+ afterCreateTeam: async ({ team, organization }) => {},
1056
+ beforeAddTeamMember: async ({ team, user }) => { return { data: { team, user } }; },
1057
+ afterAddTeamMember: async ({ team, user }) => {},
1058
+ },
1059
+ }),
1060
+ ```
1061
+
1062
+ ## Client Usage
1063
+
1064
+ ### React Hooks
1065
+
1066
+ ```tsx
1067
+ const { data: activeOrg } = authClient.useActiveOrganization();
1068
+ const { data: orgs } = authClient.useListOrganizations();
1069
+ authClient.organization.setActive({ organizationId: orgId });
1070
+ authClient.organization.create({ name: "New Org", slug: "new-org" });
1071
+ ```
1072
+
1073
+ ### Get Full Organization
1074
+
1075
+ ```ts
1076
+ const { data: fullOrg } = await authClient.organization.getFullOrganization({
1077
+ query: { organizationId: orgId },
1078
+ });
1079
+ // Returns: organization + members + invitations
1080
+ ```
1081
+
1082
+ ### Invitation Operations (Client)
1083
+
1084
+ ```ts
1085
+ const { data: invitations } = await authClient.organization.listInvitations();
1086
+ await authClient.organization.acceptInvitation({ invitationId });
1087
+ await authClient.organization.rejectInvitation({ invitationId });
1088
+ await authClient.organization.cancelInvitation({ invitationId });
1089
+ ```
1090
+
1091
+ ### Member Operations (Client)
1092
+
1093
+ ```ts
1094
+ const { data: member } = await authClient.organization.getActiveMember();
1095
+ await authClient.organization.leave();
1096
+ await authClient.organization.removeMember({ memberIdOrEmail });
1097
+ await authClient.organization.updateMemberRole({ memberId, role: "admin" });
1098
+ ```
1099
+
1100
+ ### Permission Check (Client)
1101
+
1102
+ ```ts
1103
+ const { data } = await authClient.organization.hasPermission({
1104
+ permissions: { organization: ["delete"] },
1105
+ });
1106
+ if (data?.success) {
1107
+ /* show delete button */
1108
+ }
1109
+ ```
1110
+
1111
+ ### Team Operations (Client)
1112
+
1113
+ ```ts
1114
+ const { data: teams } = await authClient.organization.listTeams();
1115
+ await authClient.organization.createTeam({ name: "Engineering" });
1116
+ await authClient.organization.setActiveTeam({ teamId });
1117
+ ```
1118
+
1119
+ ## API Reference
1120
+
1121
+ | Operation | Method | Multi-table |
1122
+ | ------------------ | --------------- | ----------- |
1123
+ | Create org | Better Auth API | Yes |
1124
+ | Update org | Better Auth API | No |
1125
+ | Delete org | Better Auth API | Yes |
1126
+ | List orgs | ORM | No |
1127
+ | Check slug | ORM | No |
1128
+ | Invite member | Better Auth API | Yes |
1129
+ | Accept invite | Better Auth API | Yes |
1130
+ | Reject invite | Better Auth API | Yes |
1131
+ | Cancel invite | Better Auth API | Yes |
1132
+ | List user invites | ORM | No |
1133
+ | Add member | Better Auth API | Yes |
1134
+ | Update role | Better Auth API | Yes |
1135
+ | Remove member | Better Auth API | Yes |
1136
+ | Leave org | Better Auth API | Yes |
1137
+ | Create team | Better Auth API | Yes |
1138
+ | Add team member | Better Auth API | Yes |
1139
+ | Remove team member | Better Auth API | Yes |
1140
+
1141
+ Use Better Auth API for multi-table operations. Use `ctx.orm` for simple single-table reads/updates.