Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion app/api/contracts/worldchain-sepolia/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 } : {})
};
}

Expand Down
65 changes: 57 additions & 8 deletions components/WorldIdGate.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<GateStatus>("idle");
const [error, setError] = useState<string | null>(null);
Expand Down Expand Up @@ -148,8 +148,15 @@ export function WorldIdGate({
return;
}

const rewriteSimulatorLinks = () => {
document.querySelectorAll<HTMLAnchorElement>('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<HTMLAnchorElement>('a[href^="https://simulator.worldcoin.org"]').forEach((link) => {
const identitySelectorUrl = getWorldIdSimulatorIdentitySelectorUrl(link.href);

if (identitySelectorUrl && link.href !== identitySelectorUrl) {
Expand All @@ -158,16 +165,57 @@ export function WorldIdGate({
});
};

rewriteSimulatorLinks();
const observer = new MutationObserver(rewriteSimulatorLinks);
observer.observe(document.body, {
const collectRoots = (root: Document | ShadowRoot, found: Array<Document | ShadowRoot>) => {
found.push(root);
root.querySelectorAll<HTMLElement>("*").forEach((element) => {
if (element.shadowRoot) {
collectRoots(element.shadowRoot, found);
}
});
};

const sweep = () => {
const roots: Array<Document | ShadowRoot> = [];
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<Document | ShadowRoot> = [];
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(
Expand Down Expand Up @@ -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");
Expand All @@ -233,6 +281,7 @@ export function WorldIdGate({
copy.configMissing,
copy.signatureError,
copy.walletRequired,
environment,
isInstalled,
resolveWorldIdError,
walletAddress,
Expand Down
15 changes: 13 additions & 2 deletions lib/world/idkit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
20 changes: 18 additions & 2 deletions lib/world/rp-signature-handler.ts
Original file line number Diff line number Diff line change
@@ -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(
Expand All @@ -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"]);
Comment on lines +36 to +38

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Keep staging RP ID consistent through verification

When TEST passes deployment: "testnet" and these new staging env vars are configured, this route now returns rp_context.rp_id from WORLD_ID_RP_ID_STAGING, but /api/identity/verify-policy-human still builds policyHumanEnvironment() with only WORLD_ID_RP_ID and posts the proof to https://developer.world.org/api/v4/verify/${serverEnvironment.rpId}. That means the simulator proof is issued for the staging RP but verified against the production RP, so the World ID verification step fails for the exact staging-credential setup this change introduces; the verification env needs the same staging RP selection/fallback.

Useful? React with 👍 / 👎.


if (!env) {
return NextResponse.json(
Expand Down