From a39a92e8283b73a06ee257f37831e334e42304de Mon Sep 17 00:00:00 2001 From: BASICBIT Date: Sat, 30 May 2026 22:57:09 -0400 Subject: [PATCH 1/4] Expand E2E coverage bundle --- .github/workflows/baseline-checks.yml | 14 + .github/workflows/deployed-health.yml | 8 + .github/workflows/staging-deploy.yml | 2 + apps/web/e2e/auth-claim.flow.spec.ts | 279 ++++++++++++++---- apps/web/e2e/profile-submission.flow.spec.ts | 65 ++++ apps/web/playwright.config.mjs | 6 + apps/web/scripts/check-vercel-env.mjs | 12 + .../e2e/adapters/discord/[...path]/route.ts | 54 ++++ .../api/e2e/adapters/vrchat-proof/route.ts | 46 +++ .../app/api/e2e/profile-submissions/route.ts | 35 ++- convex/e2e.ts | 43 ++- docs/deployment/convex-environments.md | 17 +- docs/deployment/vercel-preview.md | 11 +- docs/testing/playwright-visual-preview.md | 14 +- scripts/sync-convex-local-env.mjs | 6 + 15 files changed, 550 insertions(+), 62 deletions(-) create mode 100644 apps/web/src/app/api/e2e/adapters/discord/[...path]/route.ts create mode 100644 apps/web/src/app/api/e2e/adapters/vrchat-proof/route.ts diff --git a/.github/workflows/baseline-checks.yml b/.github/workflows/baseline-checks.yml index 4599e32..a718433 100644 --- a/.github/workflows/baseline-checks.yml +++ b/.github/workflows/baseline-checks.yml @@ -395,6 +395,8 @@ jobs: PLAYWRIGHT_BASE_URL: ${{ vars.VRDEX_HOSTED_E2E_BASE_URL }} PLAYWRIGHT_RECORD_VIDEO: "true" PLAYWRIGHT_SKIP_WEBSERVERS: "true" + VRDEX_ENABLE_E2E_AUTH_HELPERS: ${{ vars.VRDEX_HOSTED_E2E_AUTH_HELPERS }} + VRDEX_ENABLE_E2E_ADAPTER_HELPERS: ${{ vars.VRDEX_HOSTED_E2E_ADAPTER_HELPERS }} VRDEX_E2E_BROWSER_TOKEN: ${{ secrets.VRDEX_HOSTED_E2E_BROWSER_TOKEN }} VRDEX_E2E_RUN_ID: pr-${{ github.event.pull_request.number }}-${{ github.run_id }}-${{ github.run_attempt }} run: pnpm test:e2e:hosted @@ -404,6 +406,8 @@ jobs: env: HOSTED_OUTCOME: ${{ steps.hosted.outcome }} HOSTED_BASE_URL: ${{ vars.VRDEX_HOSTED_E2E_BASE_URL }} + HOSTED_AUTH_HELPERS: ${{ vars.VRDEX_HOSTED_E2E_AUTH_HELPERS }} + HOSTED_ADAPTER_HELPERS: ${{ vars.VRDEX_HOSTED_E2E_ADAPTER_HELPERS }} RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} run: | mkdir -p apps/web/playwright-artifacts @@ -414,6 +418,10 @@ jobs: Target: ${HOSTED_BASE_URL} + Hosted auth helpers: ${HOSTED_AUTH_HELPERS:-skipped} + + Hosted adapter helpers: ${HOSTED_ADAPTER_HELPERS:-skipped} + Captured flow: - hosted test-gated profile submission form - hosted Convex profile creation @@ -443,6 +451,8 @@ jobs: env: HOSTED_OUTCOME: ${{ steps.hosted.outcome }} HOSTED_BASE_URL: ${{ vars.VRDEX_HOSTED_E2E_BASE_URL }} + HOSTED_AUTH_HELPERS: ${{ vars.VRDEX_HOSTED_E2E_AUTH_HELPERS }} + HOSTED_ADAPTER_HELPERS: ${{ vars.VRDEX_HOSTED_E2E_ADAPTER_HELPERS }} ARTIFACT_URL: ${{ steps.upload.outputs.artifact-url }} with: script: | @@ -454,6 +464,8 @@ jobs: const artifactUrl = process.env.ARTIFACT_URL || ""; const outcome = process.env.HOSTED_OUTCOME || "unknown"; const target = process.env.HOSTED_BASE_URL || "not configured"; + const authHelpers = process.env.HOSTED_AUTH_HELPERS === "true" ? "enabled" : "skipped"; + const adapterHelpers = process.env.HOSTED_ADAPTER_HELPERS === "true" ? "enabled" : "skipped"; const artifactLine = artifactUrl ? `Artifact: [playwright-hosted-data-flow](${artifactUrl})` : "Artifact: not generated"; @@ -462,6 +474,8 @@ jobs: "## Playwright Hosted Data-Flow", `Outcome: ${outcome}`, `Target: ${target}`, + `Hosted auth helpers: ${authHelpers}`, + `Hosted adapter helpers: ${adapterHelpers}`, `Run: ${runUrl}`, artifactLine, "", diff --git a/.github/workflows/deployed-health.yml b/.github/workflows/deployed-health.yml index 63e6d5c..94c8a7c 100644 --- a/.github/workflows/deployed-health.yml +++ b/.github/workflows/deployed-health.yml @@ -91,6 +91,8 @@ jobs: PLAYWRIGHT_BASE_URL: ${{ steps.gate.outputs.base_url }} PLAYWRIGHT_RECORD_VIDEO: "true" PLAYWRIGHT_SKIP_WEBSERVERS: "true" + VRDEX_ENABLE_E2E_AUTH_HELPERS: ${{ vars.VRDEX_HOSTED_E2E_AUTH_HELPERS }} + VRDEX_ENABLE_E2E_ADAPTER_HELPERS: ${{ vars.VRDEX_HOSTED_E2E_ADAPTER_HELPERS }} VRDEX_E2E_BROWSER_TOKEN: ${{ secrets.VRDEX_HOSTED_E2E_BROWSER_TOKEN }} VRDEX_E2E_RUN_ID: deployed-${{ github.run_id }}-${{ github.run_attempt }} run: pnpm test:e2e:hosted @@ -100,6 +102,8 @@ jobs: env: HOSTED_OUTCOME: ${{ steps.hosted.outcome }} HOSTED_BASE_URL: ${{ steps.gate.outputs.base_url }} + HOSTED_AUTH_HELPERS: ${{ vars.VRDEX_HOSTED_E2E_AUTH_HELPERS }} + HOSTED_ADAPTER_HELPERS: ${{ vars.VRDEX_HOSTED_E2E_ADAPTER_HELPERS }} RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} run: | mkdir -p apps/web/playwright-artifacts @@ -110,6 +114,10 @@ jobs: Target: ${HOSTED_BASE_URL} + Hosted auth helpers: ${HOSTED_AUTH_HELPERS:-skipped} + + Hosted adapter helpers: ${HOSTED_ADAPTER_HELPERS:-skipped} + Captured flow: - hosted test-gated profile submission form - hosted Convex profile creation diff --git a/.github/workflows/staging-deploy.yml b/.github/workflows/staging-deploy.yml index 23deb61..2951c29 100644 --- a/.github/workflows/staging-deploy.yml +++ b/.github/workflows/staging-deploy.yml @@ -119,6 +119,8 @@ jobs: PLAYWRIGHT_BASE_URL: ${{ steps.gate.outputs.hosted_base_url }} PLAYWRIGHT_RECORD_VIDEO: "true" PLAYWRIGHT_SKIP_WEBSERVERS: "true" + VRDEX_ENABLE_E2E_AUTH_HELPERS: ${{ vars.VRDEX_HOSTED_E2E_AUTH_HELPERS }} + VRDEX_ENABLE_E2E_ADAPTER_HELPERS: ${{ vars.VRDEX_HOSTED_E2E_ADAPTER_HELPERS }} VRDEX_E2E_BROWSER_TOKEN: ${{ secrets.VRDEX_HOSTED_E2E_BROWSER_TOKEN }} VRDEX_E2E_RUN_ID: staging-deploy-${{ github.run_id }}-${{ github.run_attempt }} run: pnpm test:e2e:hosted diff --git a/apps/web/e2e/auth-claim.flow.spec.ts b/apps/web/e2e/auth-claim.flow.spec.ts index 678f4f1..a4ffce5 100644 --- a/apps/web/e2e/auth-claim.flow.spec.ts +++ b/apps/web/e2e/auth-claim.flow.spec.ts @@ -1,4 +1,4 @@ -import { expect, test } from "@playwright/test"; +import { expect, test, type APIRequestContext, type Page } from "@playwright/test"; function e2eBrowserToken() { const token = process.env.VRDEX_E2E_BROWSER_TOKEN ?? (process.env.PLAYWRIGHT_BASE_URL ? undefined : "local-playwright-token"); @@ -19,6 +19,118 @@ function e2eRunId(testInfo: { project: { name: string }; workerIndex: number; re .slice(0, 120); } +async function createE2eProfile({ + request, + e2eToken, + runId, + profileType, + displayName, + aliases = [], + tags = [], + roleTags = [], + subtype, + categoryTags = [], +}: { + request: APIRequestContext; + e2eToken: string; + runId: string; + profileType: "person" | "community"; + displayName: string; + aliases?: string[]; + tags?: string[]; + roleTags?: string[]; + subtype?: string; + categoryTags?: string[]; +}) { + const profileResponse = await request.post("/api/e2e/profile-submissions", { + headers: { "x-vrdex-e2e-token": e2eToken }, + data: { + runId, + profileType, + displayName, + aliases, + tags, + roleTags, + subtype, + categoryTags, + }, + }); + await expect(profileResponse).toBeOK(); + const profile = (await profileResponse.json()) as { slug?: string }; + expect(profile.slug).toBeTruthy(); + + return profile.slug!; +} + +async function createVerifiedE2eAccount({ + page, + request, + e2eToken, + email, + password, +}: { + page: Page; + request: APIRequestContext; + e2eToken: string; + email: string; + password: string; +}) { + 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(); +} + +async function linkDiscordAccount(request: APIRequestContext, e2eToken: string, email: string, providerAccountId: string) { + const linkResponse = await request.post("/api/e2e/auth", { + headers: { "x-vrdex-e2e-token": e2eToken }, + data: { action: "link-discord", email, providerAccountId }, + }); + + await expect(linkResponse).toBeOK(); +} + +async function cleanupAuthAndProfiles(request: APIRequestContext, e2eToken: string, email: string, slugs: Array, runId: string) { + for (const slug of slugs) { + if (slug !== undefined) { + await request.delete("/api/e2e/profile-submissions", { + headers: { "x-vrdex-e2e-token": e2eToken }, + data: { slug, runId }, + }); + } + } + + if (slugs.every((slug) => slug === undefined)) { + await request.delete("/api/e2e/profile-submissions", { + headers: { "x-vrdex-e2e-token": e2eToken }, + data: { runId }, + }); + } + + await request.delete("/api/e2e/auth", { + headers: { "x-vrdex-e2e-token": e2eToken }, + data: { email }, + }); +} + 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", @@ -34,50 +146,18 @@ test("verified email account with linked Discord can claim an E2E person profile 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 }, + createdSlug = await createE2eProfile({ + request, + e2eToken, + runId, + profileType: "person", + displayName, + aliases: [`Claim ${runSuffix}`], + tags: ["playwright", "claim-flow"], + roleTags: ["Claim test profile"], }); - 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 createVerifiedE2eAccount({ page, request, e2eToken, email, password }); + await linkDiscordAccount(request, e2eToken, email, `discord-${runSuffix}`); await page.goto("/account"); await expect(page.getByText("discord", { exact: true })).toBeVisible(); @@ -89,17 +169,110 @@ test("verified email account with linked Discord can claim an E2E person profile 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 cleanupAuthAndProfiles(request, e2eToken, email, [createdSlug], runId); + } +}); - await request.delete("/api/e2e/auth", { - headers: { "x-vrdex-e2e-token": e2eToken }, - data: { email }, +test("verified email account can complete community and VRChat adapter claims @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.", + ); + test.skip( + Boolean(process.env.PLAYWRIGHT_BASE_URL) && process.env.VRDEX_ENABLE_E2E_ADAPTER_HELPERS !== "true", + "Hosted adapter 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 email = `adapter-${runSuffix}@e2e.vrdex.local`; + const password = `VRDex-${runSuffix}-adapter-password-12345`; + let communitySlug: string | undefined; + let vrchatPersonSlug: string | undefined; + let vrcLinkingPersonSlug: string | undefined; + + try { + communitySlug = await createE2eProfile({ + request, + e2eToken, + runId, + profileType: "community", + displayName: `Playwright Community ${runSuffix}`, + aliases: [`Adapter Community ${runSuffix}`], + tags: ["playwright", "adapter-flow"], + subtype: "VRChat group", + categoryTags: ["Adapter verified"], + }); + vrchatPersonSlug = await createE2eProfile({ + request, + e2eToken, + runId, + profileType: "person", + displayName: `Playwright VRChat Proof ${runSuffix}`, + tags: ["playwright", "vrchat-proof"], + roleTags: ["Proof test profile"], }); + vrcLinkingPersonSlug = await createE2eProfile({ + request, + e2eToken, + runId, + profileType: "person", + displayName: `Playwright VRCLinking Proof ${runSuffix}`, + tags: ["playwright", "vrclinking-proof"], + roleTags: ["Proof test profile"], + }); + + await createVerifiedE2eAccount({ page, request, e2eToken, email, password }); + await linkDiscordAccount(request, e2eToken, email, `discord-${runSuffix}`); + + await page.goto("/account"); + await page.getByLabel("Community slug").fill(communitySlug!); + await page.getByLabel("Discord guild ID").fill(`e2e-guild-${runSuffix}`); + await page.getByLabel("Guild name").fill(`E2E Guild ${runSuffix}`); + await page.getByRole("button", { name: "Request admin claim" }).click(); + await expect(page.getByText(/Community claim request created/i)).toBeVisible(); + await page.getByRole("button", { name: "Check Discord admin" }).click(); + await expect(page.getByText(/Community claim verified as claimed verified/i)).toBeVisible(); + + await page.goto(`/c/${communitySlug}`); + await expect(page.getByRole("heading", { name: `Playwright Community ${runSuffix}` })).toBeVisible(); + await expect(page.getByText("Verified owner", { exact: true })).toBeVisible(); + + await page.goto("/account"); + await page.getByLabel("Profile slug").fill(vrchatPersonSlug!); + await page.getByLabel("Target type").selectOption("vrchat_user"); + await page.getByLabel("Target ID").fill(`e2e-vrchat-${runSuffix}`); + await page.getByRole("button", { name: "Create proof code" }).click(); + await expect(page.getByText(/Proof code created/i)).toBeVisible(); + await expect(page.getByText(/VRDEX-/)).toBeVisible(); + await page.getByRole("button", { name: "Check proof now" }).click(); + await expect(page.getByText(/Proof verified as claimed verified/i)).toBeVisible(); + + await page.goto(`/p/${vrchatPersonSlug}`); + await expect(page.getByRole("heading", { name: `Playwright VRChat Proof ${runSuffix}` })).toBeVisible(); + await expect(page.getByText("Verified owner", { exact: true })).toBeVisible(); + + await page.goto("/account"); + await page.getByLabel("Profile slug").fill(vrcLinkingPersonSlug!); + await page.getByLabel("Target type").selectOption("vrclinking"); + await page.getByLabel("Target ID").fill(`e2e-vrclinking-${runSuffix}`); + await page.getByRole("button", { name: "Create proof code" }).click(); + await expect(page.getByText(/Proof code created/i)).toBeVisible(); + await page.getByRole("button", { name: "Check proof now" }).click(); + await expect(page.getByText(/Proof verified as claimed verified/i)).toBeVisible(); + + await page.goto(`/p/${vrcLinkingPersonSlug}`); + await expect(page.getByRole("heading", { name: `Playwright VRCLinking Proof ${runSuffix}` })).toBeVisible(); + await expect(page.getByText("Verified owner", { exact: true })).toBeVisible(); + } finally { + await cleanupAuthAndProfiles( + request, + e2eToken, + email, + [communitySlug, vrchatPersonSlug, vrcLinkingPersonSlug], + runId, + ); } }); diff --git a/apps/web/e2e/profile-submission.flow.spec.ts b/apps/web/e2e/profile-submission.flow.spec.ts index ce7f7de..00e8526 100644 --- a/apps/web/e2e/profile-submission.flow.spec.ts +++ b/apps/web/e2e/profile-submission.flow.spec.ts @@ -78,6 +78,71 @@ test("profile submission writes through to public profile and discovery @flow", } }); +test("profile field visibility keeps unlisted fields on profiles and out of discovery @flow", async ({ page, request }, testInfo) => { + const e2eToken = e2eBrowserToken(); + const runId = e2eRunId(testInfo); + const runSuffix = runId.replace(/^playwright-?/, "").slice(0, 48); + const displayName = `Playwright Visibility ${runSuffix}`; + const directOnlyToken = runSuffix.replace(/-/g, "").split("").reverse().join("").slice(0, 20); + const directOnlyAlias = `AliasOnly ${directOnlyToken}`; + const directOnlyBio = `DirectOnlyBio ${directOnlyToken}`; + const privateRole = `role-${runSuffix.slice(0, 20)}`; + const publicTag = `vis-${runSuffix.slice(0, 20)}`; + 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: [directOnlyAlias], + tags: [publicTag], + roleTags: [privateRole], + bio: directOnlyBio, + fieldVisibility: { + aliases: "unlisted", + bio: "unlisted", + personRoleTags: "private", + }, + }, + }); + await expect(profileResponse).toBeOK(); + const profile = (await profileResponse.json()) as { slug?: string }; + createdSlug = profile.slug; + expect(createdSlug).toBeTruthy(); + + await page.goto(`/p/${createdSlug}`); + await expect(page.getByRole("heading", { name: displayName })).toBeVisible(); + await expect(page.getByText(directOnlyAlias)).toBeVisible(); + await expect(page.getByText(directOnlyBio).first()).toBeVisible(); + await expect(page.getByText(privateRole)).toHaveCount(0); + + await page.goto(`/discover?q=${encodeURIComponent(directOnlyAlias)}`); + await expect(page.getByText("No public results matched that search yet.")).toBeVisible(); + await expect(page.getByText(displayName, { exact: true })).toHaveCount(0); + + await page.goto(`/discover?q=${encodeURIComponent(directOnlyBio)}`); + await expect(page.getByText("No public results matched that search yet.")).toBeVisible(); + await expect(page.getByText(displayName, { exact: true })).toHaveCount(0); + + await page.goto(`/discover?q=${encodeURIComponent(publicTag)}`); + await expect(page.getByText(displayName, { exact: true })).toBeVisible(); + await expect(page.getByText(directOnlyBio)).toHaveCount(0); + await expect(page.getByText(privateRole)).toHaveCount(0); + } finally { + if (createdSlug || runId) { + const cleanupResponse = await request.delete("/api/e2e/profile-submissions", { + headers: { "x-vrdex-e2e-token": e2eToken }, + data: createdSlug ? { slug: createdSlug, runId } : { runId }, + }); + + await expect(cleanupResponse).toBeOK(); + } + } +}); + test("E2E profile helper stays gated without the browser token @flow", async ({ page, request }) => { const e2eToken = e2eBrowserToken(); const payload = { diff --git a/apps/web/playwright.config.mjs b/apps/web/playwright.config.mjs index 891b7b7..568a746 100644 --- a/apps/web/playwright.config.mjs +++ b/apps/web/playwright.config.mjs @@ -37,8 +37,14 @@ const localE2eHelperEnv = hostedBaseURL : { VRDEX_ENABLE_E2E_HELPERS: e2eHelpersEnabled ?? "true", VRDEX_ENABLE_E2E_AUTH_HELPERS: process.env.VRDEX_ENABLE_E2E_AUTH_HELPERS ?? "true", + VRDEX_ENABLE_E2E_ADAPTER_HELPERS: process.env.VRDEX_ENABLE_E2E_ADAPTER_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", + DISCORD_API_BASE_URL: process.env.DISCORD_API_BASE_URL ?? `${baseURL}/api/e2e/adapters/discord`, + DISCORD_BOT_TOKEN: process.env.DISCORD_BOT_TOKEN ?? "local-discord-adapter-token", + VRCHAT_PROOF_ADAPTER_URL: process.env.VRCHAT_PROOF_ADAPTER_URL ?? `${baseURL}/api/e2e/adapters/vrchat-proof`, + VRCLINKING_PROOF_ADAPTER_URL: process.env.VRCLINKING_PROOF_ADAPTER_URL ?? `${baseURL}/api/e2e/adapters/vrchat-proof`, + VRCHAT_PROOF_ADAPTER_BEARER_TOKEN: process.env.VRCHAT_PROOF_ADAPTER_BEARER_TOKEN ?? "local-proof-adapter-token", }; if (!hostedBaseURL) { diff --git a/apps/web/scripts/check-vercel-env.mjs b/apps/web/scripts/check-vercel-env.mjs index fb7a27a..7b71498 100644 --- a/apps/web/scripts/check-vercel-env.mjs +++ b/apps/web/scripts/check-vercel-env.mjs @@ -24,6 +24,18 @@ if (process.env.VRDEX_ENABLE_PLAYWRIGHT_FIXTURES === "true") { errors.push("VRDEX_ENABLE_PLAYWRIGHT_FIXTURES must not be enabled for Vercel builds."); } +if (isVercel && process.env.VERCEL_ENV === "production") { + for (const name of [ + "VRDEX_ENABLE_E2E_HELPERS", + "VRDEX_ENABLE_E2E_AUTH_HELPERS", + "VRDEX_ENABLE_E2E_ADAPTER_HELPERS", + ]) { + if (process.env[name] === "true") { + errors.push(`${name} must not be enabled for production Vercel builds.`); + } + } +} + if (process.env.NEXT_PUBLIC_VRDEX_SUBMISSIONS_AUTH_READY === "true") { errors.push("NEXT_PUBLIC_VRDEX_SUBMISSIONS_AUTH_READY must stay false until web auth is wired."); } diff --git a/apps/web/src/app/api/e2e/adapters/discord/[...path]/route.ts b/apps/web/src/app/api/e2e/adapters/discord/[...path]/route.ts new file mode 100644 index 0000000..1a367a5 --- /dev/null +++ b/apps/web/src/app/api/e2e/adapters/discord/[...path]/route.ts @@ -0,0 +1,54 @@ +import { NextRequest, NextResponse } from "next/server"; + +export const dynamic = "force-dynamic"; + +type DiscordAdapterRouteProps = { + params: Promise<{ + path?: string[]; + }>; +}; + +function adapterError(message: string, status = 403) { + return NextResponse.json({ error: message }, { status }); +} + +function requireAdapterRequest(request: NextRequest) { + const expectedToken = process.env.DISCORD_BOT_TOKEN?.trim(); + const productionBlocked = process.env.VERCEL_ENV === "production" && process.env.VRDEX_ALLOW_PRODUCTION_E2E_HELPERS !== "true"; + const authorization = request.headers.get("authorization") ?? ""; + + return ( + !productionBlocked && + process.env.VRDEX_ENABLE_E2E_HELPERS === "true" && + process.env.VRDEX_ENABLE_E2E_ADAPTER_HELPERS === "true" && + Boolean(expectedToken) && + authorization === `Bot ${expectedToken}` + ); +} + +export async function GET(request: NextRequest, { params }: DiscordAdapterRouteProps) { + if (!requireAdapterRequest(request)) { + return adapterError("E2E Discord adapter is not enabled for this request."); + } + + const path = (await params).path ?? []; + const [resource, guildId, nested, subjectId] = path; + + if (resource !== "guilds" || !guildId?.startsWith("e2e-")) { + return adapterError("Unknown E2E Discord adapter route.", 404); + } + + if (path.length === 2) { + return NextResponse.json({ id: guildId, name: `E2E Guild ${guildId}`, owner_id: `owner-${guildId}` }); + } + + if (nested === "members" && subjectId?.startsWith("discord-")) { + return NextResponse.json({ user: { id: subjectId }, roles: [`admin-${guildId}`] }); + } + + if (nested === "roles") { + return NextResponse.json([{ id: `admin-${guildId}`, name: "E2E Admin", permissions: "8" }]); + } + + return adapterError("Unknown E2E Discord adapter route.", 404); +} diff --git a/apps/web/src/app/api/e2e/adapters/vrchat-proof/route.ts b/apps/web/src/app/api/e2e/adapters/vrchat-proof/route.ts new file mode 100644 index 0000000..de2bd96 --- /dev/null +++ b/apps/web/src/app/api/e2e/adapters/vrchat-proof/route.ts @@ -0,0 +1,46 @@ +import { NextRequest, NextResponse } from "next/server"; + +export const dynamic = "force-dynamic"; + +function adapterError(message: string, status = 403) { + return NextResponse.json({ error: message }, { status }); +} + +function requireAdapterRequest(request: NextRequest) { + const expectedToken = process.env.VRCHAT_PROOF_ADAPTER_BEARER_TOKEN?.trim(); + const productionBlocked = process.env.VERCEL_ENV === "production" && process.env.VRDEX_ALLOW_PRODUCTION_E2E_HELPERS !== "true"; + const authorization = request.headers.get("authorization") ?? ""; + + return ( + !productionBlocked && + process.env.VRDEX_ENABLE_E2E_HELPERS === "true" && + process.env.VRDEX_ENABLE_E2E_ADAPTER_HELPERS === "true" && + Boolean(expectedToken) && + authorization === `Bearer ${expectedToken}` + ); +} + +export async function POST(request: NextRequest) { + if (!requireAdapterRequest(request)) { + return adapterError("E2E proof adapter is not enabled for this request."); + } + + const rawBody = await request.json().catch(() => null); + if (!rawBody || typeof rawBody !== "object") { + return adapterError("Invalid JSON body.", 400); + } + + const body = rawBody as Record; + const targetType = String(body.targetType ?? ""); + const targetExternalId = String(body.targetExternalId ?? ""); + const proofCode = String(body.proofCode ?? ""); + const verified = targetExternalId.startsWith("e2e-") && proofCode.startsWith("VRDEX-"); + + return NextResponse.json({ + verified, + evidenceSource: targetType === "vrclinking" ? "vrclinking" : "vrchat_api", + evidenceSummary: verified + ? `E2E adapter found proof code ${proofCode} for ${targetType} ${targetExternalId}.` + : `E2E adapter did not find proof code for ${targetType} ${targetExternalId}.`, + }); +} 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 b9b931a..8c11ade 100644 --- a/apps/web/src/app/api/e2e/profile-submissions/route.ts +++ b/apps/web/src/app/api/e2e/profile-submissions/route.ts @@ -32,6 +32,25 @@ function convexClient() { return new ConvexHttpClient(convexUrl); } +function stringArray(value: unknown) { + return Array.isArray(value) ? value.map(String) : []; +} + +function optionalString(value: unknown) { + return typeof value === "string" ? value : undefined; +} + +function fieldVisibility(value: unknown) { + if (!value || typeof value !== "object") { + return undefined; + } + + const allowed = new Set(["public", "unlisted", "private"]); + const entries = Object.entries(value).filter((entry): entry is [string, "public" | "unlisted" | "private"] => allowed.has(String(entry[1]))); + + return entries.length > 0 ? Object.fromEntries(entries) : undefined; +} + export async function POST(request: NextRequest) { const convexSecret = requireE2eRequest(request); @@ -50,14 +69,20 @@ export async function POST(request: NextRequest) { runId: String(body.runId ?? "playwright"), profileType: body.profileType === "community" ? "community" : "person", displayName: String(body.displayName ?? ""), - aliases: Array.isArray(body.aliases) ? body.aliases.map(String) : [], - tags: Array.isArray(body.tags) ? body.tags.map(String) : [], - person: body.profileType === "community" ? undefined : { roleTags: Array.isArray(body.roleTags) ? body.roleTags.map(String) : [] }, + aliases: stringArray(body.aliases), + tags: stringArray(body.tags), + headline: optionalString(body.headline), + bio: optionalString(body.bio), + about: optionalString(body.about), + region: optionalString(body.region), + timezone: optionalString(body.timezone), + fieldVisibility: fieldVisibility(body.fieldVisibility), + person: body.profileType === "community" ? undefined : { pronouns: optionalString(body.pronouns), roleTags: stringArray(body.roleTags) }, community: body.profileType === "community" ? { - subtype: typeof body.subtype === "string" ? body.subtype : undefined, - categoryTags: Array.isArray(body.categoryTags) ? body.categoryTags.map(String) : [], + subtype: optionalString(body.subtype), + categoryTags: stringArray(body.categoryTags), } : undefined, }); diff --git a/convex/e2e.ts b/convex/e2e.ts index 3fcf27f..56d1ea1 100644 --- a/convex/e2e.ts +++ b/convex/e2e.ts @@ -7,6 +7,29 @@ import { sanitizeCommunitySubmissionProfileInput } from "./_profileSubmissions"; import { createProfileSearchDocument, upsertSearchDocument } from "./_searchDocuments"; const profileType = v.union(v.literal("person"), v.literal("community")); +const fieldVisibilityState = v.union(v.literal("public"), v.literal("unlisted"), v.literal("private")); +const fieldVisibility = v.object({ + aliases: v.optional(fieldVisibilityState), + tags: v.optional(fieldVisibilityState), + headline: v.optional(fieldVisibilityState), + bio: v.optional(fieldVisibilityState), + about: v.optional(fieldVisibilityState), + avatarImageUrl: v.optional(fieldVisibilityState), + bannerImageUrl: v.optional(fieldVisibilityState), + outboundLinks: v.optional(fieldVisibilityState), + region: v.optional(fieldVisibilityState), + timezone: v.optional(fieldVisibilityState), + personPronouns: v.optional(fieldVisibilityState), + personRoleTags: v.optional(fieldVisibilityState), + communitySubtype: v.optional(fieldVisibilityState), + communityCategoryTags: v.optional(fieldVisibilityState), +}); + +function optionalText(value: string | undefined, maxLength = 500) { + const trimmed = value?.trim(); + + return trimmed ? trimmed.slice(0, maxLength) : undefined; +} function requireE2eHelper(secret?: string) { const expectedSecret = process.env.VRDEX_E2E_CONVEX_SECRET?.trim(); @@ -219,8 +242,15 @@ export const submitProfile = mutation({ displayName: v.string(), aliases: v.optional(v.array(v.string())), tags: v.optional(v.array(v.string())), + headline: v.optional(v.string()), + bio: v.optional(v.string()), + about: v.optional(v.string()), + region: v.optional(v.string()), + timezone: v.optional(v.string()), + fieldVisibility: v.optional(fieldVisibility), person: v.optional( v.object({ + pronouns: v.optional(v.string()), roleTags: v.optional(v.array(v.string())), }), ), @@ -252,6 +282,12 @@ export const submitProfile = mutation({ sortName: input.sortName, aliases: input.aliases, tags: input.tags, + ...(optionalText(args.headline, 160) !== undefined ? { headline: optionalText(args.headline, 160) } : {}), + ...(optionalText(args.bio, 600) !== undefined ? { bio: optionalText(args.bio, 600) } : {}), + ...(optionalText(args.about, 1_200) !== undefined ? { about: optionalText(args.about, 1_200) } : {}), + ...(optionalText(args.region, 80) !== undefined ? { region: optionalText(args.region, 80) } : {}), + ...(optionalText(args.timezone, 80) !== undefined ? { timezone: optionalText(args.timezone, 80) } : {}), + ...(args.fieldVisibility !== undefined ? { fieldVisibility: args.fieldVisibility } : {}), outboundLinks: [], claimState: "unclaimed" as const, publicationState: "published" as const, @@ -269,7 +305,12 @@ export const submitProfile = mutation({ ? { ...sharedFields, profileType: "person", - person: { roleTags: input.person.roleTags }, + person: { + ...(optionalText(args.person?.pronouns, 80) !== undefined + ? { pronouns: optionalText(args.person?.pronouns, 80) } + : {}), + roleTags: input.person.roleTags, + }, } : { ...sharedFields, diff --git a/docs/deployment/convex-environments.md b/docs/deployment/convex-environments.md index a26460e..48bab3b 100644 --- a/docs/deployment/convex-environments.md +++ b/docs/deployment/convex-environments.md @@ -38,6 +38,12 @@ 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 +- `VRDEX_ENABLE_E2E_ADAPTER_HELPERS=true`: optional, only when hosted adapter E2E is intentionally enabled +- `DISCORD_API_BASE_URL`: optional hosted adapter stub base URL, usually `https://staging.vrdex.net/api/e2e/adapters/discord` +- `DISCORD_BOT_TOKEN`: staging-only adapter token matching the hosted app environment +- `VRCHAT_PROOF_ADAPTER_URL`: optional hosted adapter stub URL, usually `https://staging.vrdex.net/api/e2e/adapters/vrchat-proof` +- `VRCLINKING_PROOF_ADAPTER_URL`: optional hosted adapter stub URL, usually `https://staging.vrdex.net/api/e2e/adapters/vrchat-proof` +- `VRCHAT_PROOF_ADAPTER_BEARER_TOKEN`: staging-only adapter token matching the hosted app environment 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. @@ -56,7 +62,16 @@ Candidate direction: use readable Convex Cloud custom domains once the deploymen - development/staging Convex API: `convex.staging.vrdex.net` - production Convex API: `convex.vrdex.net` -Convex Cloud custom domains are configured from each deployment's dashboard settings and require a Convex Pro plan. Do not create Route 53 records alone; Convex must first provide the deployment-specific DNS records and certificate binding. After binding a custom Convex function domain, update the matching Vercel environment `NEXT_PUBLIC_CONVEX_URL` and `CONVEX_URL`, then redeploy the web app and rerun deployed health. +Convex Cloud custom domains are configured from each deployment's dashboard settings and require a Convex Pro plan. Do not create Route 53 records alone; Convex must first provide the deployment-specific DNS records and certificate binding. + +Runbook once Convex Pro is enabled: + +1. In the Convex dashboard for `scrupulous-corgi-247`, request `convex.staging.vrdex.net` as the development/staging custom domain. +2. In the Convex dashboard for `superb-pig-954`, request `convex.vrdex.net` as the production custom domain. +3. Copy the exact DNS records Convex provides into Route 53 for the public hosted zone `vrdex.net`. +4. Wait for Convex certificate/domain status to become active. +5. Update the matching Vercel environment `NEXT_PUBLIC_CONVEX_URL` and `CONVEX_URL` values. +6. Rerun `Staging Deploy` for staging and deployed health for production. ## Notes diff --git a/docs/deployment/vercel-preview.md b/docs/deployment/vercel-preview.md index 6bdb520..ee3e528 100644 --- a/docs/deployment/vercel-preview.md +++ b/docs/deployment/vercel-preview.md @@ -51,8 +51,11 @@ Hosted dev/staging E2E targets must set these only on the dev/staging environmen - `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 +- `VRDEX_ENABLE_E2E_ADAPTER_HELPERS=true`: optional staging-only switch for Discord and VRChat/VRCLinking adapter stubs; keep unset until that flow is intentionally enabled +- `DISCORD_BOT_TOKEN`: optional staging-only adapter token when hosted adapter E2E is enabled +- `VRCHAT_PROOF_ADAPTER_BEARER_TOKEN`: optional staging-only adapter token when hosted adapter E2E is enabled -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. +Production should keep `VRDEX_ENABLE_E2E_HELPERS=false` or unset, should keep `VRDEX_ENABLE_E2E_AUTH_HELPERS` and `VRDEX_ENABLE_E2E_ADAPTER_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. @@ -82,10 +85,15 @@ The `staging` Vercel environment points at the shared Convex development deploym - `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 +- `VRDEX_ENABLE_E2E_ADAPTER_HELPERS`: unset until hosted adapter E2E is intentionally enabled +- `DISCORD_BOT_TOKEN`: unset until hosted adapter E2E is intentionally enabled +- `VRCHAT_PROOF_ADAPTER_BEARER_TOKEN`: unset until hosted adapter E2E is intentionally enabled GitHub Actions uses these repository settings for hosted mutation health: - variable `VRDEX_HOSTED_E2E_BASE_URL=https://staging.vrdex.net` +- variable `VRDEX_HOSTED_E2E_AUTH_HELPERS=true`: optional, only after hosted auth helpers are enabled in Vercel staging and Convex dev +- variable `VRDEX_HOSTED_E2E_ADAPTER_HELPERS=true`: optional, only after hosted adapter helpers are enabled in Vercel staging and Convex dev - secret `VRDEX_HOSTED_E2E_BROWSER_TOKEN` The `Staging Deploy` workflow runs after `Baseline Checks` succeeds on `main` and can also be run manually. It requires these settings: @@ -104,6 +112,7 @@ The Vercel build runs `pnpm build:vercel`, which executes `apps/web/scripts/chec The validation fails when: - Playwright fixtures are enabled. +- Any E2E helper switch is enabled for a production Vercel build. - public submissions are marked auth-ready before auth exists. - `NEXT_PUBLIC_CONVEX_URL` is invalid. - `NEXT_PUBLIC_CONVEX_URL` points at localhost during a Vercel build. diff --git a/docs/testing/playwright-visual-preview.md b/docs/testing/playwright-visual-preview.md index 51e3ddb..32c74ab 100644 --- a/docs/testing/playwright-visual-preview.md +++ b/docs/testing/playwright-visual-preview.md @@ -106,6 +106,8 @@ The browser token gates the Next.js helper route. The Convex secret is never sen 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. +Adapter helper routes require `VRDEX_ENABLE_E2E_ADAPTER_HELPERS=true` and are used only by Convex actions during E2E tests. Local Playwright webserver runs point Convex at local Discord and VRChat/VRCLinking adapter stubs so the UI exercises the real claim actions without real Discord, VRChat, or VRCLinking calls. + 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`: @@ -118,6 +120,16 @@ Hosted dev/staging targets must be configured outside this repository before run 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. +Hosted adapter E2E additionally requires `VRDEX_ENABLE_E2E_ADAPTER_HELPERS=true` in the hosted app and these Convex env values on the shared development deployment: + +- `DISCORD_API_BASE_URL=/api/e2e/adapters/discord` +- `DISCORD_BOT_TOKEN=` +- `VRCHAT_PROOF_ADAPTER_URL=/api/e2e/adapters/vrchat-proof` +- `VRCLINKING_PROOF_ADAPTER_URL=/api/e2e/adapters/vrchat-proof` +- `VRCHAT_PROOF_ADAPTER_BEARER_TOKEN=` + +GitHub Actions only runs hosted auth and adapter flows when repository variables `VRDEX_HOSTED_E2E_AUTH_HELPERS=true` and `VRDEX_HOSTED_E2E_ADAPTER_HELPERS=true` are set. Keep both unset until the matching hosted app and Convex env values are configured. + `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. @@ -141,7 +153,7 @@ The optional `Playwright Hosted Data Flow` job runs on pull requests only when b - repository variable `VRDEX_HOSTED_E2E_BASE_URL` - repository secret `VRDEX_HOSTED_E2E_BROWSER_TOKEN` -When configured, the job runs `pnpm test:e2e:hosted` with `PLAYWRIGHT_BASE_URL`, `PLAYWRIGHT_SKIP_WEBSERVERS=true`, `PLAYWRIGHT_RECORD_VIDEO=true`, and a GitHub Actions run-scoped `VRDEX_E2E_RUN_ID`. +When configured, the job runs `pnpm test:e2e:hosted` with `PLAYWRIGHT_BASE_URL`, `PLAYWRIGHT_SKIP_WEBSERVERS=true`, `PLAYWRIGHT_RECORD_VIDEO=true`, and a GitHub Actions run-scoped `VRDEX_E2E_RUN_ID`. Auth and adapter flows skip unless `VRDEX_HOSTED_E2E_AUTH_HELPERS` and `VRDEX_HOSTED_E2E_ADAPTER_HELPERS` are explicitly set to `true`. The `Deployed Health Checks` workflow runs after merges to `main`, after successful GitHub deployment status events for production deployments, on a daily schedule, and through manual dispatch. It has two independent checks: diff --git a/scripts/sync-convex-local-env.mjs b/scripts/sync-convex-local-env.mjs index 4ab879e..be89131 100644 --- a/scripts/sync-convex-local-env.mjs +++ b/scripts/sync-convex-local-env.mjs @@ -15,7 +15,13 @@ const localConvexEnvNames = [ "JWKS", "VRDEX_ENABLE_E2E_HELPERS", "VRDEX_ENABLE_E2E_AUTH_HELPERS", + "VRDEX_ENABLE_E2E_ADAPTER_HELPERS", "VRDEX_E2E_CONVEX_SECRET", + "DISCORD_API_BASE_URL", + "DISCORD_BOT_TOKEN", + "VRCHAT_PROOF_ADAPTER_URL", + "VRCLINKING_PROOF_ADAPTER_URL", + "VRCHAT_PROOF_ADAPTER_BEARER_TOKEN", ]; const localDeploymentName = process.env.CONVEX_LOCAL_DEPLOYMENT_NAME || "anonymous-agent"; const localCloudPort = process.env.CONVEX_LOCAL_CLOUD_PORT || "3210"; From c5b01a875b6b353f27b2872f93afa278a239563b Mon Sep 17 00:00:00 2001 From: BASICBIT Date: Sun, 31 May 2026 01:22:11 -0400 Subject: [PATCH 2/4] Gate hosted extended profile flow --- .github/workflows/baseline-checks.yml | 7 +++++++ .github/workflows/deployed-health.yml | 4 ++++ .github/workflows/staging-deploy.yml | 1 + apps/web/e2e/profile-submission.flow.spec.ts | 5 +++++ docs/deployment/vercel-preview.md | 1 + docs/testing/playwright-visual-preview.md | 6 ++++-- 6 files changed, 22 insertions(+), 2 deletions(-) diff --git a/.github/workflows/baseline-checks.yml b/.github/workflows/baseline-checks.yml index a718433..989b611 100644 --- a/.github/workflows/baseline-checks.yml +++ b/.github/workflows/baseline-checks.yml @@ -395,6 +395,7 @@ jobs: PLAYWRIGHT_BASE_URL: ${{ vars.VRDEX_HOSTED_E2E_BASE_URL }} PLAYWRIGHT_RECORD_VIDEO: "true" PLAYWRIGHT_SKIP_WEBSERVERS: "true" + VRDEX_ENABLE_E2E_EXTENDED_PROFILE_FLOW: ${{ vars.VRDEX_HOSTED_E2E_EXTENDED_PROFILE_FLOW }} VRDEX_ENABLE_E2E_AUTH_HELPERS: ${{ vars.VRDEX_HOSTED_E2E_AUTH_HELPERS }} VRDEX_ENABLE_E2E_ADAPTER_HELPERS: ${{ vars.VRDEX_HOSTED_E2E_ADAPTER_HELPERS }} VRDEX_E2E_BROWSER_TOKEN: ${{ secrets.VRDEX_HOSTED_E2E_BROWSER_TOKEN }} @@ -406,6 +407,7 @@ jobs: env: HOSTED_OUTCOME: ${{ steps.hosted.outcome }} HOSTED_BASE_URL: ${{ vars.VRDEX_HOSTED_E2E_BASE_URL }} + HOSTED_EXTENDED_PROFILE_FLOW: ${{ vars.VRDEX_HOSTED_E2E_EXTENDED_PROFILE_FLOW }} HOSTED_AUTH_HELPERS: ${{ vars.VRDEX_HOSTED_E2E_AUTH_HELPERS }} HOSTED_ADAPTER_HELPERS: ${{ vars.VRDEX_HOSTED_E2E_ADAPTER_HELPERS }} RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} @@ -418,6 +420,8 @@ jobs: Target: ${HOSTED_BASE_URL} + Hosted extended profile flow: ${HOSTED_EXTENDED_PROFILE_FLOW:-skipped} + Hosted auth helpers: ${HOSTED_AUTH_HELPERS:-skipped} Hosted adapter helpers: ${HOSTED_ADAPTER_HELPERS:-skipped} @@ -451,6 +455,7 @@ jobs: env: HOSTED_OUTCOME: ${{ steps.hosted.outcome }} HOSTED_BASE_URL: ${{ vars.VRDEX_HOSTED_E2E_BASE_URL }} + HOSTED_EXTENDED_PROFILE_FLOW: ${{ vars.VRDEX_HOSTED_E2E_EXTENDED_PROFILE_FLOW }} HOSTED_AUTH_HELPERS: ${{ vars.VRDEX_HOSTED_E2E_AUTH_HELPERS }} HOSTED_ADAPTER_HELPERS: ${{ vars.VRDEX_HOSTED_E2E_ADAPTER_HELPERS }} ARTIFACT_URL: ${{ steps.upload.outputs.artifact-url }} @@ -464,6 +469,7 @@ jobs: const artifactUrl = process.env.ARTIFACT_URL || ""; const outcome = process.env.HOSTED_OUTCOME || "unknown"; const target = process.env.HOSTED_BASE_URL || "not configured"; + const extendedProfileFlow = process.env.HOSTED_EXTENDED_PROFILE_FLOW === "true" ? "enabled" : "skipped"; const authHelpers = process.env.HOSTED_AUTH_HELPERS === "true" ? "enabled" : "skipped"; const adapterHelpers = process.env.HOSTED_ADAPTER_HELPERS === "true" ? "enabled" : "skipped"; const artifactLine = artifactUrl @@ -474,6 +480,7 @@ jobs: "## Playwright Hosted Data-Flow", `Outcome: ${outcome}`, `Target: ${target}`, + `Hosted extended profile flow: ${extendedProfileFlow}`, `Hosted auth helpers: ${authHelpers}`, `Hosted adapter helpers: ${adapterHelpers}`, `Run: ${runUrl}`, diff --git a/.github/workflows/deployed-health.yml b/.github/workflows/deployed-health.yml index 94c8a7c..c323fa2 100644 --- a/.github/workflows/deployed-health.yml +++ b/.github/workflows/deployed-health.yml @@ -91,6 +91,7 @@ jobs: PLAYWRIGHT_BASE_URL: ${{ steps.gate.outputs.base_url }} PLAYWRIGHT_RECORD_VIDEO: "true" PLAYWRIGHT_SKIP_WEBSERVERS: "true" + VRDEX_ENABLE_E2E_EXTENDED_PROFILE_FLOW: ${{ vars.VRDEX_HOSTED_E2E_EXTENDED_PROFILE_FLOW }} VRDEX_ENABLE_E2E_AUTH_HELPERS: ${{ vars.VRDEX_HOSTED_E2E_AUTH_HELPERS }} VRDEX_ENABLE_E2E_ADAPTER_HELPERS: ${{ vars.VRDEX_HOSTED_E2E_ADAPTER_HELPERS }} VRDEX_E2E_BROWSER_TOKEN: ${{ secrets.VRDEX_HOSTED_E2E_BROWSER_TOKEN }} @@ -102,6 +103,7 @@ jobs: env: HOSTED_OUTCOME: ${{ steps.hosted.outcome }} HOSTED_BASE_URL: ${{ steps.gate.outputs.base_url }} + HOSTED_EXTENDED_PROFILE_FLOW: ${{ vars.VRDEX_HOSTED_E2E_EXTENDED_PROFILE_FLOW }} HOSTED_AUTH_HELPERS: ${{ vars.VRDEX_HOSTED_E2E_AUTH_HELPERS }} HOSTED_ADAPTER_HELPERS: ${{ vars.VRDEX_HOSTED_E2E_ADAPTER_HELPERS }} RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} @@ -114,6 +116,8 @@ jobs: Target: ${HOSTED_BASE_URL} + Hosted extended profile flow: ${HOSTED_EXTENDED_PROFILE_FLOW:-skipped} + Hosted auth helpers: ${HOSTED_AUTH_HELPERS:-skipped} Hosted adapter helpers: ${HOSTED_ADAPTER_HELPERS:-skipped} diff --git a/.github/workflows/staging-deploy.yml b/.github/workflows/staging-deploy.yml index 2951c29..3a2eb98 100644 --- a/.github/workflows/staging-deploy.yml +++ b/.github/workflows/staging-deploy.yml @@ -119,6 +119,7 @@ jobs: PLAYWRIGHT_BASE_URL: ${{ steps.gate.outputs.hosted_base_url }} PLAYWRIGHT_RECORD_VIDEO: "true" PLAYWRIGHT_SKIP_WEBSERVERS: "true" + VRDEX_ENABLE_E2E_EXTENDED_PROFILE_FLOW: ${{ vars.VRDEX_HOSTED_E2E_EXTENDED_PROFILE_FLOW }} VRDEX_ENABLE_E2E_AUTH_HELPERS: ${{ vars.VRDEX_HOSTED_E2E_AUTH_HELPERS }} VRDEX_ENABLE_E2E_ADAPTER_HELPERS: ${{ vars.VRDEX_HOSTED_E2E_ADAPTER_HELPERS }} VRDEX_E2E_BROWSER_TOKEN: ${{ secrets.VRDEX_HOSTED_E2E_BROWSER_TOKEN }} diff --git a/apps/web/e2e/profile-submission.flow.spec.ts b/apps/web/e2e/profile-submission.flow.spec.ts index 00e8526..9685932 100644 --- a/apps/web/e2e/profile-submission.flow.spec.ts +++ b/apps/web/e2e/profile-submission.flow.spec.ts @@ -79,6 +79,11 @@ test("profile submission writes through to public profile and discovery @flow", }); test("profile field visibility keeps unlisted fields on profiles and out of discovery @flow", async ({ page, request }, testInfo) => { + test.skip( + Boolean(process.env.PLAYWRIGHT_BASE_URL) && process.env.VRDEX_ENABLE_E2E_EXTENDED_PROFILE_FLOW !== "true", + "Hosted extended profile flow is not enabled for this target.", + ); + const e2eToken = e2eBrowserToken(); const runId = e2eRunId(testInfo); const runSuffix = runId.replace(/^playwright-?/, "").slice(0, 48); diff --git a/docs/deployment/vercel-preview.md b/docs/deployment/vercel-preview.md index ee3e528..df68822 100644 --- a/docs/deployment/vercel-preview.md +++ b/docs/deployment/vercel-preview.md @@ -92,6 +92,7 @@ The `staging` Vercel environment points at the shared Convex development deploym GitHub Actions uses these repository settings for hosted mutation health: - variable `VRDEX_HOSTED_E2E_BASE_URL=https://staging.vrdex.net` +- variable `VRDEX_HOSTED_E2E_EXTENDED_PROFILE_FLOW=true`: optional, only after staging has deployed the E2E profile helper route version that accepts extended profile fields - variable `VRDEX_HOSTED_E2E_AUTH_HELPERS=true`: optional, only after hosted auth helpers are enabled in Vercel staging and Convex dev - variable `VRDEX_HOSTED_E2E_ADAPTER_HELPERS=true`: optional, only after hosted adapter helpers are enabled in Vercel staging and Convex dev - secret `VRDEX_HOSTED_E2E_BROWSER_TOKEN` diff --git a/docs/testing/playwright-visual-preview.md b/docs/testing/playwright-visual-preview.md index 32c74ab..f5b0a36 100644 --- a/docs/testing/playwright-visual-preview.md +++ b/docs/testing/playwright-visual-preview.md @@ -118,6 +118,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 extended profile field-visibility E2E additionally requires repository variable `VRDEX_HOSTED_E2E_EXTENDED_PROFILE_FLOW=true`. Keep it unset until the hosted target has deployed the E2E profile helper version that accepts aliases, bio, role tags, and `fieldVisibility` in helper payloads. + 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. Hosted adapter E2E additionally requires `VRDEX_ENABLE_E2E_ADAPTER_HELPERS=true` in the hosted app and these Convex env values on the shared development deployment: @@ -128,7 +130,7 @@ Hosted adapter E2E additionally requires `VRDEX_ENABLE_E2E_ADAPTER_HELPERS=true` - `VRCLINKING_PROOF_ADAPTER_URL=/api/e2e/adapters/vrchat-proof` - `VRCHAT_PROOF_ADAPTER_BEARER_TOKEN=` -GitHub Actions only runs hosted auth and adapter flows when repository variables `VRDEX_HOSTED_E2E_AUTH_HELPERS=true` and `VRDEX_HOSTED_E2E_ADAPTER_HELPERS=true` are set. Keep both unset until the matching hosted app and Convex env values are configured. +GitHub Actions only runs hosted extended profile, auth, and adapter flows when repository variables `VRDEX_HOSTED_E2E_EXTENDED_PROFILE_FLOW=true`, `VRDEX_HOSTED_E2E_AUTH_HELPERS=true`, and `VRDEX_HOSTED_E2E_ADAPTER_HELPERS=true` are set. Keep the optional variables unset until the matching hosted app and Convex capabilities are configured. `VERCEL_ENV=production` blocks the E2E route unless `VRDEX_ALLOW_PRODUCTION_E2E_HELPERS=true` is explicitly set. Keep that override unset for VRDex production. @@ -153,7 +155,7 @@ The optional `Playwright Hosted Data Flow` job runs on pull requests only when b - repository variable `VRDEX_HOSTED_E2E_BASE_URL` - repository secret `VRDEX_HOSTED_E2E_BROWSER_TOKEN` -When configured, the job runs `pnpm test:e2e:hosted` with `PLAYWRIGHT_BASE_URL`, `PLAYWRIGHT_SKIP_WEBSERVERS=true`, `PLAYWRIGHT_RECORD_VIDEO=true`, and a GitHub Actions run-scoped `VRDEX_E2E_RUN_ID`. Auth and adapter flows skip unless `VRDEX_HOSTED_E2E_AUTH_HELPERS` and `VRDEX_HOSTED_E2E_ADAPTER_HELPERS` are explicitly set to `true`. +When configured, the job runs `pnpm test:e2e:hosted` with `PLAYWRIGHT_BASE_URL`, `PLAYWRIGHT_SKIP_WEBSERVERS=true`, `PLAYWRIGHT_RECORD_VIDEO=true`, and a GitHub Actions run-scoped `VRDEX_E2E_RUN_ID`. Extended profile, auth, and adapter flows skip unless `VRDEX_HOSTED_E2E_EXTENDED_PROFILE_FLOW`, `VRDEX_HOSTED_E2E_AUTH_HELPERS`, and `VRDEX_HOSTED_E2E_ADAPTER_HELPERS` are explicitly set to `true`. The `Deployed Health Checks` workflow runs after merges to `main`, after successful GitHub deployment status events for production deployments, on a daily schedule, and through manual dispatch. It has two independent checks: From f3fa2d8989455806cc1c86e4e131cdf0704e59be Mon Sep 17 00:00:00 2001 From: BASICBIT Date: Sun, 31 May 2026 01:48:04 -0400 Subject: [PATCH 3/4] Add PostHog infrastructure setup --- apps/web/.env.example | 5 +- apps/web/scripts/check-vercel-env.mjs | 12 ++++ apps/web/src/app/PostHogProvider.tsx | 4 +- .../product-analytics-and-feature-flags.md | 10 +++ docs/deployment/vercel-preview.md | 2 + infra/terraform/README.md | 9 +++ infra/terraform/posthog/.terraform.lock.hcl | 24 +++++++ infra/terraform/posthog/README.md | 33 ++++++++++ infra/terraform/posthog/main.tf | 5 ++ infra/terraform/posthog/outputs.tf | 14 +++++ .../posthog/terraform.tfvars.example | 5 ++ infra/terraform/posthog/variables.tf | 34 ++++++++++ infra/terraform/posthog/versions.tf | 24 +++++++ infra/terraform/vercel/.terraform.lock.hcl | 24 +++++++ infra/terraform/vercel/README.md | 46 ++++++++++++++ infra/terraform/vercel/main.tf | 54 ++++++++++++++++ infra/terraform/vercel/outputs.tf | 18 ++++++ .../terraform/vercel/terraform.tfvars.example | 15 +++++ infra/terraform/vercel/variables.tf | 63 +++++++++++++++++++ infra/terraform/vercel/versions.tf | 22 +++++++ 20 files changed, 419 insertions(+), 4 deletions(-) create mode 100644 infra/terraform/README.md create mode 100644 infra/terraform/posthog/.terraform.lock.hcl create mode 100644 infra/terraform/posthog/README.md create mode 100644 infra/terraform/posthog/main.tf create mode 100644 infra/terraform/posthog/outputs.tf create mode 100644 infra/terraform/posthog/terraform.tfvars.example create mode 100644 infra/terraform/posthog/variables.tf create mode 100644 infra/terraform/posthog/versions.tf create mode 100644 infra/terraform/vercel/.terraform.lock.hcl create mode 100644 infra/terraform/vercel/README.md create mode 100644 infra/terraform/vercel/main.tf create mode 100644 infra/terraform/vercel/outputs.tf create mode 100644 infra/terraform/vercel/terraform.tfvars.example create mode 100644 infra/terraform/vercel/variables.tf create mode 100644 infra/terraform/vercel/versions.tf diff --git a/apps/web/.env.example b/apps/web/.env.example index 6f04a0d..5d76893 100644 --- a/apps/web/.env.example +++ b/apps/web/.env.example @@ -4,9 +4,10 @@ NEXT_PUBLIC_CONVEX_URL= # Set true in Vercel if previews must fail when NEXT_PUBLIC_CONVEX_URL is missing. VRDEX_REQUIRE_CONVEX_URL=false -# Optional PostHog analytics. Missing values keep analytics disabled. +# Optional PostHog analytics. Missing key keeps analytics disabled. +# BASIC BIT hosted deployments use PostHog project 447783, managed through infra/terraform/vercel. NEXT_PUBLIC_POSTHOG_KEY= -NEXT_PUBLIC_POSTHOG_HOST= +NEXT_PUBLIC_POSTHOG_HOST=https://us.i.posthog.com # Convex Auth OAuth providers. AUTH_DISCORD_ID= diff --git a/apps/web/scripts/check-vercel-env.mjs b/apps/web/scripts/check-vercel-env.mjs index 7b71498..d14ebad 100644 --- a/apps/web/scripts/check-vercel-env.mjs +++ b/apps/web/scripts/check-vercel-env.mjs @@ -1,5 +1,6 @@ const isVercel = process.env.VERCEL === "1" || process.env.VERCEL === "true"; const convexUrl = process.env.NEXT_PUBLIC_CONVEX_URL; +const posthogHost = process.env.NEXT_PUBLIC_POSTHOG_HOST; const requireConvexUrl = process.env.VRDEX_REQUIRE_CONVEX_URL === "true"; const errors = []; @@ -55,6 +56,17 @@ if (convexUrl) { warnings.push("NEXT_PUBLIC_CONVEX_URL is not set; the hosted app will render missing-backend states."); } +if (posthogHost) { + const parsedPosthogHost = parseUrl("NEXT_PUBLIC_POSTHOG_HOST", posthogHost); + + if (isVercel && parsedPosthogHost) { + const host = parsedPosthogHost.hostname.toLowerCase(); + if (host === "localhost" || host === "127.0.0.1" || host.endsWith(".local")) { + errors.push("NEXT_PUBLIC_POSTHOG_HOST must not point at a local backend for Vercel builds."); + } + } +} + for (const warning of warnings) { console.warn(`[vercel-env] ${warning}`); } diff --git a/apps/web/src/app/PostHogProvider.tsx b/apps/web/src/app/PostHogProvider.tsx index 2b132bc..01f1465 100644 --- a/apps/web/src/app/PostHogProvider.tsx +++ b/apps/web/src/app/PostHogProvider.tsx @@ -4,8 +4,8 @@ import posthog from "posthog-js"; import { PostHogProvider as Provider } from "posthog-js/react"; import { useEffect, type ReactNode } from "react"; -const posthogKey = process.env.NEXT_PUBLIC_POSTHOG_KEY; -const posthogHost = process.env.NEXT_PUBLIC_POSTHOG_HOST ?? "https://us.i.posthog.com"; +const posthogKey = process.env.NEXT_PUBLIC_POSTHOG_KEY?.trim(); +const posthogHost = process.env.NEXT_PUBLIC_POSTHOG_HOST?.trim() || "https://us.i.posthog.com"; export function PostHogProvider({ children }: { children: ReactNode }) { useEffect(() => { diff --git a/docs/agentic/product-analytics-and-feature-flags.md b/docs/agentic/product-analytics-and-feature-flags.md index 3e5ab64..7417436 100644 --- a/docs/agentic/product-analytics-and-feature-flags.md +++ b/docs/agentic/product-analytics-and-feature-flags.md @@ -127,6 +127,16 @@ Examples: Current discovery instrumentation uses PostHog when `NEXT_PUBLIC_POSTHOG_KEY` is configured and otherwise no-ops safely. +Locked hosted project: + +- organization: `BASIC BIT LLC` +- project: `VRDex Analytics` +- project ID: `447783` +- app host: `https://us.posthog.com/project/447783/home` +- ingestion host: `https://us.i.posthog.com` + +The hosted PostHog project is managed/imported through `infra/terraform/posthog`. Hosted Vercel env vars for this project are managed by `infra/terraform/vercel`. Keep the public project key out of committed defaults so forks and self-hosted installs do not accidentally send analytics into the BASIC BIT project. + Initial events: - `search_submitted` diff --git a/docs/deployment/vercel-preview.md b/docs/deployment/vercel-preview.md index df68822..dc9d83d 100644 --- a/docs/deployment/vercel-preview.md +++ b/docs/deployment/vercel-preview.md @@ -42,6 +42,8 @@ Set these in the Vercel project as needed: - `NEXT_PUBLIC_CONVEX_URL`: optional for a shell-only preview; set to the hosted Convex deployment URL for live backend reads. - `VRDEX_REQUIRE_CONVEX_URL=true`: optional; use when previews must fail instead of showing missing-backend states. - `NEXT_PUBLIC_VRDEX_SUBMISSIONS_AUTH_READY=false`: legacy flag; auth-backed submissions now rely on Convex Auth configuration. +- `NEXT_PUBLIC_POSTHOG_KEY`: optional public PostHog project key; BASIC BIT hosted deployments should set this through `infra/terraform/vercel` for PostHog project `447783`. +- `NEXT_PUBLIC_POSTHOG_HOST=https://us.i.posthog.com`: optional PostHog ingestion host; also managed through `infra/terraform/vercel` for hosted deployments. Do not set `VRDEX_ENABLE_PLAYWRIGHT_FIXTURES` in Vercel. Fixture profiles are for Playwright-only local/CI preview screenshots and must not be exposed from hosted previews. diff --git a/infra/terraform/README.md b/infra/terraform/README.md new file mode 100644 index 0000000..7bf70d7 --- /dev/null +++ b/infra/terraform/README.md @@ -0,0 +1,9 @@ +# Terraform Stacks + +VRDex keeps small infrastructure stacks separate so credentials, blast radius, and apply cadence stay clear. + +- `ses/`: AWS SES sender identity and least-privilege Convex email credentials. +- `posthog/`: hosted PostHog project metadata for product analytics. +- `vercel/`: Vercel project environment variables for the hosted web app. + +Each stack uses the shared S3 state bucket `vrdex-terraform-state` with a stack-specific state key and S3 native locking. Do not commit `terraform.tfvars`, local state, plans, or provider directories. diff --git a/infra/terraform/posthog/.terraform.lock.hcl b/infra/terraform/posthog/.terraform.lock.hcl new file mode 100644 index 0000000..ca20681 --- /dev/null +++ b/infra/terraform/posthog/.terraform.lock.hcl @@ -0,0 +1,24 @@ +# This file is maintained automatically by "terraform init". +# Manual edits may be lost in future updates. + +provider "registry.terraform.io/posthog/posthog" { + version = "1.0.11" + constraints = ">= 1.0.0, < 2.0.0" + hashes = [ + "h1:68ym8hpBHylNlXDmA5OzRktFhMTZepi4euo1dElBK6k=", + "zh:24202209a74df128eb4cbff633b90adc906cf89f4c2abe97dc039d9a54c30759", + "zh:3169bfbbd2e37b2050b30ddc4253526fda187a9a9eac04b4b76d560b2e87356c", + "zh:5e514eadae5e330668b9f74322911e24a62ded643cf30417a35ddc7d5a0e53b1", + "zh:681bd1963b46aa136595f2e5045b02218df7d83a817d336e13a1ce8bc4604f27", + "zh:68b1d4d84dc6bc8a87ab4b04bbb73fc74b92456a1442ea0cffe67a9722e10a50", + "zh:8341fca4eb48198b9b5c685046520f6d38b7a7a9d8aaa4379199040dde2a5e57", + "zh:87cb0b99bff29c6c03bc62092dd2dbc6174f0b20948915c547ee2dcf9d5706d9", + "zh:890df766e9b839623b1f0437355032a3c006226a6c200cd911e15ee1a9014e9f", + "zh:92efadc1e1fa4b315c4c28ec616b0af2325f4fb771df056c0bd3700fbe817ee8", + "zh:a323924c9756237ec9fd6f7802db97d05642f9f4c5ea29aed2fe85f3f01966cf", + "zh:d22cf117b0ea40be5ea7742a6527036323564ae7e9addeaa41a4bff947b06f4c", + "zh:e3ef4f5e679784dfedaf56b49940cb68f60b2249ac409ec64b2cb707beb1b1eb", + "zh:ed4c518babc375a5325182ced9b19fa3e5ac3d0cfe68e1eb016e00e5c8d43335", + "zh:f280669cfb23381fb4e50e941ffe835868d25b961106d2d677454ddfc07aecd3", + ] +} diff --git a/infra/terraform/posthog/README.md b/infra/terraform/posthog/README.md new file mode 100644 index 0000000..6a159b4 --- /dev/null +++ b/infra/terraform/posthog/README.md @@ -0,0 +1,33 @@ +# PostHog Terraform + +This stack records the hosted PostHog project used by VRDex product analytics. + +- organization: `BASIC BIT LLC` +- organization ID: `019e59b2-c822-0000-8123-12efe322af2d` +- project: `VRDex Analytics` +- project ID: `447783` +- API host: `https://us.posthog.com` + +## Usage + +The project already exists. Import it before the first apply so Terraform manages the existing project instead of creating a duplicate: + +```powershell +$env:POSTHOG_API_KEY="" +terraform init +terraform import posthog_project.vrdex 019e59b2-c822-0000-8123-12efe322af2d/447783 +terraform plan +``` + +Do not commit PostHog personal API keys. Use PostHog OAuth/MCP for interactive analytics work and short-lived local `POSTHOG_API_KEY` only when applying this Terraform stack. + +The sensitive output `posthog_project_api_token` is the client-exposed project key used by the Vercel stack as `posthog_public_key`. It is not a personal API key, but keep it out of committed defaults so forks and self-hosted installs do not accidentally send analytics into the BASIC BIT project. + +## State Backend + +Terraform state for this stack is stored in the S3 backend declared in `versions.tf`: + +- bucket: `vrdex-terraform-state` +- key: `posthog/terraform.tfstate` +- region: `us-east-1` +- locking: S3 native lockfile (`use_lockfile = true`) diff --git a/infra/terraform/posthog/main.tf b/infra/terraform/posthog/main.tf new file mode 100644 index 0000000..51449d3 --- /dev/null +++ b/infra/terraform/posthog/main.tf @@ -0,0 +1,5 @@ +resource "posthog_project" "vrdex" { + organization_id = var.posthog_organization_id + name = var.posthog_project_name + timezone = var.posthog_timezone +} diff --git a/infra/terraform/posthog/outputs.tf b/infra/terraform/posthog/outputs.tf new file mode 100644 index 0000000..ca7a3f5 --- /dev/null +++ b/infra/terraform/posthog/outputs.tf @@ -0,0 +1,14 @@ +output "posthog_project" { + description = "PostHog project managed by this stack." + value = { + id = posthog_project.vrdex.id + name = posthog_project.vrdex.name + timezone = posthog_project.vrdex.timezone + } +} + +output "posthog_project_api_token" { + description = "Client-exposed PostHog project key for the Vercel NEXT_PUBLIC_POSTHOG_KEY variable." + value = posthog_project.vrdex.api_token + sensitive = true +} diff --git a/infra/terraform/posthog/terraform.tfvars.example b/infra/terraform/posthog/terraform.tfvars.example new file mode 100644 index 0000000..6c869ce --- /dev/null +++ b/infra/terraform/posthog/terraform.tfvars.example @@ -0,0 +1,5 @@ +posthog_host = "https://us.posthog.com" +posthog_organization_id = "019e59b2-c822-0000-8123-12efe322af2d" +posthog_project_id = 447783 +posthog_project_name = "VRDex Analytics" +posthog_timezone = "UTC" diff --git a/infra/terraform/posthog/variables.tf b/infra/terraform/posthog/variables.tf new file mode 100644 index 0000000..a1d139c --- /dev/null +++ b/infra/terraform/posthog/variables.tf @@ -0,0 +1,34 @@ +variable "posthog_host" { + description = "PostHog API host for Terraform." + type = string + default = "https://us.posthog.com" + + validation { + condition = can(regex("^https://", var.posthog_host)) + error_message = "posthog_host must be an https URL." + } +} + +variable "posthog_organization_id" { + description = "PostHog organization ID for BASIC BIT LLC." + type = string + default = "019e59b2-c822-0000-8123-12efe322af2d" +} + +variable "posthog_project_id" { + description = "Existing VRDex Analytics PostHog project ID." + type = number + default = 447783 +} + +variable "posthog_project_name" { + description = "PostHog project name managed by this stack." + type = string + default = "VRDex Analytics" +} + +variable "posthog_timezone" { + description = "Timezone for PostHog reporting." + type = string + default = "UTC" +} diff --git a/infra/terraform/posthog/versions.tf b/infra/terraform/posthog/versions.tf new file mode 100644 index 0000000..82b3c14 --- /dev/null +++ b/infra/terraform/posthog/versions.tf @@ -0,0 +1,24 @@ +terraform { + required_version = ">= 1.10.0" + + backend "s3" { + bucket = "vrdex-terraform-state" + key = "posthog/terraform.tfstate" + region = "us-east-1" + encrypt = true + use_lockfile = true + } + + required_providers { + posthog = { + source = "PostHog/posthog" + version = ">= 1.0.0, < 2.0.0" + } + } +} + +provider "posthog" { + host = var.posthog_host + organization_id = var.posthog_organization_id + project_id = tostring(var.posthog_project_id) +} diff --git a/infra/terraform/vercel/.terraform.lock.hcl b/infra/terraform/vercel/.terraform.lock.hcl new file mode 100644 index 0000000..890218d --- /dev/null +++ b/infra/terraform/vercel/.terraform.lock.hcl @@ -0,0 +1,24 @@ +# This file is maintained automatically by "terraform init". +# Manual edits may be lost in future updates. + +provider "registry.terraform.io/vercel/vercel" { + version = "4.8.2" + constraints = ">= 4.8.0, < 5.0.0" + hashes = [ + "h1:4AfiEduR1+vTSGNFJigogsLtNtxGVGJz1yBfm+lPZEs=", + "zh:02c745e21fd3dbd89fa6762a41339d40f499a4f49e413a01baf34315db95c26e", + "zh:0cd7daf928ffb6447ad9a0c92156b8fcdbb1bbd15c0dcf497ba5b27c64fb1806", + "zh:125d0a32363598faf2effe2e4c81603e582615b11ccf4be0e07d1fcb7be79b05", + "zh:190e44b3211aa286817e3d04301b18ead38378fa8a6ffa3a63785df24f5ea497", + "zh:37028b444ff3c08c34b2e2a1236678477be5a329983234d1c02b36333036198c", + "zh:3b222ce2c88e8f08b98966c210c488b763500bb4384df06016549bae19384141", + "zh:47bf7c0c521914bf831cdd1029558747937572d5d6523aa310d9a0072f5e37e2", + "zh:72df616ee40b5375bd50d7f14c0dbf0f878d19ed7ffe3ace3deca6c04f60f3fc", + "zh:846e374c91ebcbf55c0fe7d08465f1792e2d1839c42a428fdae1b11eb896f94d", + "zh:a05eb8730d248ab84581371e8f806b9dbe9691c63126c77a5f9b740df9792910", + "zh:ad733e43dd5f1605f6a4d850db8172895fe0860fe42b17cd3ff46ee8677a8cbe", + "zh:f184acbd8bf1c42b034f90593958efccd5341c21ba46139c008facb813096a8d", + "zh:f26e0763dbe6a6b2195c94b44696f2110f7f55433dc142839be16b9697fa5597", + "zh:f8287567ecf184695b2bab1974d31e1b20d78215c9408611aea6504910f719f9", + ] +} diff --git a/infra/terraform/vercel/README.md b/infra/terraform/vercel/README.md new file mode 100644 index 0000000..333de61 --- /dev/null +++ b/infra/terraform/vercel/README.md @@ -0,0 +1,46 @@ +# Vercel Web Terraform + +This stack manages Vercel project environment variables for the hosted VRDex web app. + +It currently creates PostHog analytics variables for the existing Vercel project: + +- project: `vr-dex-web` +- team: `team_GoHh5xUc96fAIAqJoG55A71S` +- PostHog project: `447783` (`VRDex Analytics`) +- PostHog ingestion host: `https://us.i.posthog.com` + +## Managed Environment Variables + +- `NEXT_PUBLIC_POSTHOG_KEY` +- `NEXT_PUBLIC_POSTHOG_HOST` + +The PostHog project key is client-exposed once deployed, but keep the value out of git so forks and self-hosted installs do not accidentally send analytics into the BASIC BIT project. + +## Usage + +1. Copy `terraform.tfvars.example` to `terraform.tfvars`. +2. Set `posthog_public_key` from the PostHog project settings for project `447783` or from the sensitive `infra/terraform/posthog` output `posthog_project_api_token`. +3. If managing the Vercel `staging` custom environment, add its custom environment ID to `staging_custom_environment_ids`. +4. Export a Vercel token for Terraform: `VERCEL_API_TOKEN=`. If reusing the GitHub secret value locally, set `VERCEL_API_TOKEN` to the same value as `VERCEL_TOKEN`. +5. Run `terraform init`. +6. Run `terraform plan` and review Vercel environment variable changes. +7. Apply only after confirming the target project and environment scopes. + +## State Backend + +Terraform state for this stack is stored in the S3 backend declared in `versions.tf`: + +- bucket: `vrdex-terraform-state` +- key: `vercel/terraform.tfstate` +- region: `us-east-1` +- locking: S3 native lockfile (`use_lockfile = true`) + +## Existing Variables + +If any managed variable already exists in Vercel, import it before applying rather than creating a duplicate. The Vercel provider import ID is: + +```text +// +``` + +Find the Vercel environment variable ID in the dashboard network tab or through the Vercel API. diff --git a/infra/terraform/vercel/main.tf b/infra/terraform/vercel/main.tf new file mode 100644 index 0000000..1590f3c --- /dev/null +++ b/infra/terraform/vercel/main.tf @@ -0,0 +1,54 @@ +data "vercel_project" "web" { + name = var.vercel_project_name + team_id = var.vercel_team_id +} + +locals { + posthog_comment = "VRDex PostHog project ${var.posthog_project_id} (${var.posthog_project_name})." + + standard_posthog_targets = merge( + var.manage_production_environment ? { production = ["production"] } : {}, + var.manage_preview_environment ? { preview = ["preview"] } : {}, + ) + + posthog_values = { + NEXT_PUBLIC_POSTHOG_KEY = var.posthog_public_key + NEXT_PUBLIC_POSTHOG_HOST = var.posthog_host + } +} + +resource "vercel_project_environment_variable" "posthog_standard" { + for_each = { + for pair in setproduct(keys(local.posthog_values), keys(local.standard_posthog_targets)) : "${pair[0]}_${pair[1]}" => { + key = pair[0] + target = local.standard_posthog_targets[pair[1]] + value = local.posthog_values[pair[0]] + } + } + + project_id = data.vercel_project.web.id + team_id = var.vercel_team_id + key = each.value.key + value = each.value.value + target = each.value.target + sensitive = true + comment = local.posthog_comment +} + +resource "vercel_project_environment_variable" "posthog_staging_custom" { + for_each = { + for pair in setproduct(keys(local.posthog_values), var.staging_custom_environment_ids) : "${pair[0]}_${pair[1]}" => { + key = pair[0] + custom_environment_id = pair[1] + value = local.posthog_values[pair[0]] + } + } + + project_id = data.vercel_project.web.id + team_id = var.vercel_team_id + key = each.value.key + value = each.value.value + custom_environment_ids = [each.value.custom_environment_id] + sensitive = true + comment = local.posthog_comment +} diff --git a/infra/terraform/vercel/outputs.tf b/infra/terraform/vercel/outputs.tf new file mode 100644 index 0000000..a83244f --- /dev/null +++ b/infra/terraform/vercel/outputs.tf @@ -0,0 +1,18 @@ +output "vercel_project_id" { + description = "Existing Vercel project ID resolved by name." + value = data.vercel_project.web.id +} + +output "posthog_project" { + description = "PostHog project configured by this stack." + value = { + id = var.posthog_project_id + name = var.posthog_project_name + host = var.posthog_host + } +} + +output "managed_posthog_environment_keys" { + description = "PostHog environment variable names managed by this stack." + value = keys(local.posthog_values) +} diff --git a/infra/terraform/vercel/terraform.tfvars.example b/infra/terraform/vercel/terraform.tfvars.example new file mode 100644 index 0000000..87fbe77 --- /dev/null +++ b/infra/terraform/vercel/terraform.tfvars.example @@ -0,0 +1,15 @@ +vercel_team_id = "team_GoHh5xUc96fAIAqJoG55A71S" +vercel_project_name = "vr-dex-web" + +posthog_project_id = 447783 +posthog_project_name = "VRDex Analytics" +posthog_host = "https://us.i.posthog.com" + +# Set this in local terraform.tfvars from the PostHog project settings. +posthog_public_key = "phc_REPLACE_WITH_VRDEX_ANALYTICS_PROJECT_KEY" + +manage_production_environment = true +manage_preview_environment = true + +# Add the Vercel custom environment ID for staging after discovering it from Vercel. +# staging_custom_environment_ids = ["env_REPLACE_WITH_STAGING_CUSTOM_ENVIRONMENT_ID"] diff --git a/infra/terraform/vercel/variables.tf b/infra/terraform/vercel/variables.tf new file mode 100644 index 0000000..c7e8707 --- /dev/null +++ b/infra/terraform/vercel/variables.tf @@ -0,0 +1,63 @@ +variable "vercel_team_id" { + description = "Vercel team ID that owns the VRDex web project." + type = string + default = "team_GoHh5xUc96fAIAqJoG55A71S" +} + +variable "vercel_project_name" { + description = "Existing Vercel project name for apps/web." + type = string + default = "vr-dex-web" +} + +variable "manage_production_environment" { + description = "Whether to manage production PostHog env vars on the Vercel project." + type = bool + default = true +} + +variable "manage_preview_environment" { + description = "Whether to manage default preview PostHog env vars on the Vercel project." + type = bool + default = true +} + +variable "staging_custom_environment_ids" { + description = "Vercel custom environment IDs for staging-like environments that should receive PostHog env vars. Empty leaves custom environments unmanaged." + type = set(string) + default = [] +} + +variable "posthog_project_id" { + description = "PostHog project ID for operator reference." + type = number + default = 447783 +} + +variable "posthog_project_name" { + description = "PostHog project name for operator reference." + type = string + default = "VRDex Analytics" +} + +variable "posthog_public_key" { + description = "PostHog project API key for NEXT_PUBLIC_POSTHOG_KEY. Client-exposed after deployment; keep out of git to avoid self-hosted installs sending events to BASIC BIT." + type = string + sensitive = true + + validation { + condition = startswith(var.posthog_public_key, "phc_") + error_message = "posthog_public_key should be the PostHog project API key and normally starts with phc_." + } +} + +variable "posthog_host" { + description = "PostHog ingestion host for NEXT_PUBLIC_POSTHOG_HOST." + type = string + default = "https://us.i.posthog.com" + + validation { + condition = can(regex("^https://", var.posthog_host)) + error_message = "posthog_host must be an https URL." + } +} diff --git a/infra/terraform/vercel/versions.tf b/infra/terraform/vercel/versions.tf new file mode 100644 index 0000000..41dea26 --- /dev/null +++ b/infra/terraform/vercel/versions.tf @@ -0,0 +1,22 @@ +terraform { + required_version = ">= 1.10.0" + + backend "s3" { + bucket = "vrdex-terraform-state" + key = "vercel/terraform.tfstate" + region = "us-east-1" + encrypt = true + use_lockfile = true + } + + required_providers { + vercel = { + source = "vercel/vercel" + version = ">= 4.8.0, < 5.0.0" + } + } +} + +provider "vercel" { + team = var.vercel_team_id +} From 1dd12fd24013059783b20decd5dc5b08abd9baec Mon Sep 17 00:00:00 2001 From: BASICBIT Date: Sun, 31 May 2026 01:54:40 -0400 Subject: [PATCH 4/4] Address E2E review feedback --- apps/web/e2e/auth-claim.flow.spec.ts | 1 + convex/e2e.ts | 20 ++++++++++++-------- 2 files changed, 13 insertions(+), 8 deletions(-) diff --git a/apps/web/e2e/auth-claim.flow.spec.ts b/apps/web/e2e/auth-claim.flow.spec.ts index a4ffce5..efcaa0f 100644 --- a/apps/web/e2e/auth-claim.flow.spec.ts +++ b/apps/web/e2e/auth-claim.flow.spec.ts @@ -259,6 +259,7 @@ test("verified email account can complete community and VRChat adapter claims @f await page.getByLabel("Target ID").fill(`e2e-vrclinking-${runSuffix}`); await page.getByRole("button", { name: "Create proof code" }).click(); await expect(page.getByText(/Proof code created/i)).toBeVisible(); + await expect(page.getByText(/VRDEX-/)).toBeVisible(); await page.getByRole("button", { name: "Check proof now" }).click(); await expect(page.getByText(/Proof verified as claimed verified/i)).toBeVisible(); diff --git a/convex/e2e.ts b/convex/e2e.ts index 56d1ea1..326e567 100644 --- a/convex/e2e.ts +++ b/convex/e2e.ts @@ -276,17 +276,23 @@ export const submitProfile = mutation({ displayName: "Playwright E2E", }, }; + const headline = optionalText(args.headline, 160); + const bio = optionalText(args.bio, 600); + const about = optionalText(args.about, 1_200); + const region = optionalText(args.region, 80); + const timezone = optionalText(args.timezone, 80); + const pronouns = optionalText(args.person?.pronouns, 80); const sharedFields = { slug, displayName: input.displayName, sortName: input.sortName, aliases: input.aliases, tags: input.tags, - ...(optionalText(args.headline, 160) !== undefined ? { headline: optionalText(args.headline, 160) } : {}), - ...(optionalText(args.bio, 600) !== undefined ? { bio: optionalText(args.bio, 600) } : {}), - ...(optionalText(args.about, 1_200) !== undefined ? { about: optionalText(args.about, 1_200) } : {}), - ...(optionalText(args.region, 80) !== undefined ? { region: optionalText(args.region, 80) } : {}), - ...(optionalText(args.timezone, 80) !== undefined ? { timezone: optionalText(args.timezone, 80) } : {}), + ...(headline !== undefined ? { headline } : {}), + ...(bio !== undefined ? { bio } : {}), + ...(about !== undefined ? { about } : {}), + ...(region !== undefined ? { region } : {}), + ...(timezone !== undefined ? { timezone } : {}), ...(args.fieldVisibility !== undefined ? { fieldVisibility: args.fieldVisibility } : {}), outboundLinks: [], claimState: "unclaimed" as const, @@ -306,9 +312,7 @@ export const submitProfile = mutation({ ...sharedFields, profileType: "person", person: { - ...(optionalText(args.person?.pronouns, 80) !== undefined - ? { pronouns: optionalText(args.person?.pronouns, 80) } - : {}), + ...(pronouns !== undefined ? { pronouns } : {}), roleTags: input.person.roleTags, }, }