Skip to content

feat: invite-code mode for cross-device RP flows (WDP-73 / APP-9428)#239

Open
SeanROlszewski wants to merge 6 commits intomainfrom
app-9428
Open

feat: invite-code mode for cross-device RP flows (WDP-73 / APP-9428)#239
SeanROlszewski wants to merge 6 commits intomainfrom
app-9428

Conversation

@SeanROlszewski
Copy link
Copy Markdown
Contributor

Summary

Adds a code-based discovery channel to the existing Bridge proof flow. Instead of clicking a deeplink or scanning a QR code, an 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 6-char Crockford Base32 (5 random + 1 weighted check digit). HKDF-SHA256, no salt, IKM = canonical code's UTF-8 bytes; info="dx" → lookup index (lowercase hex), info="key" → AES-256-GCM key. The encryption key never reaches Bridge — only the index and ciphertext do. iv and payload are sent as standard base64, matching the URL/QR path.
  • Cross-device-first by product reality (Reddit × World): the original WDP-73 mermaid's delivery_token mechanism is fundamentally same-device (universal links route to the device that opened them), so we drop it from idkit's surface entirely. Anti-collusion in the code path now degrades to "10-min TTL + one-shot redeem + per-IP rate limit" — see the spec for the full discussion.
  • session_nonce Bearer auth sent on GET /response/:id for forward compatibility with wallet-bridge's session-nonce gate (release-blocking follow-up on Bridge). URL/QR connections keep session_nonce: None and the header is omitted, so existing bridge behavior is unchanged.
  • 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);

API surface added (mirrors existing URL-mode)

  • Rust: BridgeConnection::create_for_invite_code with retry-once-on-409. BridgeConnection.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, same Rc<RefCell<Option<…>>> close semantics. 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.
  • Examples: browser (vanilla), Next.js (React widget + mode toggle), iOS sample.

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 across all positions × all alphabet chars), 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.

Test plan

  • Run the browser example (pnpm -C js/examples/browser dev); toggle "Use invite code" and confirm a 6-char code is rendered with countdown.
  • Run the Next.js example (pnpm -C js/examples/nextjs dev); switch the demo toggle to invite-code and confirm the widget renders the code.
  • Run the iOS sample, switch to invite-code mode, and confirm the code surfaces with the same expiry semantics.
  • End-to-end: enter the code in World App on iOS (APP-9424), confirm proof completes and the RP poll loop resolves to Confirmed.
  • Verify URL/QR-mode adopters compile and run unchanged (no header sent on GET /response/:id).

Dependencies

  • wallet-bridge APP-9425 must be deployed for the code path to function. The session_nonce enforcement on GET /response/:id is a release-blocking follow-up on the Bridge side (currently stored but not verified — see spec §10).
  • world-app-ios APP-9424 ships the WalletBridgeInviteCodeRedeemer that decrypts the /code/redeem payload and runs the iOS Selfie Check flow.

🤖 Generated with Claude Code

SeanROlszewski and others added 5 commits April 30, 2026 07:15
Implements the idkit side of WDP-73. The RP shows the user a 6-character
code instead of a QR; the user types it into World App on a different
device. 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 — matches
  world-app-ios's WalletBridgeInviteCodeRedeemer); 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` base64url (no padding) to
  satisfy wallet-bridge's URL_SAFE_NO_PAD validator. Legacy URL/QR path
  is byte-identical to before — same `EncryptedPayload { iv, payload }`
  in standard base64.
- 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 (a follow-up to
  APP-9425); URL-mode connections keep `session_nonce: None` and the
  header is omitted, so existing bridge behavior is unchanged.

Cross-device implications resolved with the WDP-73 author:

- The mermaid diagram in the spec describes a `delivery_token` minted
  on /code/redeem and ferried back via universal-link to gate /response.
  That mechanism is fundamentally same-device — universal links route
  to the device that opened them, so a desktop browser ↔ phone flow
  never receives the token.
- The product is dominantly cross-device (Reddit × World), so we drop
  delivery_token from idkit's surface entirely. Anti-collusion security
  in the code path now degrades to "10-min TTL + one-shot redeem" —
  weaker than the spec mermaid implied, but it's the only model that
  actually works for the product.
- The spec's V1 Proposal section (bridge holds the connector_url) is
  superseded by the Additional Security Flow; needs a follow-up edit on
  the spec to mark V1 stale.

API surface added (mirrors existing URL-mode):

- Rust: `BridgeConnection::create_for_invite_code` (pub(crate), retry-
  once-on-409). `BridgeConnection.invite_code()` / `.code_expires_at()`
  accessors. New private fields on `BridgeConnection` keep the public
  type clean — wrappers expose them, internal type doesn't surface
  Option<...> ergonomic hazards.
- crypto.rs: `generate_invite_code`, `parse_invite_code` (private to the
  module — consumed only by tests today; world-app-ios has its own Swift
  port), `hkdf_invite_index_hex`, `hkdf_invite_key`, `generate_nonce`.
- FFI: `IDKitInviteCodeRequest` sibling to `IDKitRequestWrapper`. Method
  names mirror the URL wrapper exactly (request_id, poll_status,
  poll_status_once) — adopters write the same poll loop. New builder
  methods `constraints_with_invite_code` / `preset_with_invite_code` on
  `IDKitBuilder`.
- WASM: `IDKitInviteCodeRequest` sibling to `IDKitRequest`, same
  Rc<RefCell<Option<…>>> close semantics. `constraintsWithInviteCode` /
  `presetWithInviteCode` on the WASM builder.
- TS: `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
  `IDKitBuilder` performs.
- Shared `pollUntilCompletionLoop` helper extracted from
  `IDKitRequestImpl` so both impls share the loop without duplication.

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 across all
  positions × all alphabet chars), 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. Pre-existing
  `cast_possible_truncation`/`cast_sign_loss` lints in `rp_signature.rs`
  and `types.rs` against the wasm32 target are unchanged (introduced by
  e516ddb in January).

Adopter diff is two lines per integration:

  // before
  const req = await IDKit.request(config).constraints(...);
  showQR(req.connectorURI);
  const result = await req.pollUntilCompletion();

  // after
  const req = await IDKit.requestWithInviteCode(config).constraints(...);
  showCode(req.code);
  const result = await req.pollUntilCompletion();

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds a "Use invite code" checkbox to the browser demo's config panel.
When off, behavior is unchanged (QR mode). When on, the same 5 preset
buttons go through `IDKit.requestWithInviteCode()` and display the
6-character code formatted as ABC-DEF with a live expiry countdown.
Polling lifecycle is byte-identical between modes.

Cross-device note: the demo can render the code without a deployed
wallet-bridge with APP-9425, but the full round-trip (user types code
into World App, proof returns) requires bridge-side support which is
forthcoming.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…P-9428)

Adds sibling APIs to the React widget package, mirroring the core's
invite-code mode addition. The existing IDKitRequestWidget /
useIDKitRequest / useIDKitFlow / QRState are byte-identical — strict
additive change.

New in @worldcoin/idkit:
- useIDKitInviteCodeRequest hook (sibling to useIDKitRequest)
- useIDKitInviteCodeFlow hook helper with HookState that tracks code +
  codeExpiresAt instead of connectorURI
- IDKitInviteCodeRequestWidget component
- IDKitInviteCodeWidgetBase (sibling base; the existing
  IDKitWidgetBase reads flow.connectorURI directly so a near-copy was
  needed)
- InviteCodeState body component (large monospace ABC-DEF display, live
  "Expires in Xs" countdown, instruction line)
- New types: IDKitInviteCodeRequestHookConfig,
  UseIDKitInviteCodeRequestHookResult, IDKitInviteCodeRequestWidgetProps

Next.js example: adds a "Use invite code" checkbox to the config panel
and conditionally renders IDKitInviteCodeRequestWidget instead of
IDKitRequestWidget. Same config object — invite-code mode adds no new
input fields. Same onSuccess / handleVerify / onError callbacks.

Tests: 23/23 react package tests pass. Pre-existing type-check errors
in __tests__/{hooks,widgets}.test.tsx remain (using "All" as a defunct
credential type) — unrelated to this change.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds a Swift wrapper for the new uniffi IdKitInviteCodeRequest type
and wires it into the IDKitSampleApp.

Swift package (swift/Sources/IDKit/IDKit.swift):
- New IDKitInviteCodeRequest class — wraps uniffi-generated
  IdKitInviteCodeRequest. Exposes code: String, expiresAt: Date,
  requestID: UUID, plus pollStatusOnce() / pollUntilCompletion(options:)
  mirroring IDKitRequest. Reuses IDKitStatus, IDKitPollOptions,
  IDKitCompletionResult, IDKitErrorCode, IDKitClientError — no new
  shared types.
- IDKitBuilder.presetWithInviteCode(_:) extension method.
- constraintsWithInviteCode left as a TODO matching the existing
  "Re-enable when World ID 4.0 is live" comments around .constraints.

iOS sample app (swift/Examples/IDKitSampleApp):
- New SampleMode enum (.connectUrl / .inviteCode) and Picker in the
  form's Request section.
- Hides the "Connect URL mode" picker (App Clip) when in code mode —
  irrelevant.
- New form section "Invite Code" displays the code formatted as
  ABC-DEF in large monospace, with the expiry timestamp and a
  Copy-to-clipboard button. Visible only when in code mode and a code
  has been generated.
- The "Connector URL" section is hidden in code mode.
- generate() (renamed from generateRequestURL) branches on mode and
  routes to either the existing path or .presetWithInviteCode.
- Polling loop extracted to a generic helper that takes
  () async -> IDKitStatus, so both request types reuse the same loop.

The deep-link callback handler is unchanged; it's a no-op in code
mode (cross-device, no universal-link round-trip in this design).

Verification:
- scripts/package-swift.sh ran successfully (regenerated bindings,
  rebuilt IDKitFFI.xcframework for 5 Apple targets).
- swift build (Swift package): build complete.
- xcodebuild -scheme IDKitSampleApp -destination 'generic/platform=iOS Simulator':
  ** BUILD SUCCEEDED **.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The invite-code path was emitting base64url-no-pad to satisfy
wallet-bridge's URL_SAFE_NO_PAD validator. The URL/QR path uses
standard base64; aligning code-mode on the same encoding eliminates
the asymmetry. Requires the matching wallet-bridge change to swap
URL_SAFE_NO_PAD for STANDARD on /request iv/payload validation.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@vercel
Copy link
Copy Markdown

vercel Bot commented May 2, 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

)

Lets you point the example at a local wallet-bridge during development
without hardcoding. App ID is no longer readonly, and a Bridge URL
input is plumbed into the request config (defaults to undefined when
blank, preserving the prod default). Adds a hint that local-bridge
URLs require an app_staging_ App ID — idkit's BridgeUrl validator
only relaxes the localhost check for staging app IDs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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.

1 participant