Skip to content
File

Blob: src/worker/api/invites.ts

typescript166 lines
1import { and, eq, gt, isNull } from "drizzle-orm";
2 
3import { isNonEmptyString, readJsonBody, remoteIp, requiredParam } from "@/worker/api/request";
4import { invites } from "@/worker/db/schema";
5import type { AppContext } from "@/worker/hono";
6import { HttpError } from "@/worker/http";
7import { errorContext } from "@/worker/logger";
8import { sha256 } from "@/worker/services/crypto";
9import { enforceRateLimit, rateLimitResponse } from "@/worker/middleware/rate-limit";
10import { verifyTurnstileToken } from "@/worker/services/turnstile";
11 
12interface AcceptInviteBody {
13 password?: unknown;
14 name?: unknown;
15 turnstileToken?: unknown;
16}
17 
18// Mirrors `emailAndPassword.minPasswordLength` (12) and Better Auth's
19// default `maxPasswordLength` (128) configured in src/worker/auth/index.ts.
20// Matching Better Auth's bounds keeps invalid password lengths out of the
21// consume CAS, so they do not burn invites.
22const MIN_PASSWORD_LENGTH = 12;
23const MAX_PASSWORD_LENGTH = 128;
24 
25const isExpired = (expiresAt: string): boolean => new Date(expiresAt).getTime() < Date.now();
26 
27type InviteRow = typeof invites.$inferSelect;
28 
29const classifyMissingInvite = (row: InviteRow | undefined): HttpError => {
30 if (!row) return new HttpError(404, "invite_not_found", "Invite not found.");
31 if (row.consumedAt) return new HttpError(410, "invite_consumed", "This invite has already been used.");
32 if (isExpired(row.expiresAt)) return new HttpError(410, "invite_expired", "This invite has expired.");
33 // A CAS miss with an otherwise usable row is unexpected. Return a
34 // generic 409 rather than silently succeed.
35 return new HttpError(409, "invite_unavailable", "Invite is temporarily unavailable.");
36};
37 
38const signupFailedResponse = (): Response =>
39 new Response(
40 JSON.stringify({
41 error: "signup_failed",
42 message: "Could not complete signup. Please contact your administrator.",
43 }),
44 { status: 500, headers: { "content-type": "application/json" } },
45 );
46 
47export const handleInviteLookup = async (c: AppContext): Promise<Response> => {
48 const logger = c.var.log.child({ component: "invites" });
49 const token = requiredParam(c, "token", { status: 404, code: "invite_not_found", message: "Invite not found." });
50 const tokenHash = await sha256(token);
51 const db = c.var.db;
52 const row = await db.select().from(invites).where(eq(invites.tokenHash, tokenHash)).get();
53 
54 if (!row || row.consumedAt || isExpired(row.expiresAt)) {
55 const error = classifyMissingInvite(row);
56 logger.warn("invite_lookup_failed", {
57 inviteId: row?.id,
58 reason: error.code,
59 });
60 throw error;
61 }
62 
63 logger.info("invite_lookup_succeeded", { inviteId: row.id });
64 return c.json({
65 state: "valid",
66 email: row.email,
67 expiresAt: row.expiresAt,
68 });
69};
70 
71export const handleInviteAccept = async (c: AppContext): Promise<Response> => {
72 const logger = c.var.log.child({ component: "invites" });
73 const ip = remoteIp(c);
74 const decision = await enforceRateLimit(c.var.log, c.env.RL_AUTH, "invite", ip);
75 if (!decision.allowed) {
76 logger.warn("invite_accept_rate_limited", { retryAfterSeconds: decision.retryAfterSeconds });
77 return rateLimitResponse(decision);
78 }
79 
80 const token = requiredParam(c, "token", { status: 404, code: "invite_not_found", message: "Invite not found." });
81 const tokenHash = await sha256(token);
82 
83 const body = await readJsonBody<AcceptInviteBody>(c, "Request body must be JSON.");
84 if (!isNonEmptyString(body.password) || !isNonEmptyString(body.name) || !isNonEmptyString(body.turnstileToken)) {
85 logger.warn("invite_accept_failed", { reason: "invalid_body" });
86 throw new HttpError(400, "invalid_body", "name, password, and turnstileToken are required.");
87 }
88 if (body.password.length < MIN_PASSWORD_LENGTH || body.password.length > MAX_PASSWORD_LENGTH) {
89 logger.warn("invite_accept_failed", { reason: "invalid_password_length" });
90 throw new HttpError(
91 400,
92 "invalid_password",
93 `Password must be between ${MIN_PASSWORD_LENGTH} and ${MAX_PASSWORD_LENGTH} characters.`,
94 );
95 }
96 
97 const verification = await verifyTurnstileToken(c.env, {
98 expectedAction: "accept_invite",
99 remoteIp: ip,
100 requestUrl: c.req.url,
101 token: body.turnstileToken,
102 });
103 if (!verification.ok) {
104 logger.warn("invite_accept_turnstile_rejected", {
105 reason: verification.reason,
106 status: verification.status,
107 });
108 throw new HttpError(verification.status, "turnstile_failed", verification.message, {
109 reason: verification.reason,
110 });
111 }
112 
113 const db = c.var.db;
114 // Single-statement consume: SQLite serializes writers. One caller sets
115 // `consumed_at`; concurrent callers get zero affected rows and re-read
116 // for classification. The expiry predicate uses the same JS ISO format
117 // that `expires_at` stores.
118 const now = new Date().toISOString();
119 const consumed = await db
120 .update(invites)
121 .set({ consumedAt: now })
122 .where(and(eq(invites.tokenHash, tokenHash), isNull(invites.consumedAt), gt(invites.expiresAt, now)))
123 .returning({ id: invites.id, email: invites.email });
124 
125 if (consumed.length === 0) {
126 const row = await db.select().from(invites).where(eq(invites.tokenHash, tokenHash)).get();
127 const error = classifyMissingInvite(row);
128 logger.warn("invite_accept_failed", { inviteId: row?.id, reason: error.code });
129 throw error;
130 }
131 
132 const invite = consumed[0]!;
133 
134 // Failure-closed invite policy: signUpEmail errors leave the invite
135 // consumed. Operator remediation is to mint a new invite. Logged loudly
136 // so burned invites are visible.
137 const signUpResponse = await c.var.auth.api
138 .signUpEmail({
139 body: { email: invite.email, password: body.password, name: body.name },
140 headers: c.req.raw.headers,
141 asResponse: true,
142 })
143 .catch((err: unknown) => {
144 // Server-side log via errorContext (stack stripped at non-debug
145 // levels per logger.ts). Caller sees a generic message — never
146 // err.message — so upstream copy like "User with this email
147 // already exists." cannot leak to an unauthenticated caller.
148 logger.error("invite_accept_signup_threw_after_consume", {
149 inviteId: invite.id,
150 ...errorContext(err, c.var.logLevel),
151 });
152 return signupFailedResponse();
153 });
154 
155 if (signUpResponse.status !== 200) {
156 logger.error("invite_accept_signup_failed_after_consume", {
157 inviteId: invite.id,
158 status: signUpResponse.status,
159 });
160 return signupFailedResponse();
161 }
162 
163 logger.info("invite_consumed", { inviteId: invite.id });
164 return signUpResponse;
165};