Skip to content
File

Blob: src/worker/routes/invites.ts

typescript276 lines
1import { Hono } from "hono";
2import { and, eq, gt, isNull, sql } from "drizzle-orm";
3import { ulid } from "ulid";
4 
5import type { AppContext } from "@/worker/app-context";
6import { invites, memberships, users, workspaces } from "@/worker/db/d1/schema";
7import { requireAuth } from "@/worker/middleware/auth";
8import { rateLimit } from "@/worker/middleware/rate-limit";
9import { createAccessToken, createRefreshToken, setRefreshCookie, generateSecureToken } from "@/worker/lib/auth";
10import { checkMembership } from "@/worker/lib/membership";
11import { parseBody } from "@/worker/lib/validate";
12import { createLogger } from "@/worker/lib/logger";
13import { INVITE_EXPIRY_MS } from "@/worker/lib/constants";
14import { CreateInviteRequest, AcceptInviteRequest } from "@/shared/types";
15 
16const log = createLogger("invites");
17 
18type InviteRow = { revoked_at: string | null; accepted_at: string | null; expires_at: string };
19 
20function validateInviteState(invite: InviteRow): { error: string; message: string } | null {
21 if (invite.revoked_at) return { error: "gone", message: "This invite has been revoked" };
22 if (invite.accepted_at) return { error: "gone", message: "This invite has already been accepted" };
23 if (new Date(invite.expires_at) < new Date()) return { error: "gone", message: "This invite has expired" };
24 return null;
25}
26 
27const invitesRouter = new Hono<AppContext>();
28invitesRouter.post("/workspaces/:wid/invite", requireAuth, rateLimit("RL_API"), async (c) => {
29 const workspaceId = c.req.param("wid");
30 const user = c.get("user")!;
31 const db = c.get("db");
32 
33 const data = await parseBody(c, CreateInviteRequest);
34 if (data instanceof Response) return data;
35 
36 const { email, role } = data;
37 
38 // Check user is a member of the workspace
39 const membership = await checkMembership(db, user.id, workspaceId);
40 if (!membership) {
41 return c.json({ error: "forbidden", message: "You are not a member of this workspace" }, 403);
42 }
43 
44 // Any member role or above can create invites
45 if (membership.role === "guest") {
46 return c.json({ error: "forbidden", message: "Guests cannot create invites" }, 403);
47 }
48 
49 // Members can only invite as member or guest
50 if (role === "admin" && membership.role !== "owner" && membership.role !== "admin") {
51 return c.json({ error: "forbidden", message: "Only owners and admins can invite as admin" }, 403);
52 }
53 
54 const token = generateSecureToken();
55 const inviteId = ulid();
56 const expiresAt = new Date(Date.now() + INVITE_EXPIRY_MS).toISOString();
57 
58 await db.insert(invites).values({
59 id: inviteId,
60 email: email?.toLowerCase() ?? null,
61 workspace_id: workspaceId,
62 invited_by: user.id,
63 role,
64 token,
65 expires_at: expiresAt,
66 });
67 
68 log.info("invite_created", { inviteId, workspaceId, role, email: email?.toLowerCase() ?? null });
69 
70 const origin = new URL(c.req.url).origin;
71 
72 return c.json(
73 {
74 invite: {
75 id: inviteId,
76 token,
77 role,
78 email: email?.toLowerCase() ?? null,
79 expires_at: expiresAt,
80 invite_link: `${origin}/invite/${token}`,
81 },
82 },
83 201,
84 );
85});
86 
87// GET /invite/:token
88invitesRouter.get("/invite/:token", async (c) => {
89 const token = c.req.param("token");
90 const db = c.get("db");
91 
92 const result = await db
93 .select({
94 id: invites.id,
95 email: invites.email,
96 role: invites.role,
97 workspace_id: invites.workspace_id,
98 expires_at: invites.expires_at,
99 accepted_at: invites.accepted_at,
100 revoked_at: invites.revoked_at,
101 workspace_name: workspaces.name,
102 workspace_icon: workspaces.icon,
103 invited_by_name: users.name,
104 })
105 .from(invites)
106 .innerJoin(workspaces, eq(invites.workspace_id, workspaces.id))
107 .innerJoin(users, eq(invites.invited_by, users.id))
108 .where(eq(invites.token, token))
109 .get();
110 
111 if (!result) {
112 return c.json({ error: "not_found", message: "Invite not found" }, 404);
113 }
114 
115 const stateError = validateInviteState(result);
116 if (stateError) return c.json(stateError, 410);
117 
118 return c.json({
119 invite: {
120 id: result.id,
121 email: result.email,
122 role: result.role,
123 workspace_name: result.workspace_name,
124 workspace_icon: result.workspace_icon,
125 invited_by_name: result.invited_by_name,
126 },
127 });
128});
129 
130// POST /invite/:token/accept
131invitesRouter.post("/invite/:token/accept", requireAuth, rateLimit("RL_AUTH"), async (c) => {
132 const token = c.req.param("token");
133 const db = c.get("db");
134 const user = c.get("user")!;
135 
136 // Body is currently empty by contract, but we still validate so future fields
137 // get the same boundary treatment as the rest of the API.
138 const data = await parseBody(c, AcceptInviteRequest);
139 if (data instanceof Response) return data;
140 
141 const invite = await db.select().from(invites).where(eq(invites.token, token)).get();
142 
143 if (!invite) {
144 return c.json({ error: "not_found", message: "Invite not found" }, 404);
145 }
146 
147 const stateError = validateInviteState(invite);
148 if (stateError) return c.json(stateError, 410);
149 
150 // Email-pinned invites must match the authenticated user's tessera-owned email.
151 if (invite.email && user.email.toLowerCase() !== invite.email) {
152 return c.json({ error: "forbidden", message: "This invite is for a different email address" }, 403);
153 }
154 
155 const userId = user.id;
156 const userPayload = {
157 id: user.id,
158 email: user.email,
159 name: user.name,
160 avatar_url: user.avatar_url,
161 created_at: user.created_at,
162 };
163 
164 // Conditional acceptance gate, atomic with the membership insert.
165 // The UPDATE only matches an open invite. The INSERT-from-SELECT only
166 // materializes a membership row if this caller's UPDATE just claimed the
167 // invite (filter on the `accepted_by`/`accepted_at` we wrote in step 1).
168 // `onConflictDoNothing` makes the INSERT a no-op for an already-member
169 // caller via the (user_id, workspace_id) primary key, so one batch shape
170 // serves both paths.
171 const now = new Date().toISOString();
172 const existingMembership = await checkMembership(db, userId, invite.workspace_id);
173 
174 const [claimedRows] = await db.batch([
175 db
176 .update(invites)
177 .set({ accepted_at: now, accepted_by: userId })
178 .where(
179 and(
180 eq(invites.id, invite.id),
181 isNull(invites.accepted_at),
182 isNull(invites.revoked_at),
183 gt(invites.expires_at, now),
184 ),
185 )
186 .returning({ id: invites.id }),
187 db
188 .insert(memberships)
189 .select((qb) =>
190 qb
191 .select({
192 user_id: sql<string>`${userId}`.as("user_id"),
193 workspace_id: invites.workspace_id,
194 role: invites.role,
195 joined_at: sql<string>`${now}`.as("joined_at"),
196 })
197 .from(invites)
198 .where(and(eq(invites.id, invite.id), eq(invites.accepted_by, userId), eq(invites.accepted_at, now))),
199 )
200 .onConflictDoNothing(),
201 ]);
202 
203 if (!claimedRows || claimedRows.length === 0) {
204 // Lost the gate: invite was concurrently accepted, revoked, or expired.
205 const reread = await db.select().from(invites).where(eq(invites.id, invite.id)).get();
206 const stateError = reread ? validateInviteState(reread) : null;
207 return c.json(stateError ?? { error: "gone", message: "This invite has already been accepted" }, 410);
208 }
209 
210 const [accessToken, refreshToken] = await Promise.all([
211 createAccessToken(userId, c.env),
212 createRefreshToken(userId, c.env),
213 ]);
214 setRefreshCookie(c, refreshToken);
215 
216 if (existingMembership) {
217 log.info("invite_accepted", {
218 inviteId: invite.id,
219 userId,
220 workspaceId: invite.workspace_id,
221 alreadyMember: true,
222 });
223 return c.json({
224 user: userPayload,
225 workspace_id: invite.workspace_id,
226 accessToken,
227 already_member: true,
228 });
229 }
230 
231 log.info("invite_accepted", {
232 inviteId: invite.id,
233 userId,
234 workspaceId: invite.workspace_id,
235 alreadyMember: false,
236 });
237 
238 return c.json({
239 user: userPayload,
240 workspace_id: invite.workspace_id,
241 accessToken,
242 });
243});
244 
245// DELETE /invite/:id
246invitesRouter.delete("/invite/:id", requireAuth, rateLimit("RL_API"), async (c) => {
247 const inviteId = c.req.param("id");
248 const user = c.get("user")!;
249 const db = c.get("db");
250 
251 const invite = await db.select().from(invites).where(eq(invites.id, inviteId)).get();
252 
253 if (!invite) {
254 return c.json({ error: "not_found", message: "Invite not found" }, 404);
255 }
256 
257 // Check if user created the invite or is an admin/owner of the workspace
258 if (invite.invited_by !== user.id) {
259 const membership = await checkMembership(db, user.id, invite.workspace_id);
260 if (!membership || (membership.role !== "owner" && membership.role !== "admin")) {
261 return c.json({ error: "forbidden", message: "You cannot revoke this invite" }, 403);
262 }
263 }
264 
265 if (invite.revoked_at) {
266 return c.json({ error: "conflict", message: "Invite is already revoked" }, 409);
267 }
268 
269 await db.update(invites).set({ revoked_at: new Date().toISOString() }).where(eq(invites.id, inviteId));
270 log.info("invite_revoked", { inviteId, byUserId: user.id });
271 
272 return c.json({ ok: true });
273});
274 
275export { invitesRouter };