project-members.ts124 lines · main
| 1 | import { Hono, type Context } from 'hono'; |
| 2 | import { z } from 'zod'; |
| 3 | |
| 4 | import { projectRateLimit } from '../middleware/rate-limit.js'; |
| 5 | import { requireAuth } from '../middleware/session.js'; |
| 6 | import type { AppEnv } from '../types/app-env.js'; |
| 7 | import { assertProjectRole } from '../services/access.js'; |
| 8 | import { audit, hashIp } from '../services/audit.js'; |
| 9 | import { |
| 10 | addMemberByEmail, |
| 11 | listMembers, |
| 12 | removeMember, |
| 13 | updateMemberRole, |
| 14 | } from '../services/members.js'; |
| 15 | import { memberRole } from '../db/schema.js'; |
| 16 | |
| 17 | const roleSchema = z.enum(memberRole); |
| 18 | |
| 19 | const addMemberSchema = z.object({ |
| 20 | email: z.string().email().max(320), |
| 21 | role: roleSchema, |
| 22 | }); |
| 23 | |
| 24 | const updateMemberSchema = z.object({ |
| 25 | role: roleSchema, |
| 26 | }); |
| 27 | |
| 28 | function ipHash(c: Context<AppEnv>): string | null { |
| 29 | const fwd = c.req.raw.headers.get('x-forwarded-for'); |
| 30 | const ip = fwd ? fwd.split(',')[0]!.trim() : null; |
| 31 | return hashIp(ip); |
| 32 | } |
| 33 | |
| 34 | export const membersRouter = new Hono<AppEnv>(); |
| 35 | |
| 36 | membersRouter.use('/v1/projects/:id/members', requireAuth()); |
| 37 | membersRouter.use('/v1/projects/:id/members/*', requireAuth()); |
| 38 | |
| 39 | membersRouter.get('/v1/projects/:id/members', async (c) => { |
| 40 | const user = c.get('user')!; |
| 41 | const { project } = await assertProjectRole(c.req.param('id'), user.id, 'viewer'); |
| 42 | const rows = await listMembers(project.id); |
| 43 | return c.json({ members: rows }); |
| 44 | }); |
| 45 | |
| 46 | membersRouter.post('/v1/projects/:id/members', projectRateLimit('mutate'), async (c) => { |
| 47 | const user = c.get('user')!; |
| 48 | const { project } = await assertProjectRole(c.req.param('id'), user.id, 'admin'); |
| 49 | |
| 50 | const body = await c.req.json().catch(() => null); |
| 51 | const parsed = addMemberSchema.safeParse(body); |
| 52 | if (!parsed.success) { |
| 53 | return c.json( |
| 54 | { code: 'validation_failed', message: 'invalid request body', issues: parsed.error.issues }, |
| 55 | 400, |
| 56 | ); |
| 57 | } |
| 58 | |
| 59 | const member = await addMemberByEmail({ |
| 60 | projectId: project.id, |
| 61 | email: parsed.data.email, |
| 62 | role: parsed.data.role, |
| 63 | }); |
| 64 | |
| 65 | await audit({ |
| 66 | actorId: user.id, |
| 67 | projectId: project.id, |
| 68 | action: 'member.add', |
| 69 | ipHash: ipHash(c), |
| 70 | userAgent: c.req.header('user-agent') ?? null, |
| 71 | // Per CLAUDE.md §5.1 we do not log email addresses; record the target |
| 72 | // user id instead. |
| 73 | metadata: { userId: member.userId, role: member.role }, |
| 74 | }); |
| 75 | |
| 76 | return c.json({ member }, 201); |
| 77 | }); |
| 78 | |
| 79 | membersRouter.patch('/v1/projects/:id/members/:userId', projectRateLimit('mutate'), async (c) => { |
| 80 | const user = c.get('user')!; |
| 81 | const { project } = await assertProjectRole(c.req.param('id'), user.id, 'admin'); |
| 82 | |
| 83 | const body = await c.req.json().catch(() => null); |
| 84 | const parsed = updateMemberSchema.safeParse(body); |
| 85 | if (!parsed.success) { |
| 86 | return c.json( |
| 87 | { code: 'validation_failed', message: 'invalid request body', issues: parsed.error.issues }, |
| 88 | 400, |
| 89 | ); |
| 90 | } |
| 91 | |
| 92 | const targetId = c.req.param('userId'); |
| 93 | const member = await updateMemberRole(project.id, targetId, parsed.data.role); |
| 94 | |
| 95 | await audit({ |
| 96 | actorId: user.id, |
| 97 | projectId: project.id, |
| 98 | action: 'member.update_role', |
| 99 | ipHash: ipHash(c), |
| 100 | userAgent: c.req.header('user-agent') ?? null, |
| 101 | metadata: { userId: targetId, role: parsed.data.role }, |
| 102 | }); |
| 103 | |
| 104 | return c.json({ member }); |
| 105 | }); |
| 106 | |
| 107 | membersRouter.delete('/v1/projects/:id/members/:userId', projectRateLimit('mutate'), async (c) => { |
| 108 | const user = c.get('user')!; |
| 109 | const { project } = await assertProjectRole(c.req.param('id'), user.id, 'admin'); |
| 110 | |
| 111 | const targetId = c.req.param('userId'); |
| 112 | await removeMember(project.id, targetId); |
| 113 | |
| 114 | await audit({ |
| 115 | actorId: user.id, |
| 116 | projectId: project.id, |
| 117 | action: 'member.remove', |
| 118 | ipHash: ipHash(c), |
| 119 | userAgent: c.req.header('user-agent') ?? null, |
| 120 | metadata: { userId: targetId }, |
| 121 | }); |
| 122 | |
| 123 | return c.json({ removed: targetId }); |
| 124 | }); |