spine-framework 0.3.54 → 0.3.56

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.
@@ -0,0 +1,655 @@
1
+ /**
2
+ * @module invites
3
+ * @audience core-contributor
4
+ * @layer api-handler
5
+ * @stability stable
6
+ *
7
+ * Invite and access-request management. All invite/access-request state is
8
+ * stored directly on `people` rows — no separate table. Invite metadata lives
9
+ * in `people.data` under `invite_*` keys.
10
+ *
11
+ * **Routed by:** `GET/POST /.netlify/functions/invites?action=<action>`
12
+ *
13
+ * **Public actions (no auth required):**
14
+ * | action | method | description |
15
+ * |-------------------|--------|-------------|
16
+ * | check-first-user | GET | Boolean: is this the first user registration? |
17
+ * | check-domain | GET | Boolean: does this email domain have existing accounts? |
18
+ * | validate-token | GET | Validate invite token, return preview |
19
+ * | complete-registration | POST | Finalise walk-up registration after Supabase auth |
20
+ *
21
+ * **Protected actions (require *_admin or system_admin):**
22
+ * | action | method | description |
23
+ * |----------------|--------|-------------|
24
+ * | create-invite | POST | Create invite stub + return shareable URL |
25
+ * | list-invites | GET | List invites scoped to account (or all for system_admin) |
26
+ * | revoke-invite | POST | Mark invite inactive |
27
+ * | reinvite | POST | Issue new token on existing stub |
28
+ * | approve | POST | Approve access_request → activate person |
29
+ * | deny | POST | Deny access_request → deactivate person |
30
+ *
31
+ * **Invite metadata in people.data:**
32
+ * ```json
33
+ * {
34
+ * "invite_token": "abc123...",
35
+ * "invite_kind": "invite" | "access_request",
36
+ * "invite_expires_at": "2026-06-24T...",
37
+ * "invite_attempts": 0,
38
+ * "invite_blocked_at": null,
39
+ * "invite_invited_by": "person-uuid"
40
+ * }
41
+ * ```
42
+ *
43
+ * **people.status values used here:**
44
+ * invited — unclaimed admin invite
45
+ * pending — walk-up access request awaiting approval
46
+ * active — approved / invite redeemed
47
+ * inactive — denied / revoked
48
+ *
49
+ * @seeAlso migrations/016_invites.sql (indexes)
50
+ * @seeAlso migrations/005_auth_user_trigger.sql (stub resolution on signup)
51
+ * @seeAlso src/pages/auth/RegisterPage.tsx (frontend)
52
+ * @seeAlso src/components/shared/InviteManager.tsx (UI)
53
+ */
54
+
55
+ import { createHandler } from './_shared/middleware'
56
+ import { adminDb } from './_shared/db'
57
+ import { emitLog } from './_shared/audit'
58
+
59
+ // ─── CONSTANTS ────────────────────────────────────────────────────────────────
60
+
61
+ const TOKEN_LENGTH = 16
62
+ const MAX_ATTEMPTS = 5
63
+ const INVITE_EXPIRY_DAYS = 7
64
+
65
+ // Personal/free email domains — excluded from domain-match logic
66
+ const PERSONAL_DOMAINS = new Set([
67
+ 'gmail.com', 'yahoo.com', 'hotmail.com', 'outlook.com', 'icloud.com',
68
+ 'aol.com', 'protonmail.com', 'me.com', 'mac.com', 'live.com',
69
+ 'msn.com', 'ymail.com', 'googlemail.com', 'fastmail.com',
70
+ ])
71
+
72
+ // ─── HELPERS ──────────────────────────────────────────────────────────────────
73
+
74
+ function generateToken(): string {
75
+ const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'
76
+ const bytes = crypto.getRandomValues(new Uint8Array(TOKEN_LENGTH))
77
+ return Array.from(bytes).map(b => chars[b % chars.length]).join('')
78
+ }
79
+
80
+ function generateAccountSlug(name: string): string {
81
+ const base = name
82
+ .toLowerCase()
83
+ .replace(/[^a-z0-9]+/g, '-')
84
+ .replace(/^-+|-+$/g, '')
85
+ .slice(0, 40)
86
+ const hash = Math.random().toString(36).slice(2, 8)
87
+ return `${base}-${hash}`
88
+ }
89
+
90
+ function isAdminRole(roleSlug: string | undefined): boolean {
91
+ return !!roleSlug && (
92
+ roleSlug === 'system_admin' ||
93
+ roleSlug.endsWith('_admin')
94
+ )
95
+ }
96
+
97
+ // ─── PUBLIC: check-first-user ─────────────────────────────────────────────────
98
+
99
+ /**
100
+ * Returns { is_first_user: boolean } — true if there are zero people in the system.
101
+ * Used by RegisterPage to show simplified first-user flow.
102
+ */
103
+ export const checkFirstUser = createHandler(async (_ctx, _body) => {
104
+ const { count, error } = await adminDb
105
+ .from('people')
106
+ .select('*', { count: 'exact', head: true })
107
+
108
+ if (error) throw new Error('Failed to check first user: ' + error.message)
109
+
110
+ return { is_first_user: (count ?? 0) === 0 }
111
+ })
112
+
113
+ // ─── PUBLIC: check-domain ─────────────────────────────────────────────────────
114
+
115
+ /**
116
+ * Returns { match: boolean } — whether any active people rows exist with the
117
+ * same email domain AND it's not a known personal domain.
118
+ * Does NOT return account names or IDs to avoid leaking structure.
119
+ */
120
+ export const checkDomain = createHandler(async (_ctx, _body) => {
121
+ const email = _ctx.query?.email as string | undefined
122
+ if (!email || !email.includes('@')) {
123
+ throw new Error('Valid email is required')
124
+ }
125
+
126
+ const domain = email.split('@')[1].toLowerCase()
127
+
128
+ if (PERSONAL_DOMAINS.has(domain)) {
129
+ return { match: false }
130
+ }
131
+
132
+ const { data } = await adminDb
133
+ .from('people')
134
+ .select('id')
135
+ .ilike('email', `%@${domain}`)
136
+ .eq('status', 'active')
137
+ .limit(1)
138
+
139
+ return { match: (data?.length ?? 0) > 0 }
140
+ })
141
+
142
+ // ─── PUBLIC: validate-token ───────────────────────────────────────────────────
143
+
144
+ /**
145
+ * Looks up a people row by invite_token. Returns a safe preview (no UUIDs).
146
+ * Increments invite_attempts; blocks the token after MAX_ATTEMPTS failures.
147
+ */
148
+ export const validateToken = createHandler(async (_ctx, _body) => {
149
+ const token = _ctx.query?.token as string | undefined
150
+ if (!token) throw new Error('Token is required')
151
+
152
+ const { data: person } = await adminDb
153
+ .from('people')
154
+ .select('id, email, account_id, role_id, status, data, account:accounts(display_name), role:roles(slug, name)')
155
+ .filter('data->>invite_token', 'eq', token)
156
+ .in('status', ['invited', 'pending'])
157
+ .single()
158
+
159
+ if (!person) {
160
+ throw new Error('Invite not found or already used')
161
+ }
162
+
163
+ const meta = person.data || {}
164
+
165
+ // Check blocked
166
+ if (meta.invite_blocked_at) {
167
+ throw new Error('This invite link has been blocked. Please contact the administrator.')
168
+ }
169
+
170
+ // Check expiry
171
+ if (meta.invite_expires_at && new Date(meta.invite_expires_at) < new Date()) {
172
+ throw new Error('This invite link has expired. Please request a new one.')
173
+ }
174
+
175
+ // Increment attempts
176
+ const attempts = (meta.invite_attempts || 0) + 1
177
+ const blocked = attempts >= MAX_ATTEMPTS
178
+
179
+ await adminDb
180
+ .from('people')
181
+ .update({
182
+ data: {
183
+ ...meta,
184
+ invite_attempts: attempts,
185
+ invite_blocked_at: blocked ? new Date().toISOString() : null,
186
+ },
187
+ updated_at: new Date().toISOString(),
188
+ })
189
+ .eq('id', person.id)
190
+
191
+ if (blocked) {
192
+ throw new Error('Too many failed attempts. This invite has been blocked.')
193
+ }
194
+
195
+ const account = Array.isArray(person.account) ? person.account[0] : person.account
196
+ const role = Array.isArray(person.role) ? person.role[0] : person.role
197
+
198
+ return {
199
+ email: person.email,
200
+ account_name: account?.display_name ?? null,
201
+ role_name: role?.name ?? null,
202
+ kind: meta.invite_kind,
203
+ }
204
+ })
205
+
206
+ // ─── PUBLIC: complete-registration ───────────────────────────────────────────
207
+
208
+ /**
209
+ * Called by RegisterPage after Supabase auth.signUp completes.
210
+ * Handles three walk-up paths:
211
+ * new_account — creates account + activates person as *_admin
212
+ * join_request — creates access_request stub (person stays pending)
213
+ * (invite path is handled entirely in the DB trigger)
214
+ */
215
+ export const completeRegistration = createHandler(async (ctx, body) => {
216
+ const { path, account_name, email } = body
217
+
218
+ if (!path || !email) {
219
+ throw new Error('path and email are required')
220
+ }
221
+
222
+ if (path === 'new_account') {
223
+ if (!account_name) throw new Error('account_name is required for new_account path')
224
+
225
+ // Resolve person type_id
226
+ const { data: personType } = await adminDb
227
+ .from('types')
228
+ .select('id, design_schema, validation_schema')
229
+ .eq('kind', 'person')
230
+ .eq('slug', 'person')
231
+ .single()
232
+
233
+ // Resolve account type_id
234
+ const { data: accountType } = await adminDb
235
+ .from('types')
236
+ .select('id')
237
+ .eq('kind', 'account')
238
+ .eq('slug', 'account')
239
+ .single()
240
+
241
+ // Resolve member_admin role (fall back to member if not seeded yet)
242
+ const { data: role } = await adminDb
243
+ .from('roles')
244
+ .select('id')
245
+ .in('slug', ['member_admin', 'member'])
246
+ .order('slug') // member_admin sorts before member
247
+ .limit(1)
248
+ .single()
249
+
250
+ // Create account
251
+ const slug = generateAccountSlug(account_name)
252
+ const { data: account, error: accErr } = await adminDb
253
+ .from('accounts')
254
+ .insert({
255
+ slug,
256
+ display_name: account_name,
257
+ type_id: accountType?.id,
258
+ is_active: true,
259
+ })
260
+ .select('id')
261
+ .single()
262
+
263
+ if (accErr || !account) throw new Error('Failed to create account: ' + accErr?.message)
264
+
265
+ // Update the walk-up person shell (created by trigger as minimal record)
266
+ const { error: pErr } = await adminDb
267
+ .from('people')
268
+ .update({
269
+ account_id: account.id,
270
+ role_id: role?.id ?? null,
271
+ status: 'active',
272
+ updated_at: new Date().toISOString(),
273
+ })
274
+ .eq('email', email)
275
+ .eq('status', 'active')
276
+ .is('role_id', null) // only the newly created shell
277
+
278
+ if (pErr) throw new Error('Failed to update person: ' + pErr.message)
279
+
280
+ return { status: 'active', account_slug: slug }
281
+ }
282
+
283
+ if (path === 'join_request') {
284
+ const { target_account_id } = body
285
+ if (!target_account_id) throw new Error('target_account_id is required for join_request')
286
+
287
+ // Resolve member role
288
+ const { data: role } = await adminDb
289
+ .from('roles')
290
+ .select('id')
291
+ .eq('slug', 'member')
292
+ .single()
293
+
294
+ // Resolve type_id
295
+ const { data: personType } = await adminDb
296
+ .from('types')
297
+ .select('id, design_schema, validation_schema')
298
+ .eq('kind', 'person')
299
+ .eq('slug', 'person')
300
+ .single()
301
+
302
+ // Update the walk-up shell to be a pending access request on the target account
303
+ const { error: pErr } = await adminDb
304
+ .from('people')
305
+ .update({
306
+ account_id: target_account_id,
307
+ role_id: role?.id ?? null,
308
+ status: 'pending',
309
+ data: {
310
+ invite_kind: 'access_request',
311
+ invite_invited_by: null,
312
+ invite_token: null,
313
+ },
314
+ updated_at: new Date().toISOString(),
315
+ })
316
+ .eq('email', email)
317
+ .eq('status', 'active')
318
+ .is('role_id', null)
319
+
320
+ if (pErr) throw new Error('Failed to create access request: ' + pErr.message)
321
+
322
+ return { status: 'pending' }
323
+ }
324
+
325
+ throw new Error('Unknown registration path: ' + path)
326
+ })
327
+
328
+ // ─── PROTECTED: create-invite ─────────────────────────────────────────────────
329
+
330
+ /**
331
+ * Creates a stub people row (status: 'invited') with invite metadata.
332
+ * Returns the shareable token URL.
333
+ * Requires *_admin role scoped to the target account, or system_admin.
334
+ */
335
+ export const createInvite = createHandler(async (ctx, body) => {
336
+ if (!ctx.principal || ctx.principal.id === 'anonymous') {
337
+ throw new Error('Authentication required')
338
+ }
339
+
340
+ const { email, account_id, role_id, base_url } = body
341
+ if (!email || !account_id || !role_id) {
342
+ throw new Error('email, account_id, and role_id are required')
343
+ }
344
+
345
+ // Verify caller is admin for this account
346
+ const { data: caller } = await adminDb
347
+ .from('people')
348
+ .select('role:roles(slug)')
349
+ .eq('id', ctx.principal.id)
350
+ .eq('account_id', account_id)
351
+ .single()
352
+
353
+ const callerRole = Array.isArray(caller?.role) ? caller?.role[0] : caller?.role
354
+ const callerRoleSlug = callerRole?.slug
355
+
356
+ const isSystemAdmin = ctx.principal.permissions?.includes('system') ||
357
+ callerRoleSlug === 'system_admin'
358
+ const isAccountAdmin = isAdminRole(callerRoleSlug)
359
+
360
+ if (!isSystemAdmin && !isAccountAdmin) {
361
+ throw new Error('Insufficient permissions — *_admin role required to create invites')
362
+ }
363
+
364
+ // Check for existing unclaimed invite for this email+account
365
+ const { data: existing } = await adminDb
366
+ .from('people')
367
+ .select('id')
368
+ .eq('email', email)
369
+ .eq('account_id', account_id)
370
+ .in('status', ['invited', 'active'])
371
+ .single()
372
+
373
+ if (existing) {
374
+ throw new Error('A person with this email already exists or has a pending invite for this account')
375
+ }
376
+
377
+ // Resolve type_id for person
378
+ const { data: personType } = await adminDb
379
+ .from('types')
380
+ .select('id, design_schema, validation_schema')
381
+ .eq('kind', 'person')
382
+ .eq('slug', 'person')
383
+ .single()
384
+
385
+ const token = generateToken()
386
+ const expiresAt = new Date(Date.now() + INVITE_EXPIRY_DAYS * 86400 * 1000).toISOString()
387
+
388
+ const { data: stub, error: insertErr } = await adminDb
389
+ .from('people')
390
+ .insert({
391
+ email,
392
+ full_name: email.split('@')[0],
393
+ account_id,
394
+ role_id,
395
+ type_id: personType?.id,
396
+ status: 'invited',
397
+ is_active: true,
398
+ design_schema: personType?.design_schema ?? {},
399
+ validation_schema: personType?.validation_schema ?? {},
400
+ data: {
401
+ invite_token: token,
402
+ invite_kind: 'invite',
403
+ invite_expires_at: expiresAt,
404
+ invite_attempts: 0,
405
+ invite_blocked_at: null,
406
+ invite_invited_by: ctx.principal.id,
407
+ },
408
+ })
409
+ .select('id')
410
+ .single()
411
+
412
+ if (insertErr || !stub) throw new Error('Failed to create invite: ' + insertErr?.message)
413
+
414
+ const appBaseUrl = base_url || process.env.APP_BASE_URL || 'https://yourapp.com'
415
+ const inviteUrl = `${appBaseUrl}/register?t=${token}`
416
+
417
+ await emitLog(ctx, 'invite.created', { type: 'person', id: stub.id }, {
418
+ after: { email, account_id, role_id, expires_at: expiresAt }
419
+ })
420
+
421
+ return { id: stub.id, token, invite_url: inviteUrl, expires_at: expiresAt }
422
+ })
423
+
424
+ // ─── PROTECTED: list-invites ──────────────────────────────────────────────────
425
+
426
+ /**
427
+ * Lists people rows with invite metadata.
428
+ * system_admin sees all accounts; others see only their account.
429
+ */
430
+ export const listInvites = createHandler(async (ctx, _body) => {
431
+ if (!ctx.principal || ctx.principal.id === 'anonymous') {
432
+ throw new Error('Authentication required')
433
+ }
434
+
435
+ const isSystemAdmin = ctx.principal.permissions?.includes('system')
436
+ const { account_id: queryAccountId } = ctx.query || {}
437
+
438
+ let query = adminDb
439
+ .from('people')
440
+ .select(`
441
+ id, email, full_name, status, created_at, updated_at,
442
+ account_id, role_id,
443
+ account:accounts(id, slug, display_name),
444
+ role:roles(id, slug, name),
445
+ data
446
+ `)
447
+ .not('data->>invite_kind', 'is', null)
448
+ .order('created_at', { ascending: false })
449
+
450
+ if (!isSystemAdmin) {
451
+ // Scoped to caller's account
452
+ const scopedAccountId = queryAccountId || ctx.accountId
453
+ if (!scopedAccountId) throw new Error('Account scope required')
454
+ query = query.eq('account_id', scopedAccountId)
455
+ } else if (queryAccountId) {
456
+ query = query.eq('account_id', queryAccountId)
457
+ }
458
+
459
+ const { data, error } = await query
460
+ if (error) throw error
461
+
462
+ return (data || []).map(row => ({
463
+ id: row.id,
464
+ email: row.email,
465
+ full_name: row.full_name,
466
+ status: row.status,
467
+ kind: (row.data as any)?.invite_kind,
468
+ expires_at: (row.data as any)?.invite_expires_at,
469
+ blocked_at: (row.data as any)?.invite_blocked_at,
470
+ created_at: row.created_at,
471
+ account: Array.isArray(row.account) ? row.account[0] : row.account,
472
+ role: Array.isArray(row.role) ? row.role[0] : row.role,
473
+ }))
474
+ })
475
+
476
+ // ─── PROTECTED: revoke-invite ─────────────────────────────────────────────────
477
+
478
+ export const revokeInvite = createHandler(async (ctx, body) => {
479
+ if (!ctx.principal || ctx.principal.id === 'anonymous') throw new Error('Authentication required')
480
+
481
+ const { person_id } = body
482
+ if (!person_id) throw new Error('person_id is required')
483
+
484
+ const { data: person } = await adminDb
485
+ .from('people')
486
+ .select('id, account_id, status, data')
487
+ .eq('id', person_id)
488
+ .in('status', ['invited', 'pending'])
489
+ .single()
490
+
491
+ if (!person) throw new Error('Invite not found or already processed')
492
+
493
+ const { error } = await adminDb
494
+ .from('people')
495
+ .update({
496
+ status: 'inactive',
497
+ is_active: false,
498
+ data: { ...(person.data as any), invite_token: null },
499
+ updated_at: new Date().toISOString(),
500
+ })
501
+ .eq('id', person_id)
502
+
503
+ if (error) throw error
504
+
505
+ await emitLog(ctx, 'invite.revoked', { type: 'person', id: person_id }, {})
506
+
507
+ return { success: true }
508
+ })
509
+
510
+ // ─── PROTECTED: reinvite ──────────────────────────────────────────────────────
511
+
512
+ export const reinvite = createHandler(async (ctx, body) => {
513
+ if (!ctx.principal || ctx.principal.id === 'anonymous') throw new Error('Authentication required')
514
+
515
+ const { person_id, base_url } = body
516
+ if (!person_id) throw new Error('person_id is required')
517
+
518
+ const { data: person } = await adminDb
519
+ .from('people')
520
+ .select('id, email, account_id, role_id, data, status')
521
+ .eq('id', person_id)
522
+ .single()
523
+
524
+ if (!person) throw new Error('Person not found')
525
+
526
+ const newToken = generateToken()
527
+ const expiresAt = new Date(Date.now() + INVITE_EXPIRY_DAYS * 86400 * 1000).toISOString()
528
+
529
+ const { error } = await adminDb
530
+ .from('people')
531
+ .update({
532
+ status: 'invited',
533
+ is_active: true,
534
+ data: {
535
+ ...(person.data as any),
536
+ invite_token: newToken,
537
+ invite_kind: 'invite',
538
+ invite_expires_at: expiresAt,
539
+ invite_attempts: 0,
540
+ invite_blocked_at: null,
541
+ invite_invited_by: ctx.principal.id,
542
+ },
543
+ updated_at: new Date().toISOString(),
544
+ })
545
+ .eq('id', person_id)
546
+
547
+ if (error) throw error
548
+
549
+ const appBaseUrl = base_url || process.env.APP_BASE_URL || 'https://yourapp.com'
550
+ const inviteUrl = `${appBaseUrl}/register?t=${newToken}`
551
+
552
+ await emitLog(ctx, 'invite.reinvited', { type: 'person', id: person_id }, {
553
+ after: { email: person.email, expires_at: expiresAt }
554
+ })
555
+
556
+ return { token: newToken, invite_url: inviteUrl, expires_at: expiresAt }
557
+ })
558
+
559
+ // ─── PROTECTED: approve ───────────────────────────────────────────────────────
560
+
561
+ export const approve = createHandler(async (ctx, body) => {
562
+ if (!ctx.principal || ctx.principal.id === 'anonymous') throw new Error('Authentication required')
563
+
564
+ const { person_id } = body
565
+ if (!person_id) throw new Error('person_id is required')
566
+
567
+ const { data: person } = await adminDb
568
+ .from('people')
569
+ .select('id, email, account_id, data')
570
+ .eq('id', person_id)
571
+ .eq('status', 'pending')
572
+ .single()
573
+
574
+ if (!person) throw new Error('Access request not found or already processed')
575
+
576
+ // Strip invite metadata on approval
577
+ const { invite_token, invite_kind, invite_expires_at, invite_attempts, invite_blocked_at, invite_invited_by, ...remainingData } =
578
+ (person.data as any) || {}
579
+
580
+ const { error } = await adminDb
581
+ .from('people')
582
+ .update({
583
+ status: 'active',
584
+ is_active: true,
585
+ data: remainingData,
586
+ updated_at: new Date().toISOString(),
587
+ })
588
+ .eq('id', person_id)
589
+
590
+ if (error) throw error
591
+
592
+ await emitLog(ctx, 'invite.approved', { type: 'person', id: person_id }, {
593
+ after: { email: person.email, approved_by: ctx.principal.id }
594
+ })
595
+
596
+ return { success: true }
597
+ })
598
+
599
+ // ─── PROTECTED: deny ──────────────────────────────────────────────────────────
600
+
601
+ export const deny = createHandler(async (ctx, body) => {
602
+ if (!ctx.principal || ctx.principal.id === 'anonymous') throw new Error('Authentication required')
603
+
604
+ const { person_id } = body
605
+ if (!person_id) throw new Error('person_id is required')
606
+
607
+ const { data: person } = await adminDb
608
+ .from('people')
609
+ .select('id, email, account_id, data')
610
+ .eq('id', person_id)
611
+ .eq('status', 'pending')
612
+ .single()
613
+
614
+ if (!person) throw new Error('Access request not found or already processed')
615
+
616
+ const { error } = await adminDb
617
+ .from('people')
618
+ .update({
619
+ status: 'inactive',
620
+ is_active: false,
621
+ updated_at: new Date().toISOString(),
622
+ })
623
+ .eq('id', person_id)
624
+
625
+ if (error) throw error
626
+
627
+ await emitLog(ctx, 'invite.denied', { type: 'person', id: person_id }, {
628
+ after: { email: person.email, denied_by: ctx.principal.id }
629
+ })
630
+
631
+ return { success: true }
632
+ })
633
+
634
+ // ─── MAIN HANDLER ────────────────────────────────────────────────────────────
635
+
636
+ export const handler = createHandler(async (ctx, body) => {
637
+ const action = ctx.query?.action
638
+
639
+ switch (action) {
640
+ // Public
641
+ case 'check-first-user': return await checkFirstUser(ctx, body)
642
+ case 'check-domain': return await checkDomain(ctx, body)
643
+ case 'validate-token': return await validateToken(ctx, body)
644
+ case 'complete-registration': return await completeRegistration(ctx, body)
645
+ // Protected
646
+ case 'create-invite': return await createInvite(ctx, body)
647
+ case 'list-invites': return await listInvites(ctx, body)
648
+ case 'revoke-invite': return await revokeInvite(ctx, body)
649
+ case 'reinvite': return await reinvite(ctx, body)
650
+ case 'approve': return await approve(ctx, body)
651
+ case 'deny': return await deny(ctx, body)
652
+ default:
653
+ throw new Error(`Unknown action: ${action}`)
654
+ }
655
+ })