unshared-clientjs-sdk 2.0.0-rc.7 → 2.0.0-rc.9
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/client.d.ts +40 -2
- package/dist/client.js +1 -1
- package/dist/esm/client.d.mts +40 -2
- package/dist/esm/client.mjs +1 -1
- package/dist/esm/index.d.mts +3 -1
- package/dist/esm/index.mjs +1 -1
- package/dist/esm/middleware/index.d.mts +50 -0
- package/dist/esm/middleware/index.mjs +1 -0
- package/dist/esm/middleware/injection/fingerprint-script.d.mts +10 -0
- package/dist/esm/middleware/injection/fingerprint-script.mjs +1 -0
- package/dist/esm/middleware/rate-limit-backoff.d.mts +14 -0
- package/dist/esm/middleware/rate-limit-backoff.mjs +1 -0
- package/dist/esm/middleware/response-interceptor.d.mts +13 -0
- package/dist/esm/middleware/response-interceptor.mjs +1 -0
- package/dist/esm/middleware/routes/submit-fp.d.mts +24 -0
- package/dist/esm/middleware/routes/submit-fp.mjs +1 -0
- package/dist/esm/middleware/routes/verify.d.mts +28 -0
- package/dist/esm/middleware/routes/verify.mjs +1 -0
- package/dist/esm/middleware/utils/content-type.d.mts +6 -0
- package/dist/esm/middleware/utils/content-type.mjs +1 -0
- package/dist/esm/middleware/utils/skip-paths.d.mts +5 -0
- package/dist/esm/middleware/utils/skip-paths.mjs +1 -0
- package/dist/esm/middleware/verdict-cache.d.mts +36 -0
- package/dist/esm/middleware/verdict-cache.mjs +1 -0
- package/dist/index.d.ts +3 -1
- package/dist/index.js +1 -1
- package/dist/middleware/index.d.ts +50 -0
- package/dist/middleware/index.js +1 -0
- package/dist/middleware/injection/fingerprint-script.d.ts +10 -0
- package/dist/middleware/injection/fingerprint-script.js +1 -0
- package/dist/middleware/rate-limit-backoff.d.ts +14 -0
- package/dist/middleware/rate-limit-backoff.js +1 -0
- package/dist/middleware/response-interceptor.d.ts +13 -0
- package/dist/middleware/response-interceptor.js +1 -0
- package/dist/middleware/routes/submit-fp.d.ts +24 -0
- package/dist/middleware/routes/submit-fp.js +1 -0
- package/dist/middleware/routes/verify.d.ts +28 -0
- package/dist/middleware/routes/verify.js +1 -0
- package/dist/middleware/utils/content-type.d.ts +6 -0
- package/dist/middleware/utils/content-type.js +1 -0
- package/dist/middleware/utils/skip-paths.d.ts +5 -0
- package/dist/middleware/utils/skip-paths.js +1 -0
- package/dist/middleware/verdict-cache.d.ts +36 -0
- package/dist/middleware/verdict-cache.js +1 -0
- package/package.json +14 -1
package/dist/client.d.ts
CHANGED
|
@@ -62,6 +62,8 @@ export interface ProcessUserEventParams {
|
|
|
62
62
|
userAgent: string;
|
|
63
63
|
/** SDK encrypts before sending. */
|
|
64
64
|
emailAddress: string;
|
|
65
|
+
/** SDK encrypts before sending. */
|
|
66
|
+
fingerprintId?: string;
|
|
65
67
|
subscriptionStatus?: string | null;
|
|
66
68
|
eventDetails?: Record<string, unknown> | null;
|
|
67
69
|
}
|
|
@@ -93,6 +95,22 @@ export interface VerifyResult {
|
|
|
93
95
|
verified: boolean;
|
|
94
96
|
reason?: 'not_found' | 'code_mismatch' | 'code_expired';
|
|
95
97
|
}
|
|
98
|
+
export interface VerificationFlowStep {
|
|
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
|
+
}
|
|
96
114
|
export declare class UnsharedLabsClient {
|
|
97
115
|
private readonly _apiKey;
|
|
98
116
|
private readonly _baseUrl;
|
|
@@ -132,11 +150,17 @@ export declare class UnsharedLabsClient {
|
|
|
132
150
|
* through your infrastructure metrics, not through this method's return value.
|
|
133
151
|
*/
|
|
134
152
|
checkUser(emailAddress: string, deviceId: string): Promise<ApiResult<CheckUserResult>>;
|
|
153
|
+
checkUser(emailAddress: string, opts: {
|
|
154
|
+
deviceId?: string;
|
|
155
|
+
fingerprintId?: string;
|
|
156
|
+
}): Promise<ApiResult<CheckUserResult>>;
|
|
135
157
|
/**
|
|
136
158
|
* Send a 6-digit verification code to the user's email address.
|
|
137
159
|
* Maps to: POST /v2/trigger-email-verification
|
|
138
160
|
*/
|
|
139
|
-
triggerEmailVerification(emailAddress: string, deviceId: string
|
|
161
|
+
triggerEmailVerification(emailAddress: string, deviceId: string, opts?: {
|
|
162
|
+
fingerprintId?: string;
|
|
163
|
+
}): Promise<ApiResult<TriggerEmailVerificationResult>>;
|
|
140
164
|
/**
|
|
141
165
|
* Verify a 6-digit code submitted by the user.
|
|
142
166
|
* Maps to: POST /v2/verify
|
|
@@ -154,5 +178,19 @@ export declare class UnsharedLabsClient {
|
|
|
154
178
|
* } else { /* verified *\/ }
|
|
155
179
|
* ```
|
|
156
180
|
*/
|
|
157
|
-
verify(emailAddress: string, deviceId: string, code: string
|
|
181
|
+
verify(emailAddress: string, deviceId: string, code: string, opts?: {
|
|
182
|
+
fingerprintId?: string;
|
|
183
|
+
}): 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>;
|
|
158
196
|
}
|
package/dist/client.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
"use strict";Object.defineProperty(exports,"t",{value:!0}),exports.UnsharedLabsClient=void 0;const crypto_1=require("crypto"),util_1=require("./util"),DEFAULT_BASE_URL="https://api-ingress.unsharedlabs.com",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 UnsharedLabsClient{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()}_(e){return(0,util_1.encryptData)(e,this.l)}async p(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,
|
|
1
|
+
"use strict";Object.defineProperty(exports,"t",{value:!0}),exports.UnsharedLabsClient=void 0;const crypto_1=require("crypto"),util_1=require("./util"),DEFAULT_BASE_URL="https://api-ingress.unsharedlabs.com",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 UnsharedLabsClient{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()}_(e){return(0,util_1.encryptData)(e,this.l)}async p(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,n=setTimeout(()=>t.abort(),this.h);try{const i=await fetch(e,{method:s.method,headers:{"X-API-Key":this.i,...s.headers},body:s.body,signal:t.signal});if(clearTimeout(n),i.ok){const e=await i.text().catch(()=>"{}");let s;try{s=JSON.parse(e)}catch{s={}}const t="data"in s?s.data:s;return{success:!0,status:i.status,data:t}}const a=await parseErrorBody(i);if(i.status>=400&&i.status<500){if(429===i.status){const e=i.headers.get("Retry-After");if(null!=e){const s=parseInt(e,10);isNaN(s)||(a.retryAfter=s)}}return{success:!1,status:i.status,error:a}}r={success:!1,status:i.status,error:a}}catch(e){clearTimeout(n),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?.sessionHash&&(t.session_hash=s.sessionHash),null!=s?.eventType&&(t.event_type=s.eventType),null!=s?.ipAddress&&(t.ip_address=s.ipAddress),this.p(`${this.o}/v2/submit-fingerprint-event`,{method:"POST",headers:{"Content-Type":"application/json","X-Idempotency-Key":(0,crypto_1.randomUUID)()},body:JSON.stringify(t)})}async processUserEvent(e){const s={event_type:e.eventType,user_id:e.userId,ip_address:e.ipAddress,device_id:this._(e.deviceId),session_hash:e.sessionHash,user_agent: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.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._(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.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._(e),device_id:this._(s)};t?.fingerprintId&&(r.fingerprint_id=this._(t.fingerprintId));const i=await this.p(`${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._(e),device_id:this._(s),code:this._(t)};r?.fingerprintId&&(i.fingerprint_id=this._(r.fingerprintId));const n=await this.p(`${this.o}/v2/verify`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(i)});return!n.success&&(0===n.status||n.status>=500)?{success:!1,status:n.status,error:{code:"DELIVERY_FAILED",message:n.error?.message??"Delivery failed"}}:n.success&&!1===n.data?.verified?{success:!1,status:n.status,error:{code:"VERIFICATION_FAILED",message:"Code is incorrect or expired",details:n.data.reason?{reason:n.data.reason}:void 0}}:n}async getVerificationFlowConfig(){const e=await this.p(`${this.o}/v2/verification-flow-config`,{method:"GET"});return e.success&&e.data?e.data:null}}exports.UnsharedLabsClient=UnsharedLabsClient;
|
package/dist/esm/client.d.mts
CHANGED
|
@@ -62,6 +62,8 @@ export interface ProcessUserEventParams {
|
|
|
62
62
|
userAgent: string;
|
|
63
63
|
/** SDK encrypts before sending. */
|
|
64
64
|
emailAddress: string;
|
|
65
|
+
/** SDK encrypts before sending. */
|
|
66
|
+
fingerprintId?: string;
|
|
65
67
|
subscriptionStatus?: string | null;
|
|
66
68
|
eventDetails?: Record<string, unknown> | null;
|
|
67
69
|
}
|
|
@@ -93,6 +95,22 @@ export interface VerifyResult {
|
|
|
93
95
|
verified: boolean;
|
|
94
96
|
reason?: 'not_found' | 'code_mismatch' | 'code_expired';
|
|
95
97
|
}
|
|
98
|
+
export interface VerificationFlowStep {
|
|
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
|
+
}
|
|
96
114
|
export declare class UnsharedLabsClient {
|
|
97
115
|
private readonly _apiKey;
|
|
98
116
|
private readonly _baseUrl;
|
|
@@ -132,11 +150,17 @@ export declare class UnsharedLabsClient {
|
|
|
132
150
|
* through your infrastructure metrics, not through this method's return value.
|
|
133
151
|
*/
|
|
134
152
|
checkUser(emailAddress: string, deviceId: string): Promise<ApiResult<CheckUserResult>>;
|
|
153
|
+
checkUser(emailAddress: string, opts: {
|
|
154
|
+
deviceId?: string;
|
|
155
|
+
fingerprintId?: string;
|
|
156
|
+
}): Promise<ApiResult<CheckUserResult>>;
|
|
135
157
|
/**
|
|
136
158
|
* Send a 6-digit verification code to the user's email address.
|
|
137
159
|
* Maps to: POST /v2/trigger-email-verification
|
|
138
160
|
*/
|
|
139
|
-
triggerEmailVerification(emailAddress: string, deviceId: string
|
|
161
|
+
triggerEmailVerification(emailAddress: string, deviceId: string, opts?: {
|
|
162
|
+
fingerprintId?: string;
|
|
163
|
+
}): Promise<ApiResult<TriggerEmailVerificationResult>>;
|
|
140
164
|
/**
|
|
141
165
|
* Verify a 6-digit code submitted by the user.
|
|
142
166
|
* Maps to: POST /v2/verify
|
|
@@ -154,5 +178,19 @@ export declare class UnsharedLabsClient {
|
|
|
154
178
|
* } else { /* verified *\/ }
|
|
155
179
|
* ```
|
|
156
180
|
*/
|
|
157
|
-
verify(emailAddress: string, deviceId: string, code: string
|
|
181
|
+
verify(emailAddress: string, deviceId: string, code: string, opts?: {
|
|
182
|
+
fingerprintId?: string;
|
|
183
|
+
}): 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>;
|
|
158
196
|
}
|
package/dist/esm/client.mjs
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
import{createHash,randomUUID}from"crypto";import{encryptData}from"./util";const DEFAULT_BASE_URL="https://api-ingress.unsharedlabs.com",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 UnsharedLabsClient{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()}l(e){return encryptData(e,this.u)}async _(e,s){const t=this.h+1;let r={success:!1,status:0,error:{code:"NETWORK_ERROR",message:"Request failed"}};for(let
|
|
1
|
+
import{createHash,randomUUID}from"crypto";import{encryptData}from"./util";const DEFAULT_BASE_URL="https://api-ingress.unsharedlabs.com",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 UnsharedLabsClient{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()}l(e){return encryptData(e,this.u)}async _(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=await fetch(e,{method:s.method,headers:{"X-API-Key":this.t,...s.headers},body:s.body,signal:t.signal});if(clearTimeout(a),i.ok){const e=await i.text().catch(()=>"{}");let s;try{s=JSON.parse(e)}catch{s={}}const t="data"in s?s.data:s;return{success:!0,status:i.status,data:t}}const n=await parseErrorBody(i);if(i.status>=400&&i.status<500){if(429===i.status){const e=i.headers.get("Retry-After");if(null!=e){const s=parseInt(e,10);isNaN(s)||(n.retryAfter=s)}}return{success:!1,status:i.status,error:n}}r={success:!1,status:i.status,error:n}}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.l(s.userId)),null!=s?.sessionHash&&(t.session_hash=s.sessionHash),null!=s?.eventType&&(t.event_type=s.eventType),null!=s?.ipAddress&&(t.ip_address=s.ipAddress),this._(`${this.i}/v2/submit-fingerprint-event`,{method:"POST",headers:{"Content-Type":"application/json","X-Idempotency-Key":randomUUID()},body:JSON.stringify(t)})}async processUserEvent(e){const s={event_type:e.eventType,user_id:e.userId,ip_address:e.ipAddress,device_id:this.l(e.deviceId),session_hash:e.sessionHash,user_agent:e.userAgent,email_address:this.l(e.emailAddress)};return null!=e.fingerprintId&&(s.fingerprint_id=this.l(e.fingerprintId)),null!=e.subscriptionStatus&&(s.subscription_status=e.subscriptionStatus),null!=e.eventDetails&&(s.event_details=e.eventDetails),this._(`${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.l(e)),t.deviceId&&r.set("device_id",this.l(t.deviceId)),t.fingerprintId&&r.set("fingerprint_id",this.l(t.fingerprintId));const i=await this._(`${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.l(e),device_id:this.l(s)};t?.fingerprintId&&(r.fingerprint_id=this.l(t.fingerprintId));const i=await this._(`${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.l(e),device_id:this.l(s),code:this.l(t)};r?.fingerprintId&&(i.fingerprint_id=this.l(r.fingerprintId));const a=await this._(`${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}async getVerificationFlowConfig(){const e=await this._(`${this.i}/v2/verification-flow-config`,{method:"GET"});return e.success&&e.data?e.data:null}}
|
package/dist/esm/index.d.mts
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
export { UnsharedLabsClient } from './client';
|
|
2
2
|
export { createUnsharedMiddleware, assertTrustProxy } from './middleware';
|
|
3
3
|
export type { MiddlewareOptions } from './middleware';
|
|
4
|
-
export
|
|
4
|
+
export { unsharedBoundToUser, VerdictCache, } from './middleware/index';
|
|
5
|
+
export type { ProtectionConfig, Verdict } from './middleware/index';
|
|
6
|
+
export type { UnsharedLabsClientConfig, ApiResult, UnsharedLabsError, SubmitFingerprintOptions, SubmitFingerprintResult, ProcessUserEventParams, ProcessUserEventResult, CheckUserResult, TriggerEmailVerificationResult, VerifyResult, VerificationFlowStep, VerificationFlowConfigResult, } from './client';
|
package/dist/esm/index.mjs
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export{UnsharedLabsClient}from"./client";export{createUnsharedMiddleware,assertTrustProxy}from"./middleware";
|
|
1
|
+
export{UnsharedLabsClient}from"./client";export{createUnsharedMiddleware,assertTrustProxy}from"./middleware";export{unsharedBoundToUser,VerdictCache}from"./middleware/index";
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import type { Request, Response, NextFunction } from 'express';
|
|
2
|
+
import type { UnsharedLabsClient } from '../client';
|
|
3
|
+
import { VerdictCache } from './verdict-cache';
|
|
4
|
+
import type { Verdict } from './verdict-cache';
|
|
5
|
+
export interface ProtectionConfig {
|
|
6
|
+
/**
|
|
7
|
+
* Required. Resolves the current user's ID from the request.
|
|
8
|
+
* Return undefined for anonymous/logged-out visitors.
|
|
9
|
+
*/
|
|
10
|
+
userId: (req: Request) => string | undefined;
|
|
11
|
+
/**
|
|
12
|
+
* Resolves the current user's email address from the request.
|
|
13
|
+
* Required in Tier 2 (backend-only). Recommended in Tier 1.
|
|
14
|
+
* Falls back to HttpOnly cookie → req.body.email when not configured.
|
|
15
|
+
*/
|
|
16
|
+
emailAddress?: (req: Request) => string | undefined;
|
|
17
|
+
/** Route prefix for internal routes. @default "/__unshared" */
|
|
18
|
+
routePrefix?: string;
|
|
19
|
+
/** Allowed CORS origins for /__unshared/* routes. */
|
|
20
|
+
corsOrigins?: string | string[];
|
|
21
|
+
/** Verdict cache TTL in ms. @default 60000 */
|
|
22
|
+
cacheTTL?: number;
|
|
23
|
+
/** Paths to skip entirely (static assets, health checks). */
|
|
24
|
+
skipPaths?: string[];
|
|
25
|
+
/** Resolves a custom session ID. Falls back to __unshared_sid cookie. */
|
|
26
|
+
sessionId?: (req: Request) => string | undefined;
|
|
27
|
+
/**
|
|
28
|
+
* Resolves a device ID from the request.
|
|
29
|
+
* Falls back to __unshared_fp_id cookie → X-Device-Id header.
|
|
30
|
+
*/
|
|
31
|
+
deviceId?: (req: Request) => string | undefined;
|
|
32
|
+
/**
|
|
33
|
+
* Called when a flagged, unverified user makes a request.
|
|
34
|
+
* You own the response — block, redirect, or call next() to let it through.
|
|
35
|
+
*
|
|
36
|
+
* If not provided, flagged requests pass through (data collection only).
|
|
37
|
+
* Exceptions are caught and swallowed — the request passes through on error.
|
|
38
|
+
*/
|
|
39
|
+
onFlagged?: (context: {
|
|
40
|
+
userId: string;
|
|
41
|
+
emailAddress: string;
|
|
42
|
+
verdict: Verdict;
|
|
43
|
+
req: Request;
|
|
44
|
+
res: Response;
|
|
45
|
+
next: NextFunction;
|
|
46
|
+
}) => void;
|
|
47
|
+
}
|
|
48
|
+
export type { Verdict };
|
|
49
|
+
export { VerdictCache };
|
|
50
|
+
export declare function unsharedBoundToUser(client: UnsharedLabsClient, config: ProtectionConfig): (req: Request, res: Response, next: NextFunction) => void;
|
|
@@ -0,0 +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:n,routePrefix:i="/__unshared",corsOrigins:o,cacheTTL:s=6e4,skipPaths:c,sessionId:d,deviceId:a,onFlagged:l}=t,u=new VerdictCache(s),p=new RateLimitBackoff,f=Date.now().toString(36),m=generateFingerprintScript(i,f);let h="";try{const e=require.resolve("unshared-frontend-sdk/dist/index.umd.js");h=readFileSync(e,"utf8")}catch{}const v=handleSubmitFingerprint({client:e,verdictCache:u,rateLimitBackoff:p,resolveUserId:r,resolveEmailAddress:n,resolveSessionId:d,resolveDeviceId:a}),C=handleVerifyTrigger({client:e,verdictCache:u,resolveEmailAddress:n,resolveDeviceId:a}),g=handleVerify({client:e,verdictCache:u,resolveEmailAddress:n,resolveDeviceId:a}),I=o?Array.isArray(o)?o:[o]:null,y=`${i}/fp.js`,k=`${i}/submit-fp`,S=`${i}/verify-trigger`,_=`${i}/verify`;return function(t,o,s){const f=t.path;if(f.startsWith(i+"/"))return function(e,t){if(!I)return;const r=e.headers.origin??"",n=I.includes("*");(n||I.includes(r))&&(t.setHeader("Access-Control-Allow-Origin",n?"*":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,o),"OPTIONS"===t.method?void o.status(204).end():"GET"===t.method&&f===y?(o.setHeader("Content-Type","application/javascript"),o.setHeader("Cache-Control","public, max-age=3600"),void o.status(200).end(h)):"POST"===t.method&&f===k?void v(t,o):"POST"===t.method&&f===S?void C(t,o):"POST"===t.method&&f===_?void g(t,o):void o.status(404).json({success:!1,error:{code:"NOT_FOUND",message:"Unknown route"}});if(shouldSkipPath(f,c))return void s();let A;try{A=r(t)}catch{}if(!A)return clearEmailCookieIfPresent(t,o),interceptForInjection(t,o,m),void s();const T=resolveEmail(t,n);if(setUserIdCookie(o,A),T&&setEmailCookie(o,T),!T)return interceptForInjection(t,o,m),void s();const E=extractSessionId(t,d),x=extractDeviceId(t,a),F=extractFingerprintId(t),w=t.headers["user-agent"]??"",P=t.ip??"";p.isPaused()||dispatchUserEvent(e,u,p,{userId:A,emailAddress:T,sessionId:E,deviceId:x,fingerprintId:F,userAgent:w,ipAddress:P,eventType:`${t.method} ${t.path}`});const O=u.get(A);O?(u.isStale(A)&&!u.isRefreshing(A)&&(u.markRefreshing(A),fetchAndCacheVerdict(e,u,A,T,x,F,E).finally(()=>u.clearRefreshing(A))),applyVerdict(O,A,T,t,o,s,m,l)):fetchAndCacheVerdict(e,u,A,T,x,F,E).then(e=>{applyVerdict(e,A,T,t,o,s,m,l)}).catch(()=>{interceptForInjection(t,o,m),s()})}}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 n=e.body?.email;return"string"==typeof n&&n?n:void 0}function applyVerdict(e,t,r,n,i,o,s,c){if(interceptForInjection(n,i,s),e.isFlagged&&!e.isVerified&&c)try{c({userId:t,emailAddress:r,verdict:e,req:n,res:i,next:o})}catch{o()}else o()}function preventHtmlCaching(e,t){delete e.headers["if-none-match"],delete e.headers["if-modified-since"];const r=t.writeHead.bind(t);t.writeHead=function(e,...n){const i=t.getHeader("content-type");return i&&String(i).includes("text/html")&&(t.setHeader("Cache-Control","no-store"),t.removeHeader("ETag"),t.removeHeader("Last-Modified")),r(e,...n)}}function interceptForInjection(e,t,r){preventHtmlCaching(e,t),interceptResponse(t,(e,t)=>{if(!isHtmlContentType(t))return null;const n=e.toString("utf8"),i=n.lastIndexOf("</body>");return-1===i?n+r:n.slice(0,i)+r+n.slice(i)})}function dispatchUserEvent(e,t,r,n){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(()=>{})}async function fetchAndCacheVerdict(e,t,r,n,i,o,s){const c={};i&&"unknown"!==i&&(c.deviceId=i),o&&(c.fingerprintId=o);const d=await Promise.race([e.checkUser(n,c),new Promise(e=>setTimeout(()=>e(null),500))]);if(!d)return{isFlagged:!1,isVerified:!1,emailAddress:n,sessionId:s,cachedAt:0,ttl:0};const a=d.data?.is_user_flagged??!1;return t.set(r,{isFlagged:a,isVerified:!1,emailAddress:n,sessionId:s}),t.get(r)}function parseCookie(e,t){const r=e.headers.cookie;if(!r)return;const n=r.match(new RegExp(`(?:^|; )${t}=([^;]*)`));return n?decodeURIComponent(n[1]):void 0}function extractSessionId(e,t){if(t)try{const r=t(e);if(r)return r}catch{}return parseCookie(e,"__unshared_sid")??"unknown"}function extractDeviceId(e,t){if(t)try{const r=t(e);if(r)return r}catch{}const r=parseCookie(e,"__unshared_fp_id");if(r)return r;const n=e.headers["x-device-id"];return"string"==typeof n&&n?n:"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 n=Array.isArray(r)?[...r]:[String(r)];n.push(t),e.setHeader("Set-Cookie",n)}else e.setHeader("Set-Cookie",t)}function setUserIdCookie(e,t){appendSetCookie(e,`__unshared_uid=${encodeURIComponent(t)}; Path=/; SameSite=Lax`)}function setEmailCookie(e,t){appendSetCookie(e,`__unshared_email=${encodeURIComponent(t)}; HttpOnly; Path=/; SameSite=Lax`)}function clearEmailCookieIfPresent(e,t){parseCookie(e,"__unshared_email")&&appendSetCookie(t,"__unshared_email=; HttpOnly; Path=/; SameSite=Lax; Max-Age=0")}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Generates a small inline loader script that:
|
|
3
|
+
* 1. Loads the real fingerprint SDK from /__unshared/fp.js
|
|
4
|
+
* 2. Collects a full fingerprint (31+ signals, MurmurHash3 Merkle tree)
|
|
5
|
+
* 3. POSTs the result to /__unshared/submit-fp
|
|
6
|
+
*
|
|
7
|
+
* The actual SDK UMD bundle is served by the middleware at /__unshared/fp.js.
|
|
8
|
+
* This keeps the injected HTML small (~500 bytes) while using the full library.
|
|
9
|
+
*/
|
|
10
|
+
export declare function generateFingerprintScript(routePrefix: string, version?: string): string;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export function generateFingerprintScript(e,n){const t=n?`?v=${escapeJavaScript(n)}`:"";return`<script>\n(function(){\ntry{\nvar pfx="${escapeJavaScript(e)}";\n\n// Session cookie helpers\nfunction getCookie(n){var m=document.cookie.match(new RegExp("(?:^|; )"+n+"=([^;]*)"));return m?decodeURIComponent(m[1]):null}\nfunction setCookie(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"}\n\n// UUID helper\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\n// Ensure session ID cookie exists\nvar sid=getCookie("__unshared_sid");\nif(!sid){sid=uuid();setCookie("__unshared_sid",sid,365)}\n\n// Persistent device ID (survives across sessions via localStorage)\nvar did="";\ntry{did=localStorage.getItem("__unshared_device_id")||"";if(!did){did=uuid();localStorage.setItem("__unshared_device_id",did)}}catch(e){did=did||uuid()}\n\nvar uid=getCookie("__unshared_uid")||"";\n\n// Collect on every page load if a userId is present\nif(uid){\n var s=document.createElement("script");\n s.src=pfx+"/fp.js${t}";\n s.onload=function(){\n try{\n var client=new UnsharedLabsBrowser.UnsharedLabsBrowser({baseUrl:""});\n client.collect({exclude:["timing","navigatorConnection"]}).then(function(fp){\n var body={\n hash:fp.full_hash,\n stable_hash:fp.fingerprint_id,\n collected_at:fp.timestamp,\n is_incognito:fp.isIncognito,\n components:fp.components,\n version:fp.version,\n session_id:sid,\n user_id:uid\n };\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 xhr.setRequestHeader("X-Device-Id",did);\n xhr.send(JSON.stringify(body));\n });\n }catch(e){}\n };\n document.head.appendChild(s);\n}\n}catch(e){}\n})();\n<\/script>`}function escapeJavaScript(e){return e.replace(/\\/g,"\\\\").replace(/"/g,'\\"').replace(/'/g,"\\'")}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Global rate-limit backoff for processUserEvent calls.
|
|
3
|
+
*
|
|
4
|
+
* When the Unshared API returns 429 with a Retry-After header,
|
|
5
|
+
* the middleware pauses processUserEvent calls for the specified duration.
|
|
6
|
+
* checkUser calls are not affected — enforcement takes priority.
|
|
7
|
+
*/
|
|
8
|
+
export declare class RateLimitBackoff {
|
|
9
|
+
private _resumeAtTimestamp;
|
|
10
|
+
/** Pause processUserEvent calls for the given duration. */
|
|
11
|
+
pause(durationMs: number): void;
|
|
12
|
+
/** Returns true if processUserEvent calls should be skipped. */
|
|
13
|
+
isPaused(): boolean;
|
|
14
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export class RateLimitBackoff{constructor(){this.t=0}pause(t){const s=Date.now()+t;s>this.t&&(this.t=s)}isPaused(){return Date.now()<this.t}}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { Response } from 'express';
|
|
2
|
+
/**
|
|
3
|
+
* Intercepts the response body by wrapping res.write() and res.end().
|
|
4
|
+
*
|
|
5
|
+
* Collects all chunks written to the response. When res.end() is called,
|
|
6
|
+
* invokes the `transform` callback with the complete body buffer and the
|
|
7
|
+
* Content-Type header. The transform can return modified content or null
|
|
8
|
+
* to pass through unchanged.
|
|
9
|
+
*
|
|
10
|
+
* Does NOT monkey-patch res.send — uses the lower-level write/end API
|
|
11
|
+
* as required by the spec.
|
|
12
|
+
*/
|
|
13
|
+
export declare function interceptResponse(res: Response, transform: (body: Buffer, contentType: string | undefined) => Buffer | string | null): void;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export function interceptResponse(n,t){const f=[],e=n.write.bind(n),u=n.end.bind(n);let o=!1;n.write=function(n,t,e){if(null!=n){const e=Buffer.isBuffer(n)?n:Buffer.from(n,"string"==typeof t?t:"utf8");f.push(e)}return"function"==typeof t&&t(null),"function"==typeof e&&e(null),!0},n.end=function(r,c,l){if(o)return n;if(o=!0,null!=r){const n=Buffer.isBuffer(r)?r:Buffer.from(r,"string"==typeof c?c:"utf8");f.push(n)}const i=Buffer.concat(f),s=n.getHeader("content-type");let p;try{p=t(i,s)}catch{p=null}if(null!=p){const t=Buffer.isBuffer(p)?p:Buffer.from(p,"utf8");n.setHeader("Content-Length",t.length),n.removeHeader("Content-Encoding"),e(t)}else i.length>0&&e(i);const y="function"==typeof c?c:l;return y?u(y):u(),n}}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { Request, Response } from 'express';
|
|
2
|
+
import type { UnsharedLabsClient } from '../../client';
|
|
3
|
+
import type { VerdictCache } from '../verdict-cache';
|
|
4
|
+
import type { RateLimitBackoff } from '../rate-limit-backoff';
|
|
5
|
+
export interface SubmitFingerprintDependencies {
|
|
6
|
+
client: UnsharedLabsClient;
|
|
7
|
+
verdictCache: VerdictCache;
|
|
8
|
+
rateLimitBackoff: RateLimitBackoff;
|
|
9
|
+
resolveUserId?: (req: Request) => string | undefined;
|
|
10
|
+
resolveEmailAddress?: (req: Request) => string | undefined;
|
|
11
|
+
resolveSessionId?: (req: Request) => string | undefined;
|
|
12
|
+
resolveDeviceId?: (req: Request) => string | undefined;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Handles POST /__unshared/submit-fp
|
|
16
|
+
*
|
|
17
|
+
* Receives fingerprint data from the injected inline script and:
|
|
18
|
+
* 1. Forwards to Unshared API via client.submitFingerprintEvent() (fire-and-forget)
|
|
19
|
+
* 2. Calls processUserEvent with cache side-effect (fire-and-forget)
|
|
20
|
+
* 3. Sets __unshared_email HttpOnly cookie when email is resolved from body
|
|
21
|
+
*
|
|
22
|
+
* Always returns 200 (fire-and-forget from browser's perspective).
|
|
23
|
+
*/
|
|
24
|
+
export declare function handleSubmitFingerprint(dependencies: SubmitFingerprintDependencies): (req: Request, res: Response) => Promise<void>;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export function handleSubmitFingerprint(e){return async(n,t)=>{try{const i=n.body??{},o={full_hash:i.hash??"",fingerprint_id:i.stable_hash??"",timestamp:i.collected_at??(new Date).toISOString(),isIncognito:i.is_incognito??!1,components:i.components??{},version:i.version??"inline-1.0.0"};let s,r,a;try{s=e.resolveUserId?e.resolveUserId(n):void 0}catch{}s=s??i.user_id??void 0;try{r=e.resolveEmailAddress?e.resolveEmailAddress(n):void 0}catch{}r=r??parseCookie(n,"__unshared_email")??i.email??void 0;try{a=e.resolveSessionId?e.resolveSessionId(n):void 0}catch{}a=a??i.session_id??parseCookie(n,"__unshared_sid");const c=n.ip??"",d=n.headers["user-agent"]??"",_=extractDeviceId(n,e.resolveDeviceId),u=o.fingerprint_id||void 0,p=[];if(u&&!parseCookie(n,"__unshared_fingerprint_id")&&p.push(`__unshared_fingerprint_id=${encodeURIComponent(u)}; HttpOnly; Path=/; SameSite=Lax`),r&&!parseCookie(n,"__unshared_email")&&p.push(`__unshared_email=${encodeURIComponent(r)}; HttpOnly; Path=/; SameSite=Lax`),p.length>0){const e=t.getHeader("Set-Cookie");if(e){const n=Array.isArray(e)?[...e]:[String(e)];n.push(...p),t.setHeader("Set-Cookie",n)}else t.setHeader("Set-Cookie",p)}s&&e.client.submitFingerprintEvent(o,{userId:s,sessionHash:a,eventType:"auto_collect",ipAddress:c}).catch(()=>{}),s&&r&&!e.rateLimitBackoff.isPaused()&&e.client.processUserEvent({eventType:"auto_collect",userId:s,emailAddress:r,ipAddress:c,deviceId:_,fingerprintId:u,sessionHash:a??"unknown",userAgent:d}).then(n=>{n.success&&n.data?.analysis&&e.verdictCache.update(s,{isFlagged:n.data.analysis.is_user_flagged}),!n.success&&n.error?.retryAfter&&e.rateLimitBackoff.pause(1e3*n.error.retryAfter)}).catch(()=>{}),t.status(200).json({success:!0})}catch{t.status(200).json({success:!0})}}}function extractDeviceId(e,n){if(n)try{const t=n(e);if(t)return t}catch{}const t=parseCookie(e,"__unshared_fp_id");if(t)return t;const i=e.headers["x-device-id"];return"string"==typeof i&&i?i:"unknown"}function parseCookie(e,n){const t=e.headers.cookie;if(!t)return;const i=t.match(new RegExp(`(?:^|; )${n}=([^;]*)`));return i?decodeURIComponent(i[1]):void 0}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { Request, Response } from 'express';
|
|
2
|
+
import type { UnsharedLabsClient } from '../../client';
|
|
3
|
+
import type { VerdictCache } from '../verdict-cache';
|
|
4
|
+
export interface VerificationDependencies {
|
|
5
|
+
client: UnsharedLabsClient;
|
|
6
|
+
verdictCache: VerdictCache;
|
|
7
|
+
resolveEmailAddress?: (req: Request) => string | undefined;
|
|
8
|
+
resolveDeviceId?: (req: Request) => string | undefined;
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* POST /__unshared/verify-trigger
|
|
12
|
+
* Triggers email verification. Called by the blocker overlay UI.
|
|
13
|
+
*
|
|
14
|
+
* Body: { email: string }
|
|
15
|
+
*
|
|
16
|
+
* The deviceId is resolved via extractDeviceId (same as the middleware).
|
|
17
|
+
*/
|
|
18
|
+
export declare function handleVerifyTrigger(dependencies: VerificationDependencies): (req: Request, res: Response) => Promise<void>;
|
|
19
|
+
/**
|
|
20
|
+
* POST /__unshared/verify
|
|
21
|
+
* Validates OTP code. Called by the blocker overlay UI.
|
|
22
|
+
*
|
|
23
|
+
* Body: { email: string, code: string }
|
|
24
|
+
*
|
|
25
|
+
* On successful verification, updates the verdict cache to mark
|
|
26
|
+
* the user as verified so subsequent requests pass through.
|
|
27
|
+
*/
|
|
28
|
+
export declare function handleVerify(dependencies: VerificationDependencies): (req: Request, res: Response) => Promise<void>;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export function handleVerifyTrigger(e){return async(r,i)=>{try{const s=resolveEmail(r,r.body??{},e.resolveEmailAddress);if(!s)return void i.status(400).json({success:!1,error:{code:"VALIDATION_ERROR",message:"Email is required"}});const n=extractDeviceId(r,e.resolveDeviceId),o=parseCookie(r,"__unshared_fingerprint_id")||void 0,t=await e.client.triggerEmailVerification(s,n,{fingerprintId:o});t.success?i.status(200).json({success:!0,data:t.data}):i.status(200).json({success:!1,error:t.error??{code:"TRIGGER_FAILED",message:"Failed to send verification email"}})}catch{i.status(200).json({success:!1,error:{code:"INTERNAL_ERROR",message:"Failed to trigger verification"}})}}}export function handleVerify(e){return async(r,i)=>{try{const s=r.body??{},n=resolveEmail(r,s,e.resolveEmailAddress),o=s.code;if(!n||!o)return void i.status(400).json({success:!1,error:{code:"VALIDATION_ERROR",message:"Email and code are required"}});const t=extractDeviceId(r,e.resolveDeviceId),c=parseCookie(r,"__unshared_fingerprint_id")||void 0,a=await e.client.verify(n,t,o,{fingerprintId:c});if(a.success){const s=parseCookie(r,"__unshared_uid");s&&e.verdictCache.update(s,{isVerified:!0}),i.status(200).json({success:!0,data:{verified:!0}})}else i.status(200).json({success:!1,error:a.error??{code:"VERIFICATION_FAILED",message:"Verification failed"}})}catch{i.status(200).json({success:!1,error:{code:"INTERNAL_ERROR",message:"Verification failed"}})}}}function resolveEmail(e,r,i){if(i)try{const r=i(e);if(r)return r}catch{}const s=parseCookie(e,"__unshared_email");if(s)return s;const n=r.email;return"string"==typeof n&&n?n:void 0}function extractDeviceId(e,r){if(r)try{const i=r(e);if(i)return i}catch{}const i=parseCookie(e,"__unshared_fp_id");if(i)return i;const s=e.headers["x-device-id"];return"string"==typeof s&&s?s:"unknown"}function parseCookie(e,r){const i=e.headers.cookie;if(!i)return;const s=i.match(new RegExp(`(?:^|; )${r}=([^;]*)`));return s?decodeURIComponent(s[1]):void 0}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/** Check if a Content-Type header value indicates HTML. */
|
|
2
|
+
export declare function isHtmlContentType(contentType: string | undefined): boolean;
|
|
3
|
+
/** Check if a Content-Type header value indicates JSON. */
|
|
4
|
+
export declare function isJsonContentType(contentType: string | undefined): boolean;
|
|
5
|
+
/** Check if a Content-Type indicates a static asset (images, fonts, etc). */
|
|
6
|
+
export declare function isStaticContentType(contentType: string | undefined): boolean;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export function isHtmlContentType(t){return!!t&&t.includes("text/html")}export function isJsonContentType(t){return!!t&&t.includes("application/json")}export function isStaticContentType(t){return!!t&&["image/","font/","audio/","video/","application/javascript","text/javascript","text/css","application/wasm"].some(n=>t.includes(n))}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
const STATIC_EXTENSIONS=new Set([".js",".mjs",".cjs",".css",".map",".png",".jpg",".jpeg",".gif",".svg",".ico",".webp",".avif",".woff",".woff2",".ttf",".otf",".eot",".mp3",".mp4",".webm",".ogg",".wasm",".json",".xml",".txt",".pdf"]),STATIC_PATH_PREFIXES=["/static/","/assets/","/public/","/_next/","/__vite/","/favicon"];export function shouldSkipPath(t,f){if(f)for(const o of f)if(t.startsWith(o))return!0;const o=t.lastIndexOf(".");if(-1!==o){const f=t.slice(o).toLowerCase().split("?")[0];if(STATIC_EXTENSIONS.has(f))return!0}for(const f of STATIC_PATH_PREFIXES)if(t.startsWith(f))return!0;return!1}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
export interface Verdict {
|
|
2
|
+
isFlagged: boolean;
|
|
3
|
+
isVerified: boolean;
|
|
4
|
+
emailAddress: string;
|
|
5
|
+
sessionId: string;
|
|
6
|
+
cachedAt: number;
|
|
7
|
+
ttl: number;
|
|
8
|
+
}
|
|
9
|
+
export declare class VerdictCache {
|
|
10
|
+
private readonly _entries;
|
|
11
|
+
private readonly _activeRefreshes;
|
|
12
|
+
private readonly _defaultTtlMs;
|
|
13
|
+
constructor(defaultTTL?: number);
|
|
14
|
+
get(userId: string): Verdict | undefined;
|
|
15
|
+
set(userId: string, verdict: Omit<Verdict, 'cachedAt' | 'ttl'>, ttl?: number): void;
|
|
16
|
+
/**
|
|
17
|
+
* Update an existing cache entry (e.g. from webhook).
|
|
18
|
+
* If the user is not in cache, creates a new entry.
|
|
19
|
+
*/
|
|
20
|
+
update(userId: string, partial: Partial<Pick<Verdict, 'isFlagged' | 'isVerified'>>): void;
|
|
21
|
+
delete(userId: string): void;
|
|
22
|
+
/**
|
|
23
|
+
* Returns true if the entry exists but is past its TTL.
|
|
24
|
+
* Stale entries are served while a background refresh happens.
|
|
25
|
+
*/
|
|
26
|
+
isStale(userId: string): boolean;
|
|
27
|
+
/**
|
|
28
|
+
* Returns true if a background refresh is already in flight for this user.
|
|
29
|
+
*/
|
|
30
|
+
isRefreshing(userId: string): boolean;
|
|
31
|
+
markRefreshing(userId: string): void;
|
|
32
|
+
clearRefreshing(userId: string): void;
|
|
33
|
+
/** Number of cached entries. */
|
|
34
|
+
get size(): number;
|
|
35
|
+
clear(): void;
|
|
36
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export class VerdictCache{constructor(t=6e4){this.t=new Map,this.i=new Set,this.h=t}get(t){return this.t.get(t)}set(t,e,s){this.t.set(t,{...e,cachedAt:Date.now(),ttl:s??this.h})}update(t,e){const s=this.t.get(t);s?(void 0!==e.isFlagged&&(s.isFlagged=e.isFlagged),void 0!==e.isVerified&&(s.isVerified=e.isVerified),s.cachedAt=Date.now()):this.t.set(t,{isFlagged:e.isFlagged??!1,isVerified:e.isVerified??!1,emailAddress:"",sessionId:"",cachedAt:Date.now(),ttl:this.h})}delete(t){this.t.delete(t),this.i.delete(t)}isStale(t){const e=this.t.get(t);return!!e&&Date.now()-e.cachedAt>e.ttl}isRefreshing(t){return this.i.has(t)}markRefreshing(t){this.i.add(t)}clearRefreshing(t){this.i.delete(t)}get size(){return this.t.size}clear(){this.t.clear(),this.i.clear()}}
|
package/dist/index.d.ts
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
export { UnsharedLabsClient } from './client';
|
|
2
2
|
export { createUnsharedMiddleware, assertTrustProxy } from './middleware';
|
|
3
3
|
export type { MiddlewareOptions } from './middleware';
|
|
4
|
-
export
|
|
4
|
+
export { unsharedBoundToUser, VerdictCache, } from './middleware/index';
|
|
5
|
+
export type { ProtectionConfig, Verdict } from './middleware/index';
|
|
6
|
+
export type { UnsharedLabsClientConfig, ApiResult, UnsharedLabsError, SubmitFingerprintOptions, SubmitFingerprintResult, ProcessUserEventParams, ProcessUserEventResult, CheckUserResult, TriggerEmailVerificationResult, VerifyResult, VerificationFlowStep, VerificationFlowConfigResult, } from './client';
|
package/dist/index.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
"use strict";Object.defineProperty(exports,"t",{value:!0}),exports.assertTrustProxy=exports.createUnsharedMiddleware=exports.UnsharedLabsClient=void 0;var client_1=require("./client");Object.defineProperty(exports,"UnsharedLabsClient",{enumerable:!0,get:function(){return client_1.UnsharedLabsClient}});var middleware_1=require("./middleware");Object.defineProperty(exports,"createUnsharedMiddleware",{enumerable:!0,get:function(){return middleware_1.createUnsharedMiddleware}}),Object.defineProperty(exports,"assertTrustProxy",{enumerable:!0,get:function(){return middleware_1.assertTrustProxy}});
|
|
1
|
+
"use strict";Object.defineProperty(exports,"t",{value:!0}),exports.VerdictCache=exports.unsharedBoundToUser=exports.assertTrustProxy=exports.createUnsharedMiddleware=exports.UnsharedLabsClient=void 0;var client_1=require("./client");Object.defineProperty(exports,"UnsharedLabsClient",{enumerable:!0,get:function(){return client_1.UnsharedLabsClient}});var middleware_1=require("./middleware");Object.defineProperty(exports,"createUnsharedMiddleware",{enumerable:!0,get:function(){return middleware_1.createUnsharedMiddleware}}),Object.defineProperty(exports,"assertTrustProxy",{enumerable:!0,get:function(){return middleware_1.assertTrustProxy}});var index_1=require("./middleware/index");Object.defineProperty(exports,"unsharedBoundToUser",{enumerable:!0,get:function(){return index_1.unsharedBoundToUser}}),Object.defineProperty(exports,"VerdictCache",{enumerable:!0,get:function(){return index_1.VerdictCache}});
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import type { Request, Response, NextFunction } from 'express';
|
|
2
|
+
import type { UnsharedLabsClient } from '../client';
|
|
3
|
+
import { VerdictCache } from './verdict-cache';
|
|
4
|
+
import type { Verdict } from './verdict-cache';
|
|
5
|
+
export interface ProtectionConfig {
|
|
6
|
+
/**
|
|
7
|
+
* Required. Resolves the current user's ID from the request.
|
|
8
|
+
* Return undefined for anonymous/logged-out visitors.
|
|
9
|
+
*/
|
|
10
|
+
userId: (req: Request) => string | undefined;
|
|
11
|
+
/**
|
|
12
|
+
* Resolves the current user's email address from the request.
|
|
13
|
+
* Required in Tier 2 (backend-only). Recommended in Tier 1.
|
|
14
|
+
* Falls back to HttpOnly cookie → req.body.email when not configured.
|
|
15
|
+
*/
|
|
16
|
+
emailAddress?: (req: Request) => string | undefined;
|
|
17
|
+
/** Route prefix for internal routes. @default "/__unshared" */
|
|
18
|
+
routePrefix?: string;
|
|
19
|
+
/** Allowed CORS origins for /__unshared/* routes. */
|
|
20
|
+
corsOrigins?: string | string[];
|
|
21
|
+
/** Verdict cache TTL in ms. @default 60000 */
|
|
22
|
+
cacheTTL?: number;
|
|
23
|
+
/** Paths to skip entirely (static assets, health checks). */
|
|
24
|
+
skipPaths?: string[];
|
|
25
|
+
/** Resolves a custom session ID. Falls back to __unshared_sid cookie. */
|
|
26
|
+
sessionId?: (req: Request) => string | undefined;
|
|
27
|
+
/**
|
|
28
|
+
* Resolves a device ID from the request.
|
|
29
|
+
* Falls back to __unshared_fp_id cookie → X-Device-Id header.
|
|
30
|
+
*/
|
|
31
|
+
deviceId?: (req: Request) => string | undefined;
|
|
32
|
+
/**
|
|
33
|
+
* Called when a flagged, unverified user makes a request.
|
|
34
|
+
* You own the response — block, redirect, or call next() to let it through.
|
|
35
|
+
*
|
|
36
|
+
* If not provided, flagged requests pass through (data collection only).
|
|
37
|
+
* Exceptions are caught and swallowed — the request passes through on error.
|
|
38
|
+
*/
|
|
39
|
+
onFlagged?: (context: {
|
|
40
|
+
userId: string;
|
|
41
|
+
emailAddress: string;
|
|
42
|
+
verdict: Verdict;
|
|
43
|
+
req: Request;
|
|
44
|
+
res: Response;
|
|
45
|
+
next: NextFunction;
|
|
46
|
+
}) => void;
|
|
47
|
+
}
|
|
48
|
+
export type { Verdict };
|
|
49
|
+
export { VerdictCache };
|
|
50
|
+
export declare function unsharedBoundToUser(client: UnsharedLabsClient, config: ProtectionConfig): (req: Request, res: Response, next: NextFunction) => void;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"use strict";Object.defineProperty(exports,"t",{value:!0}),exports.VerdictCache=void 0,exports.unsharedBoundToUser=unsharedBoundToUser;const fs_1=require("fs"),verdict_cache_1=require("./verdict-cache");Object.defineProperty(exports,"VerdictCache",{enumerable:!0,get:function(){return verdict_cache_1.VerdictCache}});const rate_limit_backoff_1=require("./rate-limit-backoff"),response_interceptor_1=require("./response-interceptor"),fingerprint_script_1=require("./injection/fingerprint-script"),submit_fp_1=require("./routes/submit-fp"),verify_1=require("./routes/verify"),content_type_1=require("./utils/content-type"),skip_paths_1=require("./utils/skip-paths"),CHECK_USER_TIMEOUT_MS=500;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:n,routePrefix:i="/__unshared",corsOrigins:s,cacheTTL:o=6e4,skipPaths:c,sessionId:d,deviceId:a,onFlagged:u}=t,l=new verdict_cache_1.VerdictCache(o),f=new rate_limit_backoff_1.RateLimitBackoff,p=Date.now().toString(36),_=(0,fingerprint_script_1.generateFingerprintScript)(i,p);let h="";try{const e=require.resolve("unshared-frontend-sdk/dist/index.umd.js");h=(0,fs_1.readFileSync)(e,"utf8")}catch{}const v=(0,submit_fp_1.handleSubmitFingerprint)({client:e,verdictCache:l,rateLimitBackoff:f,resolveUserId:r,resolveEmailAddress:n,resolveSessionId:d,resolveDeviceId:a}),m=(0,verify_1.handleVerifyTrigger)({client:e,verdictCache:l,resolveEmailAddress:n,resolveDeviceId:a}),C=(0,verify_1.handleVerify)({client:e,verdictCache:l,resolveEmailAddress:n,resolveDeviceId:a}),I=s?Array.isArray(s)?s:[s]:null,g=`${i}/fp.js`,k=`${i}/submit-fp`,y=`${i}/verify-trigger`,A=`${i}/verify`;return function(t,s,o){const p=t.path;if(p.startsWith(i+"/"))return function(e,t){if(!I)return;const r=e.headers.origin??"",n=I.includes("*");(n||I.includes(r))&&(t.setHeader("Access-Control-Allow-Origin",n?"*":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?void s.status(204).end():"GET"===t.method&&p===g?(s.setHeader("Content-Type","application/javascript"),s.setHeader("Cache-Control","public, max-age=3600"),void s.status(200).end(h)):"POST"===t.method&&p===k?void v(t,s):"POST"===t.method&&p===y?void m(t,s):"POST"===t.method&&p===A?void C(t,s):void s.status(404).json({success:!1,error:{code:"NOT_FOUND",message:"Unknown route"}});if((0,skip_paths_1.shouldSkipPath)(p,c))return void o();let S;try{S=r(t)}catch{}if(!S)return clearEmailCookieIfPresent(t,s),interceptForInjection(t,s,_),void o();const T=resolveEmail(t,n);if(setUserIdCookie(s,S),T&&setEmailCookie(s,T),!T)return interceptForInjection(t,s,_),void o();const x=extractSessionId(t,d),E=extractDeviceId(t,a),w=extractFingerprintId(t),O=t.headers["user-agent"]??"",U=t.ip??"";f.isPaused()||dispatchUserEvent(e,l,f,{userId:S,emailAddress:T,sessionId:x,deviceId:E,fingerprintId:w,userAgent:O,ipAddress:U,eventType:`${t.method} ${t.path}`});const P=l.get(S);P?(l.isStale(S)&&!l.isRefreshing(S)&&(l.markRefreshing(S),fetchAndCacheVerdict(e,l,S,T,E,w,x).finally(()=>l.clearRefreshing(S))),applyVerdict(P,S,T,t,s,o,_,u)):fetchAndCacheVerdict(e,l,S,T,E,w,x).then(e=>{applyVerdict(e,S,T,t,s,o,_,u)}).catch(()=>{interceptForInjection(t,s,_),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 n=e.body?.email;return"string"==typeof n&&n?n:void 0}function applyVerdict(e,t,r,n,i,s,o,c){if(interceptForInjection(n,i,o),e.isFlagged&&!e.isVerified&&c)try{c({userId:t,emailAddress:r,verdict:e,req:n,res:i,next:s})}catch{s()}else s()}function preventHtmlCaching(e,t){delete e.headers["if-none-match"],delete e.headers["if-modified-since"];const r=t.writeHead.bind(t);t.writeHead=function(e,...n){const i=t.getHeader("content-type");return i&&String(i).includes("text/html")&&(t.setHeader("Cache-Control","no-store"),t.removeHeader("ETag"),t.removeHeader("Last-Modified")),r(e,...n)}}function interceptForInjection(e,t,r){preventHtmlCaching(e,t),(0,response_interceptor_1.interceptResponse)(t,(e,t)=>{if(!(0,content_type_1.isHtmlContentType)(t))return null;const n=e.toString("utf8"),i=n.lastIndexOf("</body>");return-1===i?n+r:n.slice(0,i)+r+n.slice(i)})}function dispatchUserEvent(e,t,r,n){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(()=>{})}async function fetchAndCacheVerdict(e,t,r,n,i,s,o){const c={};i&&"unknown"!==i&&(c.deviceId=i),s&&(c.fingerprintId=s);const d=await Promise.race([e.checkUser(n,c),new Promise(e=>setTimeout(()=>e(null),500))]);if(!d)return{isFlagged:!1,isVerified:!1,emailAddress:n,sessionId:o,cachedAt:0,ttl:0};const a=d.data?.is_user_flagged??!1;return t.set(r,{isFlagged:a,isVerified:!1,emailAddress:n,sessionId:o}),t.get(r)}function parseCookie(e,t){const r=e.headers.cookie;if(!r)return;const n=r.match(new RegExp(`(?:^|; )${t}=([^;]*)`));return n?decodeURIComponent(n[1]):void 0}function extractSessionId(e,t){if(t)try{const r=t(e);if(r)return r}catch{}return parseCookie(e,"__unshared_sid")??"unknown"}function extractDeviceId(e,t){if(t)try{const r=t(e);if(r)return r}catch{}const r=parseCookie(e,"__unshared_fp_id");if(r)return r;const n=e.headers["x-device-id"];return"string"==typeof n&&n?n:"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 n=Array.isArray(r)?[...r]:[String(r)];n.push(t),e.setHeader("Set-Cookie",n)}else e.setHeader("Set-Cookie",t)}function setUserIdCookie(e,t){appendSetCookie(e,`__unshared_uid=${encodeURIComponent(t)}; Path=/; SameSite=Lax`)}function setEmailCookie(e,t){appendSetCookie(e,`__unshared_email=${encodeURIComponent(t)}; HttpOnly; Path=/; SameSite=Lax`)}function clearEmailCookieIfPresent(e,t){parseCookie(e,"__unshared_email")&&appendSetCookie(t,"__unshared_email=; HttpOnly; Path=/; SameSite=Lax; Max-Age=0")}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Generates a small inline loader script that:
|
|
3
|
+
* 1. Loads the real fingerprint SDK from /__unshared/fp.js
|
|
4
|
+
* 2. Collects a full fingerprint (31+ signals, MurmurHash3 Merkle tree)
|
|
5
|
+
* 3. POSTs the result to /__unshared/submit-fp
|
|
6
|
+
*
|
|
7
|
+
* The actual SDK UMD bundle is served by the middleware at /__unshared/fp.js.
|
|
8
|
+
* This keeps the injected HTML small (~500 bytes) while using the full library.
|
|
9
|
+
*/
|
|
10
|
+
export declare function generateFingerprintScript(routePrefix: string, version?: string): string;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"use strict";function generateFingerprintScript(e,n){const t=n?`?v=${escapeJavaScript(n)}`:"";return`<script>\n(function(){\ntry{\nvar pfx="${escapeJavaScript(e)}";\n\n// Session cookie helpers\nfunction getCookie(n){var m=document.cookie.match(new RegExp("(?:^|; )"+n+"=([^;]*)"));return m?decodeURIComponent(m[1]):null}\nfunction setCookie(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"}\n\n// UUID helper\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\n// Ensure session ID cookie exists\nvar sid=getCookie("__unshared_sid");\nif(!sid){sid=uuid();setCookie("__unshared_sid",sid,365)}\n\n// Persistent device ID (survives across sessions via localStorage)\nvar did="";\ntry{did=localStorage.getItem("__unshared_device_id")||"";if(!did){did=uuid();localStorage.setItem("__unshared_device_id",did)}}catch(e){did=did||uuid()}\n\nvar uid=getCookie("__unshared_uid")||"";\n\n// Collect on every page load if a userId is present\nif(uid){\n var s=document.createElement("script");\n s.src=pfx+"/fp.js${t}";\n s.onload=function(){\n try{\n var client=new UnsharedLabsBrowser.UnsharedLabsBrowser({baseUrl:""});\n client.collect({exclude:["timing","navigatorConnection"]}).then(function(fp){\n var body={\n hash:fp.full_hash,\n stable_hash:fp.fingerprint_id,\n collected_at:fp.timestamp,\n is_incognito:fp.isIncognito,\n components:fp.components,\n version:fp.version,\n session_id:sid,\n user_id:uid\n };\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 xhr.setRequestHeader("X-Device-Id",did);\n xhr.send(JSON.stringify(body));\n });\n }catch(e){}\n };\n document.head.appendChild(s);\n}\n}catch(e){}\n})();\n<\/script>`}function escapeJavaScript(e){return e.replace(/\\/g,"\\\\").replace(/"/g,'\\"').replace(/'/g,"\\'")}Object.defineProperty(exports,"t",{value:!0}),exports.generateFingerprintScript=generateFingerprintScript;
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Global rate-limit backoff for processUserEvent calls.
|
|
3
|
+
*
|
|
4
|
+
* When the Unshared API returns 429 with a Retry-After header,
|
|
5
|
+
* the middleware pauses processUserEvent calls for the specified duration.
|
|
6
|
+
* checkUser calls are not affected — enforcement takes priority.
|
|
7
|
+
*/
|
|
8
|
+
export declare class RateLimitBackoff {
|
|
9
|
+
private _resumeAtTimestamp;
|
|
10
|
+
/** Pause processUserEvent calls for the given duration. */
|
|
11
|
+
pause(durationMs: number): void;
|
|
12
|
+
/** Returns true if processUserEvent calls should be skipped. */
|
|
13
|
+
isPaused(): boolean;
|
|
14
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"use strict";Object.defineProperty(exports,"t",{value:!0}),exports.RateLimitBackoff=void 0;class RateLimitBackoff{constructor(){this.i=0}pause(t){const s=Date.now()+t;s>this.i&&(this.i=s)}isPaused(){return Date.now()<this.i}}exports.RateLimitBackoff=RateLimitBackoff;
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { Response } from 'express';
|
|
2
|
+
/**
|
|
3
|
+
* Intercepts the response body by wrapping res.write() and res.end().
|
|
4
|
+
*
|
|
5
|
+
* Collects all chunks written to the response. When res.end() is called,
|
|
6
|
+
* invokes the `transform` callback with the complete body buffer and the
|
|
7
|
+
* Content-Type header. The transform can return modified content or null
|
|
8
|
+
* to pass through unchanged.
|
|
9
|
+
*
|
|
10
|
+
* Does NOT monkey-patch res.send — uses the lower-level write/end API
|
|
11
|
+
* as required by the spec.
|
|
12
|
+
*/
|
|
13
|
+
export declare function interceptResponse(res: Response, transform: (body: Buffer, contentType: string | undefined) => Buffer | string | null): void;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"use strict";function interceptResponse(t,n){const e=[],f=t.write.bind(t),u=t.end.bind(t);let o=!1;t.write=function(t,n,f){if(null!=t){const f=Buffer.isBuffer(t)?t:Buffer.from(t,"string"==typeof n?n:"utf8");e.push(f)}return"function"==typeof n&&n(null),"function"==typeof f&&f(null),!0},t.end=function(r,c,s){if(o)return t;if(o=!0,null!=r){const t=Buffer.isBuffer(r)?r:Buffer.from(r,"string"==typeof c?c:"utf8");e.push(t)}const l=Buffer.concat(e),i=t.getHeader("content-type");let p;try{p=n(l,i)}catch{p=null}if(null!=p){const n=Buffer.isBuffer(p)?p:Buffer.from(p,"utf8");t.setHeader("Content-Length",n.length),t.removeHeader("Content-Encoding"),f(n)}else l.length>0&&f(l);const y="function"==typeof c?c:s;return y?u(y):u(),t}}Object.defineProperty(exports,"t",{value:!0}),exports.interceptResponse=interceptResponse;
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { Request, Response } from 'express';
|
|
2
|
+
import type { UnsharedLabsClient } from '../../client';
|
|
3
|
+
import type { VerdictCache } from '../verdict-cache';
|
|
4
|
+
import type { RateLimitBackoff } from '../rate-limit-backoff';
|
|
5
|
+
export interface SubmitFingerprintDependencies {
|
|
6
|
+
client: UnsharedLabsClient;
|
|
7
|
+
verdictCache: VerdictCache;
|
|
8
|
+
rateLimitBackoff: RateLimitBackoff;
|
|
9
|
+
resolveUserId?: (req: Request) => string | undefined;
|
|
10
|
+
resolveEmailAddress?: (req: Request) => string | undefined;
|
|
11
|
+
resolveSessionId?: (req: Request) => string | undefined;
|
|
12
|
+
resolveDeviceId?: (req: Request) => string | undefined;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Handles POST /__unshared/submit-fp
|
|
16
|
+
*
|
|
17
|
+
* Receives fingerprint data from the injected inline script and:
|
|
18
|
+
* 1. Forwards to Unshared API via client.submitFingerprintEvent() (fire-and-forget)
|
|
19
|
+
* 2. Calls processUserEvent with cache side-effect (fire-and-forget)
|
|
20
|
+
* 3. Sets __unshared_email HttpOnly cookie when email is resolved from body
|
|
21
|
+
*
|
|
22
|
+
* Always returns 200 (fire-and-forget from browser's perspective).
|
|
23
|
+
*/
|
|
24
|
+
export declare function handleSubmitFingerprint(dependencies: SubmitFingerprintDependencies): (req: Request, res: Response) => Promise<void>;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"use strict";function handleSubmitFingerprint(e){return async(n,t)=>{try{const i=n.body??{},o={full_hash:i.hash??"",fingerprint_id:i.stable_hash??"",timestamp:i.collected_at??(new Date).toISOString(),isIncognito:i.is_incognito??!1,components:i.components??{},version:i.version??"inline-1.0.0"};let s,r,a;try{s=e.resolveUserId?e.resolveUserId(n):void 0}catch{}s=s??i.user_id??void 0;try{r=e.resolveEmailAddress?e.resolveEmailAddress(n):void 0}catch{}r=r??parseCookie(n,"__unshared_email")??i.email??void 0;try{a=e.resolveSessionId?e.resolveSessionId(n):void 0}catch{}a=a??i.session_id??parseCookie(n,"__unshared_sid");const c=n.ip??"",d=n.headers["user-agent"]??"",u=extractDeviceId(n,e.resolveDeviceId),_=o.fingerprint_id||void 0,p=[];if(_&&!parseCookie(n,"__unshared_fingerprint_id")&&p.push(`__unshared_fingerprint_id=${encodeURIComponent(_)}; HttpOnly; Path=/; SameSite=Lax`),r&&!parseCookie(n,"__unshared_email")&&p.push(`__unshared_email=${encodeURIComponent(r)}; HttpOnly; Path=/; SameSite=Lax`),p.length>0){const e=t.getHeader("Set-Cookie");if(e){const n=Array.isArray(e)?[...e]:[String(e)];n.push(...p),t.setHeader("Set-Cookie",n)}else t.setHeader("Set-Cookie",p)}s&&e.client.submitFingerprintEvent(o,{userId:s,sessionHash:a,eventType:"auto_collect",ipAddress:c}).catch(()=>{}),s&&r&&!e.rateLimitBackoff.isPaused()&&e.client.processUserEvent({eventType:"auto_collect",userId:s,emailAddress:r,ipAddress:c,deviceId:u,fingerprintId:_,sessionHash:a??"unknown",userAgent:d}).then(n=>{n.success&&n.data?.analysis&&e.verdictCache.update(s,{isFlagged:n.data.analysis.is_user_flagged}),!n.success&&n.error?.retryAfter&&e.rateLimitBackoff.pause(1e3*n.error.retryAfter)}).catch(()=>{}),t.status(200).json({success:!0})}catch{t.status(200).json({success:!0})}}}function extractDeviceId(e,n){if(n)try{const t=n(e);if(t)return t}catch{}const t=parseCookie(e,"__unshared_fp_id");if(t)return t;const i=e.headers["x-device-id"];return"string"==typeof i&&i?i:"unknown"}function parseCookie(e,n){const t=e.headers.cookie;if(!t)return;const i=t.match(new RegExp(`(?:^|; )${n}=([^;]*)`));return i?decodeURIComponent(i[1]):void 0}Object.defineProperty(exports,"t",{value:!0}),exports.handleSubmitFingerprint=handleSubmitFingerprint;
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { Request, Response } from 'express';
|
|
2
|
+
import type { UnsharedLabsClient } from '../../client';
|
|
3
|
+
import type { VerdictCache } from '../verdict-cache';
|
|
4
|
+
export interface VerificationDependencies {
|
|
5
|
+
client: UnsharedLabsClient;
|
|
6
|
+
verdictCache: VerdictCache;
|
|
7
|
+
resolveEmailAddress?: (req: Request) => string | undefined;
|
|
8
|
+
resolveDeviceId?: (req: Request) => string | undefined;
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* POST /__unshared/verify-trigger
|
|
12
|
+
* Triggers email verification. Called by the blocker overlay UI.
|
|
13
|
+
*
|
|
14
|
+
* Body: { email: string }
|
|
15
|
+
*
|
|
16
|
+
* The deviceId is resolved via extractDeviceId (same as the middleware).
|
|
17
|
+
*/
|
|
18
|
+
export declare function handleVerifyTrigger(dependencies: VerificationDependencies): (req: Request, res: Response) => Promise<void>;
|
|
19
|
+
/**
|
|
20
|
+
* POST /__unshared/verify
|
|
21
|
+
* Validates OTP code. Called by the blocker overlay UI.
|
|
22
|
+
*
|
|
23
|
+
* Body: { email: string, code: string }
|
|
24
|
+
*
|
|
25
|
+
* On successful verification, updates the verdict cache to mark
|
|
26
|
+
* the user as verified so subsequent requests pass through.
|
|
27
|
+
*/
|
|
28
|
+
export declare function handleVerify(dependencies: VerificationDependencies): (req: Request, res: Response) => Promise<void>;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"use strict";function handleVerifyTrigger(e){return async(r,i)=>{try{const s=resolveEmail(r,r.body??{},e.resolveEmailAddress);if(!s)return void i.status(400).json({success:!1,error:{code:"VALIDATION_ERROR",message:"Email is required"}});const t=extractDeviceId(r,e.resolveDeviceId),n=parseCookie(r,"__unshared_fingerprint_id")||void 0,o=await e.client.triggerEmailVerification(s,t,{fingerprintId:n});o.success?i.status(200).json({success:!0,data:o.data}):i.status(200).json({success:!1,error:o.error??{code:"TRIGGER_FAILED",message:"Failed to send verification email"}})}catch{i.status(200).json({success:!1,error:{code:"INTERNAL_ERROR",message:"Failed to trigger verification"}})}}}function handleVerify(e){return async(r,i)=>{try{const s=r.body??{},t=resolveEmail(r,s,e.resolveEmailAddress),n=s.code;if(!t||!n)return void i.status(400).json({success:!1,error:{code:"VALIDATION_ERROR",message:"Email and code are required"}});const o=extractDeviceId(r,e.resolveDeviceId),c=parseCookie(r,"__unshared_fingerprint_id")||void 0,a=await e.client.verify(t,o,n,{fingerprintId:c});if(a.success){const s=parseCookie(r,"__unshared_uid");s&&e.verdictCache.update(s,{isVerified:!0}),i.status(200).json({success:!0,data:{verified:!0}})}else i.status(200).json({success:!1,error:a.error??{code:"VERIFICATION_FAILED",message:"Verification failed"}})}catch{i.status(200).json({success:!1,error:{code:"INTERNAL_ERROR",message:"Verification failed"}})}}}function resolveEmail(e,r,i){if(i)try{const r=i(e);if(r)return r}catch{}const s=parseCookie(e,"__unshared_email");if(s)return s;const t=r.email;return"string"==typeof t&&t?t:void 0}function extractDeviceId(e,r){if(r)try{const i=r(e);if(i)return i}catch{}const i=parseCookie(e,"__unshared_fp_id");if(i)return i;const s=e.headers["x-device-id"];return"string"==typeof s&&s?s:"unknown"}function parseCookie(e,r){const i=e.headers.cookie;if(!i)return;const s=i.match(new RegExp(`(?:^|; )${r}=([^;]*)`));return s?decodeURIComponent(s[1]):void 0}Object.defineProperty(exports,"i",{value:!0}),exports.handleVerifyTrigger=handleVerifyTrigger,exports.handleVerify=handleVerify;
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/** Check if a Content-Type header value indicates HTML. */
|
|
2
|
+
export declare function isHtmlContentType(contentType: string | undefined): boolean;
|
|
3
|
+
/** Check if a Content-Type header value indicates JSON. */
|
|
4
|
+
export declare function isJsonContentType(contentType: string | undefined): boolean;
|
|
5
|
+
/** Check if a Content-Type indicates a static asset (images, fonts, etc). */
|
|
6
|
+
export declare function isStaticContentType(contentType: string | undefined): boolean;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"use strict";function isHtmlContentType(t){return!!t&&t.includes("text/html")}function isJsonContentType(t){return!!t&&t.includes("application/json")}function isStaticContentType(t){return!!t&&["image/","font/","audio/","video/","application/javascript","text/javascript","text/css","application/wasm"].some(e=>t.includes(e))}Object.defineProperty(exports,"t",{value:!0}),exports.isHtmlContentType=isHtmlContentType,exports.isJsonContentType=isJsonContentType,exports.isStaticContentType=isStaticContentType;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"use strict";Object.defineProperty(exports,"t",{value:!0}),exports.shouldSkipPath=shouldSkipPath;const STATIC_EXTENSIONS=new Set([".js",".mjs",".cjs",".css",".map",".png",".jpg",".jpeg",".gif",".svg",".ico",".webp",".avif",".woff",".woff2",".ttf",".otf",".eot",".mp3",".mp4",".webm",".ogg",".wasm",".json",".xml",".txt",".pdf"]),STATIC_PATH_PREFIXES=["/static/","/assets/","/public/","/_next/","/__vite/","/favicon"];function shouldSkipPath(t,s){if(s)for(const o of s)if(t.startsWith(o))return!0;const o=t.lastIndexOf(".");if(-1!==o){const s=t.slice(o).toLowerCase().split("?")[0];if(STATIC_EXTENSIONS.has(s))return!0}for(const s of STATIC_PATH_PREFIXES)if(t.startsWith(s))return!0;return!1}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
export interface Verdict {
|
|
2
|
+
isFlagged: boolean;
|
|
3
|
+
isVerified: boolean;
|
|
4
|
+
emailAddress: string;
|
|
5
|
+
sessionId: string;
|
|
6
|
+
cachedAt: number;
|
|
7
|
+
ttl: number;
|
|
8
|
+
}
|
|
9
|
+
export declare class VerdictCache {
|
|
10
|
+
private readonly _entries;
|
|
11
|
+
private readonly _activeRefreshes;
|
|
12
|
+
private readonly _defaultTtlMs;
|
|
13
|
+
constructor(defaultTTL?: number);
|
|
14
|
+
get(userId: string): Verdict | undefined;
|
|
15
|
+
set(userId: string, verdict: Omit<Verdict, 'cachedAt' | 'ttl'>, ttl?: number): void;
|
|
16
|
+
/**
|
|
17
|
+
* Update an existing cache entry (e.g. from webhook).
|
|
18
|
+
* If the user is not in cache, creates a new entry.
|
|
19
|
+
*/
|
|
20
|
+
update(userId: string, partial: Partial<Pick<Verdict, 'isFlagged' | 'isVerified'>>): void;
|
|
21
|
+
delete(userId: string): void;
|
|
22
|
+
/**
|
|
23
|
+
* Returns true if the entry exists but is past its TTL.
|
|
24
|
+
* Stale entries are served while a background refresh happens.
|
|
25
|
+
*/
|
|
26
|
+
isStale(userId: string): boolean;
|
|
27
|
+
/**
|
|
28
|
+
* Returns true if a background refresh is already in flight for this user.
|
|
29
|
+
*/
|
|
30
|
+
isRefreshing(userId: string): boolean;
|
|
31
|
+
markRefreshing(userId: string): void;
|
|
32
|
+
clearRefreshing(userId: string): void;
|
|
33
|
+
/** Number of cached entries. */
|
|
34
|
+
get size(): number;
|
|
35
|
+
clear(): void;
|
|
36
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"use strict";Object.defineProperty(exports,"t",{value:!0}),exports.VerdictCache=void 0;class VerdictCache{constructor(t=6e4){this.i=new Map,this.h=new Set,this.o=t}get(t){return this.i.get(t)}set(t,e,s){this.i.set(t,{...e,cachedAt:Date.now(),ttl:s??this.o})}update(t,e){const s=this.i.get(t);s?(void 0!==e.isFlagged&&(s.isFlagged=e.isFlagged),void 0!==e.isVerified&&(s.isVerified=e.isVerified),s.cachedAt=Date.now()):this.i.set(t,{isFlagged:e.isFlagged??!1,isVerified:e.isVerified??!1,emailAddress:"",sessionId:"",cachedAt:Date.now(),ttl:this.o})}delete(t){this.i.delete(t),this.h.delete(t)}isStale(t){const e=this.i.get(t);return!!e&&Date.now()-e.cachedAt>e.ttl}isRefreshing(t){return this.h.has(t)}markRefreshing(t){this.h.add(t)}clearRefreshing(t){this.h.delete(t)}get size(){return this.i.size}clear(){this.i.clear(),this.h.clear()}}exports.VerdictCache=VerdictCache;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "unshared-clientjs-sdk",
|
|
3
|
-
"version": "2.0.0-rc.
|
|
3
|
+
"version": "2.0.0-rc.9",
|
|
4
4
|
"description": "Server-side Node.js SDK for the Unshared Labs V2 API",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"module": "dist/esm/index.mjs",
|
|
@@ -28,6 +28,16 @@
|
|
|
28
28
|
"import": "./dist/esm/middleware.mjs",
|
|
29
29
|
"require": "./dist/middleware.js",
|
|
30
30
|
"types": "./dist/middleware.d.ts"
|
|
31
|
+
},
|
|
32
|
+
"./auto-middleware": {
|
|
33
|
+
"import": "./dist/esm/middleware/index.mjs",
|
|
34
|
+
"require": "./dist/middleware/index.js",
|
|
35
|
+
"types": "./dist/middleware/index.d.ts"
|
|
36
|
+
},
|
|
37
|
+
"./middleware/auto": {
|
|
38
|
+
"import": "./dist/esm/middleware/index.mjs",
|
|
39
|
+
"require": "./dist/middleware/index.js",
|
|
40
|
+
"types": "./dist/middleware/index.d.ts"
|
|
31
41
|
}
|
|
32
42
|
},
|
|
33
43
|
"engines": {
|
|
@@ -44,6 +54,9 @@
|
|
|
44
54
|
"optional": true
|
|
45
55
|
}
|
|
46
56
|
},
|
|
57
|
+
"dependencies": {
|
|
58
|
+
"unshared-frontend-sdk": "file:../frontend-sdk"
|
|
59
|
+
},
|
|
47
60
|
"devDependencies": {
|
|
48
61
|
"@types/express": "^4.17.21",
|
|
49
62
|
"@types/node": "^24.10.1",
|