project-members.ts124 lines · main
1import { Hono, type Context } from 'hono';
2import { z } from 'zod';
3
4import { projectRateLimit } from '../middleware/rate-limit.js';
5import { requireAuth } from '../middleware/session.js';
6import type { AppEnv } from '../types/app-env.js';
7import { assertProjectRole } from '../services/access.js';
8import { audit, hashIp } from '../services/audit.js';
9import {
10 addMemberByEmail,
11 listMembers,
12 removeMember,
13 updateMemberRole,
14} from '../services/members.js';
15import { memberRole } from '../db/schema.js';
16
17const roleSchema = z.enum(memberRole);
18
19const addMemberSchema = z.object({
20 email: z.string().email().max(320),
21 role: roleSchema,
22});
23
24const updateMemberSchema = z.object({
25 role: roleSchema,
26});
27
28function 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
34export const membersRouter = new Hono<AppEnv>();
35
36membersRouter.use('/v1/projects/:id/members', requireAuth());
37membersRouter.use('/v1/projects/:id/members/*', requireAuth());
38
39membersRouter.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
46membersRouter.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
79membersRouter.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
107membersRouter.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});