feat: invite-code mode + bridge contract alignment #240
Conversation
|
You must have Developer access to commit code to Worldcoin on Vercel. If you contact an administrator and receive Developer access, commit again to see your changes. Learn more: https://vercel.com/docs/accounts/team-members-and-roles/access-roles#team-level-roles |
85d60db to
fba0b3b
Compare
e3fd24c to
f9b5e19
Compare
f9b5e19 to
5bcb868
Compare
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
5bcb868 to
c2846db
Compare
Adds a code-based discovery channel to the existing Bridge proof flow. The RP shows the user a 6-character code; the user types it into World App on their phone and completes the existing Selfie Check / proof flow. Same proof flow, same poll loop, same Status enum — only the discovery channel changes. Wire shape (matches wallet-bridge APP-9425 and world-app-ios APP-9424): - Code is canonical 6-char Crockford Base32 (5 random data chars + 1 mod-32 weighted check digit, weights 1/3/5/7/9 — all coprime to 32, so 100% of single-char substitutions are caught). UI may format as "ABC-DEF" but the canonical form has no separator. - HKDF-SHA256, no salt, 32-byte output, IKM = canonical code's UTF-8 bytes. info="dx" → lookup index (lowercase hex on the wire); info="key" → AES-256-GCM key. The encryption key never reaches the bridge — only the index and ciphertext do. - POST /request body has the new `request_code_enabled: true, iv, payload, index` shape with `iv`/`payload` as standard base64 (same as the URL/QR path). - Response carries `session_nonce` and `code_expires_at`. Polling attaches `Authorization: Bearer <session_nonce>` so we're forward- compatible with the bridge's session_nonce gate (release-blocking follow-up on Bridge). URL-mode connections keep `session_nonce: None` and the header is omitted, so existing bridge behavior is unchanged. Cross-device implications: - Original WDP-73 mermaid minted a `delivery_token` ferried back via universal link. Universal links route to the device that opened them, so a desktop browser ↔ phone flow never receives the token. We drop delivery_token from idkit's surface entirely. Anti-collusion in the code path now degrades to "10-min TTL + one-shot redeem + per-IP rate limit." API surface (mirrors existing URL-mode): - Rust: `BridgeConnection::create_for_invite_code` (retry-once-on-409), `.invite_code()` / `.code_expires_at()` accessors. New crypto.rs primitives: `generate_invite_code`, `parse_invite_code`, `hkdf_invite_index_hex`, `hkdf_invite_key`, `generate_nonce`. - FFI: `IDKitInviteCodeRequest` sibling to `IDKitRequestWrapper`. New builder methods `constraints_with_invite_code` / `preset_with_invite_code` on `IDKitBuilder`. - WASM: `IDKitInviteCodeRequest` sibling to `IDKitRequest`, `constraintsWithInviteCode` / `presetWithInviteCode` on the WASM builder. - TypeScript: `IDKitInviteCodeRequest` interface + impl, `IDKitInviteCodeBuilder`, `IDKit.requestWithInviteCode(...)` entry point. Code mode is bridge-only by definition (user is on a different device than World App), so the builder skips the `isInWorldApp()` branch. - React: `IDKitInviteCodeRequestWidget`, `useIDKitInviteCodeRequest`, `useIDKitInviteCodeFlow` hooks. New `InviteCodeState` UI component. - Swift: `presetWithInviteCode(_:)` / `constraintsWithInviteCode(_:)` on the builder; `IDKitInviteCodeRequest` wrapper exposing `code` and `expiresAt: Date`. Adopter diff is two lines per integration: // before const req = await IDKit.request(config).constraints(...); showQR(req.connectorURI); // after const req = await IDKit.requestWithInviteCode(config).constraints(...); showCode(req.code); Tests: - 9 new Rust unit tests covering code generation, parser (round-trip, separator stripping, lowercase normalization, Crockford ambiguity collapse, U-rejection, length validation, check-digit validation, exhaustive single-char substitution detection), HKDF determinism, hex output shape, index/key differentiation across info strings. - 86 existing rust tests pass; 54 core JS tests pass; 23 React tests pass. Clippy clean on native + ffi feature combos. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
c2846db to
d63af4a
Compare
|
@codex review |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 8f9c513a72
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| <ErrorState | ||
| errorCode={effectiveErrorCode} | ||
| onRetry={() => { |
There was a problem hiding this comment.
Pass close callback into invite-code error state
ErrorState requires an onClose prop, but the invite-code widget only passes errorCode and onRetry. In error variants that render a "Close" action (for example configuration-related failures), clicking the button will invoke an undefined handler and can throw at runtime instead of dismissing the modal, breaking recovery for users on that path.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Already fixed in the current head — <ErrorState> in IDKitInviteCodeWidgetBase.tsx:184 now passes onClose={() => onOpenChange(false)} matching the URL/QR widget. Resolution landed before the rebase that surfaced this comment; should be visible in the diff at b1758de.
| let status = request | ||
| .poll_for_status() | ||
| .await | ||
| .map_err(|e| JsValue::from_str(&format!("Poll failed: {e}")))?; | ||
|
|
There was a problem hiding this comment.
Restore invite-code request after poll failures
This method takes the request out of inner and only reinserts it after a successful poll. If poll_for_status() returns an error (for example a transient network failure), the ? on map_err exits early before reinsertion, leaving inner as None; every later poll then fails with "Request closed", turning a retryable transport blip into a permanent request failure.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Fixed. Extracted the take-await-restore into a shared poll_taking_inner helper (wasm_bindings.rs) so both the URL/QR and invite-code wrappers always reinsert the request before propagating an error. A transient transport blip now leaves inner populated so the next poll can retry, instead of latching the request closed permanently. Also pulled status_to_js_value out of the duplicated match so both wrappers share the serialization. b1758de.
Takaros999
left a comment
There was a problem hiding this comment.
part 1
- can you please update the
nextjsexample to use the invite code flow, so we can try it out. (you could have an optional checkbox that triggers a invite code flow request)
| const wasmRequest = (await ( | ||
| wasmBuilder as unknown as { | ||
| constraintsWithInviteCode: (c: ConstraintNode) => Promise<unknown>; | ||
| } |
There was a problem hiding this comment.
casting wasBuilder to have a method can introduce footguns and runtime errors, i would look deeper here to avoid having to do this
There was a problem hiding this comment.
Removed both wasmBuilder as unknown as { ... } casts in the invite-code builder — the rebuilt WASM .d.ts exposes constraintsWithInviteCode / presetWithInviteCode directly on WasmModule.IDKitBuilder (idkit_wasm.d.ts:416,470), so the cast was indeed leftover scaffolding from a stale build. Mirrors the URL/QR-mode shape now: (await wasmBuilder.constraintsWithInviteCode(constraints)) as unknown as WasmModule.IDKitInviteCodeRequest. b1758de.
| export interface IDKitInviteCodeRequest { | ||
| /** Canonical 6-char Crockford Base32 code (no separator). UI may format as "ABC-DEF" for display. */ |
There was a problem hiding this comment.
i like this direction of having a new interface 👍
|
Rust core follow-up from review:
There is already an inline Codex thread on
// Discard the echoed request_id — we already have ours.
let _: BridgeCreateResponse = response
.json()
.await
.map_err(|e| Error::BridgeError(format!("Failed to parse bridge response: {e}")))?;Since the rest of the flow polls with the locally derived id and World App derives the same id from the code, we should assert that the bridge echoed the same |
| pub fn generate_invite_code() -> String { | ||
| let mut rng_bytes = [0u8; DATA_LEN]; | ||
| getrandom::getrandom(&mut rng_bytes) | ||
| .expect("getrandom must succeed for invite-code generation"); |
There was a problem hiding this comment.
we shouldn't panic here, this will be very hard to debug, we can just throw an error
There was a problem hiding this comment.
Fixed. generate_invite_code now returns crate::Result<String> and propagates getrandom failures via crate::Error::Crypto(...), mirroring generate_nonce's shape. Caller in try_create_invite_code_request propagates with ? (lifts via the existing From<Error> for CreateCodeError). Tests use .unwrap() since entropy is reliable in test context. b1758de.
| const wasmRequest = (await ( | ||
| wasmBuilder as unknown as { | ||
| constraintsWithInviteCode: (c: ConstraintNode) => Promise<unknown>; | ||
| } |
There was a problem hiding this comment.
if your IDE complaint locally try running pnpm build we dont need this
There was a problem hiding this comment.
Yep — the cast went away once I rebuilt WASM. Done in the same commit as the broader cast removal. b1758de.
This is already on another PR. 😄 I broke them apart to reduce review burden. |
e4726f5 to
b1758de
Compare
|
Both follow-ups addressed in b1758de:
|
Adds a code-based discovery channel to the existing Bridge proof flow. The RP shows the user a 6-character code; the user types it into World App on their phone and completes the existing Selfie Check / proof flow. Same proof flow, same poll loop, same Status enum — only the discovery channel changes. Wire shape (matches wallet-bridge APP-9425 and world-app-ios APP-9424): - Code is canonical 6-char Crockford Base32 (5 random data chars + 1 mod-32 weighted check digit, weights 1/3/5/7/9 — all coprime to 32, so 100% of single-char substitutions are caught). UI may format as "ABC-DEF" but the canonical form has no separator. - HKDF-SHA256, no salt, 32-byte output, IKM = canonical code's UTF-8 bytes. info="dx" → lookup index (lowercase hex on the wire); info="key" → AES-256-GCM key. The encryption key never reaches the bridge — only the index and ciphertext do. - POST /request body has the new `request_code_enabled: true, iv, payload, index` shape with `iv`/`payload` as standard base64 (same as the URL/QR path). - Response carries `session_nonce` and `code_expires_at`. Polling attaches `Authorization: Bearer <session_nonce>` so we're forward- compatible with the bridge's session_nonce gate (release-blocking follow-up on Bridge). URL-mode connections keep `session_nonce: None` and the header is omitted, so existing bridge behavior is unchanged. Cross-device implications: - Original WDP-73 mermaid minted a `delivery_token` ferried back via universal link. Universal links route to the device that opened them, so a desktop browser ↔ phone flow never receives the token. We drop delivery_token from idkit's surface entirely. Anti-collusion in the code path now degrades to "10-min TTL + one-shot redeem + per-IP rate limit." API surface (mirrors existing URL-mode): - Rust: `BridgeConnection::create_for_invite_code` (retry-once-on-409), `.invite_code()` / `.code_expires_at()` accessors. New crypto.rs primitives: `generate_invite_code`, `parse_invite_code`, `hkdf_invite_index_hex`, `hkdf_invite_key`, `generate_nonce`. - FFI: `IDKitInviteCodeRequest` sibling to `IDKitRequestWrapper`. New builder methods `constraints_with_invite_code` / `preset_with_invite_code` on `IDKitBuilder`. - WASM: `IDKitInviteCodeRequest` sibling to `IDKitRequest`, `constraintsWithInviteCode` / `presetWithInviteCode` on the WASM builder. - TypeScript: `IDKitInviteCodeRequest` interface + impl, `IDKitInviteCodeBuilder`, `IDKit.requestWithInviteCode(...)` entry point. Code mode is bridge-only by definition (user is on a different device than World App), so the builder skips the `isInWorldApp()` branch. - React: `IDKitInviteCodeRequestWidget`, `useIDKitInviteCodeRequest`, `useIDKitInviteCodeFlow` hooks. New `InviteCodeState` UI component. - Swift: `presetWithInviteCode(_:)` / `constraintsWithInviteCode(_:)` on the builder; `IDKitInviteCodeRequest` wrapper exposing `code` and `expiresAt: Date`. Adopter diff is two lines per integration: // before const req = await IDKit.request(config).constraints(...); showQR(req.connectorURI); // after const req = await IDKit.requestWithInviteCode(config).constraints(...); showCode(req.code); Tests: - 9 new Rust unit tests covering code generation, parser (round-trip, separator stripping, lowercase normalization, Crockford ambiguity collapse, U-rejection, length validation, check-digit validation, exhaustive single-char substitution detection), HKDF determinism, hex output shape, index/key differentiation across info strings. - 86 existing rust tests pass; 54 core JS tests pass; 23 React tests pass. Clippy clean on native + ffi feature combos. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
b1758de to
4615adb
Compare
Summary
Adds invite-code mode (the user types a 6-char code into World App on a different device instead of scanning a QR) and aligns idkit with the simplified bridge contract from worldcoin/wallet-bridge#89. The bridge becomes a generic content-addressable single-use store; invite-code mode reuses the regular
POST /requestandGET /request/:idendpoints with an HKDF-derivedrequest_id. No new bridge endpoints. No/code/redeem,index,request_code_enabled,session_nonce, or bridge-returnedcode_expires_at.The wrapper exposes a URL (not a raw code) — the same connector URL URL/QR mode produces, with
&c=<canonical_code>&a=<app_id>appended. Theworld.org/verifyserver inspects those params and renders an invite-code-aware landing page; clients that ignorec/asee the original URL/QR shape, so the change is backwards-compatible.Stacked: #239 (examples) sits on top.
Invite-code derivation
World App, given the typed code, derives the same
request_idand reads viaGET /request/:request_id(which isGETDEL-backed). K never reaches the bridge.code_expires_atis synthesized client-side asnow + 900s(matching bridge'sEXPIRE_AFTER_SECONDS).API additions
The new entry point mirrors the existing URL/QR
IDKit.request(...)builder, just with code-mode wiring. You get back anIDKitInviteCodeRequestwhoseconnectorURIandexpiresAtaccessors replace the URL/QR wrapper'sconnectorURI; everything else (pollStatusOnce,pollUntilCompletion,requestId, error handling) is identical.Per language:
IDKit.requestWithInviteCode(config).preset(...)(or.constraints(...)) →IDKitInviteCodeRequest.presetWithInviteCode/constraintsWithInviteCodeon the WASM builder →IDKitInviteCodeRequest.IDKit.request(config).presetWithInviteCode(...)(or.constraintsWithInviteCode(...)) on the existing builder →IDKitInviteCodeRequestwithconnectorURL: URL.<IDKitInviteCodeRequestWidget />(mirrors<IDKitRequestWidget />), or useuseIDKitInviteCodeRequest/useIDKitInviteCodeFlowfor headless integrations. The widget renders both a QR code of the URL and a copy-to-clipboard plaintext display.Adopter diff for invite-code mode is two lines:
Source-compatibility
The
Uuid → Stringmigration from the bridge contract is contained:IDKitRequest.requestID: stillUUID. No source breakage for URL/QR iOS adopters; the wrapper parses the FFI String back into a UUID and throwsIDKitClientError.invalidRequestIDon failure (which can't happen — bridge always mints UUID v4 there).IDKitInviteCodeRequest.requestID:String. New wrapper, no prior contract.requestId: string. Already a string.BridgeConnection::request_id() -> &str. The one structural break; URL/QR Rust adopters who held the result asUuidneedUuid::parse_str(req.request_id())at the call site.WASM-specific fix
std::time::SystemTime::now()traps tounreachableonwasm32-unknown-unknown.current_unix_seconds()iscfg-gated:js_sys::Date::now()on WASM,SystemTime::now()on native.Test plan
cargo test --lib— 98 pass (9 new invite-code unit tests).cargo clippy --all-targets --all-features -- -D warningsclean.cargo fmt --check+pnpm prettier --checkclean.pnpm build:wasmclean (production wasm-pack build).swift buildbroken onmain(feat(core): add world id availability errors #246 introducedAppErrorvariants without regenerating UniFFI bindings). Not introduced by this PR.Dependencies
request_idfield and generates a UUID, producing a downstream "no proof arrives" failure.POST /code/redeemontoGET /request/:request_idwithrequest_id = lowercase_hex(HKDF(C, "dx")). Tracked separately.🤖 Generated with Claude Code