invitations.ts149 lines · main
1import { Hono, type Context } from 'hono';
2import { z } from 'zod';
3
4import { memberRole } from '../db/schema.js';
5import { projectRateLimit } from '../middleware/rate-limit.js';
6import { requireAuth } from '../middleware/session.js';
7import type { AppEnv } from '../types/app-env.js';
8import { assertProjectRole } from '../services/access.js';
9import { audit, hashIp } from '../services/audit.js';
10import {
11 acceptInvitation,
12 acceptInvitationById,
13 createInvitation,
14 listInvitations,
15 pendingInvitationsForEmail,
16 revokeInvitation,
17} from '../services/invitations.js';
18
19const createSchema = z.object({
20 email: z.string().email().max(320),
21 role: z.enum(memberRole),
22 callbackURL: z.string().url(),
23});
24
25const acceptSchema = z.object({
26 token: z.string().min(10),
27});
28
29function ipHash(c: Context<AppEnv>): string | null {
30 const fwd = c.req.raw.headers.get('x-forwarded-for');
31 const ip = fwd ? fwd.split(',')[0]!.trim() : null;
32 return hashIp(ip);
33}
34
35export const invitationsRouter = new Hono<AppEnv>();
36
37invitationsRouter.use('/v1/projects/:id/invitations', requireAuth());
38invitationsRouter.use('/v1/projects/:id/invitations/*', requireAuth());
39invitationsRouter.use('/v1/me/invitations', requireAuth());
40invitationsRouter.use('/v1/me/invitations/*', requireAuth());
41
42invitationsRouter.get('/v1/projects/:id/invitations', async (c) => {
43 const user = c.get('user')!;
44 const { project } = await assertProjectRole(c.req.param('id'), user.id, 'admin');
45 const rows = await listInvitations(project.id);
46 return c.json({ invitations: rows });
47});
48
49invitationsRouter.post('/v1/projects/:id/invitations', projectRateLimit('mutate'), async (c) => {
50 const user = c.get('user')!;
51 const { project } = await assertProjectRole(c.req.param('id'), user.id, 'admin');
52 const body = await c.req.json().catch(() => null);
53 const parsed = createSchema.safeParse(body);
54 if (!parsed.success) {
55 return c.json(
56 { code: 'validation_failed', message: 'invalid request body', issues: parsed.error.issues },
57 400,
58 );
59 }
60
61 const invitation = await createInvitation({
62 projectId: project.id,
63 email: parsed.data.email,
64 role: parsed.data.role,
65 invitedBy: user.id,
66 callbackURL: parsed.data.callbackURL,
67 });
68
69 await audit({
70 actorId: user.id,
71 projectId: project.id,
72 action: 'invitation.create',
73 ipHash: ipHash(c),
74 userAgent: c.req.header('user-agent') ?? null,
75 // Never log the email itself — only the invitation id + role. §5.1.
76 metadata: { invitationId: invitation.id, role: invitation.role },
77 });
78 return c.json({ invitation: { id: invitation.id, role: invitation.role } }, 201);
79});
80
81invitationsRouter.delete('/v1/projects/:id/invitations/:invitationId', projectRateLimit('mutate'), async (c) => {
82 const user = c.get('user')!;
83 const { project } = await assertProjectRole(c.req.param('id'), user.id, 'admin');
84 const invitationId = c.req.param('invitationId');
85 await revokeInvitation(project.id, invitationId);
86 await audit({
87 actorId: user.id,
88 projectId: project.id,
89 action: 'invitation.revoke',
90 ipHash: ipHash(c),
91 userAgent: c.req.header('user-agent') ?? null,
92 metadata: { invitationId },
93 });
94 return c.json({ revoked: invitationId });
95});
96
97// —— recipient flows —————————————————————————————————————————
98
99invitationsRouter.get('/v1/me/invitations', async (c) => {
100 const user = c.get('user')!;
101 const rows = await pendingInvitationsForEmail(user.email);
102 return c.json({ invitations: rows });
103});
104
105invitationsRouter.post('/v1/me/invitations/accept', async (c) => {
106 const user = c.get('user')!;
107 const body = await c.req.json().catch(() => null);
108 const parsed = acceptSchema.safeParse(body);
109 if (!parsed.success) {
110 return c.json(
111 { code: 'validation_failed', message: 'invalid request body', issues: parsed.error.issues },
112 400,
113 );
114 }
115 const result = await acceptInvitation(user.id, user.email, parsed.data.token);
116 await audit({
117 actorId: user.id,
118 projectId: result.projectId,
119 action: 'invitation.accept',
120 ipHash: ipHash(c),
121 userAgent: c.req.header('user-agent') ?? null,
122 metadata: { role: result.role, via: 'token' },
123 });
124 return c.json(result);
125});
126
127// why: accept-by-id replaces the token-based flow inside the dashboard.
128// The recipient is already signed in with the matching email, which is
129// itself proof of identity — exposing the one-time token in the listing
130// API would defeat the model where the token is the email's second
131// factor. The email-link flow above stays intact for not-yet-signed-in
132// users who click straight from the inbox.
133invitationsRouter.post('/v1/me/invitations/:invitationId/accept', async (c) => {
134 const user = c.get('user')!;
135 const invitationId = c.req.param('invitationId');
136 if (!invitationId) {
137 return c.json({ code: 'validation_failed', message: 'invitationId required' }, 400);
138 }
139 const result = await acceptInvitationById(user.id, user.email, invitationId);
140 await audit({
141 actorId: user.id,
142 projectId: result.projectId,
143 action: 'invitation.accept',
144 ipHash: ipHash(c),
145 userAgent: c.req.header('user-agent') ?? null,
146 metadata: { role: result.role, via: 'dashboard' },
147 });
148 return c.json(result);
149});