Skip to content

feat: invite-code mode + bridge contract alignment #240

Merged
SeanROlszewski merged 3 commits into
mainfrom
app-9428-core
May 6, 2026
Merged

feat: invite-code mode + bridge contract alignment #240
SeanROlszewski merged 3 commits into
mainfrom
app-9428-core

Conversation

@SeanROlszewski
Copy link
Copy Markdown
Contributor

@SeanROlszewski SeanROlszewski commented May 4, 2026

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 /request and GET /request/:id endpoints with an HKDF-derived request_id. No new bridge endpoints. No /code/redeem, index, request_code_enabled, session_nonce, or bridge-returned code_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. The world.org/verify server inspects those params and renders an invite-code-aware landing page; clients that ignore c/a see the original URL/QR shape, so the change is backwards-compatible.

Stacked: #239 (examples) sits on top.

Invite-code derivation

canonical_code = generate_invite_code()                       // 6 chars Crockford B32
request_id     = hex(HKDF-SHA256(code, info="dx", L=32))      // 64 hex chars
K              = HKDF-SHA256(code, info="key", L=32)          // AES-256-GCM key
ciphertext     = AES-256-GCM(K, fresh_iv, proof_request)
POST /request  { iv, payload: ciphertext, request_id }        // 409 → retry once
connectorURI   = base ?t=wld&i=<request_id>&k=<K>&c=<canonical_code>&a=<app_id>

World App, given the typed code, derives the same request_id and reads via GET /request/:request_id (which is GETDEL-backed). K never reaches the bridge. code_expires_at is synthesized client-side as now + 900s (matching bridge's EXPIRE_AFTER_SECONDS).

API additions

The new entry point mirrors the existing URL/QR IDKit.request(...) builder, just with code-mode wiring. You get back an IDKitInviteCodeRequest whose connectorURI and expiresAt accessors replace the URL/QR wrapper's connectorURI; everything else (pollStatusOnce, pollUntilCompletion, requestId, error handling) is identical.

Per language:

  • TypeScript: IDKit.requestWithInviteCode(config).preset(...) (or .constraints(...)) → IDKitInviteCodeRequest.
  • Rust / WASM: presetWithInviteCode / constraintsWithInviteCode on the WASM builder → IDKitInviteCodeRequest.
  • Swift / FFI: chain IDKit.request(config).presetWithInviteCode(...) (or .constraintsWithInviteCode(...)) on the existing builder → IDKitInviteCodeRequest with connectorURL: URL.
  • React: drop in <IDKitInviteCodeRequestWidget /> (mirrors <IDKitRequestWidget />), or use useIDKitInviteCodeRequest / useIDKitInviteCodeFlow for 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:

const req = await IDKit.requestWithInviteCode(config).constraints(...);
displayLink(req.connectorURI);

Source-compatibility

The Uuid → String migration from the bridge contract is contained:

  • Swift IDKitRequest.requestID: still UUID. No source breakage for URL/QR iOS adopters; the wrapper parses the FFI String back into a UUID and throws IDKitClientError.invalidRequestID on failure (which can't happen — bridge always mints UUID v4 there).
  • Swift IDKitInviteCodeRequest.requestID: String. New wrapper, no prior contract.
  • TS requestId: string. Already a string.
  • Rust BridgeConnection::request_id() -> &str. The one structural break; URL/QR Rust adopters who held the result as Uuid need Uuid::parse_str(req.request_id()) at the call site.

WASM-specific fix

std::time::SystemTime::now() traps to unreachable on wasm32-unknown-unknown. current_unix_seconds() is cfg-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 warnings clean.
  • cargo fmt --check + pnpm prettier --check clean.
  • pnpm build:wasm clean (production wasm-pack build).
  • Core JS: 54 pass. React: 26 pass.
  • swift build broken on main (feat(core): add world id availability errors #246 introduced AppError variants without regenerating UniFFI bindings). Not introduced by this PR.
  • End-to-end browser → World App iOS once wallet-bridge#89 is deployed.

Dependencies

  • wallet-bridge Allow RP to supply request_id on POST /request wallet-bridge#89 — required at runtime. Without it the bridge silently ignores the request_id field and generates a UUID, producing a downstream "no proof arrives" failure.
  • world-app-ios APP-9424 — needs to migrate off POST /code/redeem onto GET /request/:request_id with request_id = lowercase_hex(HKDF(C, "dx")). Tracked separately.

🤖 Generated with Claude Code

@vercel
Copy link
Copy Markdown

vercel Bot commented May 4, 2026

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

@SeanROlszewski SeanROlszewski marked this pull request as draft May 4, 2026 04:28
@SeanROlszewski SeanROlszewski marked this pull request as ready for review May 4, 2026 17:51
@SeanROlszewski SeanROlszewski force-pushed the app-9428-core branch 2 times, most recently from e3fd24c to f9b5e19 Compare May 4, 2026 23:20
@SeanROlszewski SeanROlszewski changed the title feat: invite-code mode core API (WDP-73 / APP-9428) feat: invite-code mode + bridge contract alignment (WDP-73 / APP-9428) May 4, 2026
@vercel
Copy link
Copy Markdown

vercel Bot commented May 5, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
idkit-js-example Ready Ready Preview, Comment May 6, 2026 1:22am

Request Review

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>
@SeanROlszewski SeanROlszewski changed the title feat: invite-code mode + bridge contract alignment (WDP-73 / APP-9428) feat: invite-code mode + bridge contract alignment May 5, 2026
@Takaros999
Copy link
Copy Markdown
Contributor

@codex review

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

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

💡 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".

Comment on lines +181 to +183
<ErrorState
errorCode={effectiveErrorCode}
onRetry={() => {
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 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 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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.

Comment thread rust/core/src/wasm_bindings.rs Outdated
Comment on lines +1254 to +1258
let status = request
.poll_for_status()
.await
.map_err(|e| JsValue::from_str(&format!("Poll failed: {e}")))?;

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 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 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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.

Copy link
Copy Markdown
Contributor

@Takaros999 Takaros999 left a comment

Choose a reason for hiding this comment

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

part 1

  • can you please update the nextjs example 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)

Comment thread js/packages/core/src/request.ts Outdated
Comment on lines +656 to +659
const wasmRequest = (await (
wasmBuilder as unknown as {
constraintsWithInviteCode: (c: ConstraintNode) => Promise<unknown>;
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

casting wasBuilder to have a method can introduce footguns and runtime errors, i would look deeper here to avoid having to do this

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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.

Comment thread js/packages/core/src/request.ts Outdated
Comment on lines +156 to +157
export interface IDKitInviteCodeRequest {
/** Canonical 6-char Crockford Base32 code (no separator). UI may format as "ABC-DEF" for display. */
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

i like this direction of having a new interface 👍

@Takaros999
Copy link
Copy Markdown
Contributor

Takaros999 commented May 5, 2026

Rust core follow-up from review:

  1. WASM poll state restoration

There is already an inline Codex thread on rust/core/src/wasm_bindings.rs for the invite-code wrapper losing inner when poll_for_status() errors. The same take-await-restore pattern exists in the normal IDKitRequest::pollForStatus wrapper too, so the fix should update both wrappers or extract a shared restore-after-await helper.

  1. Validate echoed invite request_id

rust/core/src/bridge.rs invite-code mode sends a deterministic request_id, but the response body is parsed and discarded:

// 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 request_id; otherwise a bridge/proxy contract bug can silently create an unretrievable request.

Comment thread rust/core/src/crypto.rs Outdated
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");
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

we shouldn't panic here, this will be very hard to debug, we can just throw an error

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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.

Comment thread js/packages/core/src/request.ts Outdated
Comment on lines +656 to +659
const wasmRequest = (await (
wasmBuilder as unknown as {
constraintsWithInviteCode: (c: ConstraintNode) => Promise<unknown>;
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

if your IDE complaint locally try running pnpm build we dont need this

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Yep — the cast went away once I rebuilt WASM. Done in the same commit as the broader cast removal. b1758de.

@SeanROlszewski
Copy link
Copy Markdown
Contributor Author

part 1

  • can you please update the nextjs example 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)

This is already on another PR. 😄 I broke them apart to reduce review burden.

@SeanROlszewski
Copy link
Copy Markdown
Contributor Author

Both follow-ups addressed in b1758de:

  1. WASM poll state restoration — extracted shared poll_taking_inner helper in wasm_bindings.rs; both IDKitRequest::poll_for_status and IDKitInviteCodeRequest::poll_for_status now go through it. Pattern: take → await → unconditionally restore → propagate error. A transient transport blip leaves inner populated so the next poll retries instead of latching the request closed permanently. Also pulled status_to_js_value out so the serialization match isn't duplicated.

  2. Validate echoed request_idtry_create_invite_code_request no longer discards the response. It deserializes the { request_id } body and asserts it equals the locally-derived hex(HKDF(C, "dx")); mismatch surfaces Error::BridgeError("Bridge echoed mismatched request_id (sent X, got Y)") at creation time rather than letting World App fail to find the request later in the poll loop. Catches a misbehaving bridge or proxy that would otherwise create a silently-unretrievable request.

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>
@SeanROlszewski SeanROlszewski merged commit 8536143 into main May 6, 2026
18 checks passed
@SeanROlszewski SeanROlszewski deleted the app-9428-core branch May 6, 2026 18:46
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants