import { Hono } from "hono"; import { and, eq, gt, isNull, sql } from "drizzle-orm"; import { ulid } from "ulid"; import type { AppContext } from "@/worker/app-context"; import { invites, memberships, users, workspaces } from "@/worker/db/d1/schema"; import { requireAuth } from "@/worker/middleware/auth"; import { rateLimit } from "@/worker/middleware/rate-limit"; import { createAccessToken, createRefreshToken, setRefreshCookie, generateSecureToken } from "@/worker/lib/auth"; import { checkMembership } from "@/worker/lib/membership"; import { parseBody } from "@/worker/lib/validate"; import { createLogger } from "@/worker/lib/logger"; import { INVITE_EXPIRY_MS } from "@/worker/lib/constants"; import { CreateInviteRequest, AcceptInviteRequest } from "@/shared/types"; const log = createLogger("invites"); type InviteRow = { revoked_at: string | null; accepted_at: string | null; expires_at: string }; function validateInviteState(invite: InviteRow): { error: string; message: string } | null { if (invite.revoked_at) return { error: "gone", message: "This invite has been revoked" }; if (invite.accepted_at) return { error: "gone", message: "This invite has already been accepted" }; if (new Date(invite.expires_at) < new Date()) return { error: "gone", message: "This invite has expired" }; return null; } const invitesRouter = new Hono(); invitesRouter.post("/workspaces/:wid/invite", requireAuth, rateLimit("RL_API"), async (c) => { const workspaceId = c.req.param("wid"); const user = c.get("user")!; const db = c.get("db"); const data = await parseBody(c, CreateInviteRequest); if (data instanceof Response) return data; const { email, role } = data; // Check user is a member of the workspace const membership = await checkMembership(db, user.id, workspaceId); if (!membership) { return c.json({ error: "forbidden", message: "You are not a member of this workspace" }, 403); } // Any member role or above can create invites if (membership.role === "guest") { return c.json({ error: "forbidden", message: "Guests cannot create invites" }, 403); } // Members can only invite as member or guest if (role === "admin" && membership.role !== "owner" && membership.role !== "admin") { return c.json({ error: "forbidden", message: "Only owners and admins can invite as admin" }, 403); } const token = generateSecureToken(); const inviteId = ulid(); const expiresAt = new Date(Date.now() + INVITE_EXPIRY_MS).toISOString(); await db.insert(invites).values({ id: inviteId, email: email?.toLowerCase() ?? null, workspace_id: workspaceId, invited_by: user.id, role, token, expires_at: expiresAt, }); log.info("invite_created", { inviteId, workspaceId, role, email: email?.toLowerCase() ?? null }); const origin = new URL(c.req.url).origin; return c.json( { invite: { id: inviteId, token, role, email: email?.toLowerCase() ?? null, expires_at: expiresAt, invite_link: `${origin}/invite/${token}`, }, }, 201, ); }); // GET /invite/:token invitesRouter.get("/invite/:token", async (c) => { const token = c.req.param("token"); const db = c.get("db"); const result = await db .select({ id: invites.id, email: invites.email, role: invites.role, workspace_id: invites.workspace_id, expires_at: invites.expires_at, accepted_at: invites.accepted_at, revoked_at: invites.revoked_at, workspace_name: workspaces.name, workspace_icon: workspaces.icon, invited_by_name: users.name, }) .from(invites) .innerJoin(workspaces, eq(invites.workspace_id, workspaces.id)) .innerJoin(users, eq(invites.invited_by, users.id)) .where(eq(invites.token, token)) .get(); if (!result) { return c.json({ error: "not_found", message: "Invite not found" }, 404); } const stateError = validateInviteState(result); if (stateError) return c.json(stateError, 410); return c.json({ invite: { id: result.id, email: result.email, role: result.role, workspace_name: result.workspace_name, workspace_icon: result.workspace_icon, invited_by_name: result.invited_by_name, }, }); }); // POST /invite/:token/accept invitesRouter.post("/invite/:token/accept", requireAuth, rateLimit("RL_AUTH"), async (c) => { const token = c.req.param("token"); const db = c.get("db"); const user = c.get("user")!; // Body is currently empty by contract, but we still validate so future fields // get the same boundary treatment as the rest of the API. const data = await parseBody(c, AcceptInviteRequest); if (data instanceof Response) return data; const invite = await db.select().from(invites).where(eq(invites.token, token)).get(); if (!invite) { return c.json({ error: "not_found", message: "Invite not found" }, 404); } const stateError = validateInviteState(invite); if (stateError) return c.json(stateError, 410); // Email-pinned invites must match the authenticated user's tessera-owned email. if (invite.email && user.email.toLowerCase() !== invite.email) { return c.json({ error: "forbidden", message: "This invite is for a different email address" }, 403); } const userId = user.id; const userPayload = { id: user.id, email: user.email, name: user.name, avatar_url: user.avatar_url, created_at: user.created_at, }; // Conditional acceptance gate, atomic with the membership insert. // The UPDATE only matches an open invite. The INSERT-from-SELECT only // materializes a membership row if this caller's UPDATE just claimed the // invite (filter on the `accepted_by`/`accepted_at` we wrote in step 1). // `onConflictDoNothing` makes the INSERT a no-op for an already-member // caller via the (user_id, workspace_id) primary key, so one batch shape // serves both paths. const now = new Date().toISOString(); const existingMembership = await checkMembership(db, userId, invite.workspace_id); const [claimedRows] = await db.batch([ db .update(invites) .set({ accepted_at: now, accepted_by: userId }) .where( and( eq(invites.id, invite.id), isNull(invites.accepted_at), isNull(invites.revoked_at), gt(invites.expires_at, now), ), ) .returning({ id: invites.id }), db .insert(memberships) .select((qb) => qb .select({ user_id: sql`${userId}`.as("user_id"), workspace_id: invites.workspace_id, role: invites.role, joined_at: sql`${now}`.as("joined_at"), }) .from(invites) .where(and(eq(invites.id, invite.id), eq(invites.accepted_by, userId), eq(invites.accepted_at, now))), ) .onConflictDoNothing(), ]); if (!claimedRows || claimedRows.length === 0) { // Lost the gate: invite was concurrently accepted, revoked, or expired. const reread = await db.select().from(invites).where(eq(invites.id, invite.id)).get(); const stateError = reread ? validateInviteState(reread) : null; return c.json(stateError ?? { error: "gone", message: "This invite has already been accepted" }, 410); } const [accessToken, refreshToken] = await Promise.all([ createAccessToken(userId, c.env), createRefreshToken(userId, c.env), ]); setRefreshCookie(c, refreshToken); if (existingMembership) { log.info("invite_accepted", { inviteId: invite.id, userId, workspaceId: invite.workspace_id, alreadyMember: true, }); return c.json({ user: userPayload, workspace_id: invite.workspace_id, accessToken, already_member: true, }); } log.info("invite_accepted", { inviteId: invite.id, userId, workspaceId: invite.workspace_id, alreadyMember: false, }); return c.json({ user: userPayload, workspace_id: invite.workspace_id, accessToken, }); }); // DELETE /invite/:id invitesRouter.delete("/invite/:id", requireAuth, rateLimit("RL_API"), async (c) => { const inviteId = c.req.param("id"); const user = c.get("user")!; const db = c.get("db"); const invite = await db.select().from(invites).where(eq(invites.id, inviteId)).get(); if (!invite) { return c.json({ error: "not_found", message: "Invite not found" }, 404); } // Check if user created the invite or is an admin/owner of the workspace if (invite.invited_by !== user.id) { const membership = await checkMembership(db, user.id, invite.workspace_id); if (!membership || (membership.role !== "owner" && membership.role !== "admin")) { return c.json({ error: "forbidden", message: "You cannot revoke this invite" }, 403); } } if (invite.revoked_at) { return c.json({ error: "conflict", message: "Invite is already revoked" }, 409); } await db.update(invites).set({ revoked_at: new Date().toISOString() }).where(eq(invites.id, inviteId)); log.info("invite_revoked", { inviteId, byUserId: user.id }); return c.json({ ok: true }); }); export { invitesRouter };