invitations.ts149 lines · main
| 1 | import { Hono, type Context } from 'hono'; |
| 2 | import { z } from 'zod'; |
| 3 | |
| 4 | import { memberRole } from '../db/schema.js'; |
| 5 | import { projectRateLimit } from '../middleware/rate-limit.js'; |
| 6 | import { requireAuth } from '../middleware/session.js'; |
| 7 | import type { AppEnv } from '../types/app-env.js'; |
| 8 | import { assertProjectRole } from '../services/access.js'; |
| 9 | import { audit, hashIp } from '../services/audit.js'; |
| 10 | import { |
| 11 | acceptInvitation, |
| 12 | acceptInvitationById, |
| 13 | createInvitation, |
| 14 | listInvitations, |
| 15 | pendingInvitationsForEmail, |
| 16 | revokeInvitation, |
| 17 | } from '../services/invitations.js'; |
| 18 | |
| 19 | const createSchema = z.object({ |
| 20 | email: z.string().email().max(320), |
| 21 | role: z.enum(memberRole), |
| 22 | callbackURL: z.string().url(), |
| 23 | }); |
| 24 | |
| 25 | const acceptSchema = z.object({ |
| 26 | token: z.string().min(10), |
| 27 | }); |
| 28 | |
| 29 | function 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 | |
| 35 | export const invitationsRouter = new Hono<AppEnv>(); |
| 36 | |
| 37 | invitationsRouter.use('/v1/projects/:id/invitations', requireAuth()); |
| 38 | invitationsRouter.use('/v1/projects/:id/invitations/*', requireAuth()); |
| 39 | invitationsRouter.use('/v1/me/invitations', requireAuth()); |
| 40 | invitationsRouter.use('/v1/me/invitations/*', requireAuth()); |
| 41 | |
| 42 | invitationsRouter.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 | |
| 49 | invitationsRouter.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 | |
| 81 | invitationsRouter.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 | |
| 99 | invitationsRouter.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 | |
| 105 | invitationsRouter.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. |
| 133 | invitationsRouter.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 | }); |