@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,758 @@
1
+ import type { OrgStore } from './org-store'
2
+ import type {
3
+ CreateOrgParams,
4
+ Membership,
5
+ OrgInvitation,
6
+ OrgRole,
7
+ Organization,
8
+ UpdateOrgParams,
9
+ } from './org-types'
10
+ import {
11
+ CannotRemoveOwnerError,
12
+ InsufficientRoleError,
13
+ InvitationExpiredError,
14
+ InvitationNotFoundError,
15
+ MemberAlreadyExistsError,
16
+ MembershipNotFoundError,
17
+ OrgNotFoundError,
18
+ OrgSlugTakenError,
19
+ hasRoleLevel,
20
+ } from './org-types'
21
+
22
+ // ============================================================================
23
+ // Types
24
+ // ============================================================================
25
+
26
+ /**
27
+ * Response envelope returned by all org route handlers.
28
+ * Mirrors AuthRouteResponse for consistency.
29
+ */
30
+ export interface OrgRouteResponse<T> {
31
+ /** HTTP status code */
32
+ status: number
33
+ /** Either the success payload or an error message */
34
+ body: { data: T } | { error: string }
35
+ }
36
+
37
+ /**
38
+ * Configuration for the org route handlers.
39
+ */
40
+ export interface OrgRoutesConfig {
41
+ /** The organization store backing all org operations */
42
+ orgStore: OrgStore
43
+ /**
44
+ * Resolves a user's email and its verification status (a `UserStore`
45
+ * satisfies this). Invitations are listed and accepted only for the caller's
46
+ * own VERIFIED email; without a lookup, pass the verified identity to
47
+ * `acceptInvitation` / `listMyInvitations` explicitly.
48
+ */
49
+ userLookup?: {
50
+ findById(userId: string): Promise<{ email: string; emailVerified: boolean } | null>
51
+ }
52
+ }
53
+
54
+ /** A server-verified email identity used to match invitations. */
55
+ export interface VerifiedEmailIdentity {
56
+ email: string
57
+ emailVerified: boolean
58
+ }
59
+
60
+ /** Invitation as shown to its invitee: the secret token is never included. */
61
+ export type InviteeInvitation = Omit<OrgInvitation, 'token'>
62
+
63
+ function normalizeEmail(email: string): string {
64
+ return email.trim().toLowerCase()
65
+ }
66
+
67
+ /** Maximum length for org name */
68
+ const MAX_ORG_NAME_LENGTH = 200
69
+
70
+ /** Maximum length for org slug */
71
+ const MAX_SLUG_LENGTH = 100
72
+
73
+ /** Slug format: lowercase alphanumeric and hyphens, 2-100 chars */
74
+ const SLUG_PATTERN = /^[a-z0-9][a-z0-9-]{0,98}[a-z0-9]$/
75
+
76
+ /**
77
+ * Simple email format validation (same logic as auth-routes).
78
+ */
79
+ function isValidEmail(email: string): boolean {
80
+ // See the matching guard in auth-routes.ts: request bodies are untyped at
81
+ // runtime, so a missing `email` field must not crash this check.
82
+ if (typeof email !== 'string' || email.length === 0 || email.length > 254) return false
83
+ const atIndex = email.indexOf('@')
84
+ if (atIndex < 1) return false
85
+ const domain = email.slice(atIndex + 1)
86
+ if (domain.length === 0 || !domain.includes('.')) return false
87
+ if (email.indexOf('@', atIndex + 1) !== -1) return false
88
+ if (email.includes(' ')) return false
89
+ return true
90
+ }
91
+
92
+ /**
93
+ * Strip control characters from a string.
94
+ */
95
+ function sanitize(value: string): string {
96
+ // biome-ignore lint/suspicious/noControlCharactersInRegex: intentional sanitization
97
+ return value.replace(/[\x00-\x1f\x7f]/g, '').trim()
98
+ }
99
+
100
+ // ============================================================================
101
+ // OrgRoutes
102
+ // ============================================================================
103
+
104
+ /**
105
+ * Server-side route handlers for organization management.
106
+ *
107
+ * These handlers enforce authorization (role checks), input validation,
108
+ * and produce transport-agnostic response objects. Wire them to your
109
+ * HTTP framework (Express, Hono, Fastify, etc.):
110
+ *
111
+ * @example
112
+ * ```typescript
113
+ * const orgRoutes = new OrgRoutes({ orgStore: new InMemoryOrgStore() })
114
+ *
115
+ * app.post('/orgs', async (req, res) => {
116
+ * const result = await orgRoutes.createOrg(req.userId, req.body)
117
+ * res.status(result.status).json(result.body)
118
+ * })
119
+ * ```
120
+ */
121
+ export class OrgRoutes {
122
+ private readonly store: OrgStore
123
+
124
+ private readonly userLookup: OrgRoutesConfig['userLookup']
125
+
126
+ constructor(config: OrgRoutesConfig) {
127
+ this.store = config.orgStore
128
+ this.userLookup = config.userLookup
129
+ }
130
+
131
+ // --- Organizations ---
132
+
133
+ /**
134
+ * Create a new organization. The authenticated user becomes the owner.
135
+ */
136
+ async createOrg(
137
+ userId: string,
138
+ params: { name?: unknown; slug?: unknown; metadata?: unknown },
139
+ ): Promise<OrgRouteResponse<Organization>> {
140
+ // Validate name
141
+ if (typeof params.name !== 'string' || params.name.trim().length === 0) {
142
+ return { status: 400, body: { error: 'Organization name is required.' } }
143
+ }
144
+ const name = sanitize(params.name)
145
+ if (name.length > MAX_ORG_NAME_LENGTH) {
146
+ return {
147
+ status: 400,
148
+ body: { error: `Organization name must be at most ${MAX_ORG_NAME_LENGTH} characters.` },
149
+ }
150
+ }
151
+
152
+ // Validate slug (optional)
153
+ let slug: string | undefined
154
+ if (params.slug !== undefined) {
155
+ if (typeof params.slug !== 'string') {
156
+ return { status: 400, body: { error: 'Slug must be a string.' } }
157
+ }
158
+ slug = params.slug.toLowerCase().trim()
159
+ if (slug.length < 2) {
160
+ return { status: 400, body: { error: 'Slug must be at least 2 characters.' } }
161
+ }
162
+ if (slug.length > MAX_SLUG_LENGTH) {
163
+ return {
164
+ status: 400,
165
+ body: { error: `Slug must be at most ${MAX_SLUG_LENGTH} characters.` },
166
+ }
167
+ }
168
+ if (!SLUG_PATTERN.test(slug)) {
169
+ return {
170
+ status: 400,
171
+ body: {
172
+ error:
173
+ 'Slug must contain only lowercase letters, numbers, and hyphens, and cannot start or end with a hyphen.',
174
+ },
175
+ }
176
+ }
177
+ }
178
+
179
+ // Validate metadata (optional)
180
+ if (
181
+ params.metadata !== undefined &&
182
+ (typeof params.metadata !== 'object' ||
183
+ params.metadata === null ||
184
+ Array.isArray(params.metadata))
185
+ ) {
186
+ return { status: 400, body: { error: 'Metadata must be a plain object.' } }
187
+ }
188
+
189
+ try {
190
+ const createParams: CreateOrgParams = {
191
+ name,
192
+ slug,
193
+ metadata: params.metadata as Record<string, unknown> | undefined,
194
+ }
195
+ const org = await this.store.createOrg(userId, createParams)
196
+ return { status: 201, body: { data: org } }
197
+ } catch (err) {
198
+ if (err instanceof OrgSlugTakenError) {
199
+ return { status: 409, body: { error: err.message } }
200
+ }
201
+ throw err
202
+ }
203
+ }
204
+
205
+ /**
206
+ * Get an organization by ID. Requires membership.
207
+ */
208
+ async getOrg(userId: string, orgId: string): Promise<OrgRouteResponse<Organization>> {
209
+ const membership = await this.store.getMembership(orgId, userId)
210
+ if (!membership) {
211
+ return { status: 404, body: { error: 'Organization not found.' } }
212
+ }
213
+
214
+ const org = await this.store.getOrg(orgId)
215
+ if (!org) {
216
+ return { status: 404, body: { error: 'Organization not found.' } }
217
+ }
218
+
219
+ return { status: 200, body: { data: org } }
220
+ }
221
+
222
+ /**
223
+ * Update an organization. Requires admin or higher.
224
+ */
225
+ async updateOrg(
226
+ userId: string,
227
+ orgId: string,
228
+ params: { name?: unknown; slug?: unknown; metadata?: unknown },
229
+ ): Promise<OrgRouteResponse<Organization>> {
230
+ const authResult = await this.requireRole(orgId, userId, 'admin')
231
+ if (authResult) return authResult
232
+
233
+ const updateParams: UpdateOrgParams = {}
234
+
235
+ // Validate name
236
+ if (params.name !== undefined) {
237
+ if (typeof params.name !== 'string' || params.name.trim().length === 0) {
238
+ return { status: 400, body: { error: 'Organization name must be a non-empty string.' } }
239
+ }
240
+ updateParams.name = sanitize(params.name as string)
241
+ if (updateParams.name.length > MAX_ORG_NAME_LENGTH) {
242
+ return {
243
+ status: 400,
244
+ body: { error: `Organization name must be at most ${MAX_ORG_NAME_LENGTH} characters.` },
245
+ }
246
+ }
247
+ }
248
+
249
+ // Validate slug
250
+ if (params.slug !== undefined) {
251
+ if (typeof params.slug !== 'string') {
252
+ return { status: 400, body: { error: 'Slug must be a string.' } }
253
+ }
254
+ updateParams.slug = (params.slug as string).toLowerCase().trim()
255
+ if (updateParams.slug.length < 2) {
256
+ return { status: 400, body: { error: 'Slug must be at least 2 characters.' } }
257
+ }
258
+ if (updateParams.slug.length > MAX_SLUG_LENGTH) {
259
+ return {
260
+ status: 400,
261
+ body: { error: `Slug must be at most ${MAX_SLUG_LENGTH} characters.` },
262
+ }
263
+ }
264
+ if (!SLUG_PATTERN.test(updateParams.slug)) {
265
+ return {
266
+ status: 400,
267
+ body: {
268
+ error:
269
+ 'Slug must contain only lowercase letters, numbers, and hyphens, and cannot start or end with a hyphen.',
270
+ },
271
+ }
272
+ }
273
+ }
274
+
275
+ // Validate metadata
276
+ if (params.metadata !== undefined) {
277
+ if (
278
+ typeof params.metadata !== 'object' ||
279
+ params.metadata === null ||
280
+ Array.isArray(params.metadata)
281
+ ) {
282
+ return { status: 400, body: { error: 'Metadata must be a plain object.' } }
283
+ }
284
+ updateParams.metadata = params.metadata as Record<string, unknown>
285
+ }
286
+
287
+ try {
288
+ const org = await this.store.updateOrg(orgId, updateParams)
289
+ return { status: 200, body: { data: org } }
290
+ } catch (err) {
291
+ if (err instanceof OrgNotFoundError) {
292
+ return { status: 404, body: { error: err.message } }
293
+ }
294
+ if (err instanceof OrgSlugTakenError) {
295
+ return { status: 409, body: { error: err.message } }
296
+ }
297
+ throw err
298
+ }
299
+ }
300
+
301
+ /**
302
+ * Delete an organization. Requires owner.
303
+ */
304
+ async deleteOrg(userId: string, orgId: string): Promise<OrgRouteResponse<{ deleted: true }>> {
305
+ const authResult = await this.requireRole(orgId, userId, 'owner')
306
+ if (authResult) return authResult
307
+
308
+ try {
309
+ await this.store.deleteOrg(orgId)
310
+ return { status: 200, body: { data: { deleted: true } } }
311
+ } catch (err) {
312
+ if (err instanceof OrgNotFoundError) {
313
+ return { status: 404, body: { error: err.message } }
314
+ }
315
+ throw err
316
+ }
317
+ }
318
+
319
+ /**
320
+ * List all organizations the authenticated user belongs to.
321
+ */
322
+ async listUserOrgs(userId: string): Promise<OrgRouteResponse<Organization[]>> {
323
+ const orgs = await this.store.listUserOrgs(userId)
324
+ return { status: 200, body: { data: orgs } }
325
+ }
326
+
327
+ // --- Members ---
328
+
329
+ /**
330
+ * Add a member to an organization. Requires admin or higher.
331
+ */
332
+ async addMember(
333
+ userId: string,
334
+ orgId: string,
335
+ params: { targetUserId?: unknown; role?: unknown },
336
+ ): Promise<OrgRouteResponse<Membership>> {
337
+ const authResult = await this.requireRole(orgId, userId, 'admin')
338
+ if (authResult) return authResult
339
+
340
+ if (typeof params.targetUserId !== 'string' || params.targetUserId.length === 0) {
341
+ return { status: 400, body: { error: 'Target user ID is required.' } }
342
+ }
343
+ if (typeof params.role !== 'string' || !isValidRole(params.role)) {
344
+ return {
345
+ status: 400,
346
+ body: { error: 'A valid role is required (admin, member, viewer, billing).' },
347
+ }
348
+ }
349
+
350
+ // Cannot assign owner role via addMember — use transferOwnership
351
+ if (params.role === 'owner') {
352
+ return {
353
+ status: 400,
354
+ body: { error: 'Cannot assign owner role directly. Use ownership transfer.' },
355
+ }
356
+ }
357
+
358
+ // Admins cannot add other admins (only owner can)
359
+ const callerMembership = await this.store.getMembership(orgId, userId)
360
+ if (callerMembership && callerMembership.role !== 'owner' && params.role === 'admin') {
361
+ return { status: 403, body: { error: 'Only the owner can add admin members.' } }
362
+ }
363
+
364
+ try {
365
+ const membership = await this.store.addMember(
366
+ orgId,
367
+ params.targetUserId,
368
+ params.role as OrgRole,
369
+ userId,
370
+ )
371
+ return { status: 201, body: { data: membership } }
372
+ } catch (err) {
373
+ if (err instanceof OrgNotFoundError) {
374
+ return { status: 404, body: { error: err.message } }
375
+ }
376
+ if (err instanceof MemberAlreadyExistsError) {
377
+ return { status: 409, body: { error: err.message } }
378
+ }
379
+ throw err
380
+ }
381
+ }
382
+
383
+ /**
384
+ * Remove a member from an organization. Requires admin or higher.
385
+ * Members can also remove themselves (leave).
386
+ */
387
+ async removeMember(
388
+ userId: string,
389
+ orgId: string,
390
+ targetUserId: string,
391
+ ): Promise<OrgRouteResponse<{ removed: true }>> {
392
+ // Allow self-removal (leaving) for any member
393
+ const isSelfRemoval = userId === targetUserId
394
+
395
+ if (!isSelfRemoval) {
396
+ const authResult = await this.requireRole(orgId, userId, 'admin')
397
+ if (authResult) return authResult
398
+ } else {
399
+ // Verify caller is a member
400
+ const membership = await this.store.getMembership(orgId, userId)
401
+ if (!membership) {
402
+ return { status: 404, body: { error: 'Organization not found.' } }
403
+ }
404
+ }
405
+
406
+ try {
407
+ await this.store.removeMember(orgId, targetUserId)
408
+ return { status: 200, body: { data: { removed: true } } }
409
+ } catch (err) {
410
+ if (err instanceof OrgNotFoundError) {
411
+ return { status: 404, body: { error: err.message } }
412
+ }
413
+ if (err instanceof CannotRemoveOwnerError) {
414
+ return { status: 400, body: { error: err.message } }
415
+ }
416
+ if (err instanceof MembershipNotFoundError) {
417
+ return { status: 404, body: { error: err.message } }
418
+ }
419
+ throw err
420
+ }
421
+ }
422
+
423
+ /**
424
+ * Update a member's role. Requires admin or higher.
425
+ */
426
+ async updateMemberRole(
427
+ userId: string,
428
+ orgId: string,
429
+ params: { targetUserId?: unknown; role?: unknown },
430
+ ): Promise<OrgRouteResponse<Membership>> {
431
+ const authResult = await this.requireRole(orgId, userId, 'admin')
432
+ if (authResult) return authResult
433
+
434
+ if (typeof params.targetUserId !== 'string' || params.targetUserId.length === 0) {
435
+ return { status: 400, body: { error: 'Target user ID is required.' } }
436
+ }
437
+ if (typeof params.role !== 'string' || !isValidRole(params.role)) {
438
+ return {
439
+ status: 400,
440
+ body: { error: 'A valid role is required (admin, member, viewer, billing).' },
441
+ }
442
+ }
443
+ if (params.role === 'owner') {
444
+ return {
445
+ status: 400,
446
+ body: { error: 'Cannot assign owner role directly. Use ownership transfer.' },
447
+ }
448
+ }
449
+
450
+ // The owner's role cannot be changed through this endpoint. Without this
451
+ // guard an admin could demote the owner (e.g. to "viewer"), stripping the
452
+ // owner of owner-gated powers while org.ownerId still points at them —
453
+ // locking the account out of deleteOrg / transferOwnership. Ownership
454
+ // changes must go through transferOwnership.
455
+ const org = await this.store.getOrg(orgId)
456
+ if (org && org.ownerId === params.targetUserId) {
457
+ return {
458
+ status: 403,
459
+ body: { error: "The organization owner's role cannot be changed. Use ownership transfer." },
460
+ }
461
+ }
462
+
463
+ // Admins cannot promote to admin (only owner can)
464
+ const callerMembership = await this.store.getMembership(orgId, userId)
465
+ if (callerMembership && callerMembership.role !== 'owner' && params.role === 'admin') {
466
+ return { status: 403, body: { error: 'Only the owner can assign admin role.' } }
467
+ }
468
+
469
+ try {
470
+ const membership = await this.store.updateMemberRole(
471
+ orgId,
472
+ params.targetUserId,
473
+ params.role as OrgRole,
474
+ )
475
+ return { status: 200, body: { data: membership } }
476
+ } catch (err) {
477
+ if (err instanceof MembershipNotFoundError) {
478
+ return { status: 404, body: { error: err.message } }
479
+ }
480
+ throw err
481
+ }
482
+ }
483
+
484
+ /**
485
+ * List all members of an organization. Requires membership.
486
+ */
487
+ async listMembers(userId: string, orgId: string): Promise<OrgRouteResponse<Membership[]>> {
488
+ const membership = await this.store.getMembership(orgId, userId)
489
+ if (!membership) {
490
+ return { status: 404, body: { error: 'Organization not found.' } }
491
+ }
492
+
493
+ try {
494
+ const members = await this.store.listMembers(orgId)
495
+ return { status: 200, body: { data: members } }
496
+ } catch (err) {
497
+ if (err instanceof OrgNotFoundError) {
498
+ return { status: 404, body: { error: err.message } }
499
+ }
500
+ throw err
501
+ }
502
+ }
503
+
504
+ /**
505
+ * Transfer ownership to another member. Requires owner.
506
+ */
507
+ async transferOwnership(
508
+ userId: string,
509
+ orgId: string,
510
+ params: { newOwnerId?: unknown },
511
+ ): Promise<OrgRouteResponse<{ transferred: true }>> {
512
+ const authResult = await this.requireRole(orgId, userId, 'owner')
513
+ if (authResult) return authResult
514
+
515
+ if (typeof params.newOwnerId !== 'string' || params.newOwnerId.length === 0) {
516
+ return { status: 400, body: { error: 'New owner ID is required.' } }
517
+ }
518
+
519
+ if (params.newOwnerId === userId) {
520
+ return { status: 400, body: { error: 'You are already the owner.' } }
521
+ }
522
+
523
+ try {
524
+ await this.store.transferOwnership(orgId, params.newOwnerId)
525
+ return { status: 200, body: { data: { transferred: true } } }
526
+ } catch (err) {
527
+ if (err instanceof OrgNotFoundError) {
528
+ return { status: 404, body: { error: err.message } }
529
+ }
530
+ if (err instanceof MembershipNotFoundError) {
531
+ return { status: 404, body: { error: 'Target user is not a member of this organization.' } }
532
+ }
533
+ throw err
534
+ }
535
+ }
536
+
537
+ // --- Invitations ---
538
+
539
+ /**
540
+ * Create an invitation to join the organization. Requires admin or higher.
541
+ */
542
+ async createInvitation(
543
+ userId: string,
544
+ orgId: string,
545
+ params: { email?: unknown; role?: unknown },
546
+ ): Promise<OrgRouteResponse<OrgInvitation>> {
547
+ const authResult = await this.requireRole(orgId, userId, 'admin')
548
+ if (authResult) return authResult
549
+
550
+ if (typeof params.email !== 'string' || !isValidEmail(params.email.trim())) {
551
+ return { status: 400, body: { error: 'A valid email address is required.' } }
552
+ }
553
+ if (typeof params.role !== 'string' || !isValidRole(params.role)) {
554
+ return {
555
+ status: 400,
556
+ body: { error: 'A valid role is required (admin, member, viewer, billing).' },
557
+ }
558
+ }
559
+ if (params.role === 'owner') {
560
+ return {
561
+ status: 400,
562
+ body: { error: 'Cannot invite with owner role. Use ownership transfer.' },
563
+ }
564
+ }
565
+
566
+ // Admins cannot invite admins
567
+ const callerMembership = await this.store.getMembership(orgId, userId)
568
+ if (callerMembership && callerMembership.role !== 'owner' && params.role === 'admin') {
569
+ return { status: 403, body: { error: 'Only the owner can invite admin members.' } }
570
+ }
571
+
572
+ try {
573
+ const invitation = await this.store.createInvitation(orgId, userId, {
574
+ email: params.email.trim(),
575
+ role: params.role as OrgRole,
576
+ })
577
+ return { status: 201, body: { data: invitation } }
578
+ } catch (err) {
579
+ if (err instanceof OrgNotFoundError) {
580
+ return { status: 404, body: { error: err.message } }
581
+ }
582
+ throw err
583
+ }
584
+ }
585
+
586
+ /**
587
+ * Accept an invitation by its token. The authenticated user joins the org
588
+ * only if the invitation was addressed to the user's own verified email.
589
+ *
590
+ * @param userId - The authenticated user
591
+ * @param params - The invitation token
592
+ * @param identity - The user's verified email identity; resolved through
593
+ * `userLookup` when omitted
594
+ */
595
+ async acceptInvitation(
596
+ userId: string,
597
+ params: { token?: unknown },
598
+ identity?: VerifiedEmailIdentity,
599
+ ): Promise<OrgRouteResponse<Membership>> {
600
+ if (typeof params.token !== 'string' || params.token.length === 0) {
601
+ return { status: 400, body: { error: 'Invitation token is required.' } }
602
+ }
603
+
604
+ const verified = await this.resolveIdentity(userId, identity)
605
+ if (!verified) {
606
+ return {
607
+ status: 403,
608
+ body: { error: 'A verified email address is required to accept invitations.' },
609
+ }
610
+ }
611
+
612
+ try {
613
+ // Check the addressee BEFORE consuming, so a stranger holding a leaked
614
+ // token can neither join nor burn the invitation.
615
+ const pending = await this.store.getInvitationByToken(params.token)
616
+ if (pending && normalizeEmail(pending.email) !== normalizeEmail(verified.email)) {
617
+ return {
618
+ status: 403,
619
+ body: { error: 'This invitation was sent to a different email address.' },
620
+ }
621
+ }
622
+ const invitation = await this.store.consumeInvitation(params.token)
623
+
624
+ // Add the user as a member with the invited role
625
+ const membership = await this.store.addMember(
626
+ invitation.orgId,
627
+ userId,
628
+ invitation.role,
629
+ invitation.invitedBy,
630
+ )
631
+ return { status: 200, body: { data: membership } }
632
+ } catch (err) {
633
+ if (err instanceof InvitationNotFoundError) {
634
+ return { status: 404, body: { error: err.message } }
635
+ }
636
+ if (err instanceof InvitationExpiredError) {
637
+ return { status: 410, body: { error: err.message } }
638
+ }
639
+ if (err instanceof MemberAlreadyExistsError) {
640
+ return { status: 409, body: { error: 'You are already a member of this organization.' } }
641
+ }
642
+ throw err
643
+ }
644
+ }
645
+
646
+ /**
647
+ * Revoke a pending invitation. Requires admin or higher.
648
+ */
649
+ async revokeInvitation(
650
+ userId: string,
651
+ orgId: string,
652
+ invitationId: string,
653
+ ): Promise<OrgRouteResponse<{ revoked: true }>> {
654
+ const authResult = await this.requireRole(orgId, userId, 'admin')
655
+ if (authResult) return authResult
656
+
657
+ try {
658
+ await this.store.revokeInvitation(orgId, invitationId)
659
+ return { status: 200, body: { data: { revoked: true } } }
660
+ } catch (err) {
661
+ if (err instanceof InvitationNotFoundError) {
662
+ return { status: 404, body: { error: err.message } }
663
+ }
664
+ throw err
665
+ }
666
+ }
667
+
668
+ /**
669
+ * List pending invitations for an organization. Requires admin or higher.
670
+ */
671
+ async listPendingInvitations(
672
+ userId: string,
673
+ orgId: string,
674
+ ): Promise<OrgRouteResponse<OrgInvitation[]>> {
675
+ const authResult = await this.requireRole(orgId, userId, 'admin')
676
+ if (authResult) return authResult
677
+
678
+ try {
679
+ const invitations = await this.store.listPendingInvitations(orgId)
680
+ return { status: 200, body: { data: invitations } }
681
+ } catch (err) {
682
+ if (err instanceof OrgNotFoundError) {
683
+ return { status: 404, body: { error: err.message } }
684
+ }
685
+ throw err
686
+ }
687
+ }
688
+
689
+ /**
690
+ * List pending invitations addressed to the authenticated user's own
691
+ * verified email. The email is resolved server-side (never taken from the
692
+ * request) and invitation tokens are never returned.
693
+ *
694
+ * @param userId - The authenticated user
695
+ * @param identity - The user's verified identity; resolved through `userLookup` when omitted
696
+ */
697
+ async listMyInvitations(
698
+ userId: string,
699
+ identity?: VerifiedEmailIdentity,
700
+ ): Promise<OrgRouteResponse<InviteeInvitation[]>> {
701
+ const verified = await this.resolveIdentity(userId, identity)
702
+ if (!verified || !isValidEmail(verified.email)) {
703
+ return {
704
+ status: 403,
705
+ body: { error: 'A verified email address is required to list invitations.' },
706
+ }
707
+ }
708
+
709
+ const invitations = await this.store.listInvitationsForEmail(normalizeEmail(verified.email))
710
+ return {
711
+ status: 200,
712
+ body: { data: invitations.map(({ token: _token, ...rest }) => rest) },
713
+ }
714
+ }
715
+
716
+ private async resolveIdentity(
717
+ userId: string,
718
+ identity: VerifiedEmailIdentity | undefined,
719
+ ): Promise<VerifiedEmailIdentity | null> {
720
+ const resolved = identity ?? (this.userLookup ? await this.userLookup.findById(userId) : null)
721
+ if (!resolved || !resolved.emailVerified || typeof resolved.email !== 'string') return null
722
+ return { email: resolved.email, emailVerified: true }
723
+ }
724
+
725
+ // --- Private helpers ---
726
+
727
+ /**
728
+ * Check if the caller has the required role in the org.
729
+ * Returns an error response if not authorized, or null if authorized.
730
+ */
731
+ private async requireRole(
732
+ orgId: string,
733
+ userId: string,
734
+ requiredRole: OrgRole,
735
+ ): Promise<OrgRouteResponse<never> | null> {
736
+ const membership = await this.store.getMembership(orgId, userId)
737
+ if (!membership) {
738
+ return { status: 404, body: { error: 'Organization not found.' } }
739
+ }
740
+ if (!hasRoleLevel(membership.role, requiredRole)) {
741
+ return {
742
+ status: 403,
743
+ body: { error: `This action requires at least the "${requiredRole}" role.` },
744
+ }
745
+ }
746
+ return null
747
+ }
748
+ }
749
+
750
+ // ============================================================================
751
+ // Helpers
752
+ // ============================================================================
753
+
754
+ const ASSIGNABLE_ROLES = new Set<string>(['admin', 'member', 'viewer', 'billing'])
755
+
756
+ function isValidRole(role: string): boolean {
757
+ return ASSIGNABLE_ROLES.has(role) || role === 'owner'
758
+ }