From 9adb2d18cedc40baf40ca6d1a459c90d789a0ef5 Mon Sep 17 00:00:00 2001 From: Joaquin Apostolo Date: Fri, 24 Jul 2026 16:03:27 -0300 Subject: [PATCH] Fix World ID simulator sandbox: identity selector, verifier config, staging credentials Enrollment could not be completed against the World ID simulator. Reproduced locally end to end; three independent defects: 1. The simulator-link rewrite never ran. `rewriteSimulatorLinks` queries only `document`, but the IDKit widget renders its "Testing in staging?" callout inside a shadow root, so the selector matched nothing and the rewrite to `/select-id` silently did nothing. The callout therefore opened the simulator's *default* identity instead of its identity selector, which surfaces as "it returns a different user" and as spurious nullifier-already-used (409) errors, because that default identity has already opened a policy. Fixed by walking open shadow roots as well. 2. Env-configured deployments omitted `policyHumanVerifier`. `RiskaEnrollmentHome` discards a policy-human authorization whose verifier does not match the configured one, and a missing verifier is falsy, so it counts as a mismatch: every verified user is bounced back to the identity step. `readDeploymentEnv` now also reads `RISKA_WORLDCHAIN[_SEPOLIA]_POLICY_HUMAN_VERIFIER`. Deployments configured from the committed JSON are unaffected. 3. World ID apps are registered per environment, and the simulator only accepts requests from a staging app; a production app id yields a production request regardless of the `environment` flag passed to IDKit. Allow a staging app id, RP id and RP signing key selected by deployment (`NEXT_PUBLIC_WORLD_APP_ID_STAGING`, `WORLD_ID_RP_ID_STAGING`, `RP_SIGNING_KEY_STAGING`), each falling back to the existing single value, so current setups are unchanged. `/api/identity/rp-signature` now receives the deployment so it can sign with the matching RP. Verified: with (1) applied, the callout resolves to `simulator.worldcoin.org/select-id?connect_url=...` and the identity selector appears. Server-side the flow reaches `policy human authorization issued` (HTTP 200) with a v3 Orb proof on the testnet deployment. Co-Authored-By: Claude Opus 4.8 (1M context) --- app/api/contracts/worldchain-sepolia/route.ts | 11 +++- components/WorldIdGate.tsx | 65 ++++++++++++++++--- lib/world/idkit.ts | 15 ++++- lib/world/rp-signature-handler.ts | 20 +++++- 4 files changed, 98 insertions(+), 13 deletions(-) diff --git a/app/api/contracts/worldchain-sepolia/route.ts b/app/api/contracts/worldchain-sepolia/route.ts index dc43d00..1efe073 100644 --- a/app/api/contracts/worldchain-sepolia/route.ts +++ b/app/api/contracts/worldchain-sepolia/route.ts @@ -95,13 +95,22 @@ function readDeploymentEnv(environment: "production" | "testnet"): RiskaTestnetD return null; } + // The enrollment flow discards a policy-human authorization whose verifier does + // not match the configured one, and treats a missing verifier as a mismatch. So + // omitting it here silently bounces every verified user back to the identity + // step. Read it alongside the contracts so an env-configured deployment works. + const policyHumanVerifier = normalizeOptionalAddress( + process.env[`${prefix}_POLICY_HUMAN_VERIFIER${suffix}`] + ); + return { environment, chainId: String(isProduction ? WORLDCHAIN_CHAIN_ID : WORLDCHAIN_SEPOLIA_CHAIN_ID), contracts, explorerBaseUrl: isProduction ? WORLDCHAIN_EXPLORER_URL : WORLDCHAIN_SEPOLIA_EXPLORER_URL, network: isProduction ? "worldchain" : "worldchainSepolia", - rpcUrl: isProduction ? WORLDCHAIN_RPC_URL : WORLDCHAIN_SEPOLIA_RPC_URL + rpcUrl: isProduction ? WORLDCHAIN_RPC_URL : WORLDCHAIN_SEPOLIA_RPC_URL, + ...(policyHumanVerifier ? { policyHumanVerifier } : {}) }; } diff --git a/components/WorldIdGate.tsx b/components/WorldIdGate.tsx index 4b5bd04..cf56c36 100644 --- a/components/WorldIdGate.tsx +++ b/components/WorldIdGate.tsx @@ -94,7 +94,7 @@ export function WorldIdGate({ : proofOfHuman({ signal: worldIdSignal }); const { isInstalled } = useMiniKit(); const copy = t.worldIdGate; - const worldAppId = getWorldAppId(); + const worldAppId = getWorldAppId(environment); const [status, setStatus] = useState("idle"); const [error, setError] = useState(null); @@ -148,8 +148,15 @@ export function WorldIdGate({ return; } - const rewriteSimulatorLinks = () => { - document.querySelectorAll('a[href^="https://simulator.worldcoin.org"]').forEach((link) => { + // The IDKit widget renders its simulator callout inside a shadow root, so a + // plain document.querySelectorAll never sees the link. Walk open shadow roots + // too, and observe each one, otherwise this rewrite silently does nothing and + // the callout keeps opening the simulator's default identity instead of its + // identity selector. + const observers: MutationObserver[] = []; + + const rewriteIn = (root: Document | ShadowRoot) => { + root.querySelectorAll('a[href^="https://simulator.worldcoin.org"]').forEach((link) => { const identitySelectorUrl = getWorldIdSimulatorIdentitySelectorUrl(link.href); if (identitySelectorUrl && link.href !== identitySelectorUrl) { @@ -158,16 +165,57 @@ export function WorldIdGate({ }); }; - rewriteSimulatorLinks(); - const observer = new MutationObserver(rewriteSimulatorLinks); - observer.observe(document.body, { + const collectRoots = (root: Document | ShadowRoot, found: Array) => { + found.push(root); + root.querySelectorAll("*").forEach((element) => { + if (element.shadowRoot) { + collectRoots(element.shadowRoot, found); + } + }); + }; + + const sweep = () => { + const roots: Array = []; + collectRoots(document, roots); + roots.forEach(rewriteIn); + }; + + sweep(); + + // Re-sweep on any mutation: the widget mounts its shadow root asynchronously, + // and new roots can appear after the first pass. + const rootObserver = new MutationObserver(sweep); + rootObserver.observe(document.body, { attributeFilter: ["href"], attributes: true, childList: true, subtree: true }); + observers.push(rootObserver); - return () => observer.disconnect(); + const shadowRoots: Array = []; + collectRoots(document, shadowRoots); + shadowRoots.forEach((root) => { + if (root === document) { + return; + } + + const observer = new MutationObserver(sweep); + observer.observe(root as ShadowRoot, { + attributeFilter: ["href"], + attributes: true, + childList: true, + subtree: true + }); + observers.push(observer); + }); + + const interval = window.setInterval(sweep, 400); + + return () => { + observers.forEach((observer) => observer.disconnect()); + window.clearInterval(interval); + }; }, [isOpen, worldIdEnvironment]); const resolveWorldIdError = useCallback( @@ -211,7 +259,7 @@ export function WorldIdGate({ headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ action: RISKA_WORLD_ID_POLICY_ACTION }) + body: JSON.stringify({ action: RISKA_WORLD_ID_POLICY_ACTION, deployment: environment }) }); } catch { setStatus("error"); @@ -233,6 +281,7 @@ export function WorldIdGate({ copy.configMissing, copy.signatureError, copy.walletRequired, + environment, isInstalled, resolveWorldIdError, walletAddress, diff --git a/lib/world/idkit.ts b/lib/world/idkit.ts index 5ceb3b3..7427a2f 100644 --- a/lib/world/idkit.ts +++ b/lib/world/idkit.ts @@ -8,8 +8,19 @@ export function getWorldIdEnvironmentForDeployment(deployment: WorldIdDeployment return deployment === "testnet" ? "staging" : "production"; } -export function getWorldAppId(): `app_${string}` | undefined { - const appId = process.env.NEXT_PUBLIC_WORLD_APP_ID; +// World ID apps are registered per environment in the Developer Portal, and the +// simulator only accepts requests from a staging app. A production app id always +// produces a production request no matter what `environment` the client passes, +// so the staging deployment needs its own app id. Falls back to the single app id +// when no staging one is configured, which keeps existing setups working. +export function getWorldAppId( + deployment: WorldIdDeployment = "production" +): `app_${string}` | undefined { + const stagingAppId = process.env.NEXT_PUBLIC_WORLD_APP_ID_STAGING; + const appId = + getWorldIdEnvironmentForDeployment(deployment) === "staging" && stagingAppId + ? stagingAppId + : process.env.NEXT_PUBLIC_WORLD_APP_ID; if (!appId || !appId.startsWith("app_")) { return undefined; diff --git a/lib/world/rp-signature-handler.ts b/lib/world/rp-signature-handler.ts index 33fbf4f..7951186 100644 --- a/lib/world/rp-signature-handler.ts +++ b/lib/world/rp-signature-handler.ts @@ -1,16 +1,22 @@ import { signRequest } from "@worldcoin/idkit/signing"; import { NextResponse } from "next/server"; -import { RISKA_WORLD_ID_POLICY_ACTION } from "@/lib/world/idkit"; +import { + RISKA_WORLD_ID_POLICY_ACTION, + getWorldIdEnvironmentForDeployment, + type WorldIdDeployment +} from "@/lib/world/idkit"; import { requiredEnvironment } from "@/lib/world/server-env"; type RpSignatureRequest = { action?: string; + deployment?: WorldIdDeployment; }; export async function postRpSignature(request: Request) { const body = (await request.json().catch(() => null)) as RpSignatureRequest | null; const action = body?.action ?? RISKA_WORLD_ID_POLICY_ACTION; + const deployment: WorldIdDeployment = body?.deployment === "testnet" ? "testnet" : "production"; if (action !== RISKA_WORLD_ID_POLICY_ACTION) { return NextResponse.json( @@ -19,7 +25,17 @@ export async function postRpSignature(request: Request) { ); } - const env = requiredEnvironment(["WORLD_ID_RP_ID", "RP_SIGNING_KEY"]); + // The RP is registered per environment in the Developer Portal. Signing a + // staging request with the production RP yields a production request, which the + // World ID simulator rejects outright. Prefer the staging RP when the deployment + // is staging, and fall back to the single RP so existing setups keep working. + const wantsStaging = getWorldIdEnvironmentForDeployment(deployment) === "staging"; + const stagingEnv = wantsStaging + ? requiredEnvironment(["WORLD_ID_RP_ID_STAGING", "RP_SIGNING_KEY_STAGING"]) + : null; + const env = stagingEnv + ? { WORLD_ID_RP_ID: stagingEnv.WORLD_ID_RP_ID_STAGING, RP_SIGNING_KEY: stagingEnv.RP_SIGNING_KEY_STAGING } + : requiredEnvironment(["WORLD_ID_RP_ID", "RP_SIGNING_KEY"]); if (!env) { return NextResponse.json(