engagelab-captcha-sdk 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/captcha-sdk.esm.js +1 -0
- package/dist/captcha-sdk.umd.js +1 -0
- package/dist/types/api.d.ts +12 -0
- package/dist/types/challenges/dragsort.d.ts +24 -0
- package/dist/types/challenges/grid.d.ts +20 -0
- package/dist/types/challenges/icons.d.ts +18 -0
- package/dist/types/challenges/invisible.d.ts +8 -0
- package/dist/types/challenges/physics.d.ts +40 -0
- package/dist/types/challenges/rotate.d.ts +14 -0
- package/dist/types/challenges/slider.d.ts +17 -0
- package/dist/types/challenges/spatial.d.ts +12 -0
- package/dist/types/compat/hcaptcha.d.ts +1 -0
- package/dist/types/compat/recaptcha.d.ts +1 -0
- package/dist/types/crypto.d.ts +11 -0
- package/dist/types/deep_probes.d.ts +13 -0
- package/dist/types/degradation.d.ts +27 -0
- package/dist/types/device_attestation.d.ts +19 -0
- package/dist/types/fingerprint.d.ts +2 -0
- package/dist/types/headless.d.ts +2 -0
- package/dist/types/i18n.d.ts +7 -0
- package/dist/types/index.d.ts +36 -0
- package/dist/types/integrity.d.ts +3 -0
- package/dist/types/logger.d.ts +49 -0
- package/dist/types/native_monitor.d.ts +21 -0
- package/dist/types/passive_signals.d.ts +119 -0
- package/dist/types/pow.d.ts +2 -0
- package/dist/types/reporter.d.ts +8 -0
- package/dist/types/session_persist.d.ts +18 -0
- package/dist/types/theme.d.ts +11 -0
- package/dist/types/timing.d.ts +2 -0
- package/dist/types/trajectory.d.ts +18 -0
- package/dist/types/types.d.ts +182 -0
- package/dist/types/ui/container.d.ts +20 -0
- package/dist/types/ui/styles.d.ts +1 -0
- package/dist/types/wasm_core.d.ts +44 -0
- package/dist/types/web-component.d.ts +11 -0
- package/package.json +22 -0
- package/rollup.config.mjs +62 -0
- package/src/api.ts +180 -0
- package/src/challenges/dragsort.ts +168 -0
- package/src/challenges/grid.ts +147 -0
- package/src/challenges/icons.ts +106 -0
- package/src/challenges/invisible.ts +57 -0
- package/src/challenges/physics.ts +437 -0
- package/src/challenges/rotate.ts +81 -0
- package/src/challenges/slider.ts +168 -0
- package/src/challenges/spatial.ts +91 -0
- package/src/compat/hcaptcha.ts +112 -0
- package/src/compat/recaptcha.ts +108 -0
- package/src/crypto.ts +69 -0
- package/src/deep_probes.ts +690 -0
- package/src/degradation.ts +113 -0
- package/src/device_attestation.ts +109 -0
- package/src/fingerprint.ts +247 -0
- package/src/headless.ts +233 -0
- package/src/i18n.ts +455 -0
- package/src/index.ts +527 -0
- package/src/integrity.ts +100 -0
- package/src/logger.ts +170 -0
- package/src/native_monitor.ts +100 -0
- package/src/passive_signals.ts +544 -0
- package/src/pow.ts +120 -0
- package/src/reporter.ts +75 -0
- package/src/session_persist.ts +90 -0
- package/src/theme.ts +41 -0
- package/src/timing.ts +79 -0
- package/src/trajectory.ts +110 -0
- package/src/types.ts +199 -0
- package/src/ui/container.ts +161 -0
- package/src/ui/styles.ts +153 -0
- package/src/wasm_core.ts +189 -0
- package/src/web-component.ts +103 -0
- package/tsconfig.json +18 -0
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import type { DeepProbeResult } from './deep_probes';
|
|
2
|
+
import type { PassiveSignalResult } from './passive_signals';
|
|
3
|
+
export interface CaptchaConfig {
|
|
4
|
+
siteKey: string;
|
|
5
|
+
apiBase: string;
|
|
6
|
+
/** When true, enables verbose SDK logging (same effect as localStorage engagelab_captcha_debug). */
|
|
7
|
+
debug?: boolean;
|
|
8
|
+
mode?: 'popup' | 'inline' | 'invisible';
|
|
9
|
+
lang?: string;
|
|
10
|
+
entityHash?: string;
|
|
11
|
+
theme?: ThemeConfig;
|
|
12
|
+
fingerprint?: FingerprintOptions;
|
|
13
|
+
/** Disable eval/Function.constructor wrapping (e.g. to avoid HMR conflicts). Default: false */
|
|
14
|
+
disableEvalPatching?: boolean;
|
|
15
|
+
onReady?: () => void;
|
|
16
|
+
onSuccess?: (token: string, riskLabels: string[]) => void;
|
|
17
|
+
onFail?: (error: CaptchaError) => void;
|
|
18
|
+
/**
|
|
19
|
+
* Called when the CAPTCHA service is unavailable (risk engine down, HTTP 503, etc.).
|
|
20
|
+
* The developer should implement their own degradation strategy here.
|
|
21
|
+
* If not provided, falls back to onFail with code="service_unavailable".
|
|
22
|
+
*
|
|
23
|
+
* Recommended actions:
|
|
24
|
+
* - Allow the user through for low-risk business scenarios
|
|
25
|
+
* - Block the user for high-risk scenarios (e.g. payment)
|
|
26
|
+
* - Switch to an alternative verification method (e.g. SMS verification)
|
|
27
|
+
*/
|
|
28
|
+
onError?: (error: CaptchaError) => void;
|
|
29
|
+
onClose?: () => void;
|
|
30
|
+
}
|
|
31
|
+
export interface FingerprintOptions {
|
|
32
|
+
/** Audio fingerprint via OfflineAudioContext (no mic, no permission needed). Default: true */
|
|
33
|
+
audio?: boolean;
|
|
34
|
+
/** Canvas fingerprint. Default: true */
|
|
35
|
+
canvas?: boolean;
|
|
36
|
+
/** WebGL renderer/vendor fingerprint. Default: true */
|
|
37
|
+
webgl?: boolean;
|
|
38
|
+
/** Font detection via DOM measurement. Default: true */
|
|
39
|
+
fonts?: boolean;
|
|
40
|
+
/** WebRTC local IP detection. Default: true */
|
|
41
|
+
webrtc?: boolean;
|
|
42
|
+
}
|
|
43
|
+
export interface ThemeConfig {
|
|
44
|
+
primaryColor?: string;
|
|
45
|
+
successColor?: string;
|
|
46
|
+
errorColor?: string;
|
|
47
|
+
backgroundColor?: string;
|
|
48
|
+
textColor?: string;
|
|
49
|
+
borderRadius?: number;
|
|
50
|
+
fontFamily?: string;
|
|
51
|
+
darkMode?: boolean;
|
|
52
|
+
}
|
|
53
|
+
export interface CaptchaError {
|
|
54
|
+
code: string;
|
|
55
|
+
message: string;
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Thrown when the CAPTCHA backend service is unavailable (HTTP 503 or
|
|
59
|
+
* verify returns decision="service_unavailable"). The developer should
|
|
60
|
+
* implement their own degradation strategy in the onError callback.
|
|
61
|
+
*/
|
|
62
|
+
export declare class ServiceError extends Error {
|
|
63
|
+
readonly code: string;
|
|
64
|
+
constructor(code: string, message: string);
|
|
65
|
+
}
|
|
66
|
+
export interface Fingerprint {
|
|
67
|
+
canvasHash: string;
|
|
68
|
+
webglRenderer: string;
|
|
69
|
+
webglVendor: string;
|
|
70
|
+
webglHash: string;
|
|
71
|
+
audioHash: string;
|
|
72
|
+
screenWidth: number;
|
|
73
|
+
screenHeight: number;
|
|
74
|
+
colorDepth: number;
|
|
75
|
+
devicePixelRatio: number;
|
|
76
|
+
timezone: string;
|
|
77
|
+
timezoneOffset: number;
|
|
78
|
+
language: string;
|
|
79
|
+
languages: string[];
|
|
80
|
+
platform: string;
|
|
81
|
+
hardwareConcurrency: number;
|
|
82
|
+
deviceMemory: number;
|
|
83
|
+
maxTouchPoints: number;
|
|
84
|
+
fonts: string[];
|
|
85
|
+
pluginCount: number;
|
|
86
|
+
webdriver: boolean;
|
|
87
|
+
hasChromeRuntime: boolean;
|
|
88
|
+
connectionType: string;
|
|
89
|
+
screenAvailWidth: number;
|
|
90
|
+
screenAvailHeight: number;
|
|
91
|
+
outerWidth: number;
|
|
92
|
+
outerHeight: number;
|
|
93
|
+
innerWidth: number;
|
|
94
|
+
innerHeight: number;
|
|
95
|
+
headlessSignals: HeadlessSignal[];
|
|
96
|
+
mathFp: MathFingerprint;
|
|
97
|
+
storageInfo: StorageInfo;
|
|
98
|
+
/** Deep environment probes (snake_case key matches server env analyzer). */
|
|
99
|
+
deep_probes?: DeepProbeResult[];
|
|
100
|
+
/** Background behavioral signals collected before challenge interaction. */
|
|
101
|
+
passive_signals?: PassiveSignalResult;
|
|
102
|
+
/** Stack trace records from wrapped native functions. */
|
|
103
|
+
native_traces?: NativeTrace[];
|
|
104
|
+
/** Number of SDK sessions from this browser (persisted via IndexedDB). */
|
|
105
|
+
session_count?: number;
|
|
106
|
+
}
|
|
107
|
+
export interface NativeTrace {
|
|
108
|
+
fn: string;
|
|
109
|
+
stack: string;
|
|
110
|
+
timestamp: number;
|
|
111
|
+
}
|
|
112
|
+
export interface HeadlessSignal {
|
|
113
|
+
name: string;
|
|
114
|
+
detected: boolean;
|
|
115
|
+
value: string;
|
|
116
|
+
weight: number;
|
|
117
|
+
}
|
|
118
|
+
export interface MathFingerprint {
|
|
119
|
+
tanValue: number;
|
|
120
|
+
atan2Value: number;
|
|
121
|
+
logValue: number;
|
|
122
|
+
sinValue: number;
|
|
123
|
+
}
|
|
124
|
+
export interface StorageInfo {
|
|
125
|
+
localStorage: boolean;
|
|
126
|
+
sessionStorage: boolean;
|
|
127
|
+
indexedDb: boolean;
|
|
128
|
+
}
|
|
129
|
+
export interface TrajectoryPoint {
|
|
130
|
+
x: number;
|
|
131
|
+
y: number;
|
|
132
|
+
timestamp: number;
|
|
133
|
+
eventType: string;
|
|
134
|
+
isTrusted: boolean;
|
|
135
|
+
movementX: number;
|
|
136
|
+
movementY: number;
|
|
137
|
+
buttons: number;
|
|
138
|
+
force?: number;
|
|
139
|
+
radiusX?: number;
|
|
140
|
+
radiusY?: number;
|
|
141
|
+
}
|
|
142
|
+
export interface PowResult {
|
|
143
|
+
challenge: string;
|
|
144
|
+
nonce: string;
|
|
145
|
+
difficulty: number;
|
|
146
|
+
computationTimeMs: number;
|
|
147
|
+
iterations: number;
|
|
148
|
+
}
|
|
149
|
+
export interface TimingData {
|
|
150
|
+
domContentLoaded: number;
|
|
151
|
+
sdkInitTime: number;
|
|
152
|
+
interactionStart: number;
|
|
153
|
+
verificationComplete: number;
|
|
154
|
+
jsMeasurements: {
|
|
155
|
+
operation: string;
|
|
156
|
+
durationMs: number;
|
|
157
|
+
}[];
|
|
158
|
+
performanceNowPrecision: number;
|
|
159
|
+
}
|
|
160
|
+
export interface IntegrityData {
|
|
161
|
+
sdkHash: string;
|
|
162
|
+
patchedFunctions: string[];
|
|
163
|
+
devtoolsDetected: boolean;
|
|
164
|
+
}
|
|
165
|
+
export interface InitResponse {
|
|
166
|
+
sessionId: string;
|
|
167
|
+
challengeType: string;
|
|
168
|
+
powDifficulty: number;
|
|
169
|
+
powChallenge: string;
|
|
170
|
+
challengeConfig: any;
|
|
171
|
+
publicKey: string;
|
|
172
|
+
}
|
|
173
|
+
export interface VerifyResponse {
|
|
174
|
+
success: boolean;
|
|
175
|
+
decision: string;
|
|
176
|
+
score: number;
|
|
177
|
+
riskLabels: string[];
|
|
178
|
+
validateToken: string;
|
|
179
|
+
layerResults?: any[];
|
|
180
|
+
nextChallenge?: string;
|
|
181
|
+
degraded?: boolean;
|
|
182
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
export declare class CaptchaContainer {
|
|
2
|
+
private overlay;
|
|
3
|
+
private container;
|
|
4
|
+
private closeBtn;
|
|
5
|
+
private contentArea;
|
|
6
|
+
private brand;
|
|
7
|
+
private statusRegion;
|
|
8
|
+
private onCloseCallback?;
|
|
9
|
+
private autoCloseTimer?;
|
|
10
|
+
private previousFocus;
|
|
11
|
+
private onKeyDown;
|
|
12
|
+
show(content: HTMLElement, onClose?: () => void): HTMLElement;
|
|
13
|
+
close(): void;
|
|
14
|
+
showLoading(): HTMLElement;
|
|
15
|
+
showResult(success: boolean, message?: string): void;
|
|
16
|
+
updateContent(content: HTMLElement): void;
|
|
17
|
+
getContainer(): HTMLElement | null;
|
|
18
|
+
private announceStatus;
|
|
19
|
+
private setupFocusTrap;
|
|
20
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function injectStyles(): void;
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WASM Core Logic Framework — protect critical SDK logic in WebAssembly.
|
|
3
|
+
*
|
|
4
|
+
* This module provides a framework for running sensitive computations in WASM,
|
|
5
|
+
* making reverse-engineering significantly harder than plain JavaScript.
|
|
6
|
+
*
|
|
7
|
+
* Currently provides:
|
|
8
|
+
* 1. WASM-based PoW computation (faster + harder to bypass)
|
|
9
|
+
* 2. WASM-based fingerprint hashing
|
|
10
|
+
* 3. Runtime integrity verification
|
|
11
|
+
*/
|
|
12
|
+
export interface WasmCore {
|
|
13
|
+
computePoW(challenge: string, difficulty: number): Promise<{
|
|
14
|
+
nonce: string;
|
|
15
|
+
hash: string;
|
|
16
|
+
iterations: number;
|
|
17
|
+
}>;
|
|
18
|
+
hashFingerprint(data: string): string;
|
|
19
|
+
verifyIntegrity(): boolean;
|
|
20
|
+
}
|
|
21
|
+
export declare function getWasmCore(): Promise<WasmCore>;
|
|
22
|
+
/**
|
|
23
|
+
* Runtime Integrity Monitor — continuously verifies SDK hasn't been tampered with.
|
|
24
|
+
*/
|
|
25
|
+
export declare class RuntimeIntegrityMonitor {
|
|
26
|
+
private checksums;
|
|
27
|
+
private intervalId;
|
|
28
|
+
private tamperCallbacks;
|
|
29
|
+
start(checkIntervalMs?: number): void;
|
|
30
|
+
stop(): void;
|
|
31
|
+
onTamperDetected(callback: (detail: string) => void): void;
|
|
32
|
+
private snapshot;
|
|
33
|
+
private verify;
|
|
34
|
+
private simpleHash;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* SDK Version Rotation Framework — supports serving different obfuscated versions.
|
|
38
|
+
*/
|
|
39
|
+
export interface SDKVersion {
|
|
40
|
+
version: string;
|
|
41
|
+
buildHash: string;
|
|
42
|
+
features: string[];
|
|
43
|
+
}
|
|
44
|
+
export declare function getCurrentSDKVersion(): SDKVersion;
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export declare class EngageLabCaptchaElement extends HTMLElement {
|
|
2
|
+
private _instance;
|
|
3
|
+
static get observedAttributes(): ("auto" | "mode" | "sitekey" | "api-base" | "lang" | "theme" | "entity-hash" | "debug")[];
|
|
4
|
+
connectedCallback(): void;
|
|
5
|
+
disconnectedCallback(): void;
|
|
6
|
+
attributeChangedCallback(name: string, oldValue: string | null, newValue: string | null): void;
|
|
7
|
+
execute(challengeType?: string): void;
|
|
8
|
+
reset(): void;
|
|
9
|
+
private _createInstance;
|
|
10
|
+
}
|
|
11
|
+
export declare function registerWebComponent(tagName?: string): void;
|
package/package.json
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "engagelab-captcha-sdk",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "EngageLab CAPTCHA Web SDK - behavioral verification",
|
|
5
|
+
"main": "dist/captcha-sdk.umd.js",
|
|
6
|
+
"module": "dist/captcha-sdk.esm.js",
|
|
7
|
+
"types": "dist/types/index.d.ts",
|
|
8
|
+
"scripts": {
|
|
9
|
+
"build": "rollup -c rollup.config.mjs",
|
|
10
|
+
"dev": "rollup -c rollup.config.mjs -w"
|
|
11
|
+
},
|
|
12
|
+
"devDependencies": {
|
|
13
|
+
"@rollup/plugin-node-resolve": "^16.0.0",
|
|
14
|
+
"@rollup/plugin-typescript": "^12.1.0",
|
|
15
|
+
"javascript-obfuscator": "^5.3.0",
|
|
16
|
+
"rollup": "^4.28.0",
|
|
17
|
+
"rollup-plugin-obfuscator": "^1.1.0",
|
|
18
|
+
"@rollup/plugin-terser": "^0.4.0",
|
|
19
|
+
"tslib": "^2.8.1",
|
|
20
|
+
"typescript": "^5.7.2"
|
|
21
|
+
}
|
|
22
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import resolve from '@rollup/plugin-node-resolve';
|
|
2
|
+
import typescript from '@rollup/plugin-typescript';
|
|
3
|
+
import terser from '@rollup/plugin-terser';
|
|
4
|
+
|
|
5
|
+
const isProduction = process.env.NODE_ENV === 'production';
|
|
6
|
+
|
|
7
|
+
const obfuscatorConfig = {
|
|
8
|
+
compact: true,
|
|
9
|
+
controlFlowFlattening: true,
|
|
10
|
+
controlFlowFlatteningThreshold: 0.5,
|
|
11
|
+
deadCodeInjection: true,
|
|
12
|
+
deadCodeInjectionThreshold: 0.2,
|
|
13
|
+
identifierNamesGenerator: 'hexadecimal',
|
|
14
|
+
renameGlobals: false,
|
|
15
|
+
selfDefending: true,
|
|
16
|
+
stringArray: true,
|
|
17
|
+
stringArrayEncoding: ['rc4'],
|
|
18
|
+
stringArrayThreshold: 0.5,
|
|
19
|
+
transformObjectKeys: true,
|
|
20
|
+
unicodeEscapeSequence: false,
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
let obfuscatorPlugin = null;
|
|
24
|
+
if (isProduction) {
|
|
25
|
+
try {
|
|
26
|
+
const { default: obfuscator } = await import('rollup-plugin-obfuscator');
|
|
27
|
+
obfuscatorPlugin = obfuscator({ options: obfuscatorConfig });
|
|
28
|
+
} catch {
|
|
29
|
+
console.warn('rollup-plugin-obfuscator not found, skipping obfuscation');
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const sharedPlugins = [
|
|
34
|
+
resolve(),
|
|
35
|
+
typescript({ tsconfig: './tsconfig.json' }),
|
|
36
|
+
terser(),
|
|
37
|
+
...(obfuscatorPlugin ? [obfuscatorPlugin] : []),
|
|
38
|
+
];
|
|
39
|
+
|
|
40
|
+
export default [
|
|
41
|
+
{
|
|
42
|
+
input: 'src/index.ts',
|
|
43
|
+
output: {
|
|
44
|
+
file: 'dist/captcha-sdk.umd.js',
|
|
45
|
+
format: 'umd',
|
|
46
|
+
name: 'EngageLabCaptcha',
|
|
47
|
+
sourcemap: !isProduction,
|
|
48
|
+
inlineDynamicImports: true,
|
|
49
|
+
},
|
|
50
|
+
plugins: sharedPlugins,
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
input: 'src/index.ts',
|
|
54
|
+
output: {
|
|
55
|
+
file: 'dist/captcha-sdk.esm.js',
|
|
56
|
+
format: 'es',
|
|
57
|
+
sourcemap: !isProduction,
|
|
58
|
+
inlineDynamicImports: true,
|
|
59
|
+
},
|
|
60
|
+
plugins: sharedPlugins,
|
|
61
|
+
},
|
|
62
|
+
];
|
package/src/api.ts
ADDED
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import { InitResponse, VerifyResponse, ServiceError } from './types';
|
|
2
|
+
import { generateNonce } from './crypto';
|
|
3
|
+
import { sdkLogger } from './logger';
|
|
4
|
+
|
|
5
|
+
export class CaptchaAPI {
|
|
6
|
+
private baseUrl: string;
|
|
7
|
+
private siteKey: string;
|
|
8
|
+
|
|
9
|
+
constructor(baseUrl: string, siteKey?: string) {
|
|
10
|
+
this.baseUrl = baseUrl.replace(/\/$/, '');
|
|
11
|
+
this.siteKey = siteKey || '';
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
async init(siteKey: string, entityHash?: string): Promise<InitResponse> {
|
|
15
|
+
const started = performance.now();
|
|
16
|
+
const url = `${this.baseUrl}/v1/init`;
|
|
17
|
+
sdkLogger.info('API', `POST ${url}`, {
|
|
18
|
+
siteKey: siteKey ? `${siteKey.substring(0, 8)}…` : '',
|
|
19
|
+
hasEntityHash: !!entityHash,
|
|
20
|
+
});
|
|
21
|
+
try {
|
|
22
|
+
const resp = await this.post('/v1/init', { siteKey, entityHash });
|
|
23
|
+
const ms = (performance.now() - started).toFixed(1);
|
|
24
|
+
sdkLogger.info('API', `init OK in ${ms}ms`, {
|
|
25
|
+
sessionId: resp?.sessionId ? `${String(resp.sessionId).substring(0, 8)}…` : '',
|
|
26
|
+
challengeType: resp?.challengeType,
|
|
27
|
+
powDifficulty: resp?.powDifficulty,
|
|
28
|
+
});
|
|
29
|
+
return resp as InitResponse;
|
|
30
|
+
} catch (e: any) {
|
|
31
|
+
sdkLogger.error('API', `init failed after ${(performance.now() - started).toFixed(1)}ms`, e?.message || e);
|
|
32
|
+
throw e;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async verify(sessionId: string, data: any): Promise<VerifyResponse> {
|
|
37
|
+
const started = performance.now();
|
|
38
|
+
const url = `${this.baseUrl}/v1/verify`;
|
|
39
|
+
const challengeType = data?.challengeType;
|
|
40
|
+
sdkLogger.info('API', `POST ${url}`, {
|
|
41
|
+
sessionId: sessionId ? `${sessionId.substring(0, 8)}…` : '',
|
|
42
|
+
challengeType,
|
|
43
|
+
hasFingerprint: !!data?.fingerprint,
|
|
44
|
+
trajectoryPoints: Array.isArray(data?.trajectory) ? data.trajectory.length : undefined,
|
|
45
|
+
hasPow: !!data?.powResult,
|
|
46
|
+
});
|
|
47
|
+
try {
|
|
48
|
+
const resp = await this.post('/v1/verify', { sessionId, ...data });
|
|
49
|
+
const ms = (performance.now() - started).toFixed(1);
|
|
50
|
+
sdkLogger.info('API', `verify OK in ${ms}ms`, {
|
|
51
|
+
success: resp?.success,
|
|
52
|
+
decision: resp?.decision,
|
|
53
|
+
score: resp?.score,
|
|
54
|
+
nextChallenge: resp?.nextChallenge,
|
|
55
|
+
});
|
|
56
|
+
return resp as VerifyResponse;
|
|
57
|
+
} catch (e: any) {
|
|
58
|
+
sdkLogger.error('API', `verify failed after ${(performance.now() - started).toFixed(1)}ms`, e?.message || e);
|
|
59
|
+
throw e;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async getChallenge(sessionId: string, type: string): Promise<any> {
|
|
64
|
+
const started = performance.now();
|
|
65
|
+
const path = `/v1/challenge/${type}?sessionId=${sessionId}`;
|
|
66
|
+
const fullUrl = `${this.baseUrl}${path.split('?')[0]}`;
|
|
67
|
+
sdkLogger.info('API', `GET challenge`, { url: fullUrl, type, sessionId: `${sessionId.substring(0, 8)}…` });
|
|
68
|
+
try {
|
|
69
|
+
const json = await this.get(`/v1/challenge/${type}?sessionId=${sessionId}`);
|
|
70
|
+
sdkLogger.info('API', `getChallenge OK in ${(performance.now() - started).toFixed(1)}ms`, {
|
|
71
|
+
hasImage: !!(json?.imageBase64 || json?.image_url),
|
|
72
|
+
keys: json && typeof json === 'object' ? Object.keys(json).slice(0, 12) : [],
|
|
73
|
+
});
|
|
74
|
+
return json;
|
|
75
|
+
} catch (e: any) {
|
|
76
|
+
sdkLogger.error('API', `getChallenge failed after ${(performance.now() - started).toFixed(1)}ms`, e?.message || e);
|
|
77
|
+
throw e;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async getResult(sessionId: string): Promise<any> {
|
|
82
|
+
const started = performance.now();
|
|
83
|
+
sdkLogger.info('API', `GET result`, { sessionId: `${sessionId.substring(0, 8)}…` });
|
|
84
|
+
try {
|
|
85
|
+
const json = await this.get(`/v1/result/${sessionId}`);
|
|
86
|
+
sdkLogger.debug('API', `getResult OK in ${(performance.now() - started).toFixed(1)}ms`, { keys: json ? Object.keys(json) : [] });
|
|
87
|
+
return json;
|
|
88
|
+
} catch (e: any) {
|
|
89
|
+
sdkLogger.error('API', `getResult failed`, e?.message || e);
|
|
90
|
+
throw e;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
private async post(path: string, body: any): Promise<any> {
|
|
95
|
+
const t0 = performance.now();
|
|
96
|
+
const nonce = generateNonce();
|
|
97
|
+
const timestamp = Date.now();
|
|
98
|
+
const method = 'POST';
|
|
99
|
+
const url = `${this.baseUrl}${path}`;
|
|
100
|
+
sdkLogger.debug('API', `${method} ${path}`, {
|
|
101
|
+
keys: body && typeof body === 'object' ? Object.keys(body) : [],
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
let resp: Response;
|
|
105
|
+
try {
|
|
106
|
+
resp = await fetch(url, {
|
|
107
|
+
method: 'POST',
|
|
108
|
+
headers: {
|
|
109
|
+
'Content-Type': 'application/json',
|
|
110
|
+
'X-Site-Key': this.siteKey,
|
|
111
|
+
'X-Request-Nonce': nonce,
|
|
112
|
+
'X-Request-Timestamp': String(timestamp),
|
|
113
|
+
},
|
|
114
|
+
body: JSON.stringify(body),
|
|
115
|
+
});
|
|
116
|
+
} catch (e: any) {
|
|
117
|
+
sdkLogger.error('API', `fetch POST ${path} network error`, e?.message || e);
|
|
118
|
+
throw e;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const elapsed = (performance.now() - t0).toFixed(1);
|
|
122
|
+
if (!resp.ok) {
|
|
123
|
+
sdkLogger.error('API', `POST ${path} HTTP ${resp.status} in ${elapsed}ms`, resp.statusText);
|
|
124
|
+
if (resp.status === 503) {
|
|
125
|
+
let body: any = {};
|
|
126
|
+
try { body = await resp.json(); } catch { /* ignore */ }
|
|
127
|
+
throw new ServiceError('service_unavailable', body.message || 'Service temporarily unavailable');
|
|
128
|
+
}
|
|
129
|
+
throw new Error(`API error: ${resp.status} ${resp.statusText}`);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
let json: any;
|
|
133
|
+
try {
|
|
134
|
+
json = await resp.json();
|
|
135
|
+
} catch (e: any) {
|
|
136
|
+
sdkLogger.error('API', `POST ${path} JSON parse error`, e?.message || e);
|
|
137
|
+
throw e;
|
|
138
|
+
}
|
|
139
|
+
sdkLogger.debug('API', `POST ${path} ${resp.status} in ${elapsed}ms`);
|
|
140
|
+
return json;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
private async get(path: string): Promise<any> {
|
|
144
|
+
const t0 = performance.now();
|
|
145
|
+
const separator = path.includes('?') ? '&' : '?';
|
|
146
|
+
const url = `${this.baseUrl}${path}${separator}siteKey=${encodeURIComponent(this.siteKey)}`;
|
|
147
|
+
sdkLogger.debug('API', `GET ${path.split('?')[0]}`);
|
|
148
|
+
|
|
149
|
+
let resp: Response;
|
|
150
|
+
try {
|
|
151
|
+
resp = await fetch(url, {
|
|
152
|
+
headers: { 'X-Site-Key': this.siteKey },
|
|
153
|
+
});
|
|
154
|
+
} catch (e: any) {
|
|
155
|
+
sdkLogger.error('API', `fetch GET error`, e?.message || e);
|
|
156
|
+
throw e;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const elapsed = (performance.now() - t0).toFixed(1);
|
|
160
|
+
if (!resp.ok) {
|
|
161
|
+
sdkLogger.error('API', `GET HTTP ${resp.status} in ${elapsed}ms`, path);
|
|
162
|
+
if (resp.status === 503) {
|
|
163
|
+
let body: any = {};
|
|
164
|
+
try { body = await resp.json(); } catch { /* ignore */ }
|
|
165
|
+
throw new ServiceError('service_unavailable', body.message || 'Service temporarily unavailable');
|
|
166
|
+
}
|
|
167
|
+
throw new Error(`API error: ${resp.status}`);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
let json: any;
|
|
171
|
+
try {
|
|
172
|
+
json = await resp.json();
|
|
173
|
+
} catch (e: any) {
|
|
174
|
+
sdkLogger.error('API', `GET JSON parse error`, e?.message || e);
|
|
175
|
+
throw e;
|
|
176
|
+
}
|
|
177
|
+
sdkLogger.debug('API', `GET ${resp.status} in ${elapsed}ms`);
|
|
178
|
+
return json;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import { TrajectoryRecorder } from '../trajectory';
|
|
2
|
+
import { t } from '../i18n';
|
|
3
|
+
|
|
4
|
+
export interface DragSortResult {
|
|
5
|
+
sortOrder: number[];
|
|
6
|
+
trajectory: any[];
|
|
7
|
+
passtime: number;
|
|
8
|
+
dragEvents: { fromIndex: number; toIndex: number; timestamp: number }[];
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export class DragSortChallenge {
|
|
12
|
+
private recorder = new TrajectoryRecorder();
|
|
13
|
+
private startTime = 0;
|
|
14
|
+
private currentOrder: number[] = [];
|
|
15
|
+
private dragEvents: { fromIndex: number; toIndex: number; timestamp: number }[] = [];
|
|
16
|
+
private draggedEl: HTMLElement | null = null;
|
|
17
|
+
|
|
18
|
+
create(imageBase64: string, instruction: string, itemCount: number): {
|
|
19
|
+
element: HTMLElement;
|
|
20
|
+
getResult: () => DragSortResult;
|
|
21
|
+
} {
|
|
22
|
+
const wrapper = document.createElement('div');
|
|
23
|
+
wrapper.style.cssText = 'width:100%;max-width:380px;margin:0 auto;';
|
|
24
|
+
|
|
25
|
+
const instructionEl = document.createElement('div');
|
|
26
|
+
instructionEl.style.cssText = 'font-size:14px;color:#333;margin-bottom:12px;text-align:center;font-weight:500;';
|
|
27
|
+
instructionEl.textContent = instruction;
|
|
28
|
+
wrapper.appendChild(instructionEl);
|
|
29
|
+
|
|
30
|
+
const img = document.createElement('img');
|
|
31
|
+
img.src = `data:image/png;base64,${imageBase64}`;
|
|
32
|
+
img.style.cssText = 'width:100%;border-radius:8px;margin-bottom:12px;pointer-events:none;user-select:none;';
|
|
33
|
+
wrapper.appendChild(img);
|
|
34
|
+
|
|
35
|
+
const hint = document.createElement('div');
|
|
36
|
+
hint.style.cssText = 'font-size:12px;color:#999;text-align:center;margin-bottom:8px;';
|
|
37
|
+
hint.textContent = t('dragsort.hint');
|
|
38
|
+
wrapper.appendChild(hint);
|
|
39
|
+
|
|
40
|
+
const listContainer = document.createElement('div');
|
|
41
|
+
listContainer.style.cssText = 'display:flex;flex-direction:column;gap:6px;';
|
|
42
|
+
listContainer.setAttribute('role', 'list');
|
|
43
|
+
listContainer.setAttribute('aria-label', instruction);
|
|
44
|
+
wrapper.appendChild(listContainer);
|
|
45
|
+
|
|
46
|
+
this.currentOrder = Array.from({ length: itemCount }, (_, i) => i);
|
|
47
|
+
|
|
48
|
+
for (let i = 0; i < itemCount; i++) {
|
|
49
|
+
const item = document.createElement('div');
|
|
50
|
+
item.style.cssText = `
|
|
51
|
+
display:flex;align-items:center;padding:10px 12px;
|
|
52
|
+
background:#f5f5f5;border:2px solid #e0e0e0;border-radius:6px;
|
|
53
|
+
cursor:grab;user-select:none;font-size:14px;color:#333;
|
|
54
|
+
transition:transform 0.15s,box-shadow 0.15s;
|
|
55
|
+
`;
|
|
56
|
+
item.dataset.originalIndex = String(i);
|
|
57
|
+
item.draggable = true;
|
|
58
|
+
item.setAttribute('role', 'listitem');
|
|
59
|
+
item.setAttribute('tabindex', '0');
|
|
60
|
+
|
|
61
|
+
const handle = document.createElement('span');
|
|
62
|
+
handle.style.cssText = 'margin-right:10px;color:#bbb;font-size:16px;flex-shrink:0;';
|
|
63
|
+
handle.setAttribute('aria-hidden', 'true');
|
|
64
|
+
handle.textContent = '⋮⋮';
|
|
65
|
+
item.appendChild(handle);
|
|
66
|
+
|
|
67
|
+
const numBadge = document.createElement('span');
|
|
68
|
+
numBadge.style.cssText = `
|
|
69
|
+
display:inline-flex;align-items:center;justify-content:center;
|
|
70
|
+
width:24px;height:24px;border-radius:4px;margin-right:8px;
|
|
71
|
+
font-size:12px;font-weight:bold;color:white;flex-shrink:0;
|
|
72
|
+
background:#1890ff;
|
|
73
|
+
`;
|
|
74
|
+
numBadge.textContent = String(i + 1);
|
|
75
|
+
item.appendChild(numBadge);
|
|
76
|
+
|
|
77
|
+
const label = document.createElement('span');
|
|
78
|
+
label.className = 'elc-drag-label';
|
|
79
|
+
label.style.cssText = 'flex:1;';
|
|
80
|
+
label.textContent = `${t('dragsort.hint').charAt(0) === '请' ? '第' : 'Item '}${i + 1}${t('dragsort.hint').charAt(0) === '请' ? '项' : ''}`;
|
|
81
|
+
item.appendChild(label);
|
|
82
|
+
|
|
83
|
+
this.setupDragHandlers(item, listContainer);
|
|
84
|
+
listContainer.appendChild(item);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
this.recorder.start(wrapper);
|
|
88
|
+
this.startTime = Date.now();
|
|
89
|
+
|
|
90
|
+
return {
|
|
91
|
+
element: wrapper,
|
|
92
|
+
getResult: () => ({
|
|
93
|
+
sortOrder: this.getCurrentOrder(listContainer),
|
|
94
|
+
trajectory: this.recorder.getPoints(),
|
|
95
|
+
passtime: Date.now() - this.startTime,
|
|
96
|
+
dragEvents: this.dragEvents,
|
|
97
|
+
}),
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
private getCurrentOrder(container: HTMLElement): number[] {
|
|
102
|
+
return Array.from(container.children).map(
|
|
103
|
+
c => parseInt((c as HTMLElement).dataset.originalIndex || '0')
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
private setupDragHandlers(item: HTMLElement, container: HTMLElement): void {
|
|
108
|
+
item.addEventListener('dragstart', (e) => {
|
|
109
|
+
this.draggedEl = item;
|
|
110
|
+
item.style.opacity = '0.5';
|
|
111
|
+
item.style.cursor = 'grabbing';
|
|
112
|
+
e.dataTransfer!.effectAllowed = 'move';
|
|
113
|
+
e.dataTransfer!.setData('text/plain', '');
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
item.addEventListener('dragend', () => {
|
|
117
|
+
if (this.draggedEl) {
|
|
118
|
+
this.draggedEl.style.opacity = '1';
|
|
119
|
+
this.draggedEl.style.cursor = 'grab';
|
|
120
|
+
}
|
|
121
|
+
this.draggedEl = null;
|
|
122
|
+
container.querySelectorAll('[role="listitem"]').forEach(el => {
|
|
123
|
+
(el as HTMLElement).style.borderColor = '#e0e0e0';
|
|
124
|
+
});
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
item.addEventListener('dragover', (e) => {
|
|
128
|
+
e.preventDefault();
|
|
129
|
+
e.dataTransfer!.dropEffect = 'move';
|
|
130
|
+
if (this.draggedEl && this.draggedEl !== item) {
|
|
131
|
+
item.style.borderColor = '#1890ff';
|
|
132
|
+
}
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
item.addEventListener('dragleave', () => {
|
|
136
|
+
item.style.borderColor = '#e0e0e0';
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
item.addEventListener('drop', (e) => {
|
|
140
|
+
e.preventDefault();
|
|
141
|
+
item.style.borderColor = '#e0e0e0';
|
|
142
|
+
if (!this.draggedEl || this.draggedEl === item) return;
|
|
143
|
+
|
|
144
|
+
const fromIdx = parseInt(this.draggedEl.dataset.originalIndex || '0');
|
|
145
|
+
const toIdx = parseInt(item.dataset.originalIndex || '0');
|
|
146
|
+
|
|
147
|
+
this.dragEvents.push({
|
|
148
|
+
fromIndex: fromIdx,
|
|
149
|
+
toIndex: toIdx,
|
|
150
|
+
timestamp: Date.now(),
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
const children = Array.from(container.children);
|
|
154
|
+
const fromPos = children.indexOf(this.draggedEl);
|
|
155
|
+
const toPos = children.indexOf(item);
|
|
156
|
+
|
|
157
|
+
if (fromPos < toPos) {
|
|
158
|
+
container.insertBefore(this.draggedEl, item.nextSibling);
|
|
159
|
+
} else {
|
|
160
|
+
container.insertBefore(this.draggedEl, item);
|
|
161
|
+
}
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
destroy(): void {
|
|
166
|
+
this.recorder.stop();
|
|
167
|
+
}
|
|
168
|
+
}
|