Skip to content

Commit 4615adb

Browse files
feat: invite-code mode core API (WDP-73 / APP-9428)
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>
1 parent 8f9c513 commit 4615adb

11 files changed

Lines changed: 262 additions & 214 deletions

File tree

js/packages/core/src/request.ts

Lines changed: 14 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -147,15 +147,14 @@ class IDKitRequestImpl implements IDKitRequest {
147147
* An invite-code mode World ID verification request (WDP-73).
148148
*
149149
* Sibling shape to {@link IDKitRequest}, but discovery happens through a
150-
* 6-character code the user types into World App instead of a QR scan. The
151-
* polling lifecycle is byte-identical to URL mode — same `Status`, same
152-
* `IDKitCompletionResult` — so adopters write the same poll loop.
153-
*
154-
* Only the constructor and the displayable `code` differ between modes.
150+
* URL pointing at the `world.org/verify` landing page (which displays the
151+
* code for the user to type into World App). The polling lifecycle is
152+
* byte-identical to URL mode — same `Status`, same `IDKitCompletionResult` —
153+
* so adopters write the same poll loop.
155154
*/
156155
export interface IDKitInviteCodeRequest {
157-
/** Canonical 6-char Crockford Base32 code (no separator). UI may format as "ABC-DEF" for display. */
158-
readonly code: string;
156+
/** URL to display to the user. Same shape as URL/QR mode's `connectorURI` with `&c=<code>&a=<app_id>` appended. */
157+
readonly connectorURI: string;
159158
/** Unix-seconds expiry of the unredeemed code. After this point bridge will reject the redeem. */
160159
readonly expiresAt: number;
161160
/** Unique request ID for this verification */
@@ -173,19 +172,19 @@ export interface IDKitInviteCodeRequest {
173172
*/
174173
class IDKitInviteCodeRequestImpl implements IDKitInviteCodeRequest {
175174
private wasmRequest: WasmModule.IDKitInviteCodeRequest;
176-
private _code: string;
175+
private _connectorURI: string;
177176
private _expiresAt: number;
178177
private _requestId: string;
179178

180179
constructor(wasmRequest: WasmModule.IDKitInviteCodeRequest) {
181180
this.wasmRequest = wasmRequest;
182-
this._code = wasmRequest.code();
181+
this._connectorURI = wasmRequest.connectUrl();
183182
this._expiresAt = wasmRequest.expiresAt();
184183
this._requestId = wasmRequest.requestId();
185184
}
186185

187-
get code(): string {
188-
return this._code;
186+
get connectorURI(): string {
187+
return this._connectorURI;
189188
}
190189

191190
get expiresAt(): number {
@@ -644,7 +643,7 @@ class IDKitInviteCodeBuilder {
644643
* ```typescript
645644
* const request = await IDKit.requestWithInviteCode({ app_id, action, rp_context, allow_legacy_proofs: false })
646645
* .constraints(any(CredentialRequest('proof_of_human'), CredentialRequest('face')));
647-
* showCode(request.code);
646+
* displayLink(request.connectorURI);
648647
* ```
649648
*/
650649
async constraints(
@@ -653,11 +652,7 @@ class IDKitInviteCodeBuilder {
653652
await initIDKit();
654653

655654
const wasmBuilder = createWasmBuilderFromConfig(this.config);
656-
const wasmRequest = (await (
657-
wasmBuilder as unknown as {
658-
constraintsWithInviteCode: (c: ConstraintNode) => Promise<unknown>;
659-
}
660-
).constraintsWithInviteCode(
655+
const wasmRequest = (await wasmBuilder.constraintsWithInviteCode(
661656
constraints,
662657
)) as unknown as WasmModule.IDKitInviteCodeRequest;
663658
return new IDKitInviteCodeRequestImpl(wasmRequest);
@@ -682,11 +677,7 @@ class IDKitInviteCodeBuilder {
682677
await initIDKit();
683678

684679
const wasmBuilder = createWasmBuilderFromConfig(this.config);
685-
const wasmRequest = (await (
686-
wasmBuilder as unknown as {
687-
presetWithInviteCode: (p: Preset) => Promise<unknown>;
688-
}
689-
).presetWithInviteCode(
680+
const wasmRequest = (await wasmBuilder.presetWithInviteCode(
690681
preset,
691682
)) as unknown as WasmModule.IDKitInviteCodeRequest;
692683
return new IDKitInviteCodeRequestImpl(wasmRequest);
@@ -785,7 +776,7 @@ function createRequest(config: IDKitRequestConfig): IDKitBuilder {
785776
* allow_legacy_proofs: false,
786777
* }).constraints(any(CredentialRequest('proof_of_human'), CredentialRequest('face')));
787778
*
788-
* showCode(request.code); // user types this into World App
779+
* displayLink(request.connectorURI); // user opens this URL on their phone
789780
* const proof = await request.pollUntilCompletion();
790781
* ```
791782
*/

js/packages/react/src/components/States/InviteCodeState.tsx

Lines changed: 66 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -2,22 +2,14 @@ import { useEffect, useState, type ReactElement } from "react";
22
import { __ } from "../../lang";
33
import { WorldcoinIcon } from "../Icons/WorldIcon";
44
import { LoadingIcon } from "../Icons/LoadingIcon";
5+
import { QRCode } from "../../widget/QRCode";
56

67
type InviteCodeStateProps = {
7-
code: string | null;
8+
connectorURI: string | null;
89
codeExpiresAt: number | null;
910
isAwaitingUserConfirmation: boolean;
1011
};
1112

12-
function formatCodeForDisplay(code: string): string {
13-
// Canonical form is 6-char Crockford Base32 (no separator).
14-
// For display, format as "ABC-DEF" when length is exactly 6.
15-
if (code.length === 6) {
16-
return `${code.slice(0, 3)}-${code.slice(3)}`;
17-
}
18-
return code;
19-
}
20-
2113
function useNowInSeconds(): number {
2214
const [now, setNow] = useState(() => Math.floor(Date.now() / 1000));
2315
useEffect(() => {
@@ -30,7 +22,7 @@ function useNowInSeconds(): number {
3022
}
3123

3224
export function InviteCodeState({
33-
code,
25+
connectorURI,
3426
codeExpiresAt,
3527
isAwaitingUserConfirmation,
3628
}: InviteCodeStateProps): ReactElement {
@@ -47,20 +39,16 @@ export function InviteCodeState({
4739
textAlign: "center",
4840
}}
4941
>
50-
{/* World logo */}
5142
<div className="idkit-worldid-icon">
5243
<WorldcoinIcon />
5344
</div>
5445

55-
{/* Heading */}
5646
<h2 className="idkit-heading">{__("Connect your World ID")}</h2>
5747

58-
{/* Instruction */}
5948
<p className="idkit-subtext">
60-
{__("Open World App on your phone and enter this code")}
49+
{__("Scan with your phone to continue verifying")}
6150
</p>
6251

63-
{/* Code display container — mirrors qr-container layout for the spinner overlay */}
6452
<div className="idkit-qr-container">
6553
{isAwaitingUserConfirmation && (
6654
<div className="idkit-qr-overlay">
@@ -77,45 +65,73 @@ export function InviteCodeState({
7765
<div
7866
className={`idkit-qr-blur ${isAwaitingUserConfirmation ? "blurred" : ""}`}
7967
>
80-
<div
68+
{connectorURI ? <QRCode data={connectorURI} /> : null}
69+
</div>
70+
</div>
71+
72+
{connectorURI && (
73+
<div
74+
style={{
75+
marginTop: 16,
76+
display: "flex",
77+
alignItems: "center",
78+
gap: 8,
79+
width: "100%",
80+
maxWidth: 360,
81+
}}
82+
>
83+
<code
8184
style={{
82-
display: "flex",
83-
flexDirection: "column",
84-
alignItems: "center",
85-
justifyContent: "center",
86-
gap: 12,
87-
padding: "24px 16px",
85+
flex: 1,
86+
fontFamily:
87+
"ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace",
88+
fontSize: 12,
89+
padding: "8px 10px",
90+
borderRadius: 6,
91+
background: "var(--idkit-surface-muted, rgba(0,0,0,0.04))",
92+
color: "var(--idkit-text-primary)",
93+
overflow: "hidden",
94+
textOverflow: "ellipsis",
95+
whiteSpace: "nowrap",
96+
userSelect: "all",
8897
}}
98+
title={connectorURI}
8999
>
90-
<div
91-
aria-label={
92-
code ? `Invite code ${formatCodeForDisplay(code)}` : undefined
100+
{connectorURI}
101+
</code>
102+
<button
103+
type="button"
104+
onClick={() => {
105+
if (typeof navigator !== "undefined" && navigator.clipboard) {
106+
void navigator.clipboard.writeText(connectorURI);
93107
}
94-
style={{
95-
fontFamily:
96-
"ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace",
97-
fontSize: 36,
98-
fontWeight: 600,
99-
letterSpacing: "0.15em",
100-
color: "var(--idkit-text-primary)",
101-
userSelect: "all",
102-
}}
103-
>
104-
{code ? formatCodeForDisplay(code) : "------"}
105-
</div>
106-
{secondsRemaining !== null && (
107-
<div
108-
style={{
109-
fontSize: 14,
110-
color: "var(--idkit-text-secondary)",
111-
}}
112-
>
113-
{__("Expires in")} {secondsRemaining}s
114-
</div>
115-
)}
116-
</div>
108+
}}
109+
style={{
110+
padding: "6px 12px",
111+
borderRadius: 6,
112+
border: "1px solid var(--idkit-border, rgba(0,0,0,0.1))",
113+
background: "var(--idkit-surface, transparent)",
114+
color: "var(--idkit-text-primary)",
115+
fontSize: 12,
116+
cursor: "pointer",
117+
}}
118+
>
119+
{__("Copy")}
120+
</button>
117121
</div>
118-
</div>
122+
)}
123+
124+
{secondsRemaining !== null && (
125+
<div
126+
style={{
127+
marginTop: 12,
128+
fontSize: 14,
129+
color: "var(--idkit-text-secondary)",
130+
}}
131+
>
132+
{__("Expires in")} {secondsRemaining}s
133+
</div>
134+
)}
119135
</div>
120136
);
121137
}

js/packages/react/src/hooks/inviteCodeCommon.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ type IDKitHookStatus =
1010
export type InviteCodeHookState<TResult> = {
1111
isOpen: boolean;
1212
status: IDKitHookStatus;
13-
code: string | null;
13+
connectorURI: string | null;
1414
codeExpiresAt: number | null;
1515
result: TResult | null;
1616
errorCode: IDKitErrorCodes | null;
@@ -22,7 +22,7 @@ export function createInitialInviteCodeHookState<
2222
return {
2323
isOpen: false,
2424
status: "idle",
25-
code: null,
25+
connectorURI: null,
2626
codeExpiresAt: null,
2727
result: null,
2828
errorCode: null,

js/packages/react/src/hooks/useIDKitInviteCodeFlow.ts

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ export function useIDKitInviteCodeFlow<TResult>(
4848
return {
4949
isOpen: true,
5050
status: "waiting_for_connection",
51-
code: null,
51+
connectorURI: null,
5252
codeExpiresAt: null,
5353
result: null,
5454
errorCode: null,
@@ -86,18 +86,21 @@ export function useIDKitInviteCodeFlow<TResult>(
8686
ensureNotAborted(controller.signal);
8787
if (isDebug())
8888
console.debug("[IDKit] Invite-code flow created", {
89-
code: request.code,
89+
connectorURI: request.connectorURI,
9090
expiresAt: request.expiresAt,
9191
requestId: request.requestId,
9292
});
9393

94-
const code = request.code;
94+
const connectorURI = request.connectorURI;
9595
const codeExpiresAt = request.expiresAt;
9696
setState((prev) => {
97-
if (prev.code === code && prev.codeExpiresAt === codeExpiresAt) {
97+
if (
98+
prev.connectorURI === connectorURI &&
99+
prev.codeExpiresAt === codeExpiresAt
100+
) {
98101
return prev;
99102
}
100-
return { ...prev, code, codeExpiresAt };
103+
return { ...prev, connectorURI, codeExpiresAt };
101104
});
102105

103106
const pollInterval = configRef.current.polling?.interval ?? 1000;
@@ -176,7 +179,7 @@ export function useIDKitInviteCodeFlow<TResult>(
176179
isAwaitingUserConfirmation: state.status === "awaiting_confirmation",
177180
isSuccess: state.status === "confirmed",
178181
isError: state.status === "failed",
179-
code: state.code,
182+
connectorURI: state.connectorURI,
180183
codeExpiresAt: state.codeExpiresAt,
181184
result: state.result,
182185
errorCode: state.errorCode,

js/packages/react/src/types/common.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,8 +39,8 @@ export type IDKitInviteCodeHookResult<TResult> = {
3939
isAwaitingUserConfirmation: boolean;
4040
isSuccess: boolean;
4141
isError: boolean;
42-
/** Canonical 6-char Crockford Base32 invite code (no separator). UI may format as "ABC-DEF". */
43-
code: string | null;
42+
/** URL to display to the user (same shape as URL/QR mode's `connectorURI`, with `&c=<code>&a=<app_id>` appended for the `world.org/verify` landing page). */
43+
connectorURI: string | null;
4444
/** Unix-seconds expiry of the unredeemed code. */
4545
codeExpiresAt: number | null;
4646
result: TResult | null;

js/packages/react/src/widget/IDKitInviteCodeWidgetBase.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -156,7 +156,7 @@ export function IDKitInviteCodeWidgetBase<TResult>({
156156
<IDKitModal open={open} onOpenChange={onOpenChange}>
157157
{stage === "invite_code" && (
158158
<InviteCodeState
159-
code={flow.code}
159+
connectorURI={flow.connectorURI}
160160
codeExpiresAt={flow.codeExpiresAt}
161161
isAwaitingUserConfirmation={flow.isAwaitingUserConfirmation}
162162
/>
@@ -180,6 +180,7 @@ export function IDKitInviteCodeWidgetBase<TResult>({
180180
{stage === "error" && (
181181
<ErrorState
182182
errorCode={effectiveErrorCode}
183+
onClose={() => onOpenChange(false)}
183184
onRetry={() => {
184185
setHostVerifyResult(null);
185186
lastResultRef.current = null;

0 commit comments

Comments
 (0)