import { and, eq, gt, isNull } from "drizzle-orm"; import { isNonEmptyString, readJsonBody, remoteIp, requiredParam } from "@/worker/api/request"; import { invites } from "@/worker/db/schema"; import type { AppContext } from "@/worker/hono"; import { HttpError } from "@/worker/http"; import { errorContext } from "@/worker/logger"; import { sha256 } from "@/worker/services/crypto"; import { enforceRateLimit, rateLimitResponse } from "@/worker/middleware/rate-limit"; import { verifyTurnstileToken } from "@/worker/services/turnstile"; interface AcceptInviteBody { password?: unknown; name?: unknown; turnstileToken?: unknown; } // Mirrors `emailAndPassword.minPasswordLength` (12) and Better Auth's // default `maxPasswordLength` (128) configured in src/worker/auth/index.ts. // Matching Better Auth's bounds keeps invalid password lengths out of the // consume CAS, so they do not burn invites. const MIN_PASSWORD_LENGTH = 12; const MAX_PASSWORD_LENGTH = 128; const isExpired = (expiresAt: string): boolean => new Date(expiresAt).getTime() < Date.now(); type InviteRow = typeof invites.$inferSelect; const classifyMissingInvite = (row: InviteRow | undefined): HttpError => { if (!row) return new HttpError(404, "invite_not_found", "Invite not found."); if (row.consumedAt) return new HttpError(410, "invite_consumed", "This invite has already been used."); if (isExpired(row.expiresAt)) return new HttpError(410, "invite_expired", "This invite has expired."); // A CAS miss with an otherwise usable row is unexpected. Return a // generic 409 rather than silently succeed. return new HttpError(409, "invite_unavailable", "Invite is temporarily unavailable."); }; const signupFailedResponse = (): Response => new Response( JSON.stringify({ error: "signup_failed", message: "Could not complete signup. Please contact your administrator.", }), { status: 500, headers: { "content-type": "application/json" } }, ); export const handleInviteLookup = async (c: AppContext): Promise => { const logger = c.var.log.child({ component: "invites" }); const token = requiredParam(c, "token", { status: 404, code: "invite_not_found", message: "Invite not found." }); const tokenHash = await sha256(token); const db = c.var.db; const row = await db.select().from(invites).where(eq(invites.tokenHash, tokenHash)).get(); if (!row || row.consumedAt || isExpired(row.expiresAt)) { const error = classifyMissingInvite(row); logger.warn("invite_lookup_failed", { inviteId: row?.id, reason: error.code, }); throw error; } logger.info("invite_lookup_succeeded", { inviteId: row.id }); return c.json({ state: "valid", email: row.email, expiresAt: row.expiresAt, }); }; export const handleInviteAccept = async (c: AppContext): Promise => { const logger = c.var.log.child({ component: "invites" }); const ip = remoteIp(c); const decision = await enforceRateLimit(c.var.log, c.env.RL_AUTH, "invite", ip); if (!decision.allowed) { logger.warn("invite_accept_rate_limited", { retryAfterSeconds: decision.retryAfterSeconds }); return rateLimitResponse(decision); } const token = requiredParam(c, "token", { status: 404, code: "invite_not_found", message: "Invite not found." }); const tokenHash = await sha256(token); const body = await readJsonBody(c, "Request body must be JSON."); if (!isNonEmptyString(body.password) || !isNonEmptyString(body.name) || !isNonEmptyString(body.turnstileToken)) { logger.warn("invite_accept_failed", { reason: "invalid_body" }); throw new HttpError(400, "invalid_body", "name, password, and turnstileToken are required."); } if (body.password.length < MIN_PASSWORD_LENGTH || body.password.length > MAX_PASSWORD_LENGTH) { logger.warn("invite_accept_failed", { reason: "invalid_password_length" }); throw new HttpError( 400, "invalid_password", `Password must be between ${MIN_PASSWORD_LENGTH} and ${MAX_PASSWORD_LENGTH} characters.`, ); } const verification = await verifyTurnstileToken(c.env, { expectedAction: "accept_invite", remoteIp: ip, requestUrl: c.req.url, token: body.turnstileToken, }); if (!verification.ok) { logger.warn("invite_accept_turnstile_rejected", { reason: verification.reason, status: verification.status, }); throw new HttpError(verification.status, "turnstile_failed", verification.message, { reason: verification.reason, }); } const db = c.var.db; // Single-statement consume: SQLite serializes writers. One caller sets // `consumed_at`; concurrent callers get zero affected rows and re-read // for classification. The expiry predicate uses the same JS ISO format // that `expires_at` stores. const now = new Date().toISOString(); const consumed = await db .update(invites) .set({ consumedAt: now }) .where(and(eq(invites.tokenHash, tokenHash), isNull(invites.consumedAt), gt(invites.expiresAt, now))) .returning({ id: invites.id, email: invites.email }); if (consumed.length === 0) { const row = await db.select().from(invites).where(eq(invites.tokenHash, tokenHash)).get(); const error = classifyMissingInvite(row); logger.warn("invite_accept_failed", { inviteId: row?.id, reason: error.code }); throw error; } const invite = consumed[0]!; // Failure-closed invite policy: signUpEmail errors leave the invite // consumed. Operator remediation is to mint a new invite. Logged loudly // so burned invites are visible. const signUpResponse = await c.var.auth.api .signUpEmail({ body: { email: invite.email, password: body.password, name: body.name }, headers: c.req.raw.headers, asResponse: true, }) .catch((err: unknown) => { // Server-side log via errorContext (stack stripped at non-debug // levels per logger.ts). Caller sees a generic message — never // err.message — so upstream copy like "User with this email // already exists." cannot leak to an unauthenticated caller. logger.error("invite_accept_signup_threw_after_consume", { inviteId: invite.id, ...errorContext(err, c.var.logLevel), }); return signupFailedResponse(); }); if (signUpResponse.status !== 200) { logger.error("invite_accept_signup_failed_after_consume", { inviteId: invite.id, status: signUpResponse.status, }); return signupFailedResponse(); } logger.info("invite_consumed", { inviteId: invite.id }); return signUpResponse; };