spine-framework 0.3.53 → 0.3.55

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