unshared-clientjs-sdk 2.0.0-rc.9 → 2.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/README.md +6 -6
- package/dist/client.d.ts +43 -35
- package/dist/client.js +1 -1
- package/dist/esm/client.d.mts +43 -35
- package/dist/esm/client.mjs +1 -1
- package/dist/esm/index.d.mts +4 -2
- package/dist/esm/index.mjs +1 -1
- package/dist/esm/middleware/dispatch-dedupe.d.mts +11 -0
- package/dist/esm/middleware/dispatch-dedupe.mjs +1 -0
- package/dist/esm/middleware/index.d.mts +27 -12
- package/dist/esm/middleware/index.mjs +1 -1
- package/dist/esm/middleware/injection/fingerprint-script.d.mts +11 -5
- package/dist/esm/middleware/injection/fingerprint-script.mjs +1 -1
- package/dist/esm/middleware/response-interceptor.d.mts +10 -8
- package/dist/esm/middleware/response-interceptor.mjs +1 -1
- package/dist/esm/middleware/routes/submit-fp.d.mts +16 -9
- package/dist/esm/middleware/routes/submit-fp.mjs +1 -1
- package/dist/esm/middleware/routes/verify.d.mts +13 -8
- package/dist/esm/middleware/routes/verify.mjs +1 -1
- package/dist/esm/middleware/utils/client-ip.d.mts +6 -0
- package/dist/esm/middleware/utils/client-ip.mjs +1 -0
- package/dist/esm/middleware/utils/cookies.d.mts +6 -0
- package/dist/esm/middleware/utils/cookies.mjs +1 -0
- package/dist/esm/middleware/utils/device-id.d.mts +19 -0
- package/dist/esm/middleware/utils/device-id.mjs +1 -0
- package/dist/esm/middleware/utils/http-helpers.d.mts +21 -0
- package/dist/esm/middleware/utils/http-helpers.mjs +1 -0
- package/dist/esm/middleware/utils/include-path.d.mts +6 -0
- package/dist/esm/middleware/utils/include-path.mjs +1 -0
- package/dist/esm/middleware/utils/is-bot.d.mts +5 -0
- package/dist/esm/middleware/utils/is-bot.mjs +1 -0
- package/dist/esm/middleware/utils/secure.d.mts +3 -0
- package/dist/esm/middleware/utils/secure.mjs +1 -0
- package/dist/esm/middleware/utils/sentinel-user-id.d.mts +10 -0
- package/dist/esm/middleware/utils/sentinel-user-id.mjs +1 -0
- package/dist/esm/middleware/utils/skip-paths.mjs +1 -1
- package/dist/esm/middleware/verdict-cache.d.mts +12 -1
- package/dist/esm/middleware/verdict-cache.mjs +1 -1
- package/dist/esm/middleware.d.mts +12 -9
- package/dist/esm/middleware.mjs +1 -1
- package/dist/esm/types.d.mts +44 -0
- package/dist/esm/types.mjs +1 -0
- package/dist/esm/web/index.d.mts +17 -0
- package/dist/esm/web/index.mjs +1 -0
- package/dist/esm/web/protection-handler.d.mts +28 -0
- package/dist/esm/web/protection-handler.mjs +1 -0
- package/dist/esm/web/submit-handler.d.mts +27 -0
- package/dist/esm/web/submit-handler.mjs +1 -0
- package/dist/esm/web/types.d.mts +110 -0
- package/dist/esm/web/types.mjs +1 -0
- package/dist/esm/web/web-helpers.d.mts +55 -0
- package/dist/esm/web/web-helpers.mjs +1 -0
- package/dist/index.d.ts +4 -2
- package/dist/index.js +1 -1
- package/dist/middleware/dispatch-dedupe.d.ts +11 -0
- package/dist/middleware/dispatch-dedupe.js +1 -0
- package/dist/middleware/index.d.ts +27 -12
- package/dist/middleware/index.js +1 -1
- package/dist/middleware/injection/fingerprint-script.d.ts +11 -5
- package/dist/middleware/injection/fingerprint-script.js +1 -1
- package/dist/middleware/response-interceptor.d.ts +10 -8
- package/dist/middleware/response-interceptor.js +1 -1
- package/dist/middleware/routes/submit-fp.d.ts +16 -9
- package/dist/middleware/routes/submit-fp.js +1 -1
- package/dist/middleware/routes/verify.d.ts +13 -8
- package/dist/middleware/routes/verify.js +1 -1
- package/dist/middleware/utils/client-ip.d.ts +6 -0
- package/dist/middleware/utils/client-ip.js +1 -0
- package/dist/middleware/utils/cookies.d.ts +6 -0
- package/dist/middleware/utils/cookies.js +1 -0
- package/dist/middleware/utils/device-id.d.ts +19 -0
- package/dist/middleware/utils/device-id.js +1 -0
- package/dist/middleware/utils/http-helpers.d.ts +21 -0
- package/dist/middleware/utils/http-helpers.js +1 -0
- package/dist/middleware/utils/include-path.d.ts +6 -0
- package/dist/middleware/utils/include-path.js +1 -0
- package/dist/middleware/utils/is-bot.d.ts +5 -0
- package/dist/middleware/utils/is-bot.js +1 -0
- package/dist/middleware/utils/secure.d.ts +3 -0
- package/dist/middleware/utils/secure.js +1 -0
- package/dist/middleware/utils/sentinel-user-id.d.ts +10 -0
- package/dist/middleware/utils/sentinel-user-id.js +1 -0
- package/dist/middleware/utils/skip-paths.js +1 -1
- package/dist/middleware/verdict-cache.d.ts +12 -1
- package/dist/middleware/verdict-cache.js +1 -1
- package/dist/middleware.d.ts +12 -9
- package/dist/middleware.js +1 -1
- package/dist/types.d.ts +44 -0
- package/dist/types.js +1 -0
- package/dist/web/index.d.ts +17 -0
- package/dist/web/index.js +1 -0
- package/dist/web/protection-handler.d.ts +28 -0
- package/dist/web/protection-handler.js +1 -0
- package/dist/web/submit-handler.d.ts +27 -0
- package/dist/web/submit-handler.js +1 -0
- package/dist/web/types.d.ts +110 -0
- package/dist/web/types.js +1 -0
- package/dist/web/web-helpers.d.ts +55 -0
- package/dist/web/web-helpers.js +1 -0
- package/package.json +7 -10
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# unshared-clientjs-sdk
|
|
2
2
|
|
|
3
|
-
Server-side Node.js SDK for [Unshared
|
|
3
|
+
Server-side Node.js SDK for [Unshared](https://unshared.ai) — detect account sharing, analyze user events for fraud, and run email verification flows.
|
|
4
4
|
|
|
5
5
|
---
|
|
6
6
|
|
|
@@ -17,9 +17,9 @@ npm install unshared-clientjs-sdk
|
|
|
17
17
|
## Quick Start
|
|
18
18
|
|
|
19
19
|
```typescript
|
|
20
|
-
import {
|
|
20
|
+
import { UnsharedClient } from 'unshared-clientjs-sdk';
|
|
21
21
|
|
|
22
|
-
const client = new
|
|
22
|
+
const client = new UnsharedClient({
|
|
23
23
|
apiKey: process.env.UNSHARED_API_KEY, // usk_…
|
|
24
24
|
});
|
|
25
25
|
```
|
|
@@ -114,7 +114,7 @@ await client.submitFingerprintEvent(fingerprint, {
|
|
|
114
114
|
|
|
115
115
|
## Express Middleware
|
|
116
116
|
|
|
117
|
-
The middleware adds a proxy route (`POST /unshared/submit-fingerprint-event`) that the browser SDK calls. It handles forwarding fingerprints to Unshared
|
|
117
|
+
The middleware adds a proxy route (`POST /unshared/submit-fingerprint-event`) that the browser SDK calls. It handles forwarding fingerprints to Unshared and attaching your API key.
|
|
118
118
|
|
|
119
119
|
```typescript
|
|
120
120
|
import { createUnsharedMiddleware } from 'unshared-clientjs-sdk/middleware';
|
|
@@ -147,9 +147,9 @@ app.use(createUnsharedMiddleware(client, {
|
|
|
147
147
|
## Configuration
|
|
148
148
|
|
|
149
149
|
```typescript
|
|
150
|
-
new
|
|
150
|
+
new UnsharedClient({
|
|
151
151
|
apiKey: 'usk_…', // required
|
|
152
|
-
baseUrl: 'https://api
|
|
152
|
+
baseUrl: 'https://api.unshared.ai', // optional
|
|
153
153
|
timeout: 10_000, // optional, ms
|
|
154
154
|
maxRetries: 3, // optional
|
|
155
155
|
});
|
package/dist/client.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { FingerprintWireFormat } from '@unshared-labs/shared-types';
|
|
2
|
-
export interface
|
|
2
|
+
export interface UnsharedClientConfig {
|
|
3
3
|
/**
|
|
4
4
|
* Secret API key. Must be kept server-side.
|
|
5
5
|
* Format: usk_…
|
|
@@ -7,7 +7,7 @@ export interface UnsharedLabsClientConfig {
|
|
|
7
7
|
apiKey: string;
|
|
8
8
|
/**
|
|
9
9
|
* Base URL of the Unshared Labs V2 ingress.
|
|
10
|
-
* @default "https://api
|
|
10
|
+
* @default "https://api.unshared.ai"
|
|
11
11
|
*/
|
|
12
12
|
baseUrl?: string;
|
|
13
13
|
/**
|
|
@@ -20,8 +20,28 @@ export interface UnsharedLabsClientConfig {
|
|
|
20
20
|
* @default 3
|
|
21
21
|
*/
|
|
22
22
|
maxRetries?: number;
|
|
23
|
+
/**
|
|
24
|
+
* Custom fetch implementation. Use this to configure connection pooling,
|
|
25
|
+
* custom HTTP agents, or proxies. Defaults to the global `fetch`.
|
|
26
|
+
*
|
|
27
|
+
* @example
|
|
28
|
+
* ```typescript
|
|
29
|
+
* import { Agent, fetch as undiciFetch } from 'undici';
|
|
30
|
+
*
|
|
31
|
+
* const agent = new Agent({
|
|
32
|
+
* keepAliveTimeout: 30_000,
|
|
33
|
+
* connections: 50,
|
|
34
|
+
* });
|
|
35
|
+
*
|
|
36
|
+
* const client = new UnsharedClient({
|
|
37
|
+
* apiKey: 'usk_...',
|
|
38
|
+
* fetch: (url, init) => undiciFetch(url, { ...init, dispatcher: agent }),
|
|
39
|
+
* });
|
|
40
|
+
* ```
|
|
41
|
+
*/
|
|
42
|
+
fetch?: typeof globalThis.fetch;
|
|
23
43
|
}
|
|
24
|
-
export interface
|
|
44
|
+
export interface UnsharedError {
|
|
25
45
|
code: string;
|
|
26
46
|
message: string;
|
|
27
47
|
details?: Record<string, unknown>;
|
|
@@ -35,14 +55,27 @@ export interface UnsharedLabsError {
|
|
|
35
55
|
export interface ApiResult<T = unknown> {
|
|
36
56
|
success: boolean;
|
|
37
57
|
data?: T;
|
|
38
|
-
error?:
|
|
58
|
+
error?: UnsharedError;
|
|
39
59
|
status: number;
|
|
40
60
|
}
|
|
41
61
|
export interface SubmitFingerprintOptions {
|
|
42
62
|
userId?: string;
|
|
63
|
+
/** SDK encrypts before sending. */
|
|
64
|
+
emailAddress?: string;
|
|
43
65
|
sessionHash?: string;
|
|
44
66
|
eventType?: string;
|
|
67
|
+
/** SDK encrypts before sending. */
|
|
45
68
|
ipAddress?: string;
|
|
69
|
+
/** SDK encrypts before sending. */
|
|
70
|
+
userAgent?: string;
|
|
71
|
+
/**
|
|
72
|
+
* Client-supplied idempotency key. Forwarded verbatim as X-Idempotency-Key
|
|
73
|
+
* so the backend's ON CONFLICT (idempotency_key) catches duplicates across
|
|
74
|
+
* reloads, tabs, and concurrent SDK instances. When omitted, a fresh UUID
|
|
75
|
+
* is generated (best-effort dedup only within a single submitFingerprintEvent
|
|
76
|
+
* call's retries).
|
|
77
|
+
*/
|
|
78
|
+
idempotencyKey?: string;
|
|
46
79
|
}
|
|
47
80
|
export interface SubmitFingerprintResult {
|
|
48
81
|
hash: string;
|
|
@@ -53,12 +86,14 @@ export interface SubmitFingerprintResult {
|
|
|
53
86
|
}
|
|
54
87
|
export interface ProcessUserEventParams {
|
|
55
88
|
eventType: string;
|
|
89
|
+
/** SDK encrypts before sending. */
|
|
56
90
|
userId: string;
|
|
57
|
-
/**
|
|
91
|
+
/** SDK encrypts before sending. */
|
|
58
92
|
ipAddress: string;
|
|
59
93
|
/** SDK encrypts before sending. */
|
|
60
94
|
deviceId: string;
|
|
61
95
|
sessionHash: string;
|
|
96
|
+
/** SDK encrypts before sending. */
|
|
62
97
|
userAgent: string;
|
|
63
98
|
/** SDK encrypts before sending. */
|
|
64
99
|
emailAddress: string;
|
|
@@ -95,29 +130,14 @@ export interface VerifyResult {
|
|
|
95
130
|
verified: boolean;
|
|
96
131
|
reason?: 'not_found' | 'code_mismatch' | 'code_expired';
|
|
97
132
|
}
|
|
98
|
-
export
|
|
99
|
-
type: 'message' | 'email_input' | 'otp_input' | 'support_link';
|
|
100
|
-
title: string;
|
|
101
|
-
body: string;
|
|
102
|
-
buttonText?: string;
|
|
103
|
-
url?: string;
|
|
104
|
-
}
|
|
105
|
-
export interface VerificationFlowConfigResult {
|
|
106
|
-
steps: VerificationFlowStep[];
|
|
107
|
-
branding?: {
|
|
108
|
-
companyName?: string;
|
|
109
|
-
logoUrl?: string;
|
|
110
|
-
primaryColor?: string;
|
|
111
|
-
supportEmail?: string;
|
|
112
|
-
};
|
|
113
|
-
}
|
|
114
|
-
export declare class UnsharedLabsClient {
|
|
133
|
+
export declare class UnsharedClient {
|
|
115
134
|
private readonly _apiKey;
|
|
116
135
|
private readonly _baseUrl;
|
|
117
136
|
private readonly _timeout;
|
|
118
137
|
private readonly _maxRetries;
|
|
119
138
|
private readonly _encryptionKey;
|
|
120
|
-
|
|
139
|
+
private readonly _customFetch;
|
|
140
|
+
constructor(config: UnsharedClientConfig);
|
|
121
141
|
private _encrypt;
|
|
122
142
|
/**
|
|
123
143
|
* Core HTTP method with retry logic.
|
|
@@ -181,16 +201,4 @@ export declare class UnsharedLabsClient {
|
|
|
181
201
|
verify(emailAddress: string, deviceId: string, code: string, opts?: {
|
|
182
202
|
fingerprintId?: string;
|
|
183
203
|
}): Promise<ApiResult<VerifyResult>>;
|
|
184
|
-
/**
|
|
185
|
-
* Fetch the verification flow configuration for this company.
|
|
186
|
-
* Maps to: GET /v2/verification-flow-config
|
|
187
|
-
*
|
|
188
|
-
* Returns the flow steps and branding configured by the Unshared Labs
|
|
189
|
-
* team for this company. The middleware uses this to render the
|
|
190
|
-
* verification overlay.
|
|
191
|
-
*
|
|
192
|
-
* Returns `null` on any failure (network error, 4xx, 5xx) so the
|
|
193
|
-
* middleware can fall back to the default flow.
|
|
194
|
-
*/
|
|
195
|
-
getVerificationFlowConfig(): Promise<VerificationFlowConfigResult | null>;
|
|
196
204
|
}
|
package/dist/client.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
"use strict";Object.defineProperty(exports,"t",{value:!0}),exports.
|
|
1
|
+
"use strict";Object.defineProperty(exports,"t",{value:!0}),exports.UnsharedClient=void 0;const crypto_1=require("crypto"),util_1=require("./util"),DEFAULT_BASE_URL="https://api.unshared.ai",DEFAULT_TIMEOUT_MS=1e4,DEFAULT_MAX_RETRIES=3,MAX_DELAY_MS=3e4,BASE_DELAY_MS=1e3;function sleep(e){return new Promise(s=>setTimeout(s,e))}function retryDelay(e){const s=Math.min(1e3*Math.pow(2,e-1),3e4),t=s*(.5*Math.random()-.25);return Math.max(0,s+t)}async function parseErrorBody(e){const s=await e.text().catch(()=>"");try{const t=JSON.parse(s);return t?.error?.code?{code:t.error.code,message:t.error.message??"Unknown error",details:t.error.details}:{code:"UNKNOWN_ERROR",message:s||e.statusText}}catch{return{code:"UNKNOWN_ERROR",message:s||e.statusText}}}class UnsharedClient{constructor(e){if(!e.apiKey||""===e.apiKey.trim())throw new Error("apiKey is required");this.i=e.apiKey,this.o=e.baseUrl?e.baseUrl.replace(/\/$/,""):DEFAULT_BASE_URL,this.h=e.timeout??1e4,this.u=e.maxRetries??3,this.l=(0,crypto_1.createHash)("sha256").update(e.apiKey).digest(),this._=e.fetch}p(e){return(0,util_1.encryptData)(e,this.l)}async m(e,s){const t=this.u+1;let r={success:!1,status:0,error:{code:"NETWORK_ERROR",message:"Request failed"}};for(let i=1;i<=t;i++){i>1&&await sleep(retryDelay(i-1));const t=new AbortController,a=setTimeout(()=>t.abort(),this.h);try{const i=this._??globalThis.fetch,n=await i(e,{method:s.method,headers:{"X-API-Key":this.i,...s.headers},body:s.body,signal:t.signal});if(clearTimeout(a),n.ok){const e=await n.text().catch(()=>"{}");let s;try{s=JSON.parse(e)}catch{s={}}const t="data"in s?s.data:s;return{success:!0,status:n.status,data:t}}const o=await parseErrorBody(n);if(n.status>=400&&n.status<500){if(429===n.status){const e=n.headers.get("Retry-After");if(null!=e){const s=parseInt(e,10);isNaN(s)||(o.retryAfter=s)}}return{success:!1,status:n.status,error:o}}r={success:!1,status:n.status,error:o}}catch(e){clearTimeout(a),r={success:!1,status:0,error:{code:"NETWORK_ERROR",message:e instanceof Error?e.message:String(e)}}}}return r}async submitFingerprintEvent(e,s){const t={hash:e.full_hash,stable_hash:e.fingerprint_id,collected_at:e.timestamp,is_incognito:e.isIncognito,components:e.components,version:e.version};return null!=s?.userId&&(t.user_id=this.p(s.userId)),null!=s?.emailAddress&&(t.email_address=this.p(s.emailAddress)),null!=s?.sessionHash&&(t.session_hash=s.sessionHash),null!=s?.eventType&&(t.event_type=s.eventType),null!=s?.ipAddress&&(t.ip_address=this.p(s.ipAddress)),null!=s?.userAgent&&(t.user_agent=this.p(s.userAgent)),this.m(`${this.o}/v2/submit-fingerprint-event`,{method:"POST",headers:{"Content-Type":"application/json","X-Idempotency-Key":s?.idempotencyKey??(0,crypto_1.randomUUID)()},body:JSON.stringify(t)})}async processUserEvent(e){const s={event_type:e.eventType,user_id:this.p(e.userId),ip_address:this.p(e.ipAddress),device_id:this.p(e.deviceId),session_hash:e.sessionHash,user_agent:this.p(e.userAgent),email_address:this.p(e.emailAddress)};return null!=e.fingerprintId&&(s.fingerprint_id=this.p(e.fingerprintId)),null!=e.subscriptionStatus&&(s.subscription_status=e.subscriptionStatus),null!=e.eventDetails&&(s.event_details=e.eventDetails),this.m(`${this.o}/v2/process-user-event`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(s)})}async checkUser(e,s){const t="string"==typeof s?{deviceId:s}:s;if(!t.deviceId&&!t.fingerprintId)return{success:!0,status:200,data:{is_user_flagged:!1}};const r=new URLSearchParams;r.set("email_address",this.p(e)),t.deviceId&&r.set("device_id",this.p(t.deviceId)),t.fingerprintId&&r.set("fingerprint_id",this.p(t.fingerprintId));const i=await this.m(`${this.o}/v2/check-user?${r}`,{method:"GET"});return i.success?i:{success:!0,status:200,data:{is_user_flagged:!1}}}async triggerEmailVerification(e,s,t){const r={email_address:this.p(e),device_id:this.p(s)};t?.fingerprintId&&(r.fingerprint_id=this.p(t.fingerprintId));const i=await this.m(`${this.o}/v2/trigger-email-verification`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(r)});return!i.success&&(0===i.status||i.status>=500)?{success:!1,status:i.status,error:{code:"DELIVERY_FAILED",message:i.error?.message??"Delivery failed"}}:i}async verify(e,s,t,r){const i={email_address:this.p(e),device_id:this.p(s),code:this.p(t)};r?.fingerprintId&&(i.fingerprint_id=this.p(r.fingerprintId));const a=await this.m(`${this.o}/v2/verify`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(i)});return!a.success&&(0===a.status||a.status>=500)?{success:!1,status:a.status,error:{code:"DELIVERY_FAILED",message:a.error?.message??"Delivery failed"}}:a.success&&!1===a.data?.verified?{success:!1,status:a.status,error:{code:"VERIFICATION_FAILED",message:"Code is incorrect or expired",details:a.data.reason?{reason:a.data.reason}:void 0}}:a}}exports.UnsharedClient=UnsharedClient;
|
package/dist/esm/client.d.mts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { FingerprintWireFormat } from '@unshared-labs/shared-types';
|
|
2
|
-
export interface
|
|
2
|
+
export interface UnsharedClientConfig {
|
|
3
3
|
/**
|
|
4
4
|
* Secret API key. Must be kept server-side.
|
|
5
5
|
* Format: usk_…
|
|
@@ -7,7 +7,7 @@ export interface UnsharedLabsClientConfig {
|
|
|
7
7
|
apiKey: string;
|
|
8
8
|
/**
|
|
9
9
|
* Base URL of the Unshared Labs V2 ingress.
|
|
10
|
-
* @default "https://api
|
|
10
|
+
* @default "https://api.unshared.ai"
|
|
11
11
|
*/
|
|
12
12
|
baseUrl?: string;
|
|
13
13
|
/**
|
|
@@ -20,8 +20,28 @@ export interface UnsharedLabsClientConfig {
|
|
|
20
20
|
* @default 3
|
|
21
21
|
*/
|
|
22
22
|
maxRetries?: number;
|
|
23
|
+
/**
|
|
24
|
+
* Custom fetch implementation. Use this to configure connection pooling,
|
|
25
|
+
* custom HTTP agents, or proxies. Defaults to the global `fetch`.
|
|
26
|
+
*
|
|
27
|
+
* @example
|
|
28
|
+
* ```typescript
|
|
29
|
+
* import { Agent, fetch as undiciFetch } from 'undici';
|
|
30
|
+
*
|
|
31
|
+
* const agent = new Agent({
|
|
32
|
+
* keepAliveTimeout: 30_000,
|
|
33
|
+
* connections: 50,
|
|
34
|
+
* });
|
|
35
|
+
*
|
|
36
|
+
* const client = new UnsharedClient({
|
|
37
|
+
* apiKey: 'usk_...',
|
|
38
|
+
* fetch: (url, init) => undiciFetch(url, { ...init, dispatcher: agent }),
|
|
39
|
+
* });
|
|
40
|
+
* ```
|
|
41
|
+
*/
|
|
42
|
+
fetch?: typeof globalThis.fetch;
|
|
23
43
|
}
|
|
24
|
-
export interface
|
|
44
|
+
export interface UnsharedError {
|
|
25
45
|
code: string;
|
|
26
46
|
message: string;
|
|
27
47
|
details?: Record<string, unknown>;
|
|
@@ -35,14 +55,27 @@ export interface UnsharedLabsError {
|
|
|
35
55
|
export interface ApiResult<T = unknown> {
|
|
36
56
|
success: boolean;
|
|
37
57
|
data?: T;
|
|
38
|
-
error?:
|
|
58
|
+
error?: UnsharedError;
|
|
39
59
|
status: number;
|
|
40
60
|
}
|
|
41
61
|
export interface SubmitFingerprintOptions {
|
|
42
62
|
userId?: string;
|
|
63
|
+
/** SDK encrypts before sending. */
|
|
64
|
+
emailAddress?: string;
|
|
43
65
|
sessionHash?: string;
|
|
44
66
|
eventType?: string;
|
|
67
|
+
/** SDK encrypts before sending. */
|
|
45
68
|
ipAddress?: string;
|
|
69
|
+
/** SDK encrypts before sending. */
|
|
70
|
+
userAgent?: string;
|
|
71
|
+
/**
|
|
72
|
+
* Client-supplied idempotency key. Forwarded verbatim as X-Idempotency-Key
|
|
73
|
+
* so the backend's ON CONFLICT (idempotency_key) catches duplicates across
|
|
74
|
+
* reloads, tabs, and concurrent SDK instances. When omitted, a fresh UUID
|
|
75
|
+
* is generated (best-effort dedup only within a single submitFingerprintEvent
|
|
76
|
+
* call's retries).
|
|
77
|
+
*/
|
|
78
|
+
idempotencyKey?: string;
|
|
46
79
|
}
|
|
47
80
|
export interface SubmitFingerprintResult {
|
|
48
81
|
hash: string;
|
|
@@ -53,12 +86,14 @@ export interface SubmitFingerprintResult {
|
|
|
53
86
|
}
|
|
54
87
|
export interface ProcessUserEventParams {
|
|
55
88
|
eventType: string;
|
|
89
|
+
/** SDK encrypts before sending. */
|
|
56
90
|
userId: string;
|
|
57
|
-
/**
|
|
91
|
+
/** SDK encrypts before sending. */
|
|
58
92
|
ipAddress: string;
|
|
59
93
|
/** SDK encrypts before sending. */
|
|
60
94
|
deviceId: string;
|
|
61
95
|
sessionHash: string;
|
|
96
|
+
/** SDK encrypts before sending. */
|
|
62
97
|
userAgent: string;
|
|
63
98
|
/** SDK encrypts before sending. */
|
|
64
99
|
emailAddress: string;
|
|
@@ -95,29 +130,14 @@ export interface VerifyResult {
|
|
|
95
130
|
verified: boolean;
|
|
96
131
|
reason?: 'not_found' | 'code_mismatch' | 'code_expired';
|
|
97
132
|
}
|
|
98
|
-
export
|
|
99
|
-
type: 'message' | 'email_input' | 'otp_input' | 'support_link';
|
|
100
|
-
title: string;
|
|
101
|
-
body: string;
|
|
102
|
-
buttonText?: string;
|
|
103
|
-
url?: string;
|
|
104
|
-
}
|
|
105
|
-
export interface VerificationFlowConfigResult {
|
|
106
|
-
steps: VerificationFlowStep[];
|
|
107
|
-
branding?: {
|
|
108
|
-
companyName?: string;
|
|
109
|
-
logoUrl?: string;
|
|
110
|
-
primaryColor?: string;
|
|
111
|
-
supportEmail?: string;
|
|
112
|
-
};
|
|
113
|
-
}
|
|
114
|
-
export declare class UnsharedLabsClient {
|
|
133
|
+
export declare class UnsharedClient {
|
|
115
134
|
private readonly _apiKey;
|
|
116
135
|
private readonly _baseUrl;
|
|
117
136
|
private readonly _timeout;
|
|
118
137
|
private readonly _maxRetries;
|
|
119
138
|
private readonly _encryptionKey;
|
|
120
|
-
|
|
139
|
+
private readonly _customFetch;
|
|
140
|
+
constructor(config: UnsharedClientConfig);
|
|
121
141
|
private _encrypt;
|
|
122
142
|
/**
|
|
123
143
|
* Core HTTP method with retry logic.
|
|
@@ -181,16 +201,4 @@ export declare class UnsharedLabsClient {
|
|
|
181
201
|
verify(emailAddress: string, deviceId: string, code: string, opts?: {
|
|
182
202
|
fingerprintId?: string;
|
|
183
203
|
}): Promise<ApiResult<VerifyResult>>;
|
|
184
|
-
/**
|
|
185
|
-
* Fetch the verification flow configuration for this company.
|
|
186
|
-
* Maps to: GET /v2/verification-flow-config
|
|
187
|
-
*
|
|
188
|
-
* Returns the flow steps and branding configured by the Unshared Labs
|
|
189
|
-
* team for this company. The middleware uses this to render the
|
|
190
|
-
* verification overlay.
|
|
191
|
-
*
|
|
192
|
-
* Returns `null` on any failure (network error, 4xx, 5xx) so the
|
|
193
|
-
* middleware can fall back to the default flow.
|
|
194
|
-
*/
|
|
195
|
-
getVerificationFlowConfig(): Promise<VerificationFlowConfigResult | null>;
|
|
196
204
|
}
|
package/dist/esm/client.mjs
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
import{createHash,randomUUID}from"crypto";import{encryptData}from"./util";const DEFAULT_BASE_URL="https://api
|
|
1
|
+
import{createHash,randomUUID}from"crypto";import{encryptData}from"./util";const DEFAULT_BASE_URL="https://api.unshared.ai",DEFAULT_TIMEOUT_MS=1e4,DEFAULT_MAX_RETRIES=3,MAX_DELAY_MS=3e4,BASE_DELAY_MS=1e3;function sleep(e){return new Promise(s=>setTimeout(s,e))}function retryDelay(e){const s=Math.min(1e3*Math.pow(2,e-1),3e4),t=s*(.5*Math.random()-.25);return Math.max(0,s+t)}async function parseErrorBody(e){const s=await e.text().catch(()=>"");try{const t=JSON.parse(s);return t?.error?.code?{code:t.error.code,message:t.error.message??"Unknown error",details:t.error.details}:{code:"UNKNOWN_ERROR",message:s||e.statusText}}catch{return{code:"UNKNOWN_ERROR",message:s||e.statusText}}}export class UnsharedClient{constructor(e){if(!e.apiKey||""===e.apiKey.trim())throw new Error("apiKey is required");this.t=e.apiKey,this.i=e.baseUrl?e.baseUrl.replace(/\/$/,""):DEFAULT_BASE_URL,this.o=e.timeout??1e4,this.h=e.maxRetries??3,this.u=createHash("sha256").update(e.apiKey).digest(),this.l=e.fetch}_(e){return encryptData(e,this.u)}async p(e,s){const t=this.h+1;let r={success:!1,status:0,error:{code:"NETWORK_ERROR",message:"Request failed"}};for(let i=1;i<=t;i++){i>1&&await sleep(retryDelay(i-1));const t=new AbortController,a=setTimeout(()=>t.abort(),this.o);try{const i=this.l??globalThis.fetch,n=await i(e,{method:s.method,headers:{"X-API-Key":this.t,...s.headers},body:s.body,signal:t.signal});if(clearTimeout(a),n.ok){const e=await n.text().catch(()=>"{}");let s;try{s=JSON.parse(e)}catch{s={}}const t="data"in s?s.data:s;return{success:!0,status:n.status,data:t}}const o=await parseErrorBody(n);if(n.status>=400&&n.status<500){if(429===n.status){const e=n.headers.get("Retry-After");if(null!=e){const s=parseInt(e,10);isNaN(s)||(o.retryAfter=s)}}return{success:!1,status:n.status,error:o}}r={success:!1,status:n.status,error:o}}catch(e){clearTimeout(a),r={success:!1,status:0,error:{code:"NETWORK_ERROR",message:e instanceof Error?e.message:String(e)}}}}return r}async submitFingerprintEvent(e,s){const t={hash:e.full_hash,stable_hash:e.fingerprint_id,collected_at:e.timestamp,is_incognito:e.isIncognito,components:e.components,version:e.version};return null!=s?.userId&&(t.user_id=this._(s.userId)),null!=s?.emailAddress&&(t.email_address=this._(s.emailAddress)),null!=s?.sessionHash&&(t.session_hash=s.sessionHash),null!=s?.eventType&&(t.event_type=s.eventType),null!=s?.ipAddress&&(t.ip_address=this._(s.ipAddress)),null!=s?.userAgent&&(t.user_agent=this._(s.userAgent)),this.p(`${this.i}/v2/submit-fingerprint-event`,{method:"POST",headers:{"Content-Type":"application/json","X-Idempotency-Key":s?.idempotencyKey??randomUUID()},body:JSON.stringify(t)})}async processUserEvent(e){const s={event_type:e.eventType,user_id:this._(e.userId),ip_address:this._(e.ipAddress),device_id:this._(e.deviceId),session_hash:e.sessionHash,user_agent:this._(e.userAgent),email_address:this._(e.emailAddress)};return null!=e.fingerprintId&&(s.fingerprint_id=this._(e.fingerprintId)),null!=e.subscriptionStatus&&(s.subscription_status=e.subscriptionStatus),null!=e.eventDetails&&(s.event_details=e.eventDetails),this.p(`${this.i}/v2/process-user-event`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(s)})}async checkUser(e,s){const t="string"==typeof s?{deviceId:s}:s;if(!t.deviceId&&!t.fingerprintId)return{success:!0,status:200,data:{is_user_flagged:!1}};const r=new URLSearchParams;r.set("email_address",this._(e)),t.deviceId&&r.set("device_id",this._(t.deviceId)),t.fingerprintId&&r.set("fingerprint_id",this._(t.fingerprintId));const i=await this.p(`${this.i}/v2/check-user?${r}`,{method:"GET"});return i.success?i:{success:!0,status:200,data:{is_user_flagged:!1}}}async triggerEmailVerification(e,s,t){const r={email_address:this._(e),device_id:this._(s)};t?.fingerprintId&&(r.fingerprint_id=this._(t.fingerprintId));const i=await this.p(`${this.i}/v2/trigger-email-verification`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(r)});return!i.success&&(0===i.status||i.status>=500)?{success:!1,status:i.status,error:{code:"DELIVERY_FAILED",message:i.error?.message??"Delivery failed"}}:i}async verify(e,s,t,r){const i={email_address:this._(e),device_id:this._(s),code:this._(t)};r?.fingerprintId&&(i.fingerprint_id=this._(r.fingerprintId));const a=await this.p(`${this.i}/v2/verify`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(i)});return!a.success&&(0===a.status||a.status>=500)?{success:!1,status:a.status,error:{code:"DELIVERY_FAILED",message:a.error?.message??"Delivery failed"}}:a.success&&!1===a.data?.verified?{success:!1,status:a.status,error:{code:"VERIFICATION_FAILED",message:"Code is incorrect or expired",details:a.data.reason?{reason:a.data.reason}:void 0}}:a}}
|
package/dist/esm/index.d.mts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
|
-
export {
|
|
1
|
+
export { UnsharedClient } from './client';
|
|
2
2
|
export { createUnsharedMiddleware, assertTrustProxy } from './middleware';
|
|
3
3
|
export type { MiddlewareOptions } from './middleware';
|
|
4
4
|
export { unsharedBoundToUser, VerdictCache, } from './middleware/index';
|
|
5
5
|
export type { ProtectionConfig, Verdict } from './middleware/index';
|
|
6
|
-
export type {
|
|
6
|
+
export type { UnsharedClientConfig, ApiResult, UnsharedError, SubmitFingerprintOptions, SubmitFingerprintResult, ProcessUserEventParams, ProcessUserEventResult, CheckUserResult, TriggerEmailVerificationResult, VerifyResult, } from './client';
|
|
7
|
+
export type { UnsharedRequest, UnsharedResponse, UnsharedNextFunction } from './types';
|
|
8
|
+
export { sendJson } from './middleware/utils/http-helpers';
|
package/dist/esm/index.mjs
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export{
|
|
1
|
+
export{UnsharedClient}from"./client";export{createUnsharedMiddleware,assertTrustProxy}from"./middleware";export{unsharedBoundToUser,VerdictCache}from"./middleware/index";export{sendJson}from"./middleware/utils/http-helpers";
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export declare class DispatchDedupe {
|
|
2
|
+
private readonly _entries;
|
|
3
|
+
private readonly _ttlMs;
|
|
4
|
+
constructor(ttlMs?: number);
|
|
5
|
+
private _key;
|
|
6
|
+
mark(userId: string, eventType: string): void;
|
|
7
|
+
wasRecentlyDispatched(userId: string, eventType: string): boolean;
|
|
8
|
+
clear(): void;
|
|
9
|
+
get size(): number;
|
|
10
|
+
private _sweep;
|
|
11
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
const DEFAULT_TTL_MS=1e4,SWEEP_THRESHOLD=1e3;export class DispatchDedupe{constructor(t=1e4){this.t=new Map,this.i=t}h(t,s){return`${t}|${s}`}mark(t,s){this.t.set(this.h(t,s),Date.now()),this.t.size>1e3&&this.o()}wasRecentlyDispatched(t,s){const e=this.h(t,s),i=this.t.get(e);return!(void 0===i||Date.now()-i>this.i&&(this.t.delete(e),1))}clear(){this.t.clear()}get size(){return this.t.size}o(){const t=Date.now()-this.i;for(const[s,e]of this.t)e<t&&this.t.delete(s)}}
|
|
@@ -1,19 +1,19 @@
|
|
|
1
|
-
import type {
|
|
2
|
-
import type {
|
|
1
|
+
import type { UnsharedRequest, UnsharedResponse, UnsharedNextFunction } from '../types';
|
|
2
|
+
import type { UnsharedClient } from '../client';
|
|
3
3
|
import { VerdictCache } from './verdict-cache';
|
|
4
4
|
import type { Verdict } from './verdict-cache';
|
|
5
|
-
export interface ProtectionConfig {
|
|
5
|
+
export interface ProtectionConfig<TReq extends UnsharedRequest = UnsharedRequest> {
|
|
6
6
|
/**
|
|
7
7
|
* Required. Resolves the current user's ID from the request.
|
|
8
8
|
* Return undefined for anonymous/logged-out visitors.
|
|
9
9
|
*/
|
|
10
|
-
userId: (req:
|
|
10
|
+
userId: (req: TReq) => string | undefined;
|
|
11
11
|
/**
|
|
12
12
|
* Resolves the current user's email address from the request.
|
|
13
13
|
* Required in Tier 2 (backend-only). Recommended in Tier 1.
|
|
14
14
|
* Falls back to HttpOnly cookie → req.body.email when not configured.
|
|
15
15
|
*/
|
|
16
|
-
emailAddress?: (req:
|
|
16
|
+
emailAddress?: (req: TReq) => string | undefined;
|
|
17
17
|
/** Route prefix for internal routes. @default "/__unshared" */
|
|
18
18
|
routePrefix?: string;
|
|
19
19
|
/** Allowed CORS origins for /__unshared/* routes. */
|
|
@@ -22,13 +22,15 @@ export interface ProtectionConfig {
|
|
|
22
22
|
cacheTTL?: number;
|
|
23
23
|
/** Paths to skip entirely (static assets, health checks). */
|
|
24
24
|
skipPaths?: string[];
|
|
25
|
+
/** When set, only paths matching one of these prefixes get events dispatched and checkUser called. */
|
|
26
|
+
includePathPrefix?: string[];
|
|
25
27
|
/** Resolves a custom session ID. Falls back to __unshared_sid cookie. */
|
|
26
|
-
sessionId?: (req:
|
|
28
|
+
sessionId?: (req: TReq) => string | undefined;
|
|
27
29
|
/**
|
|
28
30
|
* Resolves a device ID from the request.
|
|
29
|
-
* Falls back to
|
|
31
|
+
* Falls back to X-Device-Id header → __unshared_fp_id cookie.
|
|
30
32
|
*/
|
|
31
|
-
deviceId?: (req:
|
|
33
|
+
deviceId?: (req: TReq) => string | undefined;
|
|
32
34
|
/**
|
|
33
35
|
* Called when a flagged, unverified user makes a request.
|
|
34
36
|
* You own the response — block, redirect, or call next() to let it through.
|
|
@@ -40,11 +42,24 @@ export interface ProtectionConfig {
|
|
|
40
42
|
userId: string;
|
|
41
43
|
emailAddress: string;
|
|
42
44
|
verdict: Verdict;
|
|
43
|
-
req:
|
|
44
|
-
res:
|
|
45
|
-
next:
|
|
45
|
+
req: TReq;
|
|
46
|
+
res: UnsharedResponse;
|
|
47
|
+
next: UnsharedNextFunction;
|
|
48
|
+
}) => void;
|
|
49
|
+
/**
|
|
50
|
+
* Called when a background SDK operation fails (fire-and-forget API calls,
|
|
51
|
+
* verdict refreshes, etc.). Use this to pipe errors to your logging or
|
|
52
|
+
* monitoring system for observability.
|
|
53
|
+
*
|
|
54
|
+
* Without this callback, background errors are silently swallowed (fail-open).
|
|
55
|
+
* The middleware never blocks requests due to these errors regardless.
|
|
56
|
+
*/
|
|
57
|
+
onError?: (error: unknown, context: {
|
|
58
|
+
operation: 'processUserEvent' | 'submitFingerprintEvent' | 'checkUser' | 'verifyTrigger' | 'verify';
|
|
59
|
+
userId?: string;
|
|
60
|
+
emailAddress?: string;
|
|
46
61
|
}) => void;
|
|
47
62
|
}
|
|
48
63
|
export type { Verdict };
|
|
49
64
|
export { VerdictCache };
|
|
50
|
-
export declare function unsharedBoundToUser(client:
|
|
65
|
+
export declare function unsharedBoundToUser<TReq extends UnsharedRequest = UnsharedRequest>(client: UnsharedClient, config: ProtectionConfig<TReq>): (req: TReq, res: UnsharedResponse, next: UnsharedNextFunction) => void;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
import{readFileSync}from"fs";import{VerdictCache}from"./verdict-cache";import{RateLimitBackoff}from"./rate-limit-backoff";import{interceptResponse}from"./response-interceptor";import{generateFingerprintScript}from"./injection/fingerprint-script";import{handleSubmitFingerprint}from"./routes/submit-fp";import{handleVerifyTrigger,handleVerify}from"./routes/verify";import{isHtmlContentType}from"./utils/content-type";import{shouldSkipPath}from"./utils/skip-paths";export{VerdictCache};const CHECK_USER_TIMEOUT_MS=500;export function unsharedBoundToUser(e,t){if(!t.userId)throw new Error("[Unshared] userId resolver is required");if(!t.emailAddress){let e=!1;try{require.resolve("unshared-frontend-sdk"),e=!0}catch{}e||console.warn("[Unshared] Warning: emailAddress resolver is not configured and unshared-frontend-sdk is not installed.\nNo user events will be submitted. Either install unshared-frontend-sdk (Tier 1) or\nprovide emailAddress in your middleware config (Tier 2).")}const{userId:r,emailAddress:
|
|
1
|
+
import{readFileSync}from"fs";import{VerdictCache}from"./verdict-cache";import{RateLimitBackoff}from"./rate-limit-backoff";import{DispatchDedupe}from"./dispatch-dedupe";import{interceptResponse}from"./response-interceptor";import{generateFingerprintScript}from"./injection/fingerprint-script";import{handleSubmitFingerprint}from"./routes/submit-fp";import{handleVerifyTrigger,handleVerify}from"./routes/verify";import{sendJson,sendEmpty,sendBody,getRequestPath}from"./utils/http-helpers";import{isHtmlContentType}from"./utils/content-type";import{shouldSkipPath}from"./utils/skip-paths";import{shouldIncludePath}from"./utils/include-path";import{isBot}from"./utils/is-bot";import{extractClientIp}from"./utils/client-ip";import{parseCookie}from"./utils/cookies";import{extractDeviceIdOrUndefined}from"./utils/device-id";import{isSecureRequest}from"./utils/secure";import{isSentinelUserId,SENTINEL_STICKINESS_TTL_MS}from"./utils/sentinel-user-id";export{VerdictCache};const CHECK_USER_TIMEOUT_MS=500;export function unsharedBoundToUser(e,t){if(!t.userId)throw new Error("[Unshared] userId resolver is required");if(!t.emailAddress){let e=!1;try{require.resolve("unshared-frontend-sdk"),e=!0}catch{}e||console.warn("[Unshared] Warning: emailAddress resolver is not configured and unshared-frontend-sdk is not installed.\nNo user events will be submitted. Either install unshared-frontend-sdk (Tier 1) or\nprovide emailAddress in your middleware config (Tier 2).")}const{userId:r,emailAddress:i,routePrefix:n="/__unshared",corsOrigins:s,cacheTTL:o=6e4,skipPaths:d,includePathPrefix:a,sessionId:c,deviceId:u,onFlagged:l,onError:p}=t,f=new VerdictCache(o),m=new RateLimitBackoff,h=new DispatchDedupe,S=Date.now().toString(36),I=generateFingerprintScript(n,S);let v="";try{const e=require.resolve("unshared-frontend-sdk/dist/index.umd.js");v=readFileSync(e,"utf8")}catch{}const _=handleSubmitFingerprint({client:e,verdictCache:f,rateLimitBackoff:m,dispatchDedupe:h,resolveUserId:r,resolveEmailAddress:i,resolveSessionId:c,resolveDeviceId:u,onError:p}),C=handleVerifyTrigger({client:e,verdictCache:f,resolveEmailAddress:i,resolveDeviceId:u,onError:p}),g=handleVerify({client:e,verdictCache:f,resolveEmailAddress:i,resolveDeviceId:u,onError:p}),y=s?Array.isArray(s)?s:[s]:null,k=`${n}/fp.js`,A=`${n}/submit-fp`,T=`${n}/verify-trigger`,E=`${n}/verify`,x=`${n}/status`;return function(t,s,o){const S=getRequestPath(t.url),P=t.url||S;if(S.startsWith(n+"/")){if(function(e,t){if(!y)return;const r=e.headers.origin??"",i=y.includes("*");(i||y.includes(r))&&(t.setHeader("Access-Control-Allow-Origin",i?"*":r),t.setHeader("Access-Control-Allow-Methods","POST, OPTIONS"),t.setHeader("Access-Control-Allow-Headers","Content-Type, X-Idempotency-Key, X-Session-Id, X-Device-Id"),t.setHeader("Access-Control-Allow-Credentials","true"))}(t,s),"OPTIONS"===t.method)return void sendEmpty(s,204);if("GET"===t.method&&S===k)return s.setHeader("Content-Type","application/javascript"),s.setHeader("Cache-Control","public, max-age=3600"),void sendBody(s,200,v);if("POST"===t.method&&(S===A||S===T||S===E))return void 0===t.body?void sendJson(s,400,{success:!1,error:{code:"BODY_PARSER_MISSING",message:"req.body is undefined. Mount a JSON body-parsing middleware (e.g., express.json()) before the Unshared middleware."}}):S===A?void _(t,s):S===T?void C(t,s):void g(t,s);if("GET"===t.method&&S===x){let n;try{n=r(t)}catch{}if(!n)return void sendJson(s,200,{status:"anonymous"});const o=resolveEmail(t,i);return void(async()=>{let r=f.get(n);if((!r||f.isStale(n))&&o&&!m.isPaused()&&!f.isRefreshing(n)){f.markRefreshing(n);try{const i=extractDeviceIdOrUndefined(t,u),s=extractFingerprintId(t),d=extractSessionId(t,c),a=i??s??"unknown";await fetchAndCacheVerdict(e,f,n,o,a,s,d),r=f.get(n)}catch(e){p&&p(e,{operation:"checkUser",userId:n,emailAddress:o})}finally{f.clearRefreshing(n)}}r&&r.isFlagged&&!r.isVerified&&l&&o?sendJson(s,200,{status:"flagged",email:o}):sendJson(s,200,{status:"ok"})})()}return void sendJson(s,404,{success:!1,error:{code:"NOT_FOUND",message:"Unknown route"}})}if(shouldSkipPath(S,d))return void o();if(!shouldIncludePath(S,a))return interceptForInjection(t,s,I),void o();let w;try{w=r(t)}catch{}if(isSentinelUserId(w)){const e=parseCookie(t,"__unshared_uid"),r=parseCookie(t,"__unshared_uid_at"),i=r?Number(r):NaN,n=Number.isFinite(i)&&Date.now()-i<=SENTINEL_STICKINESS_TTL_MS;w=e&&n?e:void 0}if(!w){const e=isSecureRequest(t)?"; Secure":"";return appendSetCookie(s,`__unshared_uid=; Path=/; SameSite=Lax; Max-Age=0${e}`),appendSetCookie(s,`__unshared_uid_at=; Path=/; SameSite=Lax; Max-Age=0${e}`),appendSetCookie(s,`__unshared_sid=; Path=/; SameSite=Lax; Max-Age=0${e}`),appendSetCookie(s,`__unshared_email=; Path=/; SameSite=Lax; Max-Age=0${e}`),interceptForInjection(t,s,I),void o()}const U=resolveEmail(t,i);if(setUserIdCookie(t,s,w),U&&setEmailCookie(t,s,U),!U)return interceptForInjection(t,s,I),void o();const F=extractSessionId(t,c),D=extractDeviceIdOrUndefined(t,u),N=extractFingerprintId(t),O=t.headers["user-agent"]??"",V=extractClientIp(t),b=D??N;if(isBot(O))return void o();const L=f.get(w);function R(){"unknown"!==F&&b&&(m.isPaused()||dispatchUserEvent(e,f,m,h,{userId:w,emailAddress:U,sessionId:F,deviceId:b,fingerprintId:N,userAgent:O,ipAddress:V,eventType:P},p))}L?(f.isStale(w)&&!f.isRefreshing(w)&&(f.markRefreshing(w),fetchAndCacheVerdict(e,f,w,U,b??"unknown",N,F).finally(()=>f.clearRefreshing(w))),L.isFlagged||R(),applyVerdict(L,w,U,t,s,o,I,l)):fetchAndCacheVerdict(e,f,w,U,b??"unknown",N,F).then(e=>{e.isFlagged||R(),applyVerdict(e,w,U,t,s,o,I,l)}).catch(()=>{R(),interceptForInjection(t,s,I),o()})}}function resolveEmail(e,t){if(t)try{const r=t(e);if(r)return r}catch{}const r=parseCookie(e,"__unshared_email");if(r)return r;const i=e.body?.email;return"string"==typeof i&&i?i:void 0}function applyVerdict(e,t,r,i,n,s,o,d){if(interceptForInjection(i,n,o),e.isFlagged&&!e.isVerified&&d)try{d({userId:t,emailAddress:r,verdict:e,req:i,res:n,next:s})}catch{s()}else s()}function interceptForInjection(e,t,r){delete e.headers["if-none-match"],delete e.headers["if-modified-since"],interceptResponse(t,(e,t)=>{if(!isHtmlContentType(t))return null;const i=e.toString("utf8"),n=i.lastIndexOf("</body>");return-1===n?i+r:i.slice(0,n)+r+i.slice(n)},{preventCaching:!0})}function dispatchUserEvent(e,t,r,i,n,s){i.mark(n.userId,n.eventType),e.processUserEvent({eventType:n.eventType,userId:n.userId,emailAddress:n.emailAddress,ipAddress:n.ipAddress,deviceId:n.deviceId,fingerprintId:n.fingerprintId,sessionHash:n.sessionId,userAgent:n.userAgent}).then(e=>{e.success&&e.data?.analysis&&t.update(n.userId,{isFlagged:e.data.analysis.is_user_flagged}),!e.success&&e.error?.retryAfter&&r.pause(1e3*e.error.retryAfter)}).catch(e=>{s&&s(e,{operation:"processUserEvent",userId:n.userId,emailAddress:n.emailAddress})})}async function fetchAndCacheVerdict(e,t,r,i,n,s,o){const d={};let a;n&&"unknown"!==n&&(d.deviceId=n),s&&(d.fingerprintId=s);const c=await Promise.race([e.checkUser(i,d),new Promise(e=>{a=setTimeout(()=>e(null),500)})]);if(clearTimeout(a),!c)return{isFlagged:!1,isVerified:!1,emailAddress:i,sessionId:o,cachedAt:0,ttl:0};const u=c.data?.is_user_flagged??!1;return t.set(r,{isFlagged:u,isVerified:!1,emailAddress:i,sessionId:o}),t.get(r)}function extractSessionId(e,t){if(t)try{const r=t(e);if(r)return r}catch{}return parseCookie(e,"__unshared_sid")??"unknown"}function extractFingerprintId(e){return parseCookie(e,"__unshared_fingerprint_id")||void 0}function appendSetCookie(e,t){const r=e.getHeader("Set-Cookie");if(r){const i=Array.isArray(r)?[...r]:[String(r)];i.push(t),e.setHeader("Set-Cookie",i)}else e.setHeader("Set-Cookie",t)}function setUserIdCookie(e,t,r){const i=isSecureRequest(e)?"; Secure":"";appendSetCookie(t,`__unshared_uid=${encodeURIComponent(r)}; Path=/; SameSite=Lax${i}`),appendSetCookie(t,`__unshared_uid_at=${Date.now()}; Path=/; SameSite=Lax${i}`)}function setEmailCookie(e,t,r){const i=isSecureRequest(e)?"; Secure":"";appendSetCookie(t,`__unshared_email=${encodeURIComponent(r)}; HttpOnly; Path=/; SameSite=Lax${i}`)}
|
|
@@ -1,10 +1,16 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Generates
|
|
3
|
-
* 1. Loads the
|
|
4
|
-
* 2. Collects a
|
|
5
|
-
* 3.
|
|
2
|
+
* Generates an inline loader script that:
|
|
3
|
+
* 1. Loads the fingerprint SDK from /__unshared/fp.js
|
|
4
|
+
* 2. Collects a fingerprint and POSTs to /__unshared/submit-fp
|
|
5
|
+
* 3. Caches fingerprint in sessionStorage for reuse on SPA navigations
|
|
6
|
+
* 4. Patches History API to detect SPA route changes → re-submits fingerprint
|
|
7
|
+
* 5. Patches fetch/XHR to detect 403 account_flagged → dispatches "unshared:flagged" event
|
|
6
8
|
*
|
|
7
9
|
* The actual SDK UMD bundle is served by the middleware at /__unshared/fp.js.
|
|
8
|
-
*
|
|
10
|
+
*
|
|
11
|
+
* Event contract:
|
|
12
|
+
* window.addEventListener("unshared:flagged", (e) => {
|
|
13
|
+
* e.detail.email — the flagged user's email (from 403 response body)
|
|
14
|
+
* });
|
|
9
15
|
*/
|
|
10
16
|
export declare function generateFingerprintScript(routePrefix: string, version?: string): string;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
export function generateFingerprintScript(e,
|
|
1
|
+
export function generateFingerprintScript(e,t){const n=t?`?v=${escapeJavaScript(t)}`:"";return`<script>\n(function(){\ntry{\n// --- Bot drop (defense-in-depth) ---\n// Must be the first statement: we do not want to write cookies, localStorage,\n// session IDs, or any network requests for known-bot traffic. Mirrors the\n// regex in unshared-fingerprint-lib/src/detect/bot.ts and Node middleware\n// utils/is-bot.ts. Keep all three in sync.\nvar BOT_RE=/googlebot|bingbot|slurp|baiduspider|duckduckbot|yandex|sogou|exabot|ia_archiver|curl|wget|python-requests|python-urllib|axios|node-fetch|go-http-client|java\\/|libwww-perl|okhttp|apache-httpclient|http_request|httpie|headlesschrome|phantomjs|puppeteer|playwright|cypress|selenium|webdriver|electron|jsdom|vercel-screenshot|screenshot|prerender|lighthouse|chrome-lighthouse|pagespeed|gtmetrix|pingdom|nessus|nikto|sqlmap|burp|zap|qualys|openvas|nmap|masscan|facebookexternalhit|twitterbot|linkedinbot|whatsapp|telegrambot|slackbot|discordbot|bot|crawl|spider|scrape|fetch|scan/i;\nif(typeof navigator!=="undefined"&&navigator.userAgent&&BOT_RE.test(navigator.userAgent))return;\n\nvar pfx="${escapeJavaScript(e)}";\nvar SS_FP="__unshared_fp";\nvar SS_LAST_SUBMIT="__unshared_last_submit";\n\n// Dedup state: skip submit if (user_id + URL) matches last submission.\n// Modern SPAs (Next.js App Router, React Router, etc.) call replaceState\n// 3-5 times during hydration with the same URL — without this guard,\n// each call generates a redundant FP row with identical stable_hash.\n// Persisted to sessionStorage so hard reloads and framework double-boots\n// inside the same tab still dedupe (the in-memory value resets on reload).\nvar lastSubmitKey="";\ntry{lastSubmitKey=sessionStorage.getItem(SS_LAST_SUBMIT)||""}catch(e){}\n\n// --- Helpers ---\nfunction gC(n){var m=document.cookie.match(new RegExp("(?:^|; )"+n+"=([^;]*)"));return m?decodeURIComponent(m[1]):null}\nfunction sC(n,v,d){var e="";if(d){var dt=new Date();dt.setTime(dt.getTime()+d*864e5);e="; expires="+dt.toUTCString()}document.cookie=n+"="+encodeURIComponent(v)+e+"; path=/; SameSite=Lax"}\nfunction uuid(){return(typeof crypto!=="undefined"&&crypto.randomUUID)?crypto.randomUUID():("xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g,function(c){var r=Math.random()*16|0;return(c==="x"?r:r&0x3|0x8).toString(16)}))}\n// Sentinel user IDs that must never be treated as real users. Mirrors\n// the Set in sentinel-user-id.ts — keep in sync. Empty string is handled\n// by the separate !uid checks below.\nvar SENTINEL_UIDS={"__pre_auth__":1,"anonymous":1,"guest":1,"undefined":1,"null":1};\nfunction isSentinelUid(v){return typeof v==="string"&&SENTINEL_UIDS.hasOwnProperty(v)}\n\n// --- Session + device IDs ---\n// Session ID is a UUID because it's supposed to be tab-scoped and random.\n// Device ID is intentionally NOT a UUID — Issue 9: random UUIDs wrote\n// meaningless device_ids to every fingerprint row. Instead we read the\n// stable fingerprint hash from localStorage if a previous submission\n// already persisted it; otherwise we leave did empty and let submitFP()\n// reconcile on the first successful collection. The Node middleware's\n// Issue 8 bootstrap-skip branch handles the empty-device_id window so we\n// never dispatch with a random or "unknown" value.\nvar sid=gC("__unshared_sid");\nif(!sid){sid=uuid();sC("__unshared_sid",sid,365)}\nvar did="";\ntry{did=localStorage.getItem("__unshared_device_id")||""}catch(e){}\nif(did){sC("__unshared_fp_id",did,365)}\n\n// --- Fingerprint cache (sessionStorage) ---\nfunction getFP(){try{var r=sessionStorage.getItem(SS_FP);return r?JSON.parse(r):null}catch(e){return null}}\nfunction setFP(fp){try{sessionStorage.setItem(SS_FP,JSON.stringify(fp))}catch(e){}}\n\n// --- Submit fingerprint to backend ---\nfunction submitFP(fp){\n var uid=gC("__unshared_uid");\n if(!uid||isSentinelUid(uid))return;\n // Issue 9: reconcile device_id to the stable fingerprint hash. This runs\n // before we send the X-Device-Id header so the very first submission\n // already carries the real value. Persist to localStorage so other tabs\n // (and future reloads) pick up the same stable ID without needing to\n // re-collect the fingerprint.\n if(fp.fingerprint_id){\n did=fp.fingerprint_id;\n try{localStorage.setItem("__unshared_device_id",did)}catch(e){}\n sC("__unshared_fp_id",did,365);\n }\n // event_type is the SPA route, not a fixed enum. Page-level event names\n // (page_load/route_change) collapsed every row into one of two buckets;\n // the URL is more useful for analytics and matches the frontend SDK.\n var route=(location.pathname||"/")+(location.search||"");\n var key=uid+"|"+route;\n if(key===lastSubmitKey)return;\n lastSubmitKey=key;\n try{sessionStorage.setItem(SS_LAST_SUBMIT,key)}catch(e){}\n // collected_at is stamped fresh at submit time rather than carried from fp.timestamp,\n // because fp is cached per-tab in sessionStorage — reusing its original timestamp would\n // freeze collected_at at first load and drift against server created_at as the tab ages.\n // The server authoritatively overwrites this value again on ingress.\n var body={hash:fp.full_hash,stable_hash:fp.fingerprint_id,collected_at:(new Date()).toISOString(),is_incognito:fp.isIncognito,components:fp.components,version:fp.version,session_id:sid,user_id:uid,event_type:route};\n // Deterministic idempotency key: (stable_hash, user_id, event_type) fully\n // identifies one logical submission. A stable key lets the backend's\n // ON CONFLICT (idempotency_key) DO NOTHING actually catch duplicates across\n // reloads, tabs, and concurrent SDK instances — a fresh UUID could not.\n var idem=fp.fingerprint_id+"|"+uid+"|"+route;\n var xhr=new XMLHttpRequest();\n xhr.open("POST",pfx+"/submit-fp",true);\n xhr.setRequestHeader("Content-Type","application/json");\n xhr.setRequestHeader("X-Session-Id",sid);\n if(did)xhr.setRequestHeader("X-Device-Id",did);\n xhr.setRequestHeader("X-Idempotency-Key",idem);\n xhr.send(JSON.stringify(body));\n}\n\n// --- Collect fingerprint (loads fp.js if needed) then submit ---\nvar fpReady=false;\nfunction collectAndSubmit(){\n var uid=gC("__unshared_uid");\n if(!uid||isSentinelUid(uid))return;\n var cached=getFP();\n if(cached){submitFP(cached);return}\n if(!fpReady)return;\n try{\n var c=new UnsharedBrowser.UnsharedBrowser({baseUrl:""});\n c.collect({exclude:["timing","speech"]}).then(function(fp){setFP(fp);submitFP(fp)});\n }catch(e){}\n}\n\n// --- Load fp.js (always — browser caches it for 1h) ---\n// Submit cached FP immediately if available; load fp.js for fresh collection\nvar pageLoadSubmitted=false;\nvar _boot_uid=gC("__unshared_uid");\nif(getFP()&&_boot_uid&&!isSentinelUid(_boot_uid)){submitFP(getFP());pageLoadSubmitted=true;deferredCheck()}\nvar s=document.createElement("script");\ns.src=pfx+"/fp.js${n}";\ns.onload=function(){fpReady=true;if(!pageLoadSubmitted){collectAndSubmit();deferredCheck()}};\ndocument.head.appendChild(s);\n\n// --- Deferred verdict check ---\n// After fingerprint submission, the backend processes the event async.\n// If the user was just flagged, the initial page load may have beaten\n// the verdict update. Re-check after a delay so newly flagged sessions\n// get caught without waiting for user interaction.\n// The endpoint always returns 200 so browsers don't log a scary red\n// network error — we inspect the body and dispatch the flagged event\n// ourselves when status==="flagged".\nfunction deferredCheck(){\n var uid=gC("__unshared_uid");\n if(!uid||isSentinelUid(uid))return;\n setTimeout(function(){\n try{fetch(pfx+"/status",{method:"GET",credentials:"same-origin"}).then(function(r){return r.json()}).then(function(b){if(b&&b.status==="flagged")emitFlagged(b)}).catch(function(){})}catch(e){}\n },500);\n}\n\n// --- SPA route change tracking (History API + popstate) ---\nvar oPush=history.pushState,oReplace=history.replaceState;\nhistory.pushState=function(){oPush.apply(this,arguments);try{collectAndSubmit()}catch(e){}};\nhistory.replaceState=function(){oReplace.apply(this,arguments);try{collectAndSubmit()}catch(e){}};\nwindow.addEventListener("popstate",function(){try{collectAndSubmit()}catch(e){}});\n\n// --- 403 interception: dispatch "unshared:flagged" event ---\nfunction emitFlagged(body){\n try{window.dispatchEvent(new CustomEvent("unshared:flagged",{detail:{email:body.email||""}}))}catch(e){}\n}\n\n// Patch fetch\nvar oFetch=window.fetch;\nif(oFetch){window.fetch=function(){return oFetch.apply(this,arguments).then(function(r){if(r.status===403){try{var cl=r.clone();cl.json().then(function(b){if(b&&b.error==="account_flagged")emitFlagged(b)}).catch(function(){})}catch(e){}}return r})}}\n\n// Patch XMLHttpRequest\nvar oXSend=XMLHttpRequest.prototype.send;\nXMLHttpRequest.prototype.send=function(){var x=this;x.addEventListener("load",function(){if(x.status===403){try{var b=JSON.parse(x.responseText);if(b&&b.error==="account_flagged")emitFlagged(b)}catch(e){}}});return oXSend.apply(this,arguments)};\n\n}catch(e){}\n})();\n<\/script>`}function escapeJavaScript(e){return e.replace(/\\/g,"\\\\").replace(/"/g,'\\"').replace(/'/g,"\\'")}
|
|
@@ -1,13 +1,15 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { UnsharedResponse } from '../types';
|
|
2
2
|
/**
|
|
3
3
|
* Intercepts the response body by wrapping res.write() and res.end().
|
|
4
4
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
* to pass through unchanged.
|
|
5
|
+
* Only buffers HTML responses (text/html). Non-HTML responses (JSON, images,
|
|
6
|
+
* CSS, JS, etc.) pass through to the original write/end without buffering,
|
|
7
|
+
* avoiding unnecessary memory usage on large payloads.
|
|
9
8
|
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
9
|
+
* When res.end() is called on an HTML response, invokes the `transform`
|
|
10
|
+
* callback with the complete body buffer and the Content-Type header.
|
|
11
|
+
* The transform can return modified content or null to pass through unchanged.
|
|
12
12
|
*/
|
|
13
|
-
export declare function interceptResponse(res:
|
|
13
|
+
export declare function interceptResponse(res: UnsharedResponse, transform: (body: Buffer, contentType: string | undefined) => Buffer | string | null, options?: {
|
|
14
|
+
preventCaching?: boolean;
|
|
15
|
+
}): void;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
export function interceptResponse(n,t){const f
|
|
1
|
+
export function interceptResponse(n,t,f){const e=f?.preventCaching??!1,o=n.write.bind(n),u=n.end.bind(n),r=n.writeHead.bind(n),c=[];let i=!1,l=null;function s(){if(null!==l)return;const t=n.getHeader("content-type");null!=t&&(l=String(t).includes("text/html"),l||function(){n.write=o,n.end=u;for(const n of c)o(n);c.length=0}())}n.writeHead=function(t,...f){return s(),e&&l&&(n.setHeader("Cache-Control","no-store"),n.removeHeader("ETag"),n.removeHeader("Last-Modified")),r(t,...f)},n.write=function(n,t,f){if(s(),!1===l)return o(n,t,f);if(null!=n){const f=Buffer.isBuffer(n)?n:Buffer.from(n,"string"==typeof t?t:"utf8");c.push(f)}return"function"==typeof t&&t(null),"function"==typeof f&&f(null),!0},n.end=function(f,e,r){if(i)return n;if(i=!0,s(),!1===l)return u(f,e,r);if(null!=f){const n=Buffer.isBuffer(f)?f:Buffer.from(f,"string"==typeof e?e:"utf8");c.push(n)}const p=Buffer.concat(c),y=n.getHeader("content-type");let B;try{B=t(p,y)}catch{B=null}if(null!=B){const t=Buffer.isBuffer(B)?B:Buffer.from(B,"utf8");n.setHeader("Content-Length",t.length),n.removeHeader("Content-Encoding"),o(t)}else p.length>0&&o(p);const g="function"==typeof e?e:r;return g?u(g):u(),n}}
|