@humanmark/sdk-js
Version:
Browser-native JavaScript SDK for Humanmark human verification challenges
1,048 lines (1,047 loc) • 32.5 kB
JavaScript
const ENDPOINTS = {
WAIT_CHALLENGE: "/api/v1/challenge/wait"
};
const DEFAULT_BASE_URL = "https://humanmark.io";
function decodeChallengeToken(binary) {
return _decodeChallengeToken(wrapByteBuffer(binary));
}
function _decodeChallengeToken(bb) {
let message = {};
end_of_message: while (!isAtEnd(bb)) {
let tag = readVarint32(bb);
switch (tag >>> 3) {
case 0:
break end_of_message;
// optional uint32 version = 1;
case 1: {
message.version = readVarint32(bb) >>> 0;
break;
}
// optional uint32 key_id = 2;
case 2: {
message.key_id = readVarint32(bb) >>> 0;
break;
}
// optional uint32 issued_at = 3;
case 3: {
message.issued_at = readVarint32(bb) >>> 0;
break;
}
// optional uint32 expires_at = 4;
case 4: {
message.expires_at = readVarint32(bb) >>> 0;
break;
}
// optional string challenge = 5;
case 5: {
message.challenge = readString(bb, readVarint32(bb));
break;
}
// optional string shard = 6;
case 6: {
message.shard = readString(bb, readVarint32(bb));
break;
}
// optional string domain = 7;
case 7: {
message.domain = readString(bb, readVarint32(bb));
break;
}
default:
skipUnknownField(bb, tag & 7);
}
}
return message;
}
function skipUnknownField(bb, type) {
switch (type) {
case 0:
while (readByte(bb) & 128) {
}
break;
case 2:
skip(bb, readVarint32(bb));
break;
case 5:
skip(bb, 4);
break;
case 1:
skip(bb, 8);
break;
default:
throw new Error("Unimplemented type: " + type);
}
}
let f32 = new Float32Array(1);
new Uint8Array(f32.buffer);
let f64 = new Float64Array(1);
new Uint8Array(f64.buffer);
function wrapByteBuffer(bytes) {
return { bytes, offset: 0, limit: bytes.length };
}
function skip(bb, offset) {
if (bb.offset + offset > bb.limit) {
throw new Error("Skip past limit");
}
bb.offset += offset;
}
function isAtEnd(bb) {
return bb.offset >= bb.limit;
}
function advance(bb, count) {
let offset = bb.offset;
if (offset + count > bb.limit) {
throw new Error("Read past limit");
}
bb.offset += count;
return offset;
}
function readString(bb, count) {
let offset = advance(bb, count);
let fromCharCode = String.fromCharCode;
let bytes = bb.bytes;
let invalid = "�";
let text = "";
for (let i = 0; i < count; i++) {
let c1 = bytes[i + offset], c2, c3, c4, c;
if ((c1 & 128) === 0) {
text += fromCharCode(c1);
} else if ((c1 & 224) === 192) {
if (i + 1 >= count) text += invalid;
else {
c2 = bytes[i + offset + 1];
if ((c2 & 192) !== 128) text += invalid;
else {
c = (c1 & 31) << 6 | c2 & 63;
if (c < 128) text += invalid;
else {
text += fromCharCode(c);
i++;
}
}
}
} else if ((c1 & 240) == 224) {
if (i + 2 >= count) text += invalid;
else {
c2 = bytes[i + offset + 1];
c3 = bytes[i + offset + 2];
if (((c2 | c3 << 8) & 49344) !== 32896) text += invalid;
else {
c = (c1 & 15) << 12 | (c2 & 63) << 6 | c3 & 63;
if (c < 2048 || c >= 55296 && c <= 57343) text += invalid;
else {
text += fromCharCode(c);
i += 2;
}
}
}
} else if ((c1 & 248) == 240) {
if (i + 3 >= count) text += invalid;
else {
c2 = bytes[i + offset + 1];
c3 = bytes[i + offset + 2];
c4 = bytes[i + offset + 3];
if (((c2 | c3 << 8 | c4 << 16) & 12632256) !== 8421504)
text += invalid;
else {
c = (c1 & 7) << 18 | (c2 & 63) << 12 | (c3 & 63) << 6 | c4 & 63;
if (c < 65536 || c > 1114111) text += invalid;
else {
c -= 65536;
text += fromCharCode((c >> 10) + 55296, (c & 1023) + 56320);
i += 3;
}
}
}
} else text += invalid;
}
return text;
}
function readByte(bb) {
return bb.bytes[advance(bb, 1)];
}
function readVarint32(bb) {
let c = 0;
let value = 0;
let b;
do {
b = readByte(bb);
if (c < 32) value |= (b & 127) << c;
c += 7;
} while (b & 128);
return value;
}
const HTTP_STATUS = {
/** The server cannot or will not process the request due to an apparent client error */
BAD_REQUEST: 400,
/** Invalid or missing API key */
UNAUTHORIZED: 401,
/** API key does not have access to this resource */
FORBIDDEN: 403,
/** Server timeout waiting for the request */
REQUEST_TIMEOUT: 408,
/** The requested content has been permanently deleted */
GONE: 410,
/** User has sent too many requests in a given amount of time */
TOO_MANY_REQUESTS: 429,
/** Server has encountered a situation it doesn't know how to handle */
INTERNAL_SERVER_ERROR: 500
};
const HTTP_METHODS = {
GET: "GET",
POST: "POST"
};
const HTTP_HEADERS = {
CONTENT_TYPE: "Content-Type",
API_KEY: "hm-api-key"
};
var ErrorCode = /* @__PURE__ */ ((ErrorCode2) => {
ErrorCode2["INVALID_API_KEY"] = "invalid_api_key";
ErrorCode2["INVALID_CONFIG"] = "invalid_config";
ErrorCode2["NETWORK_ERROR"] = "network_error";
ErrorCode2["TIMEOUT"] = "timeout";
ErrorCode2["INVALID_RESPONSE"] = "invalid_response";
ErrorCode2["RATE_LIMITED"] = "rate_limited";
ErrorCode2["SERVER_ERROR"] = "server_error";
ErrorCode2["CHALLENGE_EXPIRED"] = "challenge_expired";
ErrorCode2["CHALLENGE_NOT_FOUND"] = "challenge_not_found";
ErrorCode2["INVALID_CHALLENGE_FORMAT"] = "invalid_challenge_format";
ErrorCode2["NO_ACTIVE_CHALLENGE"] = "no_active_challenge";
ErrorCode2["VERIFICATION_FAILED"] = "verification_failed";
ErrorCode2["NO_RECEIPT_RECEIVED"] = "no_receipt_received";
ErrorCode2["MODULE_LOAD_FAILED"] = "module_load_failed";
ErrorCode2["QR_CODE_GENERATION_FAILED"] = "qr_code_generation_failed";
ErrorCode2["USER_CANCELLED"] = "user_cancelled";
return ErrorCode2;
})(ErrorCode || {});
class HumanmarkError extends Error {
constructor(message, code, statusCode, metadata) {
super(message);
this.name = "HumanmarkError";
this.code = code;
if (statusCode !== void 0) {
this.statusCode = statusCode;
}
if (metadata !== void 0) {
this.metadata = metadata;
}
this.timestamp = /* @__PURE__ */ new Date();
if (Error.captureStackTrace) {
Error.captureStackTrace(this, this.constructor);
}
}
/**
* Returns a JSON representation of the error for logging
*/
toJSON() {
return {
name: this.name,
message: this.message,
code: this.code,
timestamp: this.timestamp.toISOString(),
...this.stack && { stack: this.stack },
...this.metadata && {
metadata: {
...this.metadata,
...this.statusCode && { statusCode: this.statusCode }
}
}
};
}
}
class HumanmarkConfigError extends HumanmarkError {
constructor(message, code, metadata) {
super(message, code, HTTP_STATUS.BAD_REQUEST, metadata);
this.name = "HumanmarkConfigError";
}
}
class HumanmarkNetworkError extends HumanmarkError {
constructor(message, code, statusCode, metadata) {
super(message, code, statusCode, metadata);
this.name = "HumanmarkNetworkError";
this.errorCategory = metadata?.errorCategory ?? "unknown";
this.isTemporary = this.errorCategory === "temporary";
}
}
class HumanmarkApiError extends HumanmarkError {
constructor(message, code, statusCode, metadata) {
super(message, code, statusCode ?? HTTP_STATUS.BAD_REQUEST, metadata);
this.name = "HumanmarkApiError";
}
}
class HumanmarkVerificationError extends HumanmarkError {
constructor(message, code, metadata) {
super(message, code, HTTP_STATUS.BAD_REQUEST, metadata);
this.name = "HumanmarkVerificationError";
}
}
class HumanmarkChallengeError extends HumanmarkError {
constructor(message, code, challenge, metadata) {
super(message, code, HTTP_STATUS.BAD_REQUEST, { ...metadata, challenge });
this.name = "HumanmarkChallengeError";
if (challenge !== void 0) {
this.challenge = challenge;
}
}
}
function isHumanmarkError(error) {
return error instanceof HumanmarkError;
}
function base64urlDecode(base64url) {
const base64 = base64url.replace(/-/g, "+").replace(/_/g, "/");
const padding = (4 - base64.length % 4) % 4;
const padded = base64 + "=".repeat(padding);
const binary = atob(padded);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) {
bytes[i] = binary.charCodeAt(i);
}
return bytes;
}
function parseChallengeToken(token) {
try {
const parts = token.split(".");
if (parts.length !== 2) {
throw new Error("Invalid token format");
}
const payload = parts[0];
if (!payload) {
throw new Error("Invalid token format: missing payload");
}
const bytes = base64urlDecode(payload);
try {
const challengeToken = decodeChallengeToken(bytes);
const challenge = challengeToken.challenge;
const shard = challengeToken.shard;
const domain = challengeToken.domain;
const expiresAt = challengeToken.expires_at;
const issuedAt = challengeToken.issued_at;
if (!shard || !challenge || !expiresAt) {
throw new Error("Missing required token claims");
}
const exp = expiresAt;
const iat = issuedAt ?? void 0;
const claims = {
shard,
challenge,
exp,
...iat && { iat },
...domain && { domain }
};
return {
token,
claims
};
} catch (protobufError) {
try {
const decoded = new TextDecoder().decode(bytes);
const mockData = JSON.parse(decoded);
if (mockData.shard && mockData.challenge && (mockData.exp ?? mockData.expiresAt ?? mockData.expires_at)) {
const claims = {
shard: mockData.shard,
challenge: mockData.challenge,
exp: mockData.exp ?? mockData.expires_at ?? (mockData.expiresAt ? mockData.expiresAt / 1e3 : 0),
...mockData.iat && { iat: mockData.iat },
...mockData.issued_at && !mockData.iat && { iat: mockData.issued_at },
...mockData.issuedAt && !mockData.iat && !mockData.issued_at && { iat: mockData.issuedAt / 1e3 },
...mockData.domain && { domain: mockData.domain }
};
return {
token,
claims
};
}
} catch {
throw protobufError;
}
throw new Error("Invalid token format");
}
} catch (error) {
throw new HumanmarkChallengeError(
`Invalid challenge token: ${error instanceof Error ? error.message : "Unknown error"}`,
ErrorCode.INVALID_CHALLENGE_FORMAT
);
}
}
function parseShardFromToken(token) {
const parsed = parseChallengeToken(token);
return parsed.claims.shard;
}
function parseChallengeFromToken(token) {
const parsed = parseChallengeToken(token);
return parsed.claims.challenge;
}
function getTokenExpiration(token) {
const parsed = parseChallengeToken(token);
return parsed.claims.exp * 1e3;
}
function isTokenExpired(token) {
const expirationMs = getTokenExpiration(token);
return Date.now() >= expirationMs;
}
function constructShardUrl(baseUrl, shard) {
try {
const url = new URL(baseUrl);
url.hostname = `${shard}.${url.hostname}`;
const result = url.toString();
if (result.endsWith("/") && !url.search && url.pathname === "/") {
return result.slice(0, -1);
}
return result;
} catch (error) {
throw new HumanmarkNetworkError(
`Failed to construct shard URL: ${error instanceof Error ? error.message : "Unknown error"}`,
ErrorCode.NETWORK_ERROR
);
}
}
const TIME_UNITS = {
/** Milliseconds per second */
MS_PER_SECOND: 1e3,
/** Milliseconds per minute */
MS_PER_MINUTE: 6e4
};
const RETRY_CONFIG = {
/** Maximum number of retry attempts */
MAX_RETRIES: 20,
/** Initial delay before first retry in milliseconds */
INITIAL_DELAY_MS: TIME_UNITS.MS_PER_SECOND,
/** Factor by which to multiply the delay for each retry */
BACKOFF_FACTOR: 2,
/** Random jitter factor to prevent thundering herd (±10%) */
JITTER_FACTOR: 0.1
};
const TIMEOUT_CONFIG = {
/** Total timeout for wait challenge operation (10 minutes) */
WAIT_CHALLENGE_TOTAL_MS: 10 * TIME_UNITS.MS_PER_MINUTE,
/** Single request timeout (must be > 25s server timeout) */
SINGLE_REQUEST_MS: 30 * TIME_UNITS.MS_PER_SECOND
};
const SPECIAL_VALUES = {
/** Return value indicating no expiry is set */
NO_EXPIRY: -1,
/** Minimum value for time calculations */
MIN_TIME: 0
};
function isServerError(status) {
return status >= HTTP_STATUS.INTERNAL_SERVER_ERROR && status < 600;
}
function isRetryableStatus(status) {
return isServerError(status) || status === HTTP_STATUS.TOO_MANY_REQUESTS;
}
function isRetryableNetworkError(error) {
if (!(error instanceof Error)) return false;
if (error.name === "AbortError") return false;
return error.name === "TypeError" || error.message?.includes("Failed to fetch") || error.message?.includes("Network request failed") || error.message?.includes("ERR_NETWORK") || error.message?.includes("ERR_INTERNET_DISCONNECTED") || error.message?.includes("ECONNREFUSED") || error.message?.includes("ETIMEDOUT") || error.message?.includes("ENOTFOUND");
}
function categorizeNetworkError(error) {
if (!(error instanceof Error)) return "unknown";
if (error.name === "AbortError") return "permanent";
if (error.message?.includes("Failed to fetch") || error.message?.includes("Network request failed") || error.message?.includes("ERR_NETWORK") || error.message?.includes("ERR_INTERNET_DISCONNECTED") || error.message?.includes("ECONNREFUSED") || error.message?.includes("ETIMEDOUT") || error.message?.includes("ENOTFOUND")) {
return "temporary";
}
if (error.message?.includes("ERR_CERT") || error.message?.includes("SSL") || error.message?.includes("TLS")) {
return "permanent";
}
if (error.message?.includes("CORS")) {
return "permanent";
}
return "unknown";
}
function createFetchOptions(method, headers, body, signal) {
const options = {
method,
headers
};
return options;
}
function isOnline() {
return typeof navigator !== "undefined" ? navigator.onLine : true;
}
function calculateRetryDelay(attempt) {
const baseDelay = RETRY_CONFIG.INITIAL_DELAY_MS * Math.pow(RETRY_CONFIG.BACKOFF_FACTOR, attempt);
const jitter = baseDelay * RETRY_CONFIG.JITTER_FACTOR * (Math.random() * 2 - 1);
return Math.max(0, baseDelay + jitter);
}
async function delay(ms, signal) {
return new Promise((resolve, reject) => {
if (signal?.aborted) {
reject(new DOMException("Aborted", "AbortError"));
return;
}
const timeoutId = setTimeout(resolve, ms);
const abortHandler = () => {
clearTimeout(timeoutId);
reject(new DOMException("Aborted", "AbortError"));
};
signal?.addEventListener("abort", abortHandler, { once: true });
});
}
class HumanmarkVerificationCancelledError extends HumanmarkError {
constructor(message = "User cancelled verification") {
super(message, ErrorCode.USER_CANCELLED);
this.name = "HumanmarkVerificationCancelledError";
}
}
function createTimeoutError(operation) {
const message = operation ? `${operation} timed out` : "Request timed out";
return new HumanmarkNetworkError(message, ErrorCode.TIMEOUT);
}
function createNetworkError(error) {
if (error instanceof HumanmarkError) {
return error;
}
const message = error instanceof Error ? error.message : "Network error occurred";
const category = categorizeNetworkError(error);
return new HumanmarkNetworkError(
message,
ErrorCode.NETWORK_ERROR,
void 0,
{ errorCategory: category }
);
}
function createApiErrorFromStatus(status, statusText) {
let code;
let message;
switch (status) {
case HTTP_STATUS.UNAUTHORIZED:
case HTTP_STATUS.FORBIDDEN:
code = ErrorCode.INVALID_API_KEY;
message = `HTTP ${status}: ${statusText}`;
break;
case HTTP_STATUS.TOO_MANY_REQUESTS:
code = ErrorCode.RATE_LIMITED;
message = `HTTP ${status}: ${statusText}`;
break;
case HTTP_STATUS.GONE:
code = ErrorCode.CHALLENGE_EXPIRED;
message = "Challenge expired";
break;
default:
code = ErrorCode.SERVER_ERROR;
message = `HTTP ${status}: ${statusText}`;
}
return new HumanmarkApiError(message, code, status);
}
function createCancelledError() {
return new HumanmarkVerificationCancelledError("User cancelled verification");
}
function createConfigError(message, field) {
const metadata = { field };
return new HumanmarkConfigError(message, ErrorCode.INVALID_CONFIG, metadata);
}
function createInvalidChallengeError(challenge) {
return new HumanmarkChallengeError(
"Invalid challenge ID format",
ErrorCode.INVALID_CHALLENGE_FORMAT,
challenge
);
}
function createNoChallengeError() {
return new HumanmarkChallengeError(
"No active challenge available",
ErrorCode.NO_ACTIVE_CHALLENGE
);
}
function createNoReceiptError() {
return new HumanmarkVerificationError(
"No receipt received from verification",
ErrorCode.NO_RECEIPT_RECEIVED
);
}
class ApiClient {
/**
* Creates a new ApiClient instance
*
* @param baseUrl - Base URL for API requests (default: https://humanmark.io)
*/
constructor(baseUrl = DEFAULT_BASE_URL) {
this.abortController = null;
this.baseUrl = baseUrl;
}
/**
* Cancels any ongoing API requests
*/
cancelPendingRequests() {
if (this.abortController) {
this.abortController.abort();
this.abortController = null;
}
}
/**
* Waits for challenge completion using long polling
*
* Continuously polls the server until:
* - User completes verification (returns receipt)
* - Challenge expires (410 status)
* - Timeout is reached
*
* @param token - Challenge token containing shard and challenge
* @param headers - Required API headers including api key
* @param options - Optional request configuration
* @returns Promise resolving to verification response with receipt
* @throws {Error} On challenge expiry, timeout, or other errors
*/
async waitForChallengeToken(token, headers, options) {
this.abortController = options?.signal ? new AbortController() : new AbortController();
if (options?.signal) {
const abortHandler = () => this.abortController?.abort();
options.signal.addEventListener("abort", abortHandler, { once: true });
}
try {
const startTime = Date.now();
const timeout = options?.timeout ?? TIMEOUT_CONFIG.WAIT_CHALLENGE_TOTAL_MS;
const shard = parseShardFromToken(token);
const challenge = parseChallengeFromToken(token);
const shardUrl = constructShardUrl(this.baseUrl, shard);
let retryCount = 0;
while (true) {
try {
const remainingTime = this.performPreRequestChecks(
startTime,
timeout
);
if (retryCount > 0) {
await this.waitForRetry(retryCount, startTime, timeout);
}
const requestTimeout = Math.min(
remainingTime,
TIMEOUT_CONFIG.SINGLE_REQUEST_MS
);
const response = await this.makeRequest(
`${shardUrl}${ENDPOINTS.WAIT_CHALLENGE}/${challenge}`,
createFetchOptions(HTTP_METHODS.GET, {
[HTTP_HEADERS.API_KEY]: headers[HTTP_HEADERS.API_KEY]
}),
requestTimeout
);
const result = await this.handleWaitResponse(response);
if (result === "retry") {
retryCount = 0;
continue;
}
return result;
} catch (error) {
if (!this.shouldRetryError(error, retryCount, startTime, timeout)) {
throw this.mapNetworkError(error);
}
retryCount++;
}
}
} finally {
this.abortController = null;
}
}
/**
* Checks if the request has been cancelled
* @throws {HumanmarkNetworkError} If request was cancelled
*/
checkCancellation() {
if (this.abortController?.signal.aborted) {
throw createNetworkError(
new DOMException("Request cancelled", "AbortError")
);
}
}
/**
* Checks if we've exceeded the maximum time allowed
* @throws {HumanmarkNetworkError} If timeout exceeded
*/
checkTimeout(startTime, maxTotalTime) {
const elapsedTime = Date.now() - startTime;
const remainingTime = maxTotalTime - elapsedTime;
if (remainingTime <= 0) {
throw createTimeoutError("Client request");
}
return remainingTime;
}
/**
* Waits before retrying with logging
*/
async waitForRetry(attempt, startTime, maxTotalTime) {
const retryDelay = calculateRetryDelay(attempt);
const elapsedTime = Date.now() - startTime;
if (retryDelay > 0 && retryDelay + elapsedTime > maxTotalTime) {
throw createTimeoutError("Client request");
}
if (retryDelay > 0) {
await delay(retryDelay, this.abortController?.signal);
}
}
/**
* Creates an abort controller linked to the main controller
*/
createLinkedAbortController(timeout) {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeout);
const mainAbortHandler = () => controller.abort();
this.abortController?.signal.addEventListener("abort", mainAbortHandler, {
once: true
});
controller.timeoutId = timeoutId;
return controller;
}
/**
* Handles wait response and determines if retry is needed
*/
async handleWaitResponse(response) {
if (response.status === HTTP_STATUS.REQUEST_TIMEOUT) {
return "retry";
}
if (response.ok) {
return await this.parseJsonResponse(response);
}
if (isRetryableStatus(response.status)) {
throw createApiErrorFromStatus(response.status, response.statusText);
}
this.handleResponseError(response);
}
/**
* Makes a request with timeout and cancellation support
*/
async makeRequest(url, options, timeout) {
const controller = this.createLinkedAbortController(timeout);
try {
return await fetch(url, { ...options, signal: controller.signal });
} finally {
clearTimeout(controller.timeoutId);
}
}
/**
* Performs pre-request validation checks
*/
performPreRequestChecks(startTime, maxTime) {
this.checkCancellation();
if (!isOnline()) {
throw new HumanmarkNetworkError(
"No internet connection",
ErrorCode.NETWORK_ERROR,
void 0,
{
isTemporary: true,
errorCategory: "temporary"
}
);
}
return this.checkTimeout(startTime, maxTime);
}
/**
* Handles retry logic for network errors
*/
shouldRetryError(error, attempt, startTime, maxTime) {
const elapsedTime = Date.now() - startTime;
const hasTimeRemaining = elapsedTime < maxTime;
const hasAttemptsRemaining = attempt < RETRY_CONFIG.MAX_RETRIES - 1;
if (!hasTimeRemaining || !hasAttemptsRemaining) {
return false;
}
if (error instanceof HumanmarkError) {
if (error instanceof HumanmarkNetworkError && error.isTemporary) {
return true;
}
if (error instanceof HumanmarkApiError && error.statusCode) {
return isRetryableStatus(error.statusCode);
}
return false;
}
return isRetryableNetworkError(error);
}
/**
* Maps errors to appropriate Humanmark error types
*/
mapNetworkError(error) {
if (error instanceof HumanmarkError) {
return error;
}
if (error instanceof Error && error.name === "AbortError") {
return createTimeoutError();
}
return createNetworkError(error);
}
/**
* Handles response errors with appropriate error codes
*/
handleResponseError(response) {
throw createApiErrorFromStatus(response.status, response.statusText);
}
/**
* Parses JSON response safely
*/
async parseJsonResponse(response) {
try {
return await response.json();
} catch {
throw new HumanmarkNetworkError(
"Invalid JSON response from server",
ErrorCode.INVALID_RESPONSE,
response.status
);
}
}
}
class ChallengeManager {
constructor() {
this.token = null;
}
/**
* Stores a challenge token
*
* @param token - Challenge token to store
* @throws {HumanmarkChallengeError} If the token is invalid
*/
setChallengeToken(token) {
try {
parseChallengeToken(token);
} catch {
throw createInvalidChallengeError(token);
}
this.token = token;
}
/**
* Retrieves the current challenge token if valid
*
* Automatically returns null for expired tokens.
*
* @returns Current challenge token or null if none exists or expired
*/
getCurrentToken() {
if (!this.token || this.isExpired()) {
return null;
}
return this.token;
}
/**
* Checks if the current challenge token has expired
*
* @returns true if expired or no token, false if valid
*/
isExpired() {
if (!this.token) {
return true;
}
try {
return isTokenExpired(this.token);
} catch {
return true;
}
}
/**
* Gets remaining time until challenge token expires
*
* @returns Milliseconds until expiry, 0 if expired, -1 if no token
*/
getTimeRemaining() {
if (!this.token) {
return SPECIAL_VALUES.NO_EXPIRY;
}
try {
const expirationMs = getTokenExpiration(this.token);
const remaining = expirationMs - Date.now();
return Math.max(SPECIAL_VALUES.MIN_TIME, remaining);
} catch {
return SPECIAL_VALUES.MIN_TIME;
}
}
/**
* Clears the stored challenge token
*/
clearChallengeToken() {
this.token = null;
}
}
class ThemeManager {
/**
* Initialize theme by setting data-hm-theme attribute
* CSS handles all theme logic including auto theme via media queries
*/
static initialize(theme = "auto") {
document.documentElement.setAttribute("data-hm-theme", theme ?? "auto");
}
}
class HumanmarkSdk {
/**
* Creates a new instance of HumanmarkSdk
*
* @param config - Configuration object for the SDK
* @param config.apiKey - Your Humanmark API key (required)
* @param config.challengeToken - Pre-created challenge token from your backend (required)
* @param config.baseUrl - Base URL for API requests (optional, defaults to 'https://humanmark.io')
* @param config.theme - Theme for the modal: 'light', 'dark', or 'auto' (optional, defaults to 'auto')
*
* @throws {Error} If configuration is invalid
*/
constructor(config) {
this.uiManager = null;
this.verificationInProgress = null;
this.validateConfig(config);
this.config = { ...config };
this.apiClient = new ApiClient(config.baseUrl ?? DEFAULT_BASE_URL);
this.challengeManager = new ChallengeManager();
ThemeManager.initialize(config.theme);
}
validateConfig(config) {
if (!config.apiKey || typeof config.apiKey !== "string") {
throw new HumanmarkConfigError(
"API key is required and must be a string",
ErrorCode.INVALID_API_KEY
);
}
if (!config.challengeToken || typeof config.challengeToken !== "string") {
throw createConfigError(
"Challenge token is required and must be a string",
"challengeToken"
);
}
}
/**
* Starts the verification process
*
* Shows a modal with either a QR code (desktop) or deep link button (mobile)
* and waits for the user to complete verification in the Humanmark app.
*
* @returns Promise that resolves with the receipt
* @throws {Error} If verification fails or times out
*
* @example
* try {
* const receipt = await sdk.verify();
* // Send receipt to your backend for validation
* await submitToBackend(receipt);
* } catch (error) {
* console.error('Verification failed:', error);
* }
*/
async verify() {
const existingPromise = this.verificationInProgress;
if (existingPromise) {
return existingPromise;
}
const verificationPromise = this.performVerification();
this.verificationInProgress = verificationPromise;
verificationPromise.finally(() => {
if (this.verificationInProgress === verificationPromise) {
this.verificationInProgress = null;
}
}).catch(() => {
});
return verificationPromise;
}
async performVerification() {
let verificationCompleted = false;
const abortController = new AbortController();
try {
this.initialize();
await this.showVerificationModal();
this.uiManager?.onModalClosed(() => {
if (!verificationCompleted) {
abortController.abort();
this.apiClient.cancelPendingRequests();
}
});
const result = await this.waitForVerification(abortController.signal);
verificationCompleted = true;
if (this.uiManager) {
const successAnimationComplete = new Promise((resolve) => {
this.uiManager?.onSuccess(() => {
this.cleanup();
resolve();
});
});
this.uiManager.showSuccess();
await successAnimationComplete;
} else {
this.cleanup();
}
return result;
} catch (error) {
verificationCompleted = true;
this.cleanup();
throw error;
}
}
initialize() {
this.challengeManager.setChallengeToken(this.config.challengeToken);
}
async showVerificationModal() {
const token = this.challengeManager.getCurrentToken();
if (!token) {
throw createNoChallengeError();
}
if (!this.uiManager) {
const { loadUIManager } = await import("./index-htvd8qd5.js");
const UIManagerClass = await loadUIManager();
this.uiManager = new UIManagerClass();
}
await this.uiManager.showVerificationModal(token);
}
async waitForVerification(signal) {
const token = this.challengeManager.getCurrentToken();
if (!token) {
throw createNoChallengeError();
}
if (this.challengeManager.isExpired()) {
throw new HumanmarkApiError(
"Challenge expired",
ErrorCode.CHALLENGE_EXPIRED,
410
);
}
if (signal.aborted) {
throw createCancelledError();
}
let abortHandler = null;
try {
const waitForAbort = new Promise((resolve) => {
abortHandler = () => resolve();
signal.addEventListener("abort", abortHandler, { once: true });
});
const apiCallPromise = this.apiClient.waitForChallengeToken(token, {
[HTTP_HEADERS.API_KEY]: this.config.apiKey
});
const raceResult = await Promise.race([
apiCallPromise.then((result2) => ({ type: "success", result: result2 })),
waitForAbort.then(() => ({ type: "aborted" }))
]);
if (raceResult.type === "aborted") {
this.apiClient.cancelPendingRequests();
throw createCancelledError();
}
const result = raceResult.result;
if (result.receipt) {
return result.receipt;
} else {
throw createNoReceiptError();
}
} finally {
if (abortHandler) {
signal.removeEventListener("abort", abortHandler);
}
}
}
/**
* Cleans up resources after verification
* Called automatically after verification completes or fails
* @param immediate - Skip animations for immediate cleanup (useful for tests)
*/
cleanup(immediate = false) {
this.apiClient.cancelPendingRequests();
this.challengeManager.clearChallengeToken();
this.uiManager?.hideModal(immediate);
}
}
async function preloadUIComponents() {
const { preloadUIComponents: preloadUIComponents2 } = await import("./index-htvd8qd5.js");
return preloadUIComponents2();
}
export {
ErrorCode as E,
HumanmarkError as H,
HumanmarkChallengeError as a,
HumanmarkSdk as b,
HumanmarkVerificationCancelledError as c,
preloadUIComponents as d,
getTokenExpiration as g,
isHumanmarkError as i,
parseChallengeToken as p
};
//# sourceMappingURL=index-CjrzYJmo.js.map