Skip to content
Merged
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: 11 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ strum = { version = "0.27", features = ["derive"] }
# Cryptography
aes-gcm = "0.10"
getrandom = { version = "0.2", features = ["js"] }
hkdf = "0.12"
sha2 = "0.10"
tiny-keccak = { version = "2.0", features = ["keccak"] }

# Encoding
Expand Down
1 change: 1 addition & 0 deletions js/packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ export {
selfieCheckLegacy,
// Types
type IDKitRequest,
type IDKitInviteCodeRequest,
type IDKitCompletionResult,
type WaitOptions,
type RpContext,
Expand Down
248 changes: 222 additions & 26 deletions js/packages/core/src/request.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,46 @@ export interface IDKitRequest {
pollUntilCompletion(options?: WaitOptions): Promise<IDKitCompletionResult>;
}

/**
* Shared poll loop. Used by both URL-mode and invite-code-mode request impls;
* the loop body is identical between the two paths because the bridge
* `Status` shape is mode-agnostic.
*/
async function pollUntilCompletionLoop(
pollOnce: () => Promise<Status>,
options?: WaitOptions,
): Promise<IDKitCompletionResult> {
const pollInterval = options?.pollInterval ?? 1000;
const timeout = options?.timeout ?? 900_000; // 15 minutes default
const startTime = Date.now();

while (true) {
if (options?.signal?.aborted) {
return { success: false, error: IDKitErrorCodes.Cancelled };
}

if (Date.now() - startTime > timeout) {
return { success: false, error: IDKitErrorCodes.Timeout };
}

const status = await pollOnce();

if (status.type === "confirmed" && status.result) {
return { success: true, result: status.result };
}

if (status.type === "failed") {
return {
success: false,
error:
(status.error as IDKitErrorCodes) ?? IDKitErrorCodes.GenericError,
};
}

await new Promise((resolve) => setTimeout(resolve, pollInterval));
}
}

/**
* Internal request implementation (bridge/WASM path)
*/
Expand All @@ -98,38 +138,69 @@ class IDKitRequestImpl implements IDKitRequest {
return (await this.wasmRequest.pollForStatus()) as Status;
}

async pollUntilCompletion(
options?: WaitOptions,
): Promise<IDKitCompletionResult> {
const pollInterval = options?.pollInterval ?? 1000;
const timeout = options?.timeout ?? 900_000; // 15 minutes default
const startTime = Date.now();
pollUntilCompletion(options?: WaitOptions): Promise<IDKitCompletionResult> {
return pollUntilCompletionLoop(() => this.pollOnce(), options);
}
}

while (true) {
if (options?.signal?.aborted) {
return { success: false, error: IDKitErrorCodes.Cancelled };
}
/**
* An invite-code mode World ID verification request (WDP-73).
*
* Sibling shape to {@link IDKitRequest}, but discovery happens through a
* URL pointing at the `world.org/verify` landing page (which displays the
* code for the user to type into World App). The polling lifecycle is
* byte-identical to URL mode — same `Status`, same `IDKitCompletionResult` —
* so adopters write the same poll loop.
*/
export interface IDKitInviteCodeRequest {
/** URL to display to the user. Same shape as URL/QR mode's `connectorURI` with `&c=<code>&a=<app_id>` appended. */
readonly connectorURI: string;
/** Unix-seconds expiry of the unredeemed code. After this point bridge will reject the redeem. */
readonly expiresAt: number;
/** Unique request ID for this verification */
readonly requestId: string;
/** Poll once for current status (for manual polling) */
pollOnce(): Promise<Status>;
/** Poll continuously until completion or timeout */
pollUntilCompletion(options?: WaitOptions): Promise<IDKitCompletionResult>;
}

if (Date.now() - startTime > timeout) {
return { success: false, error: IDKitErrorCodes.Timeout };
}
/**
* Internal invite-code request implementation (bridge/WASM only — code mode
* has no in-app native postMessage path by design; the user is on a different
* device than World App).
*/
class IDKitInviteCodeRequestImpl implements IDKitInviteCodeRequest {
private wasmRequest: WasmModule.IDKitInviteCodeRequest;
private _connectorURI: string;
private _expiresAt: number;
private _requestId: string;

const status = await this.pollOnce();
constructor(wasmRequest: WasmModule.IDKitInviteCodeRequest) {
this.wasmRequest = wasmRequest;
this._connectorURI = wasmRequest.connectUrl();
this._expiresAt = wasmRequest.expiresAt();
this._requestId = wasmRequest.requestId();
}

if (status.type === "confirmed" && status.result) {
return { success: true, result: status.result };
}
get connectorURI(): string {
return this._connectorURI;
}

if (status.type === "failed") {
return {
success: false,
error:
(status.error as IDKitErrorCodes) ?? IDKitErrorCodes.GenericError,
};
}
get expiresAt(): number {
return this._expiresAt;
}

await new Promise((resolve) => setTimeout(resolve, pollInterval));
}
get requestId(): string {
return this._requestId;
}

async pollOnce(): Promise<Status> {
return (await this.wasmRequest.pollForStatus()) as Status;
}

pollUntilCompletion(options?: WaitOptions): Promise<IDKitCompletionResult> {
return pollUntilCompletionLoop(() => this.pollOnce(), options);
}
}

Expand Down Expand Up @@ -547,6 +618,72 @@ class IDKitBuilder {
}
}

/**
* Builder for invite-code mode requests (WDP-73).
*
* Code mode is bridge-only by definition: the user is on a different device
* than World App (e.g. desktop browser ↔ phone), so there's no in-app native
* postMessage path to branch on. This builder skips the `isInWorldApp()`
* check that {@link IDKitBuilder} performs.
*/
class IDKitInviteCodeBuilder {
private config: BuilderConfig;

constructor(config: BuilderConfig) {
this.config = config;
}

/**
* Creates an invite-code mode IDKit request with the given constraints.
*
* @param constraints - Constraint tree (CredentialRequest or any/all/enumerate combinators)
* @returns A new IDKitInviteCodeRequest instance
*
* @example
* ```typescript
* const request = await IDKit.requestWithInviteCode({ app_id, action, rp_context, allow_legacy_proofs: false })
* .constraints(any(CredentialRequest('proof_of_human'), CredentialRequest('face')));
* displayLink(request.connectorURI);
* ```
*/
async constraints(
constraints: ConstraintNode,
): Promise<IDKitInviteCodeRequest> {
await initIDKit();

const wasmBuilder = createWasmBuilderFromConfig(this.config);
const wasmRequest = (await wasmBuilder.constraintsWithInviteCode(
constraints,
)) as unknown as WasmModule.IDKitInviteCodeRequest;
return new IDKitInviteCodeRequestImpl(wasmRequest);
}

/**
* Creates an invite-code mode IDKit request from a preset.
*
* @param preset - A preset object from orbLegacy(), secureDocumentLegacy(), documentLegacy(), selfieCheckLegacy(), or deviceLegacy()
* @returns A new IDKitInviteCodeRequest instance
*/
async preset(preset: Preset): Promise<IDKitInviteCodeRequest> {
if (
this.config.type === "createSession" ||
this.config.type === "proveSession"
) {
throw new Error(
"Presets are not supported for session flows. Use .constraints() instead.",
);
}

await initIDKit();

const wasmBuilder = createWasmBuilderFromConfig(this.config);
const wasmRequest = (await wasmBuilder.presetWithInviteCode(
preset,
)) as unknown as WasmModule.IDKitInviteCodeRequest;
return new IDKitInviteCodeRequestImpl(wasmRequest);
}
}

// ─────────────────────────────────────────────────────────────────────────────
// Entry points
// ─────────────────────────────────────────────────────────────────────────────
Expand Down Expand Up @@ -623,6 +760,63 @@ function createRequest(config: IDKitRequestConfig): IDKitBuilder {
});
}

/**
* Creates an invite-code mode IDKit request builder (WDP-73).
*
* Sibling entry point to {@link createRequest}. Validates the same required
* fields, returns a {@link IDKitInviteCodeBuilder} whose `.constraints()` /
* `.preset()` methods produce {@link IDKitInviteCodeRequest} handles.
*
* @example
* ```typescript
* const request = await IDKit.requestWithInviteCode({
* app_id: 'app_staging_xxxxx',
* action: 'my-action',
* rp_context: { ... },
* allow_legacy_proofs: false,
* }).constraints(any(CredentialRequest('proof_of_human'), CredentialRequest('face')));
*
* displayLink(request.connectorURI); // user opens this URL on their phone
* const proof = await request.pollUntilCompletion();
* ```
*/
function createRequestWithInviteCode(
config: IDKitRequestConfig,
): IDKitInviteCodeBuilder {
// Validate required fields — mirror createRequest exactly so integrators
// don't get different validation between the two paths.
if (!config.app_id) {
throw new Error("app_id is required");
}
if (!config.action) {
throw new Error("action is required");
}
if (!config.rp_context) {
throw new Error(
"rp_context is required. Generate it on your backend using signRequest().",
);
}
if (typeof config.allow_legacy_proofs !== "boolean") {
throw new Error(
"allow_legacy_proofs is required. Set to true to accept v3 proofs during migration, " +
"or false to only accept v4 proofs.",
);
}

return new IDKitInviteCodeBuilder({
type: "request",
app_id: config.app_id,
action: String(config.action),
rp_context: config.rp_context,
action_description: config.action_description,
bridge_url: config.bridge_url,
return_to: config.return_to,
allow_legacy_proofs: config.allow_legacy_proofs,
override_connect_base_url: config.override_connect_base_url,
environment: config.environment,
});
}

/**
* Creates a new session builder (no action, no existing session_id)
*
Expand Down Expand Up @@ -757,6 +951,8 @@ function proveSession(
export const IDKit = {
/** Create a new verification request */
request: createRequest,
/** Create a new invite-code mode verification request (WDP-73) */
requestWithInviteCode: createRequestWithInviteCode,
/** Create a new session (no action, no existing session_id) */
createSession,
/** Prove an existing session (no action, has session_id) */
Expand Down
Loading
Loading