@korajs/auth 1.0.0-beta.12 → 1.0.0-beta.14

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 (101) hide show
  1. package/README.md +52 -47
  2. package/dist/{create-org-session-RsDj9cl4.d.cts → create-org-session-ChFdulEM.d.cts} +211 -17
  3. package/dist/{create-org-session-RsDj9cl4.d.ts → create-org-session-ChFdulEM.d.ts} +211 -17
  4. package/dist/index.cjs +645 -150
  5. package/dist/index.cjs.map +1 -1
  6. package/dist/index.d.cts +27 -9
  7. package/dist/index.d.ts +27 -9
  8. package/dist/index.js +644 -150
  9. package/dist/index.js.map +1 -1
  10. package/dist/{operation-encryptor-DRmKNWpF.d.cts → operation-encryptor-DDdlb9bm.d.cts} +16 -0
  11. package/dist/{operation-encryptor-DRmKNWpF.d.ts → operation-encryptor-DDdlb9bm.d.ts} +16 -0
  12. package/dist/react.d.cts +2 -2
  13. package/dist/react.d.ts +2 -2
  14. package/dist/server.cjs +2880 -1667
  15. package/dist/server.cjs.map +1 -1
  16. package/dist/server.d.cts +810 -169
  17. package/dist/server.d.ts +810 -169
  18. package/dist/server.js +2848 -1646
  19. package/dist/server.js.map +1 -1
  20. package/dist/svelte.cjs +2 -2
  21. package/dist/svelte.cjs.map +1 -1
  22. package/dist/svelte.d.cts +2 -2
  23. package/dist/svelte.d.ts +2 -2
  24. package/dist/svelte.js +2 -2
  25. package/dist/svelte.js.map +1 -1
  26. package/dist/vue.d.cts +1 -1
  27. package/dist/vue.d.ts +1 -1
  28. package/package.json +7 -7
  29. package/src/admin/admin-api.ts +327 -0
  30. package/src/admin/audit-log.ts +324 -0
  31. package/src/admin/webhooks.ts +576 -0
  32. package/src/bindings/create-auth-session.ts +184 -0
  33. package/src/bindings/create-org-session.ts +130 -0
  34. package/src/client/auth-client.ts +1592 -0
  35. package/src/client/auth-sync.ts +213 -0
  36. package/src/client/device-session.ts +104 -0
  37. package/src/client/org-client.ts +399 -0
  38. package/src/client/quickstart.ts +108 -0
  39. package/src/client/storage.ts +94 -0
  40. package/src/device/device-identity.ts +330 -0
  41. package/src/device/device-store.ts +379 -0
  42. package/src/encryption/auto-lock.ts +170 -0
  43. package/src/encryption/database-encryption.ts +265 -0
  44. package/src/encryption/key-derivation.ts +149 -0
  45. package/src/encryption/operation-encryptor.ts +361 -0
  46. package/src/index.ts +132 -0
  47. package/src/mfa/totp.ts +826 -0
  48. package/src/org/org-routes.ts +758 -0
  49. package/src/org/org-store.ts +490 -0
  50. package/src/org/org-types.ts +230 -0
  51. package/src/passkey/passkey-client.ts +597 -0
  52. package/src/passkey/passkey-server.ts +779 -0
  53. package/src/postgres/ensure-schema.ts +65 -0
  54. package/src/provider/adapter.ts +246 -0
  55. package/src/provider/built-in/auth-routes.ts +1313 -0
  56. package/src/provider/built-in/email-verification.ts +303 -0
  57. package/src/provider/built-in/password-hash.ts +118 -0
  58. package/src/provider/built-in/password-reset.ts +416 -0
  59. package/src/provider/built-in/postgres-user-store.ts +365 -0
  60. package/src/provider/built-in/quickstart-server.ts +760 -0
  61. package/src/provider/built-in/sqlite-user-store.ts +335 -0
  62. package/src/provider/built-in/sync-scopes.ts +85 -0
  63. package/src/provider/built-in/user-store.ts +465 -0
  64. package/src/provider/external/clerk-adapter.ts +157 -0
  65. package/src/provider/external/external-jwt-provider.ts +491 -0
  66. package/src/provider/external/supabase-adapter.ts +163 -0
  67. package/src/provider/oauth/linked-identity-store.ts +108 -0
  68. package/src/provider/oauth/oauth-flow.ts +550 -0
  69. package/src/provider/oauth/oauth-types.ts +184 -0
  70. package/src/provider/oauth/postgres-oauth-store.ts +296 -0
  71. package/src/provider/oauth/sqlite-oauth-store.ts +285 -0
  72. package/src/rbac/rbac-engine.ts +323 -0
  73. package/src/rbac/rbac-types.ts +210 -0
  74. package/src/rbac/scope-resolver.ts +140 -0
  75. package/src/react/AuthProvider.tsx +97 -0
  76. package/src/react/OrgProvider.tsx +41 -0
  77. package/src/react/auth-context.ts +26 -0
  78. package/src/react/hooks.ts +110 -0
  79. package/src/react/org-hooks.ts +214 -0
  80. package/src/react.ts +26 -0
  81. package/src/server.ts +338 -0
  82. package/src/session/session.ts +401 -0
  83. package/src/svelte/auth-context.ts +50 -0
  84. package/src/svelte/org-context.ts +32 -0
  85. package/src/svelte/org-hooks.ts +201 -0
  86. package/src/svelte/use-auth.ts +115 -0
  87. package/src/svelte.ts +25 -0
  88. package/src/tokens/encrypted-token-store.ts +360 -0
  89. package/src/tokens/jwt.ts +236 -0
  90. package/src/tokens/postgres-token-revocation-store.ts +140 -0
  91. package/src/tokens/sqlite-token-revocation-store.ts +121 -0
  92. package/src/tokens/token-manager.ts +821 -0
  93. package/src/tokens/token-store.ts +192 -0
  94. package/src/types.ts +394 -0
  95. package/src/vue/auth-context.ts +10 -0
  96. package/src/vue/auth-provider-types.ts +5 -0
  97. package/src/vue/auth-provider.ts +76 -0
  98. package/src/vue/org-hooks.ts +193 -0
  99. package/src/vue/org-provider.ts +49 -0
  100. package/src/vue/use-auth.ts +139 -0
  101. package/src/vue.ts +10 -0
@@ -0,0 +1,490 @@
1
+ import type {
2
+ CreateInvitationParams,
3
+ CreateOrgParams,
4
+ InvitationStatus,
5
+ Membership,
6
+ OrgInvitation,
7
+ OrgRole,
8
+ Organization,
9
+ UpdateOrgParams,
10
+ } from './org-types'
11
+ import {
12
+ CannotRemoveOwnerError,
13
+ InvitationExpiredError,
14
+ InvitationNotFoundError,
15
+ MemberAlreadyExistsError,
16
+ MembershipNotFoundError,
17
+ OrgNotFoundError,
18
+ OrgSlugTakenError,
19
+ } from './org-types'
20
+
21
+ // ============================================================================
22
+ // OrgStore interface
23
+ // ============================================================================
24
+
25
+ /**
26
+ * Persistence interface for organizations, memberships, and invitations.
27
+ *
28
+ * Implement this interface to back organizations with any storage:
29
+ * - `InMemoryOrgStore` for development and testing
30
+ * - PostgreSQL/MySQL via Drizzle for production
31
+ * - SQLite for self-hosted or embedded scenarios
32
+ *
33
+ * All methods are async to support any storage backend.
34
+ */
35
+ export interface OrgStore {
36
+ // --- Organizations ---
37
+
38
+ /** Create a new organization. The caller becomes the owner. */
39
+ createOrg(ownerId: string, params: CreateOrgParams): Promise<Organization>
40
+
41
+ /** Get an organization by ID. Returns null if not found. */
42
+ getOrg(orgId: string): Promise<Organization | null>
43
+
44
+ /** Get an organization by slug. Returns null if not found. */
45
+ getOrgBySlug(slug: string): Promise<Organization | null>
46
+
47
+ /** Update an organization's mutable fields. */
48
+ updateOrg(orgId: string, params: UpdateOrgParams): Promise<Organization>
49
+
50
+ /** Delete an organization and all its memberships and invitations. */
51
+ deleteOrg(orgId: string): Promise<void>
52
+
53
+ /** List all organizations a user is a member of. */
54
+ listUserOrgs(userId: string): Promise<Organization[]>
55
+
56
+ // --- Memberships ---
57
+
58
+ /** Add a user as a member of an organization. */
59
+ addMember(
60
+ orgId: string,
61
+ userId: string,
62
+ role: OrgRole,
63
+ invitedBy: string | null,
64
+ ): Promise<Membership>
65
+
66
+ /** Remove a user from an organization. Cannot remove the owner. */
67
+ removeMember(orgId: string, userId: string): Promise<void>
68
+
69
+ /** Update a member's role within an organization. */
70
+ updateMemberRole(orgId: string, userId: string, role: OrgRole): Promise<Membership>
71
+
72
+ /** List all members of an organization. */
73
+ listMembers(orgId: string): Promise<Membership[]>
74
+
75
+ /** Get a specific user's membership in an organization. Returns null if not a member. */
76
+ getMembership(orgId: string, userId: string): Promise<Membership | null>
77
+
78
+ /** Transfer ownership of an organization to another member. */
79
+ transferOwnership(orgId: string, newOwnerId: string): Promise<void>
80
+
81
+ // --- Invitations ---
82
+
83
+ /** Create an invitation to join an organization. */
84
+ createInvitation(
85
+ orgId: string,
86
+ invitedBy: string,
87
+ params: CreateInvitationParams,
88
+ ): Promise<OrgInvitation>
89
+
90
+ /** Look up an invitation by its single-use token. Returns null if not found or already consumed. */
91
+ getInvitationByToken(token: string): Promise<OrgInvitation | null>
92
+
93
+ /** Consume an invitation (mark as accepted). Returns the invitation details. */
94
+ consumeInvitation(token: string): Promise<OrgInvitation>
95
+
96
+ /** Revoke a pending invitation. */
97
+ /**
98
+ * Revoke a pending invitation of `orgId`. Must throw InvitationNotFoundError
99
+ * when the invitation belongs to another org (AUTH-4).
100
+ */
101
+ revokeInvitation(orgId: string, invitationId: string): Promise<void>
102
+
103
+ /** List pending invitations for an organization. */
104
+ listPendingInvitations(orgId: string): Promise<OrgInvitation[]>
105
+
106
+ /** List pending invitations for a specific email address. */
107
+ listInvitationsForEmail(email: string): Promise<OrgInvitation[]>
108
+
109
+ /** Remove expired invitations. Returns count of removed invitations. */
110
+ cleanExpiredInvitations(): Promise<number>
111
+ }
112
+
113
+ // ============================================================================
114
+ // InMemoryOrgStore
115
+ // ============================================================================
116
+
117
+ /**
118
+ * In-memory implementation of OrgStore for development and testing.
119
+ *
120
+ * Data is lost when the process exits. For production, implement OrgStore
121
+ * with a persistent backend (PostgreSQL, MySQL, SQLite via Drizzle).
122
+ *
123
+ * @example
124
+ * ```typescript
125
+ * const orgStore = new InMemoryOrgStore()
126
+ * const org = await orgStore.createOrg('user-1', { name: 'Acme Inc', slug: 'acme' })
127
+ * ```
128
+ */
129
+ export class InMemoryOrgStore implements OrgStore {
130
+ private orgs = new Map<string, Organization>()
131
+ private memberships = new Map<string, Membership>()
132
+ private invitations = new Map<string, OrgInvitation>()
133
+
134
+ // --- Organizations ---
135
+
136
+ async createOrg(ownerId: string, params: CreateOrgParams): Promise<Organization> {
137
+ const slug = params.slug ?? slugify(params.name)
138
+
139
+ // Check slug uniqueness
140
+ for (const org of this.orgs.values()) {
141
+ if (org.slug === slug) {
142
+ throw new OrgSlugTakenError()
143
+ }
144
+ }
145
+
146
+ const now = Date.now()
147
+ const org: Organization = {
148
+ id: generateId(),
149
+ name: params.name,
150
+ slug,
151
+ ownerId,
152
+ createdAt: now,
153
+ updatedAt: now,
154
+ metadata: params.metadata ?? {},
155
+ }
156
+
157
+ this.orgs.set(org.id, org)
158
+
159
+ // Add the creator as owner
160
+ await this.addMember(org.id, ownerId, 'owner', null)
161
+
162
+ return org
163
+ }
164
+
165
+ async getOrg(orgId: string): Promise<Organization | null> {
166
+ return this.orgs.get(orgId) ?? null
167
+ }
168
+
169
+ async getOrgBySlug(slug: string): Promise<Organization | null> {
170
+ for (const org of this.orgs.values()) {
171
+ if (org.slug === slug) return org
172
+ }
173
+ return null
174
+ }
175
+
176
+ async updateOrg(orgId: string, params: UpdateOrgParams): Promise<Organization> {
177
+ const org = this.orgs.get(orgId)
178
+ if (!org) throw new OrgNotFoundError()
179
+
180
+ if (params.slug !== undefined && params.slug !== org.slug) {
181
+ // Check slug uniqueness
182
+ for (const existing of this.orgs.values()) {
183
+ if (existing.slug === params.slug && existing.id !== orgId) {
184
+ throw new OrgSlugTakenError()
185
+ }
186
+ }
187
+ }
188
+
189
+ const updated: Organization = {
190
+ ...org,
191
+ name: params.name ?? org.name,
192
+ slug: params.slug ?? org.slug,
193
+ updatedAt: Date.now(),
194
+ metadata: params.metadata ? { ...org.metadata, ...params.metadata } : org.metadata,
195
+ }
196
+
197
+ this.orgs.set(orgId, updated)
198
+ return updated
199
+ }
200
+
201
+ async deleteOrg(orgId: string): Promise<void> {
202
+ if (!this.orgs.has(orgId)) throw new OrgNotFoundError()
203
+
204
+ // Remove all memberships
205
+ for (const [id, membership] of this.memberships) {
206
+ if (membership.orgId === orgId) {
207
+ this.memberships.delete(id)
208
+ }
209
+ }
210
+
211
+ // Remove all invitations
212
+ for (const [id, invitation] of this.invitations) {
213
+ if (invitation.orgId === orgId) {
214
+ this.invitations.delete(id)
215
+ }
216
+ }
217
+
218
+ this.orgs.delete(orgId)
219
+ }
220
+
221
+ async listUserOrgs(userId: string): Promise<Organization[]> {
222
+ const orgIds = new Set<string>()
223
+ for (const membership of this.memberships.values()) {
224
+ if (membership.userId === userId) {
225
+ orgIds.add(membership.orgId)
226
+ }
227
+ }
228
+
229
+ const result: Organization[] = []
230
+ for (const orgId of orgIds) {
231
+ const org = this.orgs.get(orgId)
232
+ if (org) result.push(org)
233
+ }
234
+ return result
235
+ }
236
+
237
+ // --- Memberships ---
238
+
239
+ async addMember(
240
+ orgId: string,
241
+ userId: string,
242
+ role: OrgRole,
243
+ invitedBy: string | null,
244
+ ): Promise<Membership> {
245
+ if (!this.orgs.has(orgId)) throw new OrgNotFoundError()
246
+
247
+ // Check if already a member
248
+ for (const membership of this.memberships.values()) {
249
+ if (membership.orgId === orgId && membership.userId === userId) {
250
+ throw new MemberAlreadyExistsError()
251
+ }
252
+ }
253
+
254
+ const membership: Membership = {
255
+ id: generateId(),
256
+ orgId,
257
+ userId,
258
+ role,
259
+ invitedBy,
260
+ joinedAt: Date.now(),
261
+ metadata: {},
262
+ }
263
+
264
+ this.memberships.set(membership.id, membership)
265
+ return membership
266
+ }
267
+
268
+ async removeMember(orgId: string, userId: string): Promise<void> {
269
+ const org = this.orgs.get(orgId)
270
+ if (!org) throw new OrgNotFoundError()
271
+
272
+ if (org.ownerId === userId) {
273
+ throw new CannotRemoveOwnerError()
274
+ }
275
+
276
+ let found = false
277
+ for (const [id, membership] of this.memberships) {
278
+ if (membership.orgId === orgId && membership.userId === userId) {
279
+ this.memberships.delete(id)
280
+ found = true
281
+ break
282
+ }
283
+ }
284
+
285
+ if (!found) throw new MembershipNotFoundError()
286
+ }
287
+
288
+ async updateMemberRole(orgId: string, userId: string, role: OrgRole): Promise<Membership> {
289
+ for (const [id, membership] of this.memberships) {
290
+ if (membership.orgId === orgId && membership.userId === userId) {
291
+ const updated = { ...membership, role }
292
+ this.memberships.set(id, updated)
293
+
294
+ // If promoting to owner, update the org's ownerId and demote previous owner
295
+ if (role === 'owner') {
296
+ const org = this.orgs.get(orgId)
297
+ if (org) {
298
+ // Demote previous owner to admin
299
+ for (const [mId, m] of this.memberships) {
300
+ if (m.orgId === orgId && m.userId === org.ownerId && m.userId !== userId) {
301
+ this.memberships.set(mId, { ...m, role: 'admin' })
302
+ }
303
+ }
304
+ this.orgs.set(orgId, { ...org, ownerId: userId, updatedAt: Date.now() })
305
+ }
306
+ }
307
+
308
+ return updated
309
+ }
310
+ }
311
+
312
+ throw new MembershipNotFoundError()
313
+ }
314
+
315
+ async listMembers(orgId: string): Promise<Membership[]> {
316
+ if (!this.orgs.has(orgId)) throw new OrgNotFoundError()
317
+
318
+ const result: Membership[] = []
319
+ for (const membership of this.memberships.values()) {
320
+ if (membership.orgId === orgId) {
321
+ result.push(membership)
322
+ }
323
+ }
324
+ return result
325
+ }
326
+
327
+ async getMembership(orgId: string, userId: string): Promise<Membership | null> {
328
+ for (const membership of this.memberships.values()) {
329
+ if (membership.orgId === orgId && membership.userId === userId) {
330
+ return membership
331
+ }
332
+ }
333
+ return null
334
+ }
335
+
336
+ async transferOwnership(orgId: string, newOwnerId: string): Promise<void> {
337
+ const org = this.orgs.get(orgId)
338
+ if (!org) throw new OrgNotFoundError()
339
+
340
+ // Verify new owner is a member
341
+ const membership = await this.getMembership(orgId, newOwnerId)
342
+ if (!membership) throw new MembershipNotFoundError()
343
+
344
+ // Demote current owner to admin
345
+ await this.updateMemberRole(orgId, org.ownerId, 'admin')
346
+
347
+ // Promote new owner
348
+ await this.updateMemberRole(orgId, newOwnerId, 'owner')
349
+ }
350
+
351
+ // --- Invitations ---
352
+
353
+ async createInvitation(
354
+ orgId: string,
355
+ invitedBy: string,
356
+ params: CreateInvitationParams,
357
+ ): Promise<OrgInvitation> {
358
+ if (!this.orgs.has(orgId)) throw new OrgNotFoundError()
359
+
360
+ const now = Date.now()
361
+ const invitation: OrgInvitation = {
362
+ id: generateId(),
363
+ orgId,
364
+ email: params.email.toLowerCase().trim(),
365
+ role: params.role,
366
+ invitedBy,
367
+ token: generateToken(),
368
+ createdAt: now,
369
+ expiresAt: now + 7 * 24 * 60 * 60 * 1000, // 7 days
370
+ status: 'pending',
371
+ }
372
+
373
+ this.invitations.set(invitation.id, invitation)
374
+ return invitation
375
+ }
376
+
377
+ async getInvitationByToken(token: string): Promise<OrgInvitation | null> {
378
+ for (const invitation of this.invitations.values()) {
379
+ if (invitation.token === token && invitation.status === 'pending') {
380
+ return invitation
381
+ }
382
+ }
383
+ return null
384
+ }
385
+
386
+ async consumeInvitation(token: string): Promise<OrgInvitation> {
387
+ for (const [id, invitation] of this.invitations) {
388
+ if (invitation.token === token) {
389
+ if (invitation.status !== 'pending') {
390
+ throw new InvitationNotFoundError()
391
+ }
392
+ if (Date.now() > invitation.expiresAt) {
393
+ this.invitations.set(id, { ...invitation, status: 'expired' })
394
+ throw new InvitationExpiredError()
395
+ }
396
+
397
+ const consumed = { ...invitation, status: 'accepted' as InvitationStatus }
398
+ this.invitations.set(id, consumed)
399
+ return consumed
400
+ }
401
+ }
402
+
403
+ throw new InvitationNotFoundError()
404
+ }
405
+
406
+ async revokeInvitation(orgId: string, invitationId: string): Promise<void> {
407
+ const invitation = this.invitations.get(invitationId)
408
+ if (!invitation || invitation.orgId !== orgId || invitation.status !== 'pending') {
409
+ throw new InvitationNotFoundError()
410
+ }
411
+ this.invitations.set(invitationId, { ...invitation, status: 'revoked' })
412
+ }
413
+
414
+ async listPendingInvitations(orgId: string): Promise<OrgInvitation[]> {
415
+ if (!this.orgs.has(orgId)) throw new OrgNotFoundError()
416
+
417
+ const result: OrgInvitation[] = []
418
+ const now = Date.now()
419
+ for (const invitation of this.invitations.values()) {
420
+ if (invitation.orgId === orgId && invitation.status === 'pending') {
421
+ if (now > invitation.expiresAt) {
422
+ // Auto-expire
423
+ continue
424
+ }
425
+ result.push(invitation)
426
+ }
427
+ }
428
+ return result
429
+ }
430
+
431
+ async listInvitationsForEmail(email: string): Promise<OrgInvitation[]> {
432
+ const normalizedEmail = email.toLowerCase().trim()
433
+ const result: OrgInvitation[] = []
434
+ const now = Date.now()
435
+ for (const invitation of this.invitations.values()) {
436
+ if (
437
+ invitation.email === normalizedEmail &&
438
+ invitation.status === 'pending' &&
439
+ now <= invitation.expiresAt
440
+ ) {
441
+ result.push(invitation)
442
+ }
443
+ }
444
+ return result
445
+ }
446
+
447
+ async cleanExpiredInvitations(): Promise<number> {
448
+ let count = 0
449
+ const now = Date.now()
450
+ for (const [id, invitation] of this.invitations) {
451
+ if (invitation.status === 'pending' && now > invitation.expiresAt) {
452
+ this.invitations.set(id, { ...invitation, status: 'expired' })
453
+ count++
454
+ }
455
+ }
456
+ return count
457
+ }
458
+ }
459
+
460
+ // ============================================================================
461
+ // Internal helpers
462
+ // ============================================================================
463
+
464
+ function generateId(): string {
465
+ return globalThis.crypto.randomUUID()
466
+ }
467
+
468
+ function generateToken(): string {
469
+ const bytes = new Uint8Array(32)
470
+ globalThis.crypto.getRandomValues(bytes)
471
+ let binary = ''
472
+ for (let i = 0; i < bytes.length; i++) {
473
+ binary += String.fromCharCode(bytes[i] as number)
474
+ }
475
+ return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
476
+ }
477
+
478
+ /**
479
+ * Convert a name to a URL-friendly slug.
480
+ * Lowercase, replace spaces/special chars with hyphens, collapse multiple hyphens.
481
+ */
482
+ function slugify(name: string): string {
483
+ return name
484
+ .toLowerCase()
485
+ .trim()
486
+ .replace(/[^a-z0-9\s-]/g, '')
487
+ .replace(/[\s]+/g, '-')
488
+ .replace(/-+/g, '-')
489
+ .replace(/^-|-$/g, '')
490
+ }
@@ -0,0 +1,230 @@
1
+ import { KoraError } from '@korajs/core'
2
+
3
+ // ============================================================================
4
+ // Organization
5
+ // ============================================================================
6
+
7
+ /**
8
+ * An organization (workspace/team) that groups users together.
9
+ *
10
+ * Organizations are the fundamental unit of multi-tenancy in Kora.
11
+ * Data is scoped to organizations, and users access data through
12
+ * their organization memberships and roles.
13
+ */
14
+ export interface Organization {
15
+ /** Unique identifier (UUID v7) */
16
+ id: string
17
+ /** Display name of the organization */
18
+ name: string
19
+ /** URL-friendly identifier (unique, lowercase, alphanumeric + hyphens) */
20
+ slug: string
21
+ /** User ID of the organization owner */
22
+ ownerId: string
23
+ /** When the organization was created (ms since epoch) */
24
+ createdAt: number
25
+ /** When the organization was last updated (ms since epoch) */
26
+ updatedAt: number
27
+ /** Arbitrary metadata (plan, billing info, settings, etc.) */
28
+ metadata: Record<string, unknown>
29
+ }
30
+
31
+ /**
32
+ * Parameters for creating a new organization.
33
+ */
34
+ export interface CreateOrgParams {
35
+ /** Display name */
36
+ name: string
37
+ /** URL-friendly slug (auto-generated from name if omitted) */
38
+ slug?: string
39
+ /** Optional metadata to attach */
40
+ metadata?: Record<string, unknown>
41
+ }
42
+
43
+ /**
44
+ * Parameters for updating an existing organization.
45
+ */
46
+ export interface UpdateOrgParams {
47
+ /** New display name */
48
+ name?: string
49
+ /** New slug */
50
+ slug?: string
51
+ /** Metadata to merge (shallow merge) */
52
+ metadata?: Record<string, unknown>
53
+ }
54
+
55
+ // ============================================================================
56
+ // Roles
57
+ // ============================================================================
58
+
59
+ /**
60
+ * Built-in organization roles, ordered by decreasing privilege.
61
+ *
62
+ * - **owner**: Full control, can delete org, transfer ownership, manage billing
63
+ * - **admin**: Manage members and settings, full data access
64
+ * - **member**: Read + write own data, read shared data
65
+ * - **viewer**: Read-only access to shared data
66
+ * - **billing**: Billing management only, no data access
67
+ */
68
+ export const ORG_ROLES = ['owner', 'admin', 'member', 'viewer', 'billing'] as const
69
+ export type OrgRole = (typeof ORG_ROLES)[number]
70
+
71
+ /**
72
+ * Role hierarchy for permission inheritance.
73
+ * Higher number = more privilege.
74
+ */
75
+ export const ROLE_HIERARCHY: Record<OrgRole, number> = {
76
+ viewer: 10,
77
+ billing: 15,
78
+ member: 20,
79
+ admin: 30,
80
+ owner: 40,
81
+ } as const
82
+
83
+ /**
84
+ * Check if one role has at least the privilege level of another.
85
+ *
86
+ * @param userRole - The user's current role
87
+ * @param requiredRole - The minimum role required
88
+ * @returns true if userRole >= requiredRole in the hierarchy
89
+ */
90
+ export function hasRoleLevel(userRole: OrgRole, requiredRole: OrgRole): boolean {
91
+ return ROLE_HIERARCHY[userRole] >= ROLE_HIERARCHY[requiredRole]
92
+ }
93
+
94
+ // ============================================================================
95
+ // Membership
96
+ // ============================================================================
97
+
98
+ /**
99
+ * A user's membership in an organization.
100
+ */
101
+ export interface Membership {
102
+ /** Unique identifier for this membership record */
103
+ id: string
104
+ /** Organization this membership belongs to */
105
+ orgId: string
106
+ /** User who is a member */
107
+ userId: string
108
+ /** Role within the organization */
109
+ role: OrgRole
110
+ /** User who invited this member (null if founder) */
111
+ invitedBy: string | null
112
+ /** When the user joined the organization (ms since epoch) */
113
+ joinedAt: number
114
+ /** Arbitrary metadata (department, title, etc.) */
115
+ metadata: Record<string, unknown>
116
+ }
117
+
118
+ // ============================================================================
119
+ // Invitations
120
+ // ============================================================================
121
+
122
+ /**
123
+ * Invitation status lifecycle.
124
+ */
125
+ export const INVITATION_STATUSES = ['pending', 'accepted', 'revoked', 'expired'] as const
126
+ export type InvitationStatus = (typeof INVITATION_STATUSES)[number]
127
+
128
+ /**
129
+ * An invitation to join an organization.
130
+ *
131
+ * Invitations are sent by email and include a single-use token.
132
+ * They expire after a configurable duration (default: 7 days).
133
+ */
134
+ export interface OrgInvitation {
135
+ /** Unique identifier */
136
+ id: string
137
+ /** Organization the invitation is for */
138
+ orgId: string
139
+ /** Email address of the invitee */
140
+ email: string
141
+ /** Role the invitee will receive upon accepting */
142
+ role: OrgRole
143
+ /** User who created the invitation */
144
+ invitedBy: string
145
+ /** Cryptographically random single-use token */
146
+ token: string
147
+ /** When the invitation was created (ms since epoch) */
148
+ createdAt: number
149
+ /** When the invitation expires (ms since epoch) */
150
+ expiresAt: number
151
+ /** Current status */
152
+ status: InvitationStatus
153
+ }
154
+
155
+ /**
156
+ * Parameters for creating an invitation.
157
+ */
158
+ export interface CreateInvitationParams {
159
+ /** Email address to invite */
160
+ email: string
161
+ /** Role to assign when the invitation is accepted */
162
+ role: OrgRole
163
+ }
164
+
165
+ // ============================================================================
166
+ // Errors
167
+ // ============================================================================
168
+
169
+ /**
170
+ * Base error for organization-related operations.
171
+ */
172
+ export class OrgError extends KoraError {
173
+ constructor(message: string, code: string, context?: Record<string, unknown>) {
174
+ super(message, code, context)
175
+ this.name = 'OrgError'
176
+ }
177
+ }
178
+
179
+ export class OrgNotFoundError extends OrgError {
180
+ constructor() {
181
+ super('Organization not found.', 'ORG_NOT_FOUND')
182
+ }
183
+ }
184
+
185
+ export class OrgSlugTakenError extends OrgError {
186
+ constructor() {
187
+ super('An organization with this slug already exists.', 'ORG_SLUG_TAKEN')
188
+ }
189
+ }
190
+
191
+ export class MembershipNotFoundError extends OrgError {
192
+ constructor() {
193
+ super('User is not a member of this organization.', 'MEMBERSHIP_NOT_FOUND')
194
+ }
195
+ }
196
+
197
+ export class MemberAlreadyExistsError extends OrgError {
198
+ constructor() {
199
+ super('User is already a member of this organization.', 'MEMBER_ALREADY_EXISTS')
200
+ }
201
+ }
202
+
203
+ export class InsufficientRoleError extends OrgError {
204
+ constructor(required: OrgRole) {
205
+ super(`This action requires at least the "${required}" role.`, 'INSUFFICIENT_ROLE', {
206
+ requiredRole: required,
207
+ })
208
+ }
209
+ }
210
+
211
+ export class CannotRemoveOwnerError extends OrgError {
212
+ constructor() {
213
+ super(
214
+ 'The organization owner cannot be removed. Transfer ownership first.',
215
+ 'CANNOT_REMOVE_OWNER',
216
+ )
217
+ }
218
+ }
219
+
220
+ export class InvitationNotFoundError extends OrgError {
221
+ constructor() {
222
+ super('Invitation not found or has already been used.', 'INVITATION_NOT_FOUND')
223
+ }
224
+ }
225
+
226
+ export class InvitationExpiredError extends OrgError {
227
+ constructor() {
228
+ super('This invitation has expired.', 'INVITATION_EXPIRED')
229
+ }
230
+ }