diff --git a/apps/web/e2e/auth-claim.flow.spec.ts b/apps/web/e2e/auth-claim.flow.spec.ts new file mode 100644 index 0000000..678f4f1 --- /dev/null +++ b/apps/web/e2e/auth-claim.flow.spec.ts @@ -0,0 +1,148 @@ +import { expect, test } from "@playwright/test"; + +function e2eBrowserToken() { + const token = process.env.VRDEX_E2E_BROWSER_TOKEN ?? (process.env.PLAYWRIGHT_BASE_URL ? undefined : "local-playwright-token"); + + if (!token) { + throw new Error("VRDEX_E2E_BROWSER_TOKEN must be set for hosted Playwright data-flow runs."); + } + + return token; +} + +function e2eRunId(testInfo: { project: { name: string }; workerIndex: number; repeatEachIndex: number }) { + const prefix = process.env.VRDEX_E2E_RUN_ID ?? "playwright-auth"; + + return `${prefix}-${testInfo.project.name}-${testInfo.workerIndex}-${testInfo.repeatEachIndex}-${Date.now()}` + .replace(/[^a-z0-9]+/gi, "-") + .toLowerCase() + .slice(0, 120); +} + +test("verified email account with linked Discord can claim an E2E person profile @flow", async ({ page, request }, testInfo) => { + test.skip( + Boolean(process.env.PLAYWRIGHT_BASE_URL) && process.env.VRDEX_ENABLE_E2E_AUTH_HELPERS !== "true", + "Hosted auth E2E helpers are not enabled for this target.", + ); + + const e2eToken = e2eBrowserToken(); + const runId = e2eRunId(testInfo); + const runSuffix = runId.replace(/^playwright-auth-?/, "").slice(0, 48); + const displayName = `Playwright Claim ${runSuffix}`; + const email = `${runSuffix}@e2e.vrdex.local`; + const password = `VRDex-${runSuffix}-password-12345`; + let createdSlug: string | undefined; + + try { + const profileResponse = await request.post("/api/e2e/profile-submissions", { + headers: { "x-vrdex-e2e-token": e2eToken }, + data: { + runId, + profileType: "person", + displayName, + aliases: [`Claim ${runSuffix}`], + tags: ["playwright", "claim-flow"], + roleTags: ["Claim test profile"], + }, + }); + await expect(profileResponse).toBeOK(); + const profile = (await profileResponse.json()) as { slug?: string }; + createdSlug = profile.slug; + expect(createdSlug).toBeTruthy(); + + await page.goto("/sign-in"); + await page.getByRole("button", { name: "Create account" }).click(); + await page.getByLabel("Email").fill(email); + await page.getByLabel("Password").fill(password); + await page.getByRole("button", { name: "Create account" }).click(); + await expect(page.getByText(new RegExp(`Check ${email} for a verification code`, "i"))).toBeVisible(); + + const codeResponse = await request.post("/api/e2e/auth", { + headers: { "x-vrdex-e2e-token": e2eToken }, + data: { action: "consume-code", email }, + }); + await expect(codeResponse).toBeOK(); + const authCode = (await codeResponse.json()) as { code?: string }; + expect(authCode.code).toBeTruthy(); + + await page.getByLabel("Verification code").fill(authCode.code!); + await Promise.all([ + page.waitForURL(/\/account$/), + page.getByRole("button", { name: "Verify email" }).click(), + ]); + await expect(page.getByRole("heading", { name: email })).toBeVisible(); + await expect(page.getByText("Verified", { exact: true })).toBeVisible(); + + const linkResponse = await request.post("/api/e2e/auth", { + headers: { "x-vrdex-e2e-token": e2eToken }, + data: { action: "link-discord", email, providerAccountId: `discord-${runSuffix}` }, + }); + await expect(linkResponse).toBeOK(); + + await page.goto("/account"); + await expect(page.getByText("discord", { exact: true })).toBeVisible(); + await page.getByLabel("Person slug").fill(createdSlug!); + await page.getByRole("button", { name: "Claim with Discord" }).click(); + await expect(page.getByText(/Person profile claimed as claimed unverified/i)).toBeVisible(); + + await page.goto(`/p/${createdSlug}`); + await expect(page.getByRole("heading", { name: displayName })).toBeVisible(); + await expect(page.getByText("Claimed", { exact: true })).toBeVisible(); + } finally { + if (createdSlug || runId) { + await request.delete("/api/e2e/profile-submissions", { + headers: { "x-vrdex-e2e-token": e2eToken }, + data: createdSlug ? { slug: createdSlug, runId } : { runId }, + }); + } + + await request.delete("/api/e2e/auth", { + headers: { "x-vrdex-e2e-token": e2eToken }, + data: { email }, + }); + } +}); + +test("E2E auth helper stays gated without the browser token @flow", async ({ request }) => { + test.skip( + Boolean(process.env.PLAYWRIGHT_BASE_URL) && process.env.VRDEX_ENABLE_E2E_AUTH_HELPERS !== "true", + "Hosted auth E2E helpers are not enabled for this target.", + ); + + const e2eToken = e2eBrowserToken(); + const payload = { action: "consume-code", email: "negative-gate@e2e.vrdex.local" }; + + const missingTokenResponse = await request.post("/api/e2e/auth", { + data: payload, + }); + expect(missingTokenResponse.status()).toBe(403); + + const wrongTokenResponse = await request.post("/api/e2e/auth", { + headers: { "x-vrdex-e2e-token": "wrong-token" }, + data: payload, + }); + expect(wrongTokenResponse.status()).toBe(403); + + const malformedPostResponse = await request.post("/api/e2e/auth", { + headers: { "content-type": "application/json", "x-vrdex-e2e-token": e2eToken }, + data: "{not-json", + }); + expect(malformedPostResponse.status()).toBe(400); + + const unsupportedActionResponse = await request.post("/api/e2e/auth", { + headers: { "x-vrdex-e2e-token": e2eToken }, + data: { action: "unsupported", email: "negative-gate@e2e.vrdex.local" }, + }); + expect(unsupportedActionResponse.status()).toBe(400); + + const missingDeleteTokenResponse = await request.delete("/api/e2e/auth", { + data: { email: "negative-gate@e2e.vrdex.local" }, + }); + expect(missingDeleteTokenResponse.status()).toBe(403); + + const malformedDeleteResponse = await request.delete("/api/e2e/auth", { + headers: { "content-type": "application/json", "x-vrdex-e2e-token": e2eToken }, + data: "{not-json", + }); + expect(malformedDeleteResponse.status()).toBe(400); +}); diff --git a/apps/web/playwright.config.mjs b/apps/web/playwright.config.mjs index 07212b6..891b7b7 100644 --- a/apps/web/playwright.config.mjs +++ b/apps/web/playwright.config.mjs @@ -1,4 +1,5 @@ import { defineConfig, devices } from "@playwright/test"; +import { generateKeyPairSync } from "node:crypto"; import path from "node:path"; import { fileURLToPath } from "node:url"; @@ -16,6 +17,18 @@ const skipWebServers = process.env.PLAYWRIGHT_SKIP_WEBSERVERS === "true" || Bool const skipConvexServer = skipWebServers || process.env.PLAYWRIGHT_SKIP_CONVEX_DEV === "true"; const recordVideo = process.env.PLAYWRIGHT_RECORD_VIDEO === "true"; const e2eHelpersEnabled = process.env.VRDEX_ENABLE_E2E_HELPERS ?? (hostedBaseURL ? undefined : "true"); +const localJwtKeys = hostedBaseURL + ? {} + : (() => { + const { privateKey, publicKey } = generateKeyPairSync("rsa", { modulusLength: 2048 }); + const jwtPrivateKey = privateKey.export({ format: "pem", type: "pkcs8" }).toString().trimEnd().replace(/\n/g, " "); + const jwk = publicKey.export({ format: "jwk" }); + + return { + JWT_PRIVATE_KEY: process.env.JWT_PRIVATE_KEY ?? jwtPrivateKey, + JWKS: process.env.JWKS ?? JSON.stringify({ keys: [{ use: "sig", ...jwk }] }), + }; + })(); const allowFixtureSearchFallthrough = process.env.VRDEX_ALLOW_PLAYWRIGHT_FIXTURE_SEARCH_FALLTHROUGH === "true" || e2eHelpersEnabled === "true"; @@ -23,6 +36,7 @@ const localE2eHelperEnv = hostedBaseURL ? {} : { VRDEX_ENABLE_E2E_HELPERS: e2eHelpersEnabled ?? "true", + VRDEX_ENABLE_E2E_AUTH_HELPERS: process.env.VRDEX_ENABLE_E2E_AUTH_HELPERS ?? "true", VRDEX_E2E_BROWSER_TOKEN: process.env.VRDEX_E2E_BROWSER_TOKEN ?? "local-playwright-token", VRDEX_E2E_CONVEX_SECRET: process.env.VRDEX_E2E_CONVEX_SECRET ?? "local-convex-e2e-secret", }; @@ -36,6 +50,8 @@ const sharedEnv = { ...process.env, CONVEX_URL: convexUrl, NEXT_PUBLIC_CONVEX_URL: convexUrl, + SITE_URL: process.env.SITE_URL ?? baseURL, + ...localJwtKeys, VRDEX_ENABLE_PLAYWRIGHT_FIXTURES: "true", ...localE2eHelperEnv, ...(allowFixtureSearchFallthrough diff --git a/apps/web/src/app/account/account-panel.tsx b/apps/web/src/app/account/account-panel.tsx index e6d1b6c..e40e498 100644 --- a/apps/web/src/app/account/account-panel.tsx +++ b/apps/web/src/app/account/account-panel.tsx @@ -69,7 +69,8 @@ function ClaimActions({ emailVerified, hasDiscord }: { emailVerified: boolean; h async function submitDiscordPersonClaim(event: FormEvent) { event.preventDefault(); - const formData = new FormData(event.currentTarget); + const form = event.currentTarget; + const formData = new FormData(form); setStatus({ kind: "submitting", label: "Claiming person profile..." }); @@ -85,7 +86,7 @@ function ClaimActions({ emailVerified, hasDiscord }: { emailVerified: boolean; h href: result.profilePath, }), ); - event.currentTarget.reset(); + form.reset(); } catch (error) { startTransition(() => setStatus({ kind: "error", message: claimErrorMessage(error) })); } @@ -93,7 +94,8 @@ function ClaimActions({ emailVerified, hasDiscord }: { emailVerified: boolean; h async function submitCommunityDiscordClaim(event: FormEvent) { event.preventDefault(); - const formData = new FormData(event.currentTarget); + const form = event.currentTarget; + const formData = new FormData(form); setStatus({ kind: "submitting", label: "Requesting community claim..." }); @@ -114,7 +116,7 @@ function ClaimActions({ emailVerified, hasDiscord }: { emailVerified: boolean; h ...("claimRequestId" in result ? { claimRequestId: result.claimRequestId } : {}), }), ); - event.currentTarget.reset(); + form.reset(); } catch (error) { startTransition(() => setStatus({ kind: "error", message: claimErrorMessage(error) })); } @@ -122,7 +124,8 @@ function ClaimActions({ emailVerified, hasDiscord }: { emailVerified: boolean; h async function submitVrchatProof(event: FormEvent) { event.preventDefault(); - const formData = new FormData(event.currentTarget); + const form = event.currentTarget; + const formData = new FormData(form); setStatus({ kind: "submitting", label: "Creating proof code..." }); @@ -141,7 +144,7 @@ function ClaimActions({ emailVerified, hasDiscord }: { emailVerified: boolean; h attemptId: result.attemptId, }), ); - event.currentTarget.reset(); + form.reset(); } catch (error) { startTransition(() => setStatus({ kind: "error", message: claimErrorMessage(error) })); } diff --git a/apps/web/src/app/api/e2e/auth/route.ts b/apps/web/src/app/api/e2e/auth/route.ts new file mode 100644 index 0000000..0581e8a --- /dev/null +++ b/apps/web/src/app/api/e2e/auth/route.ts @@ -0,0 +1,93 @@ +import { ConvexHttpClient } from "convex/browser"; +import { NextRequest, NextResponse } from "next/server"; + +import { api } from "@convex-generated-api"; + +export const dynamic = "force-dynamic"; + +function e2eError(message: string, status = 403) { + return NextResponse.json({ error: message }, { status }); +} + +function requireE2eAuthRequest(request: NextRequest) { + const browserToken = process.env.VRDEX_E2E_BROWSER_TOKEN?.trim(); + const convexSecret = process.env.VRDEX_E2E_CONVEX_SECRET?.trim(); + const requestToken = request.headers.get("x-vrdex-e2e-token") ?? request.cookies.get("vrdex_e2e_token")?.value; + const productionBlocked = process.env.VERCEL_ENV === "production" && process.env.VRDEX_ALLOW_PRODUCTION_E2E_HELPERS !== "true"; + + if ( + productionBlocked || + process.env.VRDEX_ENABLE_E2E_HELPERS !== "true" || + process.env.VRDEX_ENABLE_E2E_AUTH_HELPERS !== "true" || + !browserToken || + !convexSecret || + requestToken !== browserToken + ) { + return null; + } + + return convexSecret; +} + +function convexClient() { + const convexUrl = process.env.CONVEX_URL ?? process.env.NEXT_PUBLIC_CONVEX_URL; + + if (!convexUrl) { + throw new Error("Convex URL is not configured for E2E auth helpers."); + } + + return new ConvexHttpClient(convexUrl); +} + +export async function POST(request: NextRequest) { + const convexSecret = requireE2eAuthRequest(request); + + if (convexSecret === null) { + return e2eError("E2E auth helpers are not enabled for this request."); + } + + const rawBody = await request.json().catch(() => null); + if (!rawBody || typeof rawBody !== "object") { + return e2eError("Invalid JSON body.", 400); + } + const body = rawBody as Record; + const action = typeof body.action === "string" ? body.action : ""; + const email = typeof body.email === "string" ? body.email : ""; + + if (action === "consume-code") { + const result = await convexClient().mutation(api.e2e.consumeAuthCode, { secret: convexSecret, email }); + + return NextResponse.json(result); + } + + if (action === "link-discord") { + const providerAccountId = typeof body.providerAccountId === "string" ? body.providerAccountId : ""; + const result = await convexClient().mutation(api.e2e.linkDiscordAccountByEmail, { + secret: convexSecret, + email, + providerAccountId, + }); + + return NextResponse.json(result); + } + + return e2eError("Unsupported E2E auth helper action.", 400); +} + +export async function DELETE(request: NextRequest) { + const convexSecret = requireE2eAuthRequest(request); + + if (convexSecret === null) { + return e2eError("E2E auth helpers are not enabled for this request."); + } + + const rawBody = await request.json().catch(() => null); + if (!rawBody || typeof rawBody !== "object") { + return e2eError("Invalid JSON body.", 400); + } + const body = rawBody as Record; + const email = typeof body.email === "string" ? body.email : ""; + const result = await convexClient().mutation(api.e2e.cleanupAuthUserByEmail, { secret: convexSecret, email }); + + return NextResponse.json(result); +} diff --git a/apps/web/src/app/api/e2e/profile-submissions/route.ts b/apps/web/src/app/api/e2e/profile-submissions/route.ts index 072ed61..b9b931a 100644 --- a/apps/web/src/app/api/e2e/profile-submissions/route.ts +++ b/apps/web/src/app/api/e2e/profile-submissions/route.ts @@ -19,7 +19,7 @@ function requireE2eRequest(request: NextRequest) { return null; } - return true; + return convexSecret; } function convexClient() { @@ -33,9 +33,9 @@ function convexClient() { } export async function POST(request: NextRequest) { - const allowed = requireE2eRequest(request); + const convexSecret = requireE2eRequest(request); - if (allowed === null) { + if (convexSecret === null) { return e2eError("E2E helpers are not enabled for this request."); } @@ -46,6 +46,7 @@ export async function POST(request: NextRequest) { const body = rawBody as Record; const result = await convexClient().mutation(api.e2e.submitProfile, { + secret: convexSecret, runId: String(body.runId ?? "playwright"), profileType: body.profileType === "community" ? "community" : "person", displayName: String(body.displayName ?? ""), @@ -65,9 +66,9 @@ export async function POST(request: NextRequest) { } export async function DELETE(request: NextRequest) { - const allowed = requireE2eRequest(request); + const convexSecret = requireE2eRequest(request); - if (allowed === null) { + if (convexSecret === null) { return e2eError("E2E helpers are not enabled for this request."); } @@ -85,8 +86,8 @@ export async function DELETE(request: NextRequest) { } const result = slug - ? await convexClient().mutation(api.e2e.cleanupProfileBySlug, { slug }) - : await convexClient().mutation(api.e2e.cleanupProfilesByRunId, { runId }); + ? await convexClient().mutation(api.e2e.cleanupProfileBySlug, { secret: convexSecret, slug }) + : await convexClient().mutation(api.e2e.cleanupProfilesByRunId, { secret: convexSecret, runId }); return NextResponse.json(result); } diff --git a/apps/web/src/app/sign-in/sign-in-form.tsx b/apps/web/src/app/sign-in/sign-in-form.tsx index 75125f8..46999da 100644 --- a/apps/web/src/app/sign-in/sign-in-form.tsx +++ b/apps/web/src/app/sign-in/sign-in-form.tsx @@ -1,6 +1,7 @@ "use client"; import { useAuthActions } from "@convex-dev/auth/react"; +import { useRouter } from "next/navigation"; import { FormEvent, useState, useTransition } from "react"; type PasswordMode = "signIn" | "signUp" | "email-verification"; @@ -37,6 +38,7 @@ function stringField(value: FormDataEntryValue | null): string { function ConnectedSignInForm() { const { signIn } = useAuthActions(); + const router = useRouter(); const [mode, setMode] = useState("signIn"); const [status, setStatus] = useState({ kind: "idle" }); const [, startTransition] = useTransition(); @@ -51,20 +53,32 @@ function ConnectedSignInForm() { try { if (mode === "email-verification") { - await signIn("password", { + const result = await signIn("password", { email, code: stringField(formData.get("code")), flow: "email-verification", }); + + if (!result.signingIn) { + startTransition(() => setStatus({ kind: "error", message: "Verification code did not match or expired." })); + return; + } + + router.replace("/account"); return; } - await signIn("password", { + const result = await signIn("password", { email, password: stringField(formData.get("password")), flow: mode, }); + if (result.signingIn) { + router.replace("/account"); + return; + } + startTransition(() => setStatus({ kind: "verify-email", email })); setMode("email-verification"); } catch (error) { diff --git a/convex/auth.config.ts b/convex/auth.config.ts new file mode 100644 index 0000000..f4eb564 --- /dev/null +++ b/convex/auth.config.ts @@ -0,0 +1,8 @@ +export default { + providers: [ + { + domain: process.env.CONVEX_SITE_URL, + applicationID: "convex", + }, + ], +}; diff --git a/convex/auth.ts b/convex/auth.ts index 8aba93e..f5ead45 100644 --- a/convex/auth.ts +++ b/convex/auth.ts @@ -5,6 +5,8 @@ import { Email } from "@convex-dev/auth/providers/Email"; import { Password } from "@convex-dev/auth/providers/Password"; import { convexAuth } from "@convex-dev/auth/server"; +import { internal } from "./_generated/api"; + function requiredEnv(name: string): string { const value = process.env[name]?.trim(); @@ -47,9 +49,38 @@ async function sendSesVerificationCode(email: string, token: string) { ); } +function e2eAuthHelpersEnabled() { + return ( + process.env.VRDEX_ENABLE_E2E_HELPERS === "true" && + process.env.VRDEX_ENABLE_E2E_AUTH_HELPERS === "true" && + Boolean(process.env.VRDEX_E2E_CONVEX_SECRET?.trim()) + ); +} + +function isE2eEmail(email: string) { + return email.toLowerCase().endsWith("@e2e.vrdex.local"); +} + const SesOtp = Email({ - async sendVerificationRequest({ identifier, token }) { - await sendSesVerificationCode(identifier, token); + async sendVerificationRequest( + params, + ctx?: { + runMutation: (mutation: unknown, args: unknown) => Promise; + }, + ) { + const { identifier, token, expires } = params; + const email = identifier.trim().toLowerCase(); + + if (ctx !== undefined && e2eAuthHelpersEnabled() && isE2eEmail(email)) { + await ctx.runMutation(internal.e2e.recordAuthCode, { + email, + code: token, + expiresAt: expires.getTime(), + }); + return; + } + + await sendSesVerificationCode(email, token); }, }); diff --git a/convex/e2e.ts b/convex/e2e.ts index 7a25681..3fcf27f 100644 --- a/convex/e2e.ts +++ b/convex/e2e.ts @@ -1,19 +1,47 @@ import { v } from "convex/values"; -import type { Doc } from "./_generated/dataModel"; -import { mutation, type MutationCtx } from "./_generated/server"; +import type { Doc, Id } from "./_generated/dataModel"; +import { internalMutation, mutation, type MutationCtx } from "./_generated/server"; import { findAvailableProfileSlug, getProfileBySlug } from "./_profileSlugs"; import { sanitizeCommunitySubmissionProfileInput } from "./_profileSubmissions"; import { createProfileSearchDocument, upsertSearchDocument } from "./_searchDocuments"; const profileType = v.union(v.literal("person"), v.literal("community")); -function requireE2eHelper() { +function requireE2eHelper(secret?: string) { const expectedSecret = process.env.VRDEX_E2E_CONVEX_SECRET?.trim(); if (process.env.VRDEX_ENABLE_E2E_HELPERS !== "true" || !expectedSecret) { throw new Error("E2E helpers are not enabled for this deployment."); } + + if (secret !== undefined && secret !== expectedSecret) { + throw new Error("E2E helper secret did not match this deployment."); + } +} + +function requireE2eAuthHelper(secret?: string) { + requireE2eHelper(secret); + + if (process.env.VRDEX_ENABLE_E2E_AUTH_HELPERS !== "true") { + throw new Error("E2E auth helpers are not enabled for this deployment."); + } +} + +function normalizeE2eEmail(email: string) { + const normalized = email.trim().toLowerCase(); + + if (!normalized.endsWith("@e2e.vrdex.local")) { + throw new Error("E2E auth helpers only accept @e2e.vrdex.local emails."); + } + + return normalized; +} + +async function deleteE2eAuthCodes(ctx: MutationCtx, email: string) { + const codes = await ctx.db.query("e2eAuthCodes").withIndex("by_email", (query) => query.eq("email", email)).collect(); + + await Promise.all(codes.map((code) => ctx.db.delete(code._id))); } async function deleteE2eProfile(ctx: MutationCtx, profile: Doc<"profiles">) { @@ -21,20 +49,171 @@ async function deleteE2eProfile(ctx: MutationCtx, profile: Doc<"profiles">) { throw new Error("Only E2E-created profiles can be cleaned up by this helper."); } - const [searchDocuments, auditEvents] = await Promise.all([ + const [searchDocuments, auditEvents, owners, claimRequests, verificationAttempts] = await Promise.all([ ctx.db.query("searchDocuments").withIndex("by_profileId", (query) => query.eq("profileId", profile._id)).collect(), ctx.db.query("profileAuditEvents").withIndex("by_profileId_createdAt", (query) => query.eq("profileId", profile._id)).collect(), + ctx.db.query("profileOwners").withIndex("by_profileId_state", (query) => query.eq("profileId", profile._id)).collect(), + ctx.db.query("profileClaimRequests").withIndex("by_profileId_state", (query) => query.eq("profileId", profile._id)).collect(), + ctx.db.query("profileVerificationAttempts").withIndex("by_profileId_state", (query) => query.eq("profileId", profile._id)).collect(), ]); await Promise.all([ ...searchDocuments.map((document) => ctx.db.delete(document._id)), ...auditEvents.map((event) => ctx.db.delete(event._id)), + ...owners.map((owner) => ctx.db.delete(owner._id)), + ...claimRequests.map((claimRequest) => ctx.db.delete(claimRequest._id)), + ...verificationAttempts.map((attempt) => ctx.db.delete(attempt._id)), ctx.db.delete(profile._id), ]); } +async function userByEmail(ctx: MutationCtx, email: string) { + return await ctx.db.query("users").withIndex("email", (query) => query.eq("email", email)).unique(); +} + +async function cleanupE2eUserByEmail(ctx: MutationCtx, email: string) { + const user = await userByEmail(ctx, email); + + await deleteE2eAuthCodes(ctx, email); + + if (user === null) { + return { deleted: false }; + } + + const [accounts, sessions, claimRequests, verificationAttempts, profileOwners] = await Promise.all([ + ctx.db.query("authAccounts").withIndex("userIdAndProvider", (query) => query.eq("userId", user._id)).collect(), + ctx.db.query("authSessions").withIndex("userId", (query) => query.eq("userId", user._id)).collect(), + ctx.db.query("profileClaimRequests").withIndex("by_userId_state", (query) => query.eq("userId", user._id)).collect(), + ctx.db.query("profileVerificationAttempts").withIndex("by_userId_state", (query) => query.eq("userId", user._id)).collect(), + ctx.db.query("profileOwners").withIndex("by_userId_state", (query) => query.eq("userId", user._id)).collect(), + ]); + const verificationCodes = await Promise.all( + accounts.map((account) => ctx.db.query("authVerificationCodes").withIndex("accountId", (query) => query.eq("accountId", account._id)).collect()), + ); + const refreshTokens = await Promise.all( + sessions.map((session) => ctx.db.query("authRefreshTokens").withIndex("sessionId", (query) => query.eq("sessionId", session._id)).collect()), + ); + + await Promise.all([ + ...verificationCodes.flat().map((code) => ctx.db.delete(code._id)), + ...refreshTokens.flat().map((token) => ctx.db.delete(token._id)), + ...claimRequests.map((claimRequest) => ctx.db.delete(claimRequest._id)), + ...verificationAttempts.map((attempt) => ctx.db.delete(attempt._id)), + ...profileOwners.map((owner) => ctx.db.delete(owner._id)), + ...accounts.map((account) => ctx.db.delete(account._id)), + ...sessions.map((session) => ctx.db.delete(session._id)), + ctx.db.delete(user._id), + ]); + + return { deleted: true }; +} + +export const recordAuthCode = internalMutation({ + args: { + email: v.string(), + code: v.string(), + expiresAt: v.number(), + }, + handler: async (ctx, args) => { + requireE2eAuthHelper(); + + const email = normalizeE2eEmail(args.email); + await deleteE2eAuthCodes(ctx, email); + + return await ctx.db.insert("e2eAuthCodes", { + email, + code: args.code, + createdAt: Date.now(), + expiresAt: args.expiresAt, + }); + }, +}); + +export const consumeAuthCode = mutation({ + args: { + secret: v.string(), + email: v.string(), + }, + handler: async (ctx, args) => { + requireE2eAuthHelper(args.secret); + + const email = normalizeE2eEmail(args.email); + const codes = await ctx.db.query("e2eAuthCodes").withIndex("by_email", (query) => query.eq("email", email)).collect(); + const code = [...codes].sort((left, right) => right._creationTime - left._creationTime)[0]; + + if (code === undefined || code.expiresAt < Date.now()) { + throw new Error("No active E2E auth code found for this email."); + } + + await Promise.all(codes.map((entry) => ctx.db.delete(entry._id))); + + return { code: code.code }; + }, +}); + +export const linkDiscordAccountByEmail = mutation({ + args: { + secret: v.string(), + email: v.string(), + providerAccountId: v.string(), + }, + handler: async (ctx, args) => { + requireE2eAuthHelper(args.secret); + + const email = normalizeE2eEmail(args.email); + const user = await userByEmail(ctx, email); + + if (user === null) { + throw new Error("E2E user not found."); + } + + if (user.emailVerificationTime === undefined) { + throw new Error("E2E user email is not verified."); + } + + const providerAccountId = args.providerAccountId.trim(); + if (!providerAccountId) { + throw new Error("Discord provider account id is required."); + } + + const existing = await ctx.db + .query("authAccounts") + .withIndex("userIdAndProvider", (query) => query.eq("userId", user._id).eq("provider", "discord")) + .unique(); + + if (existing !== null) { + await ctx.db.patch(existing._id, { providerAccountId, emailVerified: email }); + return { linked: true }; + } + + await ctx.db.insert("authAccounts", { + userId: user._id, + provider: "discord", + providerAccountId, + emailVerified: email, + }); + + return { linked: true }; + }, +}); + +export const cleanupAuthUserByEmail = mutation({ + args: { + secret: v.string(), + email: v.string(), + }, + handler: async (ctx, args) => { + requireE2eAuthHelper(args.secret); + + const email = normalizeE2eEmail(args.email); + + return await cleanupE2eUserByEmail(ctx, email); + }, +}); + export const submitProfile = mutation({ args: { + secret: v.string(), runId: v.string(), profileType, displayName: v.string(), @@ -53,7 +232,7 @@ export const submitProfile = mutation({ ), }, handler: async (ctx, args) => { - requireE2eHelper(); + requireE2eHelper(args.secret); const input = sanitizeCommunitySubmissionProfileInput(args); const now = Date.now(); @@ -125,10 +304,11 @@ export const submitProfile = mutation({ export const cleanupProfileBySlug = mutation({ args: { + secret: v.string(), slug: v.string(), }, handler: async (ctx, args) => { - requireE2eHelper(); + requireE2eHelper(args.secret); const profile = await getProfileBySlug(ctx.db, args.slug); @@ -144,10 +324,11 @@ export const cleanupProfileBySlug = mutation({ export const cleanupProfilesByRunId = mutation({ args: { + secret: v.string(), runId: v.string(), }, handler: async (ctx, args) => { - requireE2eHelper(); + requireE2eHelper(args.secret); const runId = args.runId.trim().slice(0, 120); diff --git a/convex/schema.ts b/convex/schema.ts index a50ef74..21129d0 100644 --- a/convex/schema.ts +++ b/convex/schema.ts @@ -576,6 +576,12 @@ export default defineSchema({ }) .index("by_profileId_createdAt", ["profileId", "createdAt"]) .index("by_action_createdAt", ["action", "createdAt"]), + e2eAuthCodes: defineTable({ + email: v.string(), + code: v.string(), + createdAt: v.number(), + expiresAt: v.number(), + }).index("by_email", ["email"]), vocabularyTerms: defineTable({ scope: vocabularyScope, key: v.string(), diff --git a/docs/deployment/convex-environments.md b/docs/deployment/convex-environments.md index f8db53b..a26460e 100644 --- a/docs/deployment/convex-environments.md +++ b/docs/deployment/convex-environments.md @@ -37,6 +37,7 @@ Development/staging Convex env names: - `VRDEX_ENABLE_E2E_HELPERS=true` - `VRDEX_E2E_CONVEX_SECRET`: non-empty sentinel also configured in the hosted app environment +- `VRDEX_ENABLE_E2E_AUTH_HELPERS=true`: optional, only when hosted auth/claim E2E is intentionally enabled The browser-facing token stays in the web host and GitHub Actions as `VRDEX_E2E_BROWSER_TOKEN` / `VRDEX_HOSTED_E2E_BROWSER_TOKEN`; it is not needed by Convex. diff --git a/docs/deployment/vercel-preview.md b/docs/deployment/vercel-preview.md index 6c7b5d4..6bdb520 100644 --- a/docs/deployment/vercel-preview.md +++ b/docs/deployment/vercel-preview.md @@ -50,8 +50,9 @@ Hosted dev/staging E2E targets must set these only on the dev/staging environmen - `VRDEX_ENABLE_E2E_HELPERS=true` - `VRDEX_E2E_BROWSER_TOKEN`: same value as the GitHub Actions secret `VRDEX_HOSTED_E2E_BROWSER_TOKEN` - `VRDEX_E2E_CONVEX_SECRET`: non-empty sentinel matching the Convex deployment secret name +- `VRDEX_ENABLE_E2E_AUTH_HELPERS=true`: optional staging-only switch for auth/claim E2E helper routes; keep unset until that flow is intentionally enabled -Production should keep `VRDEX_ENABLE_E2E_HELPERS=false` or unset, and should not set `VRDEX_ALLOW_PRODUCTION_E2E_HELPERS` unless a human explicitly approves a temporary incident/debug window. +Production should keep `VRDEX_ENABLE_E2E_HELPERS=false` or unset, should keep `VRDEX_ENABLE_E2E_AUTH_HELPERS` unset, and should not set `VRDEX_ALLOW_PRODUCTION_E2E_HELPERS` unless a human explicitly approves a temporary incident/debug window. Preview deployment protection must allow unauthenticated reads if the PR preview is meant to be reviewed outside the Vercel dashboard. @@ -80,6 +81,7 @@ The `staging` Vercel environment points at the shared Convex development deploym - `VRDEX_ENABLE_E2E_HELPERS=true` - `VRDEX_E2E_BROWSER_TOKEN`: sensitive value matching GitHub Actions secret `VRDEX_HOSTED_E2E_BROWSER_TOKEN` - `VRDEX_E2E_CONVEX_SECRET`: sensitive value matching Convex dev env `VRDEX_E2E_CONVEX_SECRET` +- `VRDEX_ENABLE_E2E_AUTH_HELPERS`: unset until hosted auth/claim E2E is intentionally enabled GitHub Actions uses these repository settings for hosted mutation health: diff --git a/docs/testing/playwright-visual-preview.md b/docs/testing/playwright-visual-preview.md index c42ab2c..51e3ddb 100644 --- a/docs/testing/playwright-visual-preview.md +++ b/docs/testing/playwright-visual-preview.md @@ -86,12 +86,26 @@ The `@flow` Playwright test is the first mutation-backed journey. It: - captures screenshots for both readback pages - cleans up the E2E-created profile, search document, and audit event by slug +The local `@flow` suite also covers the first auth/claim path without real OAuth or SES: + +- creates an `@e2e.vrdex.local` email/password account through the sign-in UI +- captures the one-time verification code through a token-gated E2E auth helper +- verifies the email with Convex Auth +- links a Discord account through a token-gated E2E helper +- claims an E2E-created person profile through the account UI +- verifies the public profile moves to the `Claimed` trust label +- cleans up the test user, auth records, profile owner, claim request, and E2E profile + The helper route is disabled unless all of these are true: - `VRDEX_ENABLE_E2E_HELPERS=true` - `VRDEX_E2E_BROWSER_TOKEN` is configured and matches the request cookie or header - `VRDEX_E2E_CONVEX_SECRET` is configured for the server route and Convex helper deployment +The browser token gates the Next.js helper route. The Convex secret is never sent to the browser; the server route passes it to the public Convex E2E mutations so direct Convex calls also need the matching deployment secret. + +Auth helper routes also require `VRDEX_ENABLE_E2E_AUTH_HELPERS=true` and only accept `@e2e.vrdex.local` emails. Local Playwright webserver runs set this automatically; hosted staging must opt in explicitly before hosted auth/claim flows run. + Do not enable these helpers in production. They are for local, CI, and disposable preview/dev deployments. Hosted dev/staging targets must be configured outside this repository before running `pnpm test:e2e:hosted`: @@ -102,6 +116,8 @@ Hosted dev/staging targets must be configured outside this repository before run - Convex env: `VRDEX_ENABLE_E2E_HELPERS=true` - Convex env: `VRDEX_E2E_CONVEX_SECRET=` +Hosted auth/claim E2E additionally requires `VRDEX_ENABLE_E2E_AUTH_HELPERS=true` in both the hosted app and Convex deployment. Keep it unset until the staging auth flow is intentionally enabled; production must never enable it. + `VERCEL_ENV=production` blocks the E2E route unless `VRDEX_ALLOW_PRODUCTION_E2E_HELPERS=true` is explicitly set. Keep that override unset for VRDex production. Each data-flow run uses a unique `VRDEX_E2E_RUN_ID` prefix and creates only `e2e:`-attributed profiles. Cleanup deletes by slug on the happy path and can fall back to deleting profiles for the run ID if the slug was not captured. diff --git a/scripts/sync-convex-local-env.mjs b/scripts/sync-convex-local-env.mjs index 2ee39d8..4ab879e 100644 --- a/scripts/sync-convex-local-env.mjs +++ b/scripts/sync-convex-local-env.mjs @@ -9,19 +9,21 @@ const scriptDir = path.dirname(fileURLToPath(import.meta.url)); const repoRoot = path.resolve(scriptDir, ".."); const convexHome = path.join(repoRoot, ".convex-home"); const convexTmp = path.join(repoRoot, ".convex-tmp"); -const localConvexEnvNames = ["VRDEX_ENABLE_E2E_HELPERS", "VRDEX_E2E_CONVEX_SECRET"]; +const localConvexEnvNames = [ + "SITE_URL", + "JWT_PRIVATE_KEY", + "JWKS", + "VRDEX_ENABLE_E2E_HELPERS", + "VRDEX_ENABLE_E2E_AUTH_HELPERS", + "VRDEX_E2E_CONVEX_SECRET", +]; const localDeploymentName = process.env.CONVEX_LOCAL_DEPLOYMENT_NAME || "anonymous-agent"; const localCloudPort = process.env.CONVEX_LOCAL_CLOUD_PORT || "3210"; const localConvexUrl = process.env.CONVEX_LOCAL_URL || `http://127.0.0.1:${localCloudPort}`; const readyTimeoutMs = Number(process.env.CONVEX_LOCAL_READY_TIMEOUT_MS || 180_000); const readyPollMs = Number(process.env.CONVEX_LOCAL_READY_POLL_MS || 500); const healthStatus = makeFunctionReference("health:status"); -const convexBin = path.join( - repoRoot, - "node_modules", - ".bin", - process.platform === "win32" ? "convex.cmd" : "convex", -); +const convexCli = path.join(repoRoot, "node_modules", "convex", "bin", "main.js"); function convexEnv() { return { @@ -50,11 +52,10 @@ function sleep(ms) { } function runConvex(args) { - return spawnSync(convexBin, args, { + return spawnSync(process.execPath, [convexCli, ...args], { cwd: repoRoot, encoding: "utf8", env: convexEnv(), - shell: process.platform === "win32", timeout: 10_000, }); } @@ -85,19 +86,20 @@ function syncEnvVarsOnce() { .filter((entry) => entry[1] !== undefined && entry[1] !== ""); for (const [name, value] of entries) { - const result = spawnSync(convexBin, ["env", "set", name, value], { + // Convex CLI documents `convex env set NAME` as reading the value from stdin. + const result = spawnSync(process.execPath, [convexCli, "env", "set", name], { cwd: repoRoot, encoding: "utf8", env: convexEnv(), - shell: process.platform === "win32", + input: value, }); if (result.status !== 0) { - return false; + return { ok: false, name, result }; } } - return true; + return { ok: true }; } if (!(await waitForFunctionsReady())) { @@ -111,13 +113,27 @@ if (!localConvexEnvNames.some((name) => process.env[name])) { mkdirSync(convexHome, { recursive: true }); mkdirSync(convexTmp, { recursive: true }); +let lastFailure; for (let attempt = 0; attempt < 80; attempt += 1) { - if (syncEnvVarsOnce()) { + const syncResult = syncEnvVarsOnce(); + if (syncResult.ok) { process.exit(0); } + lastFailure = syncResult; + await new Promise((resolve) => setTimeout(resolve, 250)); } console.error("Failed to sync E2E helper env vars into the local Convex deployment."); +if (lastFailure) { + const output = `${lastFailure.result.stdout || ""}${lastFailure.result.stderr || ""}`.trim(); + console.error(`Last failed variable: ${lastFailure.name}`); + if (lastFailure.result.error) { + console.error(lastFailure.result.error.message); + } + if (output) { + console.error(output); + } +} process.exit(1);