unshared-clientjs-sdk 2.3.0 → 3.0.0-rc.14
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 +176 -151
- package/dist/client.d.ts +54 -25
- package/dist/client.js +1 -1
- package/dist/esm/client.d.mts +54 -25
- package/dist/esm/client.mjs +1 -1
- package/dist/esm/index.d.mts +8 -8
- package/dist/esm/index.mjs +1 -1
- package/dist/esm/middleware/dispatch-processing.d.mts +6 -3
- package/dist/esm/middleware/dispatch-processing.mjs +1 -1
- package/dist/esm/middleware/index.d.mts +7 -5
- package/dist/esm/middleware/index.mjs +1 -1
- package/dist/esm/middleware/injection/fingerprint-script.d.mts +7 -0
- package/dist/esm/middleware/injection/fingerprint-script.mjs +1 -1
- package/dist/esm/middleware/injection/fp-bundle-source.d.mts +5 -4
- package/dist/esm/middleware/injection/fp-bundle-source.mjs +1 -1
- package/dist/esm/middleware/response-interceptor.d.mts +1 -1
- package/dist/esm/middleware/routes/interstitial.d.mts +2 -2
- package/dist/esm/middleware/routes/interstitial.mjs +1 -1
- package/dist/esm/middleware/routes/submit-fp.d.mts +5 -5
- package/dist/esm/middleware/routes/submit-fp.mjs +1 -1
- package/dist/esm/middleware/routes/verify.d.mts +3 -3
- package/dist/esm/middleware/routes/verify.mjs +1 -1
- package/dist/esm/middleware/utils/client-ip.d.mts +1 -1
- package/dist/esm/middleware/utils/cookies.d.mts +1 -1
- package/dist/esm/middleware/utils/device-id.d.mts +1 -1
- package/dist/esm/middleware/utils/device-id.mjs +1 -1
- package/dist/esm/middleware/utils/flagged-response.mjs +1 -1
- package/dist/esm/middleware/utils/http-helpers.d.mts +1 -1
- package/dist/esm/middleware/utils/permanent-device-id.d.mts +7 -0
- package/dist/esm/middleware/utils/permanent-device-id.mjs +1 -0
- package/dist/esm/middleware/utils/read-json-body.d.mts +3 -0
- package/dist/esm/middleware/utils/read-json-body.mjs +1 -0
- package/dist/esm/middleware/utils/secure.d.mts +1 -1
- package/dist/esm/middleware.d.mts +5 -6
- package/dist/esm/middleware.mjs +1 -1
- package/dist/esm/shared-types.d.mts +148 -0
- package/dist/esm/web/index.d.mts +6 -6
- package/dist/esm/web/index.mjs +1 -1
- package/dist/esm/web/protection-handler.d.mts +5 -5
- package/dist/esm/web/protection-handler.mjs +1 -1
- package/dist/esm/web/submit-handler.d.mts +2 -2
- package/dist/esm/web/submit-handler.mjs +1 -1
- package/dist/esm/web/types.d.mts +1 -1
- package/dist/esm/web/web-helpers.d.mts +11 -0
- package/dist/esm/web/web-helpers.mjs +1 -1
- package/dist/middleware/dispatch-processing.d.ts +3 -0
- package/dist/middleware/dispatch-processing.js +1 -1
- package/dist/middleware/index.d.ts +2 -0
- package/dist/middleware/index.js +1 -1
- package/dist/middleware/injection/fingerprint-script.d.ts +7 -0
- package/dist/middleware/injection/fingerprint-script.js +1 -1
- package/dist/middleware/injection/fp-bundle-source.d.ts +5 -4
- package/dist/middleware/injection/fp-bundle-source.js +1 -1
- package/dist/middleware/routes/submit-fp.js +1 -1
- package/dist/middleware/routes/verify.js +1 -1
- package/dist/middleware/utils/permanent-device-id.d.ts +7 -0
- package/dist/middleware/utils/permanent-device-id.js +1 -0
- package/dist/middleware/utils/read-json-body.d.ts +3 -0
- package/dist/middleware/utils/read-json-body.js +1 -0
- package/dist/middleware.d.ts +3 -4
- package/dist/middleware.js +1 -1
- package/dist/shared-types.d.ts +148 -0
- package/dist/web/protection-handler.d.ts +1 -1
- package/dist/web/protection-handler.js +1 -1
- package/dist/web/submit-handler.js +1 -1
- package/dist/web/web-helpers.d.ts +11 -0
- package/dist/web/web-helpers.js +1 -1
- package/package.json +4 -4
package/dist/client.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
"use strict";Object.defineProperty(exports,"t",{value:!0}),exports.UnsharedClient=void 0;const webcrypto_1=require("./webcrypto"),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(this.i=null,!e.apiKey||""===e.apiKey.trim())throw new Error("apiKey is required");this.o=e.apiKey,this.l=e.baseUrl?e.baseUrl.replace(/\/$/,""):DEFAULT_BASE_URL,this.u=e.timeout??1e4,this.h=e.maxRetries??3,this.m=e.fetch}_(){return this.i||(this.i=(0,webcrypto_1.sha256Bytes)(this.o).then(webcrypto_1.importAesGcmKey)),this.i}async p(e){return(0,webcrypto_1.encryptDataWeb)(e,await this._())}async R(e,s,t){const i=(t?.maxRetries??this.h)+1,r=t?.timeoutMs??this.u;let a={success:!1,status:0,error:{code:"NETWORK_ERROR",message:"Request failed"}};for(let t=1;t<=i;t++){t>1&&await sleep(retryDelay(t-1));const i=new AbortController,o=setTimeout(()=>i.abort(),r);try{const t=this.m??globalThis.fetch,r=await t(e,{method:s.method,headers:{"X-API-Key":this.o,...s.headers},body:s.body,signal:i.signal});if(r.ok){let e,s;try{e=await r.text()}catch(e){a={success:!1,status:0,error:{code:"NETWORK_ERROR",message:e instanceof Error?e.message:String(e)}};continue}if(""===e.trim())return{success:!0,status:r.status,data:{}};try{s=JSON.parse(e)}catch{return{success:!1,status:r.status,error:{code:"PARSE_ERROR",message:"Unparseable 2xx response body"}}}const t="data"in s?s.data:s;return{success:!0,status:r.status,data:t}}const o=await parseErrorBody(r);if(r.status>=400&&r.status<500){if(429===r.status){const e=r.headers.get("Retry-After");if(null!=e){const s=parseInt(e,10);isNaN(s)||(o.retryAfter=s)}if(null==o.retryAfter){const e=o.details?.retry_after_seconds;"number"!=typeof e||isNaN(e)||(o.retryAfter=e)}}return{success:!1,status:r.status,error:o}}a={success:!1,status:r.status,error:o}}catch(e){a={success:!1,status:0,error:{code:"NETWORK_ERROR",message:e instanceof Error?e.message:String(e)}}}finally{clearTimeout(o)}}return a}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};null!=e.source&&(t.source=e.source);const[i,r,a,o]=await Promise.all([null!=s?.userId?this.p(s.userId):Promise.resolve(void 0),null!=s?.emailAddress?this.p(s.emailAddress):Promise.resolve(void 0),null!=s?.ipAddress?this.p(s.ipAddress):Promise.resolve(void 0),null!=s?.userAgent?this.p(s.userAgent):Promise.resolve(void 0)]);return null!=i&&(t.user_id=i),null!=r&&(t.email_address=r),null!=s?.sessionHash&&(t.session_hash=s.sessionHash),null!=s?.eventType&&(t.event_type=s.eventType),null!=a&&(t.ip_address=a),null!=o&&(t.user_agent=o),this.R(`${this.l}/v2/submit-fingerprint-event`,{method:"POST",headers:{"Content-Type":"application/json","X-Idempotency-Key":s?.idempotencyKey??globalThis.crypto.randomUUID()},body:JSON.stringify(t)})}async processUserEvent(e,s){const[t,i,r,a,o,n]=await Promise.all([this.p(e.userId),this.p(e.ipAddress),this.p(e.deviceId),this.p(e.userAgent),this.p(e.emailAddress),null!=e.fingerprintId?this.p(e.fingerprintId):Promise.resolve(void 0)]),c={event_type:e.eventType,user_id:t,ip_address:i,device_id:r,session_hash:e.sessionHash,user_agent:a,email_address:o};return null!=n&&(c.fingerprint_id=n),null!=e.subscriptionStatus&&(c.subscription_status=e.subscriptionStatus),null!=e.eventDetails&&(c.event_details=e.eventDetails),this.R(`${this.l}/v2/process-user-event`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(c)},{timeoutMs:s?.timeoutMs,maxRetries:s?.maxRetries??0})}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},failedOpen:{status:0,reason:"no_device_id"}};const[i,r,a]=await Promise.all([this.p(e),t.deviceId?this.p(t.deviceId):Promise.resolve(void 0),t.fingerprintId?this.p(t.fingerprintId):Promise.resolve(void 0)]),o=new URLSearchParams;o.set("email_address",i),t.deviceId&&o.set("device_id",r),t.fingerprintId&&o.set("fingerprint_id",a);const n=await this.R(`${this.l}/v2/check-user?${o}`,{method:"GET"},{timeoutMs:t.timeoutMs,maxRetries:t.maxRetries});return n.success?n:{success:!0,status:200,data:{is_user_flagged:!1},failedOpen:{status:n.status}}}async triggerEmailVerification(e,s,t){const[i,r,a]=await Promise.all([this.p(e),this.p(s),t?.fingerprintId?this.p(t.fingerprintId):Promise.resolve(void 0)]),o={email_address:i,device_id:r};t?.fingerprintId&&(o.fingerprint_id=a);const n=await this.R(`${this.l}/v2/trigger-email-verification`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(o)},{maxRetries:0});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}async verify(e,s,t,i){const[r,a,o,n]=await Promise.all([this.p(e),this.p(s),this.p(t),i?.fingerprintId?this.p(i.fingerprintId):Promise.resolve(void 0)]),c={email_address:r,device_id:a,code:o};i?.fingerprintId&&(c.fingerprint_id=n);const l=await this.R(`${this.l}/v2/verify`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(c)},{maxRetries:0});return!l.success&&(0===l.status||l.status>=500)?{success:!1,status:l.status,error:{code:"DELIVERY_FAILED",message:l.error?.message??"Delivery failed"}}:l.success&&!0!==l.data?.verified?{success:!1,status:l.status,error:{code:"VERIFICATION_FAILED",message:"Code is incorrect or expired",details:l.data?.reason?{reason:l.data.reason}:void 0}}:l}async getInterstitialFlow(e){const s=new URLSearchParams;return s.set("flow_type",e?.flowType||"email_verification"),s.set("platform",e?.platform||"web"),this.R(`${this.l}/v2/interstitial-flow?${s}`,{method:"GET"})}}exports.UnsharedClient=UnsharedClient;
|
|
1
|
+
"use strict";Object.defineProperty(exports,"t",{value:!0}),exports.UnsharedClient=void 0;const webcrypto_1=require("./webcrypto"),DEFAULT_BASE_URL="https://api.unshared.ai",DEFAULT_TIMEOUT_MS=1e4,DEFAULT_MAX_RETRIES=3,MAX_DELAY_MS=3e4,BASE_DELAY_MS=1e3,MAX_REQUEST_BYTES=1<<20,MAX_ENCRYPTED_REQUEST_BYTES=Math.floor(1572864),SDK_VERSION="3.0.0",UUID_V4_PATTERN=/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;function isSecureOrLoopbackUrl(e){try{const s=new URL(e);return"https:"===s.protocol||"localhost"===s.hostname||"[::1]"===s.hostname||/^127\./.test(s.hostname)}catch{return!1}}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)}function asJSONObject(e){return null===e||"object"!=typeof e||Array.isArray(e)?{}:e}function isJSONObject(e,s=new WeakSet){if(null===e||"object"!=typeof e||Array.isArray(e)||s.has(e))return!1;const t=Object.getPrototypeOf(e);if(t!==Object.prototype&&null!==t)return!1;s.add(e);for(const t of Object.values(e))if(null!==t&&"string"!=typeof t&&"boolean"!=typeof t&&!("number"==typeof t&&Number.isFinite(t)||Array.isArray(t)&&isJSONValue(t,s)||isJSONObject(t,s)))return!1;return s.delete(e),!0}function isJSONValue(e,s){if(null===e||"string"==typeof e||"boolean"==typeof e)return!0;if("number"==typeof e)return Number.isFinite(e);if(Array.isArray(e)){if(s.has(e))return!1;s.add(e);let t=Object.keys(e).length===e.length;for(let r=0;t&&r<e.length;r++)t=Object.prototype.hasOwnProperty.call(e,r)&&isJSONValue(e[r],s);return s.delete(e),t}return isJSONObject(e,s)}function hasExactKeys(e,s){return null!==e&&"object"==typeof e&&!Array.isArray(e)&&(Object.keys(e).length===s.length&&s.every(s=>Object.prototype.hasOwnProperty.call(e,s)))}function isValidDateTime(e){if("string"!=typeof e)return!1;const s=/^(\d{4})-(\d{2})-(\d{2})T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$/.exec(e);if(!s||Number.isNaN(Date.parse(e)))return!1;const t=Number(s[1]),r=Number(s[2]),i=Number(s[3]);return r>=1&&r<=12&&i>=1&&i<=[31,t%4!=0||t%100==0&&t%400!=0?28:29,31,30,31,30,31,31,30,31,30,31][r-1]}function isIngestResult(e){return hasExactKeys(e,["event_id","collected_at","endpoint_version"])&&"string"==typeof e.event_id&&UUID_V4_PATTERN.test(e.event_id)&&isValidDateTime(e.collected_at)&&"v3"===e.endpoint_version}function isCheckUserResult(e){if(!hasExactKeys(e,["is_user_flagged","decision_id","decision_code"]))return!1;const s=new Set(["FLAGGED_RISK_MATCH","NO_ENFORCING_RISK","NO_IDENTITY_MATCH","IDENTITY_UNCERTAIN","CHECK_UNAVAILABLE"]);return"boolean"==typeof e.is_user_flagged&&"string"==typeof e.decision_id&&UUID_V4_PATTERN.test(e.decision_id)&&"string"==typeof e.decision_code&&s.has(e.decision_code)&&e.is_user_flagged===("FLAGGED_RISK_MATCH"===e.decision_code)}function isTriggerEmailResult(e){return hasExactKeys(e,["verification_id","next_allowed_at","retry_after_seconds"])&&"string"==typeof e.verification_id&&UUID_V4_PATTERN.test(e.verification_id)&&isValidDateTime(e.next_allowed_at)&&Number.isInteger(e.retry_after_seconds)&&e.retry_after_seconds>=0}function isVerifyEmailResult(e){return hasExactKeys(e,["verified","verification_scope"])?!0===e.verified&&"email"===e.verification_scope:hasExactKeys(e,["verified","verification_scope","reason"])&&!1===e.verified&&"email"===e.verification_scope&&"invalid_code"===e.reason}function compactIdentifiers(e){const s={};for(const[t,r]of Object.entries(e))"string"==typeof r&&""!==r.trim()&&(s[t]=r.trim());return s}async function parseErrorBody(e,s=!1){const t=await e.text();try{const r=JSON.parse(t);if(s)return hasExactKeys(r,["success","error"])&&!1===r.success&&hasExactKeys(r.error,["code","message"])&&"string"==typeof r.error.code&&"string"==typeof r.error.message?{code:r.error.code,message:r.error.message}:{code:"PARSE_ERROR",message:"Invalid v3 error envelope"};const i=r;return"string"==typeof i?.error?.code?{code:i.error.code,message:"string"==typeof i.error.message?i.error.message:"Unknown error",details:i.error.details}:{code:"UNKNOWN_ERROR",message:t||e.statusText}}catch{return{code:"UNKNOWN_ERROR",message:t||e.statusText}}}class UnsharedClient{constructor(e){if(this.i=new Map,!e.apiKey||""===e.apiKey.trim())throw new Error("apiKey is required");if(e.baseUrl&&!isSecureOrLoopbackUrl(e.baseUrl))throw new Error("baseUrl must be a valid HTTPS URL except for loopback development URLs");this.o=e.apiKey,this.u=e.baseUrl?e.baseUrl.replace(/\/$/,""):DEFAULT_BASE_URL,this._=e.timeout??1e4,this.l=e.maxRetries??3,this.p=e.fetch}async R(e,s,t){const r=t?.maxRequestBytes??1048576;if(s.body&&(new TextEncoder).encode(s.body).byteLength>r)return{success:!1,status:413,error:{code:"REQUEST_TOO_LARGE",message:"Request body exceeds "+(1048576===r?"1 MiB":"1.5 MiB")}};const i=(t?.maxRetries??this.l)+1,a=t?.timeoutMs??this._;let n={success:!1,status:0,error:{code:"NETWORK_ERROR",message:"Request failed"}};for(let r=1;r<=i;r++){r>1&&await sleep(retryDelay(r-1));const i=new AbortController,o=setTimeout(()=>i.abort(),a);try{const r=this.p??globalThis.fetch,a=await r(e,{method:s.method,headers:{"X-API-Key":this.o,...s.headers},body:s.body,signal:i.signal});if(a.ok){let e,s;try{e=await a.text()}catch(e){n={success:!1,status:0,error:{code:"NETWORK_ERROR",message:e instanceof Error?e.message:String(e)}};continue}if(""===e.trim())return{success:!1,status:a.status,error:{code:"PARSE_ERROR",message:"Empty v3 response body"}};try{s=JSON.parse(e)}catch{return{success:!1,status:a.status,error:{code:"PARSE_ERROR",message:"Unparseable 2xx response body"}}}return(t?.strictEnvelope?hasExactKeys(s,["success","data"])&&!0===s.success:!0===s?.success&&Object.prototype.hasOwnProperty.call(s,"data"))?void 0!==t?.expectedStatus&&a.status!==t.expectedStatus?{success:!1,status:a.status,error:{code:"PARSE_ERROR",message:`Unexpected success status ${a.status}`}}:t?.validateData&&!t.validateData(s.data)?{success:!1,status:a.status,error:{code:"PARSE_ERROR",message:"Invalid v3 response data"}}:{success:!0,status:a.status,data:s.data}:{success:!1,status:a.status,error:{code:"PARSE_ERROR",message:"Invalid v3 response envelope"}}}const o=await parseErrorBody(a,t?.strictEnvelope);if(a.status>=400&&a.status<500){if(429===a.status){const e=a.headers.get("Retry-After");if(null!=e){const s=parseInt(e,10);isNaN(s)||(o.retryAfter=s)}if(null==o.retryAfter){const e=o.details?.retry_after_seconds;"number"!=typeof e||isNaN(e)||(o.retryAfter=e)}}return{success:!1,status:a.status,error:o}}if(n={success:!1,status:a.status,error:o},t?.retryHttpError&&!t.retryHttpError(a.status,o))return n}catch(e){n={success:!1,status:0,error:{code:"NETWORK_ERROR",message:e instanceof Error?e.message:String(e)}}}finally{clearTimeout(o)}}return n}async m(e,s,t){if(!isJSONObject(s))return{success:!1,status:400,error:{code:"INVALID_JSON",message:"Request body must be a JSON object"}};let r,i;try{r=JSON.stringify(s)}catch(e){return{success:!1,status:400,error:{code:"INVALID_JSON",message:e instanceof Error?e.message:String(e)}}}if((new TextEncoder).encode(r).byteLength>1048576)return{success:!1,status:413,error:{code:"REQUEST_TOO_LARGE",message:"Request body exceeds 1 MiB"}};try{this.h??(this.h=(0,webcrypto_1.sha256Bytes)(this.o).then(webcrypto_1.importAesGcmKey)),i=JSON.stringify({encrypted_data:await(0,webcrypto_1.encryptDataWeb)(r,await this.h)})}catch(e){return{success:!1,status:0,error:{code:"CLIENT_ENCRYPTION_ERROR",message:e instanceof Error?e.message:String(e)}}}return this.R(`${this.u}${e}`,{method:"POST",headers:{"Content-Type":"application/json",...t?.idempotencyKey?{"X-Idempotency-Key":t.idempotencyKey}:{}},body:i},{timeoutMs:t?.timeoutMs,maxRetries:0,maxRequestBytes:MAX_ENCRYPTED_REQUEST_BYTES,expectedStatus:202,strictEnvelope:!0,validateData:e=>isIngestResult(e)})}async submitFingerprint(e,s){return this.m("/v3/submit-fingerprint-event",e,{idempotencyKey:s?.idempotencyKey??globalThis.crypto.randomUUID()})}async submitFingerprintEvent(e,s){const t=compactIdentifiers({unshared_device_id:s?.permanentDeviceId,device_id:s?.deviceId,stable_hash:s?.stableHash??e.fingerprint_id,full_hash:s?.fullHash??e.full_hash,session_hash:s?.sessionHash}),r={fingerprint:asJSONObject(e),context:{sdk:{name:"unshared-clientjs-sdk",version:"3.0.0",runtime:"node"},identity:{...s?.userId?{user_id:s.userId}:{},...s?.emailAddress?{email_address:s.emailAddress}:{}},identifiers:t,event:{...s?.eventType?{event_type:s.eventType}:{},...s?.ipAddress?{ip_address:s.ipAddress}:{},...s?.userAgent?{user_agent:s.userAgent}:{}}}},i=await this.m("/v3/submit-fingerprint-event",r,{idempotencyKey:s?.idempotencyKey??globalThis.crypto.randomUUID()});return i.success?i.data?{success:!0,status:i.status,data:{...i.data,hash:e.full_hash,stable_hash:e.fingerprint_id,version:e.version??"unknown"}}:{success:!1,status:i.status,error:{code:"PARSE_ERROR",message:"Invalid v3 ingestion result"}}:{success:!1,status:i.status,error:i.error}}async processUserEvent(e,s){if(null===e||"object"!=typeof e||Array.isArray(e))return{success:!1,status:400,error:{code:"INVALID_JSON",message:"Request body must be a JSON object"}};const t=new Set(["eventType","userId","ipAddress","deviceId","sessionHash","userAgent","emailAddress","fingerprintId","permanentDeviceId","subscriptionStatus","eventDetails"]);if(!Object.keys(e).every(e=>t.has(e))||"string"!=typeof e.eventType||"string"!=typeof e.userId||"string"!=typeof e.emailAddress||"string"!=typeof e.deviceId||"string"!=typeof e.sessionHash)return this.m("/v3/process-user-event",e,{timeoutMs:s?.timeoutMs});const r=e,i=compactIdentifiers({unshared_device_id:r.permanentDeviceId,device_id:r.deviceId,stable_hash:r.fingerprintId,session_hash:r.sessionHash}),a={event_type:r.eventType,identity:{user_id:r.userId,email_address:r.emailAddress},identifiers:i,context:{ip_address:r.ipAddress,user_agent:r.userAgent,...r.subscriptionStatus?{subscription_status:r.subscriptionStatus}:{}},...r.eventDetails?{event_details:asJSONObject(r.eventDetails)}:{}},n=await this.m("/v3/process-user-event",a,{timeoutMs:s?.timeoutMs});if(!n.success)return{success:!1,status:n.status,error:n.error};const o=await this.checkUser({user_id:r.userId,email_address:r.emailAddress,identifiers:i},{timeoutMs:s?.timeoutMs,maxRetries:s?.maxRetries});return o.success?{success:!0,status:o.status,data:{event:{event_type:r.eventType,user_id:r.userId,email_address:r.emailAddress,ip_address:r.ipAddress,device_id:r.deviceId,...r.permanentDeviceId?{permanent_device_id:r.permanentDeviceId}:{},session_hash:r.sessionHash,user_agent:r.userAgent,...r.subscriptionStatus?{subscription_status:r.subscriptionStatus}:{},...r.eventDetails?{event_details:JSON.stringify(r.eventDetails)}:{}},analysis:{status:o.failedOpen?"error":"success",is_user_flagged:o.data.is_user_flagged,...o.data.decision_code?{status_details:o.data.decision_code}:{}}},...o.failedOpen?{failedOpen:o.failedOpen}:{}}:{success:!1,status:o.status,error:o.error}}async checkUser(e,s={}){const t="string"==typeof s?{deviceId:s}:s,r="string"==typeof e?{email_address:e,identifiers:compactIdentifiers({unshared_device_id:t.permanentDeviceId,device_id:t.deviceId,stable_hash:t.fingerprintId,full_hash:t.fullHash,session_hash:t.sessionHash})}:e;if("string"!=typeof r.email_address||""===r.email_address.trim())return{success:!1,status:400,error:{code:"VALIDATION_ERROR",message:"email_address must be a non-empty string"}};if(0===Object.keys(r.identifiers).length)return{success:!0,status:200,data:{is_user_flagged:!1},failedOpen:{status:0,reason:"no_device_id"}};const i=await this.R(`${this.u}/v3/check-user`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(r)},{timeoutMs:t.timeoutMs,maxRetries:t.maxRetries,expectedStatus:200,strictEnvelope:!0,validateData:isCheckUserResult,retryHttpError:(e,s)=>"RATE_LIMIT_UNAVAILABLE"===s.code||"AUDIT_UNAVAILABLE"===s.code});return i.success?"CHECK_UNAVAILABLE"===i.data?.decision_code?{...i,failedOpen:{status:i.status}}:i:{success:!0,status:200,data:{is_user_flagged:!1},failedOpen:{status:i.status}}}async triggerEmailVerification(e,s,t){if("string"!=typeof e||""===e.trim())return{success:!1,status:400,error:{code:"VALIDATION_ERROR",message:"emailAddress must be a non-empty string"}};const r={email_address:e,identifiers:compactIdentifiers({unshared_device_id:t?.permanentDeviceId,device_id:s,stable_hash:t?.fingerprintId,full_hash:t?.fullHash,session_hash:t?.sessionHash})};if(0===Object.keys(r.identifiers).length)return{success:!1,status:400,error:{code:"VALIDATION_ERROR",message:"At least one non-empty identifier is required"}};const i=await this.R(`${this.u}/v3/trigger-email-verification`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(r)},{maxRetries:0,expectedStatus:200,strictEnvelope:!0,validateData:isTriggerEmailResult});return i.success?(this.i.set(e.trim().toLowerCase(),i.data.verification_id),{...i,data:{...i.data,message:"Verification code sent"}}):!i.success&&(0===i.status||i.status>=500)?{success:!1,status:i.status,error:{code:"DELIVERY_FAILED",message:i.error?.message??"Delivery failed"}}:{success:!1,status:i.status,error:i.error??{code:"PARSE_ERROR",message:"Missing v3 verification response"}}}async verify(e,s,t,r){const i=r?.verificationId??this.i.get(e.trim().toLowerCase());if(!i)return{success:!1,status:400,error:{code:"MISSING_VERIFICATION_ID",message:"Trigger email verification before verifying the code"}};if(!UUID_V4_PATTERN.test(i)||!/^\d{6}$/.test(t))return{success:!1,status:400,error:{code:"VALIDATION_ERROR",message:"verificationId must be a UUIDv4 and code must be exactly 6 digits"}};const a={verification_id:i,code:t},n=await this.R(`${this.u}/v3/verify`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(a)},{maxRetries:0,expectedStatus:200,strictEnvelope:!0,validateData:isVerifyEmailResult});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?!0!==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}}:(this.i.delete(e.trim().toLowerCase()),n):n}async verifyEmail(e){return e&&UUID_V4_PATTERN.test(e.verification_id)&&/^\d{6}$/.test(e.code)?this.R(`${this.u}/v3/verify`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(e)},{maxRetries:0,expectedStatus:200,strictEnvelope:!0,validateData:isVerifyEmailResult}):{success:!1,status:400,error:{code:"VALIDATION_ERROR",message:"verification_id must be a UUIDv4 and code must be exactly 6 digits"}}}async getInterstitialFlow(e){const s=new URLSearchParams;return s.set("flow_type",e?.flowType||"email_verification"),s.set("platform",e?.platform||"web"),this.R(`${this.u}/v2/interstitial-flow?${s}`,{method:"GET"})}}exports.UnsharedClient=UnsharedClient;
|
package/dist/esm/client.d.mts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { FingerprintWireFormat } from '
|
|
1
|
+
import type { CheckUserRequest, CheckUserResult as V3CheckUserResult, FingerprintWireFormat, IngestResult, JSONObject, VerifyEmailRequest, VerifyEmailResult } from './shared-types.mjs';
|
|
2
2
|
export interface UnsharedClientConfig {
|
|
3
3
|
/**
|
|
4
4
|
* Secret API key. Must be kept server-side.
|
|
@@ -6,7 +6,7 @@ export interface UnsharedClientConfig {
|
|
|
6
6
|
*/
|
|
7
7
|
apiKey: string;
|
|
8
8
|
/**
|
|
9
|
-
* Base URL of the Unshared Labs
|
|
9
|
+
* Base URL of the Unshared Labs ingress.
|
|
10
10
|
* @default "https://api.unshared.ai"
|
|
11
11
|
*/
|
|
12
12
|
baseUrl?: string;
|
|
@@ -70,14 +70,15 @@ export interface ApiResult<T = unknown> {
|
|
|
70
70
|
}
|
|
71
71
|
export interface SubmitFingerprintOptions {
|
|
72
72
|
userId?: string;
|
|
73
|
-
/** SDK encrypts before sending. */
|
|
74
73
|
emailAddress?: string;
|
|
75
74
|
sessionHash?: string;
|
|
76
75
|
eventType?: string;
|
|
77
|
-
/** SDK encrypts before sending. */
|
|
78
76
|
ipAddress?: string;
|
|
79
|
-
/** SDK encrypts before sending. */
|
|
80
77
|
userAgent?: string;
|
|
78
|
+
permanentDeviceId?: string;
|
|
79
|
+
deviceId?: string;
|
|
80
|
+
stableHash?: string;
|
|
81
|
+
fullHash?: string;
|
|
81
82
|
/**
|
|
82
83
|
* Client-supplied idempotency key, forwarded verbatim as X-Idempotency-Key.
|
|
83
84
|
* NOTE: the middleware that calls this (submit-fp.ts / protection-handler.ts)
|
|
@@ -92,25 +93,21 @@ export interface SubmitFingerprintOptions {
|
|
|
92
93
|
export interface SubmitFingerprintResult {
|
|
93
94
|
hash: string;
|
|
94
95
|
stable_hash: string;
|
|
95
|
-
collected_at: string;
|
|
96
|
-
/** Always present; set to "unknown" by the server if not provided. */
|
|
97
96
|
version: string;
|
|
97
|
+
event_id: string;
|
|
98
|
+
collected_at: string;
|
|
99
|
+
endpoint_version: 'v3';
|
|
98
100
|
}
|
|
99
101
|
export interface ProcessUserEventParams {
|
|
100
102
|
eventType: string;
|
|
101
|
-
/** SDK encrypts before sending. */
|
|
102
103
|
userId: string;
|
|
103
|
-
/** SDK encrypts before sending. */
|
|
104
104
|
ipAddress: string;
|
|
105
|
-
/** SDK encrypts before sending. */
|
|
106
105
|
deviceId: string;
|
|
107
106
|
sessionHash: string;
|
|
108
|
-
/** SDK encrypts before sending. */
|
|
109
107
|
userAgent: string;
|
|
110
|
-
/** SDK encrypts before sending. */
|
|
111
108
|
emailAddress: string;
|
|
112
|
-
/** SDK encrypts before sending. */
|
|
113
109
|
fingerprintId?: string;
|
|
110
|
+
permanentDeviceId?: string;
|
|
114
111
|
subscriptionStatus?: string | null;
|
|
115
112
|
eventDetails?: Record<string, unknown> | null;
|
|
116
113
|
}
|
|
@@ -121,6 +118,7 @@ export interface ProcessUserEventResult {
|
|
|
121
118
|
email_address: string;
|
|
122
119
|
ip_address: string;
|
|
123
120
|
device_id: string;
|
|
121
|
+
permanent_device_id?: string;
|
|
124
122
|
session_hash: string;
|
|
125
123
|
user_agent: string;
|
|
126
124
|
event_details?: string;
|
|
@@ -134,25 +132,31 @@ export interface ProcessUserEventResult {
|
|
|
134
132
|
}
|
|
135
133
|
export interface CheckUserResult {
|
|
136
134
|
is_user_flagged: boolean;
|
|
135
|
+
/** Present on authoritative v3 responses; omitted by local fail-open defaults. */
|
|
136
|
+
decision_id?: string;
|
|
137
|
+
/** Present on authoritative v3 responses; omitted by local fail-open defaults. */
|
|
138
|
+
decision_code?: V3CheckUserResult['decision_code'];
|
|
137
139
|
}
|
|
138
140
|
export interface TriggerEmailVerificationResult {
|
|
139
141
|
message: string;
|
|
142
|
+
verification_id?: string;
|
|
143
|
+
next_allowed_at?: string;
|
|
144
|
+
retry_after_seconds?: number;
|
|
140
145
|
}
|
|
141
146
|
export interface VerifyResult {
|
|
142
147
|
verified: boolean;
|
|
143
|
-
reason?: 'not_found' | 'code_mismatch' | 'code_expired';
|
|
148
|
+
reason?: 'not_found' | 'code_mismatch' | 'code_expired' | 'invalid_code';
|
|
149
|
+
verification_scope?: 'email';
|
|
144
150
|
}
|
|
145
151
|
export declare class UnsharedClient {
|
|
146
152
|
private readonly _apiKey;
|
|
147
153
|
private readonly _baseUrl;
|
|
148
154
|
private readonly _timeout;
|
|
149
155
|
private readonly _maxRetries;
|
|
150
|
-
private _encryptionKey;
|
|
151
156
|
private readonly _customFetch;
|
|
157
|
+
private _ingestionKey?;
|
|
158
|
+
private readonly _verificationIds;
|
|
152
159
|
constructor(config: UnsharedClientConfig);
|
|
153
|
-
/** Lazily derive + memoize the imported AES-GCM CryptoKey (async via WebCrypto). */
|
|
154
|
-
private _getKey;
|
|
155
|
-
private _encrypt;
|
|
156
160
|
/**
|
|
157
161
|
* Core HTTP method with retry logic.
|
|
158
162
|
* - 2xx → success result
|
|
@@ -161,22 +165,33 @@ export declare class UnsharedClient {
|
|
|
161
165
|
* - Network/timeout → retried, NETWORK_ERROR result returned
|
|
162
166
|
*/
|
|
163
167
|
private _fetch;
|
|
168
|
+
private _postIngestion;
|
|
169
|
+
/** Submit an arbitrary v3 fingerprint payload without reshaping it. */
|
|
170
|
+
submitFingerprint(payload: JSONObject, opts?: {
|
|
171
|
+
idempotencyKey?: string;
|
|
172
|
+
}): Promise<ApiResult<IngestResult>>;
|
|
164
173
|
/**
|
|
165
|
-
* Submit a browser fingerprint event
|
|
166
|
-
*
|
|
174
|
+
* Submit a browser fingerprint event using the source-compatible v2 method.
|
|
175
|
+
* The wire payload and endpoint use the v3 contract.
|
|
167
176
|
*/
|
|
168
177
|
submitFingerprintEvent(fingerprint: FingerprintWireFormat, opts?: SubmitFingerprintOptions): Promise<ApiResult<SubmitFingerprintResult>>;
|
|
169
178
|
/**
|
|
170
|
-
* Record a user event and receive
|
|
171
|
-
* Maps to: POST /
|
|
179
|
+
* Record a user event and receive the current risk decision.
|
|
180
|
+
* Maps to: POST /v3/process-user-event followed by POST /v3/check-user.
|
|
172
181
|
*/
|
|
173
182
|
processUserEvent(params: ProcessUserEventParams, overrides?: {
|
|
174
183
|
timeoutMs?: number;
|
|
175
184
|
maxRetries?: number;
|
|
176
185
|
}): Promise<ApiResult<ProcessUserEventResult>>;
|
|
186
|
+
processUserEvent(payload: JSONObject, overrides?: {
|
|
187
|
+
timeoutMs?: number;
|
|
188
|
+
maxRetries?: number;
|
|
189
|
+
}): Promise<ApiResult<IngestResult>>;
|
|
177
190
|
/**
|
|
178
191
|
* Check if a user is flagged in the naughty list.
|
|
179
|
-
* Maps to:
|
|
192
|
+
* Maps to: POST /v3/check-user
|
|
193
|
+
* Requires a nonblank email_address; user_id cannot replace it. Invalid email
|
|
194
|
+
* returns a local VALIDATION_ERROR without making a request.
|
|
180
195
|
*
|
|
181
196
|
* **Defensive default:** On any failure (network error, 4xx, 5xx, timeout) this
|
|
182
197
|
* method returns `{ success: true, data: { is_user_flagged: false } }` rather
|
|
@@ -190,19 +205,29 @@ export declare class UnsharedClient {
|
|
|
190
205
|
checkUser(emailAddress: string, opts: {
|
|
191
206
|
deviceId?: string;
|
|
192
207
|
fingerprintId?: string;
|
|
208
|
+
permanentDeviceId?: string;
|
|
209
|
+
fullHash?: string;
|
|
210
|
+
sessionHash?: string;
|
|
211
|
+
timeoutMs?: number;
|
|
212
|
+
maxRetries?: number;
|
|
213
|
+
}): Promise<ApiResult<CheckUserResult>>;
|
|
214
|
+
checkUser(request: CheckUserRequest, opts?: {
|
|
193
215
|
timeoutMs?: number;
|
|
194
216
|
maxRetries?: number;
|
|
195
217
|
}): Promise<ApiResult<CheckUserResult>>;
|
|
196
218
|
/**
|
|
197
219
|
* Send a 6-digit verification code to the user's email address.
|
|
198
|
-
* Maps to: POST /
|
|
220
|
+
* Maps to: POST /v3/trigger-email-verification
|
|
199
221
|
*/
|
|
200
222
|
triggerEmailVerification(emailAddress: string, deviceId: string, opts?: {
|
|
201
223
|
fingerprintId?: string;
|
|
224
|
+
permanentDeviceId?: string;
|
|
225
|
+
fullHash?: string;
|
|
226
|
+
sessionHash?: string;
|
|
202
227
|
}): Promise<ApiResult<TriggerEmailVerificationResult>>;
|
|
203
228
|
/**
|
|
204
229
|
* Verify a 6-digit code submitted by the user.
|
|
205
|
-
* Maps to: POST /
|
|
230
|
+
* Maps to: POST /v3/verify
|
|
206
231
|
*
|
|
207
232
|
* `result.success` reliably indicates whether verification succeeded:
|
|
208
233
|
* - `success: true` → code was correct, user is verified
|
|
@@ -219,7 +244,11 @@ export declare class UnsharedClient {
|
|
|
219
244
|
*/
|
|
220
245
|
verify(emailAddress: string, deviceId: string, code: string, opts?: {
|
|
221
246
|
fingerprintId?: string;
|
|
247
|
+
permanentDeviceId?: string;
|
|
248
|
+
verificationId?: string;
|
|
222
249
|
}): Promise<ApiResult<VerifyResult>>;
|
|
250
|
+
/** Verify a v3 email challenge without relying on process-local state. */
|
|
251
|
+
verifyEmail(request: VerifyEmailRequest): Promise<ApiResult<VerifyEmailResult>>;
|
|
223
252
|
/**
|
|
224
253
|
* Fetch the published interstitial flow for this company (secret key, server-side).
|
|
225
254
|
* Returns the projected flow definition to hand to the browser for rendering.
|
package/dist/esm/client.mjs
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
import{encryptDataWeb,sha256Bytes,importAesGcmKey}from"./webcrypto";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(this.t=null,!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.l=e.timeout??1e4,this.h=e.maxRetries??3,this.u=e.fetch}m(){return this.t||(this.t=sha256Bytes(this.i).then(importAesGcmKey)),this.t}async _(e){return encryptDataWeb(e,await this.m())}async p(e,s,t){const i=(t?.maxRetries??this.h)+1,r=t?.timeoutMs??this.l;let a={success:!1,status:0,error:{code:"NETWORK_ERROR",message:"Request failed"}};for(let t=1;t<=i;t++){t>1&&await sleep(retryDelay(t-1));const i=new AbortController,o=setTimeout(()=>i.abort(),r);try{const t=this.u??globalThis.fetch,r=await t(e,{method:s.method,headers:{"X-API-Key":this.i,...s.headers},body:s.body,signal:i.signal});if(r.ok){let e,s;try{e=await r.text()}catch(e){a={success:!1,status:0,error:{code:"NETWORK_ERROR",message:e instanceof Error?e.message:String(e)}};continue}if(""===e.trim())return{success:!0,status:r.status,data:{}};try{s=JSON.parse(e)}catch{return{success:!1,status:r.status,error:{code:"PARSE_ERROR",message:"Unparseable 2xx response body"}}}const t="data"in s?s.data:s;return{success:!0,status:r.status,data:t}}const o=await parseErrorBody(r);if(r.status>=400&&r.status<500){if(429===r.status){const e=r.headers.get("Retry-After");if(null!=e){const s=parseInt(e,10);isNaN(s)||(o.retryAfter=s)}if(null==o.retryAfter){const e=o.details?.retry_after_seconds;"number"!=typeof e||isNaN(e)||(o.retryAfter=e)}}return{success:!1,status:r.status,error:o}}a={success:!1,status:r.status,error:o}}catch(e){a={success:!1,status:0,error:{code:"NETWORK_ERROR",message:e instanceof Error?e.message:String(e)}}}finally{clearTimeout(o)}}return a}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};null!=e.source&&(t.source=e.source);const[i,r,a,o]=await Promise.all([null!=s?.userId?this._(s.userId):Promise.resolve(void 0),null!=s?.emailAddress?this._(s.emailAddress):Promise.resolve(void 0),null!=s?.ipAddress?this._(s.ipAddress):Promise.resolve(void 0),null!=s?.userAgent?this._(s.userAgent):Promise.resolve(void 0)]);return null!=i&&(t.user_id=i),null!=r&&(t.email_address=r),null!=s?.sessionHash&&(t.session_hash=s.sessionHash),null!=s?.eventType&&(t.event_type=s.eventType),null!=a&&(t.ip_address=a),null!=o&&(t.user_agent=o),this.p(`${this.o}/v2/submit-fingerprint-event`,{method:"POST",headers:{"Content-Type":"application/json","X-Idempotency-Key":s?.idempotencyKey??globalThis.crypto.randomUUID()},body:JSON.stringify(t)})}async processUserEvent(e,s){const[t,i,r,a,o,n]=await Promise.all([this._(e.userId),this._(e.ipAddress),this._(e.deviceId),this._(e.userAgent),this._(e.emailAddress),null!=e.fingerprintId?this._(e.fingerprintId):Promise.resolve(void 0)]),c={event_type:e.eventType,user_id:t,ip_address:i,device_id:r,session_hash:e.sessionHash,user_agent:a,email_address:o};return null!=n&&(c.fingerprint_id=n),null!=e.subscriptionStatus&&(c.subscription_status=e.subscriptionStatus),null!=e.eventDetails&&(c.event_details=e.eventDetails),this.p(`${this.o}/v2/process-user-event`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(c)},{timeoutMs:s?.timeoutMs,maxRetries:s?.maxRetries??0})}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},failedOpen:{status:0,reason:"no_device_id"}};const[i,r,a]=await Promise.all([this._(e),t.deviceId?this._(t.deviceId):Promise.resolve(void 0),t.fingerprintId?this._(t.fingerprintId):Promise.resolve(void 0)]),o=new URLSearchParams;o.set("email_address",i),t.deviceId&&o.set("device_id",r),t.fingerprintId&&o.set("fingerprint_id",a);const n=await this.p(`${this.o}/v2/check-user?${o}`,{method:"GET"},{timeoutMs:t.timeoutMs,maxRetries:t.maxRetries});return n.success?n:{success:!0,status:200,data:{is_user_flagged:!1},failedOpen:{status:n.status}}}async triggerEmailVerification(e,s,t){const[i,r,a]=await Promise.all([this._(e),this._(s),t?.fingerprintId?this._(t.fingerprintId):Promise.resolve(void 0)]),o={email_address:i,device_id:r};t?.fingerprintId&&(o.fingerprint_id=a);const n=await this.p(`${this.o}/v2/trigger-email-verification`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(o)},{maxRetries:0});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}async verify(e,s,t,i){const[r,a,o,n]=await Promise.all([this._(e),this._(s),this._(t),i?.fingerprintId?this._(i.fingerprintId):Promise.resolve(void 0)]),c={email_address:r,device_id:a,code:o};i?.fingerprintId&&(c.fingerprint_id=n);const l=await this.p(`${this.o}/v2/verify`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(c)},{maxRetries:0});return!l.success&&(0===l.status||l.status>=500)?{success:!1,status:l.status,error:{code:"DELIVERY_FAILED",message:l.error?.message??"Delivery failed"}}:l.success&&!0!==l.data?.verified?{success:!1,status:l.status,error:{code:"VERIFICATION_FAILED",message:"Code is incorrect or expired",details:l.data?.reason?{reason:l.data.reason}:void 0}}:l}async getInterstitialFlow(e){const s=new URLSearchParams;return s.set("flow_type",e?.flowType||"email_verification"),s.set("platform",e?.platform||"web"),this.p(`${this.o}/v2/interstitial-flow?${s}`,{method:"GET"})}}
|
|
1
|
+
import{encryptDataWeb,importAesGcmKey,sha256Bytes}from"./webcrypto.mjs";const DEFAULT_BASE_URL="https://api.unshared.ai",DEFAULT_TIMEOUT_MS=1e4,DEFAULT_MAX_RETRIES=3,MAX_DELAY_MS=3e4,BASE_DELAY_MS=1e3,MAX_REQUEST_BYTES=1<<20,MAX_ENCRYPTED_REQUEST_BYTES=Math.floor(1572864),SDK_VERSION="3.0.0",UUID_V4_PATTERN=/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;function isSecureOrLoopbackUrl(e){try{const s=new URL(e);return"https:"===s.protocol||"localhost"===s.hostname||"[::1]"===s.hostname||/^127\./.test(s.hostname)}catch{return!1}}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)}function asJSONObject(e){return null===e||"object"!=typeof e||Array.isArray(e)?{}:e}function isJSONObject(e,s=new WeakSet){if(null===e||"object"!=typeof e||Array.isArray(e)||s.has(e))return!1;const t=Object.getPrototypeOf(e);if(t!==Object.prototype&&null!==t)return!1;s.add(e);for(const t of Object.values(e))if(null!==t&&"string"!=typeof t&&"boolean"!=typeof t&&!("number"==typeof t&&Number.isFinite(t)||Array.isArray(t)&&isJSONValue(t,s)||isJSONObject(t,s)))return!1;return s.delete(e),!0}function isJSONValue(e,s){if(null===e||"string"==typeof e||"boolean"==typeof e)return!0;if("number"==typeof e)return Number.isFinite(e);if(Array.isArray(e)){if(s.has(e))return!1;s.add(e);let t=Object.keys(e).length===e.length;for(let r=0;t&&r<e.length;r++)t=Object.prototype.hasOwnProperty.call(e,r)&&isJSONValue(e[r],s);return s.delete(e),t}return isJSONObject(e,s)}function hasExactKeys(e,s){return null!==e&&"object"==typeof e&&!Array.isArray(e)&&(Object.keys(e).length===s.length&&s.every(s=>Object.prototype.hasOwnProperty.call(e,s)))}function isValidDateTime(e){if("string"!=typeof e)return!1;const s=/^(\d{4})-(\d{2})-(\d{2})T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$/.exec(e);if(!s||Number.isNaN(Date.parse(e)))return!1;const t=Number(s[1]),r=Number(s[2]),i=Number(s[3]);return r>=1&&r<=12&&i>=1&&i<=[31,t%4!=0||t%100==0&&t%400!=0?28:29,31,30,31,30,31,31,30,31,30,31][r-1]}function isIngestResult(e){return hasExactKeys(e,["event_id","collected_at","endpoint_version"])&&"string"==typeof e.event_id&&UUID_V4_PATTERN.test(e.event_id)&&isValidDateTime(e.collected_at)&&"v3"===e.endpoint_version}function isCheckUserResult(e){if(!hasExactKeys(e,["is_user_flagged","decision_id","decision_code"]))return!1;const s=new Set(["FLAGGED_RISK_MATCH","NO_ENFORCING_RISK","NO_IDENTITY_MATCH","IDENTITY_UNCERTAIN","CHECK_UNAVAILABLE"]);return"boolean"==typeof e.is_user_flagged&&"string"==typeof e.decision_id&&UUID_V4_PATTERN.test(e.decision_id)&&"string"==typeof e.decision_code&&s.has(e.decision_code)&&e.is_user_flagged===("FLAGGED_RISK_MATCH"===e.decision_code)}function isTriggerEmailResult(e){return hasExactKeys(e,["verification_id","next_allowed_at","retry_after_seconds"])&&"string"==typeof e.verification_id&&UUID_V4_PATTERN.test(e.verification_id)&&isValidDateTime(e.next_allowed_at)&&Number.isInteger(e.retry_after_seconds)&&e.retry_after_seconds>=0}function isVerifyEmailResult(e){return hasExactKeys(e,["verified","verification_scope"])?!0===e.verified&&"email"===e.verification_scope:hasExactKeys(e,["verified","verification_scope","reason"])&&!1===e.verified&&"email"===e.verification_scope&&"invalid_code"===e.reason}function compactIdentifiers(e){const s={};for(const[t,r]of Object.entries(e))"string"==typeof r&&""!==r.trim()&&(s[t]=r.trim());return s}async function parseErrorBody(e,s=!1){const t=await e.text();try{const r=JSON.parse(t);if(s)return hasExactKeys(r,["success","error"])&&!1===r.success&&hasExactKeys(r.error,["code","message"])&&"string"==typeof r.error.code&&"string"==typeof r.error.message?{code:r.error.code,message:r.error.message}:{code:"PARSE_ERROR",message:"Invalid v3 error envelope"};const i=r;return"string"==typeof i?.error?.code?{code:i.error.code,message:"string"==typeof i.error.message?i.error.message:"Unknown error",details:i.error.details}:{code:"UNKNOWN_ERROR",message:t||e.statusText}}catch{return{code:"UNKNOWN_ERROR",message:t||e.statusText}}}export class UnsharedClient{constructor(e){if(this.t=new Map,!e.apiKey||""===e.apiKey.trim())throw new Error("apiKey is required");if(e.baseUrl&&!isSecureOrLoopbackUrl(e.baseUrl))throw new Error("baseUrl must be a valid HTTPS URL except for loopback development URLs");this.i=e.apiKey,this.o=e.baseUrl?e.baseUrl.replace(/\/$/,""):DEFAULT_BASE_URL,this.u=e.timeout??1e4,this._=e.maxRetries??3,this.l=e.fetch}async p(e,s,t){const r=t?.maxRequestBytes??1048576;if(s.body&&(new TextEncoder).encode(s.body).byteLength>r)return{success:!1,status:413,error:{code:"REQUEST_TOO_LARGE",message:"Request body exceeds "+(1048576===r?"1 MiB":"1.5 MiB")}};const i=(t?.maxRetries??this._)+1,a=t?.timeoutMs??this.u;let n={success:!1,status:0,error:{code:"NETWORK_ERROR",message:"Request failed"}};for(let r=1;r<=i;r++){r>1&&await sleep(retryDelay(r-1));const i=new AbortController,o=setTimeout(()=>i.abort(),a);try{const r=this.l??globalThis.fetch,a=await r(e,{method:s.method,headers:{"X-API-Key":this.i,...s.headers},body:s.body,signal:i.signal});if(a.ok){let e,s;try{e=await a.text()}catch(e){n={success:!1,status:0,error:{code:"NETWORK_ERROR",message:e instanceof Error?e.message:String(e)}};continue}if(""===e.trim())return{success:!1,status:a.status,error:{code:"PARSE_ERROR",message:"Empty v3 response body"}};try{s=JSON.parse(e)}catch{return{success:!1,status:a.status,error:{code:"PARSE_ERROR",message:"Unparseable 2xx response body"}}}return(t?.strictEnvelope?hasExactKeys(s,["success","data"])&&!0===s.success:!0===s?.success&&Object.prototype.hasOwnProperty.call(s,"data"))?void 0!==t?.expectedStatus&&a.status!==t.expectedStatus?{success:!1,status:a.status,error:{code:"PARSE_ERROR",message:`Unexpected success status ${a.status}`}}:t?.validateData&&!t.validateData(s.data)?{success:!1,status:a.status,error:{code:"PARSE_ERROR",message:"Invalid v3 response data"}}:{success:!0,status:a.status,data:s.data}:{success:!1,status:a.status,error:{code:"PARSE_ERROR",message:"Invalid v3 response envelope"}}}const o=await parseErrorBody(a,t?.strictEnvelope);if(a.status>=400&&a.status<500){if(429===a.status){const e=a.headers.get("Retry-After");if(null!=e){const s=parseInt(e,10);isNaN(s)||(o.retryAfter=s)}if(null==o.retryAfter){const e=o.details?.retry_after_seconds;"number"!=typeof e||isNaN(e)||(o.retryAfter=e)}}return{success:!1,status:a.status,error:o}}if(n={success:!1,status:a.status,error:o},t?.retryHttpError&&!t.retryHttpError(a.status,o))return n}catch(e){n={success:!1,status:0,error:{code:"NETWORK_ERROR",message:e instanceof Error?e.message:String(e)}}}finally{clearTimeout(o)}}return n}async m(e,s,t){if(!isJSONObject(s))return{success:!1,status:400,error:{code:"INVALID_JSON",message:"Request body must be a JSON object"}};let r,i;try{r=JSON.stringify(s)}catch(e){return{success:!1,status:400,error:{code:"INVALID_JSON",message:e instanceof Error?e.message:String(e)}}}if((new TextEncoder).encode(r).byteLength>1048576)return{success:!1,status:413,error:{code:"REQUEST_TOO_LARGE",message:"Request body exceeds 1 MiB"}};try{this.R??(this.R=sha256Bytes(this.i).then(importAesGcmKey)),i=JSON.stringify({encrypted_data:await encryptDataWeb(r,await this.R)})}catch(e){return{success:!1,status:0,error:{code:"CLIENT_ENCRYPTION_ERROR",message:e instanceof Error?e.message:String(e)}}}return this.p(`${this.o}${e}`,{method:"POST",headers:{"Content-Type":"application/json",...t?.idempotencyKey?{"X-Idempotency-Key":t.idempotencyKey}:{}},body:i},{timeoutMs:t?.timeoutMs,maxRetries:0,maxRequestBytes:MAX_ENCRYPTED_REQUEST_BYTES,expectedStatus:202,strictEnvelope:!0,validateData:e=>isIngestResult(e)})}async submitFingerprint(e,s){return this.m("/v3/submit-fingerprint-event",e,{idempotencyKey:s?.idempotencyKey??globalThis.crypto.randomUUID()})}async submitFingerprintEvent(e,s){const t=compactIdentifiers({unshared_device_id:s?.permanentDeviceId,device_id:s?.deviceId,stable_hash:s?.stableHash??e.fingerprint_id,full_hash:s?.fullHash??e.full_hash,session_hash:s?.sessionHash}),r={fingerprint:asJSONObject(e),context:{sdk:{name:"unshared-clientjs-sdk",version:"3.0.0",runtime:"node"},identity:{...s?.userId?{user_id:s.userId}:{},...s?.emailAddress?{email_address:s.emailAddress}:{}},identifiers:t,event:{...s?.eventType?{event_type:s.eventType}:{},...s?.ipAddress?{ip_address:s.ipAddress}:{},...s?.userAgent?{user_agent:s.userAgent}:{}}}},i=await this.m("/v3/submit-fingerprint-event",r,{idempotencyKey:s?.idempotencyKey??globalThis.crypto.randomUUID()});return i.success?i.data?{success:!0,status:i.status,data:{...i.data,hash:e.full_hash,stable_hash:e.fingerprint_id,version:e.version??"unknown"}}:{success:!1,status:i.status,error:{code:"PARSE_ERROR",message:"Invalid v3 ingestion result"}}:{success:!1,status:i.status,error:i.error}}async processUserEvent(e,s){if(null===e||"object"!=typeof e||Array.isArray(e))return{success:!1,status:400,error:{code:"INVALID_JSON",message:"Request body must be a JSON object"}};const t=new Set(["eventType","userId","ipAddress","deviceId","sessionHash","userAgent","emailAddress","fingerprintId","permanentDeviceId","subscriptionStatus","eventDetails"]);if(!Object.keys(e).every(e=>t.has(e))||"string"!=typeof e.eventType||"string"!=typeof e.userId||"string"!=typeof e.emailAddress||"string"!=typeof e.deviceId||"string"!=typeof e.sessionHash)return this.m("/v3/process-user-event",e,{timeoutMs:s?.timeoutMs});const r=e,i=compactIdentifiers({unshared_device_id:r.permanentDeviceId,device_id:r.deviceId,stable_hash:r.fingerprintId,session_hash:r.sessionHash}),a={event_type:r.eventType,identity:{user_id:r.userId,email_address:r.emailAddress},identifiers:i,context:{ip_address:r.ipAddress,user_agent:r.userAgent,...r.subscriptionStatus?{subscription_status:r.subscriptionStatus}:{}},...r.eventDetails?{event_details:asJSONObject(r.eventDetails)}:{}},n=await this.m("/v3/process-user-event",a,{timeoutMs:s?.timeoutMs});if(!n.success)return{success:!1,status:n.status,error:n.error};const o=await this.checkUser({user_id:r.userId,email_address:r.emailAddress,identifiers:i},{timeoutMs:s?.timeoutMs,maxRetries:s?.maxRetries});return o.success?{success:!0,status:o.status,data:{event:{event_type:r.eventType,user_id:r.userId,email_address:r.emailAddress,ip_address:r.ipAddress,device_id:r.deviceId,...r.permanentDeviceId?{permanent_device_id:r.permanentDeviceId}:{},session_hash:r.sessionHash,user_agent:r.userAgent,...r.subscriptionStatus?{subscription_status:r.subscriptionStatus}:{},...r.eventDetails?{event_details:JSON.stringify(r.eventDetails)}:{}},analysis:{status:o.failedOpen?"error":"success",is_user_flagged:o.data.is_user_flagged,...o.data.decision_code?{status_details:o.data.decision_code}:{}}},...o.failedOpen?{failedOpen:o.failedOpen}:{}}:{success:!1,status:o.status,error:o.error}}async checkUser(e,s={}){const t="string"==typeof s?{deviceId:s}:s,r="string"==typeof e?{email_address:e,identifiers:compactIdentifiers({unshared_device_id:t.permanentDeviceId,device_id:t.deviceId,stable_hash:t.fingerprintId,full_hash:t.fullHash,session_hash:t.sessionHash})}:e;if("string"!=typeof r.email_address||""===r.email_address.trim())return{success:!1,status:400,error:{code:"VALIDATION_ERROR",message:"email_address must be a non-empty string"}};if(0===Object.keys(r.identifiers).length)return{success:!0,status:200,data:{is_user_flagged:!1},failedOpen:{status:0,reason:"no_device_id"}};const i=await this.p(`${this.o}/v3/check-user`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(r)},{timeoutMs:t.timeoutMs,maxRetries:t.maxRetries,expectedStatus:200,strictEnvelope:!0,validateData:isCheckUserResult,retryHttpError:(e,s)=>"RATE_LIMIT_UNAVAILABLE"===s.code||"AUDIT_UNAVAILABLE"===s.code});return i.success?"CHECK_UNAVAILABLE"===i.data?.decision_code?{...i,failedOpen:{status:i.status}}:i:{success:!0,status:200,data:{is_user_flagged:!1},failedOpen:{status:i.status}}}async triggerEmailVerification(e,s,t){if("string"!=typeof e||""===e.trim())return{success:!1,status:400,error:{code:"VALIDATION_ERROR",message:"emailAddress must be a non-empty string"}};const r={email_address:e,identifiers:compactIdentifiers({unshared_device_id:t?.permanentDeviceId,device_id:s,stable_hash:t?.fingerprintId,full_hash:t?.fullHash,session_hash:t?.sessionHash})};if(0===Object.keys(r.identifiers).length)return{success:!1,status:400,error:{code:"VALIDATION_ERROR",message:"At least one non-empty identifier is required"}};const i=await this.p(`${this.o}/v3/trigger-email-verification`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(r)},{maxRetries:0,expectedStatus:200,strictEnvelope:!0,validateData:isTriggerEmailResult});return i.success?(this.t.set(e.trim().toLowerCase(),i.data.verification_id),{...i,data:{...i.data,message:"Verification code sent"}}):!i.success&&(0===i.status||i.status>=500)?{success:!1,status:i.status,error:{code:"DELIVERY_FAILED",message:i.error?.message??"Delivery failed"}}:{success:!1,status:i.status,error:i.error??{code:"PARSE_ERROR",message:"Missing v3 verification response"}}}async verify(e,s,t,r){const i=r?.verificationId??this.t.get(e.trim().toLowerCase());if(!i)return{success:!1,status:400,error:{code:"MISSING_VERIFICATION_ID",message:"Trigger email verification before verifying the code"}};if(!UUID_V4_PATTERN.test(i)||!/^\d{6}$/.test(t))return{success:!1,status:400,error:{code:"VALIDATION_ERROR",message:"verificationId must be a UUIDv4 and code must be exactly 6 digits"}};const a={verification_id:i,code:t},n=await this.p(`${this.o}/v3/verify`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(a)},{maxRetries:0,expectedStatus:200,strictEnvelope:!0,validateData:isVerifyEmailResult});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?!0!==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}}:(this.t.delete(e.trim().toLowerCase()),n):n}async verifyEmail(e){return e&&UUID_V4_PATTERN.test(e.verification_id)&&/^\d{6}$/.test(e.code)?this.p(`${this.o}/v3/verify`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(e)},{maxRetries:0,expectedStatus:200,strictEnvelope:!0,validateData:isVerifyEmailResult}):{success:!1,status:400,error:{code:"VALIDATION_ERROR",message:"verification_id must be a UUIDv4 and code must be exactly 6 digits"}}}async getInterstitialFlow(e){const s=new URLSearchParams;return s.set("flow_type",e?.flowType||"email_verification"),s.set("platform",e?.platform||"web"),this.p(`${this.o}/v2/interstitial-flow?${s}`,{method:"GET"})}}
|
package/dist/esm/index.d.mts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
export { UnsharedClient } from './client';
|
|
2
|
-
export { createUnsharedMiddleware, assertTrustProxy } from './middleware';
|
|
3
|
-
export type { MiddlewareOptions } from './middleware';
|
|
4
|
-
export { unsharedBoundToUser, VerdictCache, flaggedResponse, ACCOUNT_FLAGGED_ERROR, } from './middleware/index';
|
|
5
|
-
export type { ProtectionConfig, Verdict } from './middleware/index';
|
|
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';
|
|
1
|
+
export { UnsharedClient } from './client.mjs';
|
|
2
|
+
export { createUnsharedMiddleware, assertTrustProxy } from './middleware.mjs';
|
|
3
|
+
export type { MiddlewareOptions } from './middleware.mjs';
|
|
4
|
+
export { unsharedBoundToUser, VerdictCache, flaggedResponse, ACCOUNT_FLAGGED_ERROR, } from './middleware/index.mjs';
|
|
5
|
+
export type { ProtectionConfig, Verdict } from './middleware/index.mjs';
|
|
6
|
+
export type { UnsharedClientConfig, ApiResult, UnsharedError, SubmitFingerprintOptions, SubmitFingerprintResult, ProcessUserEventParams, ProcessUserEventResult, CheckUserResult, TriggerEmailVerificationResult, VerifyResult, } from './client.mjs';
|
|
7
|
+
export type { UnsharedRequest, UnsharedResponse, UnsharedNextFunction } from './types.mjs';
|
|
8
|
+
export { sendJson } from './middleware/utils/http-helpers.mjs';
|
package/dist/esm/index.mjs
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export{UnsharedClient}from"./client";export{createUnsharedMiddleware,assertTrustProxy}from"./middleware";export{unsharedBoundToUser,VerdictCache,flaggedResponse,ACCOUNT_FLAGGED_ERROR}from"./middleware/index";export{sendJson}from"./middleware/utils/http-helpers";
|
|
1
|
+
export{UnsharedClient}from"./client.mjs";export{createUnsharedMiddleware,assertTrustProxy}from"./middleware.mjs";export{unsharedBoundToUser,VerdictCache,flaggedResponse,ACCOUNT_FLAGGED_ERROR}from"./middleware/index.mjs";export{sendJson}from"./middleware/utils/http-helpers.mjs";
|
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
import type { UnsharedClient, ProcessUserEventParams } from '../client';
|
|
2
|
-
import type { VerdictCache } from './verdict-cache';
|
|
3
|
-
import type { RateLimitBackoff } from './rate-limit-backoff';
|
|
1
|
+
import type { UnsharedClient, ProcessUserEventParams } from '../client.mjs';
|
|
2
|
+
import type { VerdictCache } from './verdict-cache.mjs';
|
|
3
|
+
import type { RateLimitBackoff } from './rate-limit-backoff.mjs';
|
|
4
|
+
import type { CheckUserRequest } from '../shared-types.mjs';
|
|
4
5
|
/** Single policy point for background processUserEvent budgets. */
|
|
5
6
|
export declare const DISPATCH_TIMEOUT_MS = 2000;
|
|
6
7
|
export declare const SUBMIT_FP_TIMEOUT_MS = 3000;
|
|
@@ -14,6 +15,8 @@ export interface DispatchIo {
|
|
|
14
15
|
emailAddress: string;
|
|
15
16
|
}) => void;
|
|
16
17
|
}
|
|
18
|
+
/** Warm the verdict cache after fingerprint ingestion without creating a duplicate event. */
|
|
19
|
+
export declare function runCheckUser(io: DispatchIo, request: CheckUserRequest, userId: string, emailAddress: string, timeoutMs: number): Promise<void>;
|
|
17
20
|
/**
|
|
18
21
|
* Run processUserEvent with the standard bounded budget and backoff wiring.
|
|
19
22
|
* Never rejects. All four dispatch sites (classic/web x dispatch/submit-fp)
|
|
@@ -1 +1 @@
|
|
|
1
|
-
export const DISPATCH_TIMEOUT_MS=2e3;export const SUBMIT_FP_TIMEOUT_MS=3e3;export async function
|
|
1
|
+
export const DISPATCH_TIMEOUT_MS=2e3;export const SUBMIT_FP_TIMEOUT_MS=3e3;export async function runCheckUser(e,t,r,s,o){try{const s=await e.client.checkUser(t,{timeoutMs:o,maxRetries:0});if(s.success&&s.data&&!s.failedOpen)return e.cache.update(r,{isFlagged:s.data.is_user_flagged}),void e.backoff.noteSuccess();if(s.failedOpen)return void e.backoff.noteFailure();s.error?.retryAfter?e.backoff.pause(1e3*s.error.retryAfter):e.backoff.noteFailure()}catch(t){e.backoff.noteFailure();try{e.onError&&e.onError(t,{operation:"processUserEvent",userId:r,emailAddress:s})}catch{}}}export async function runProcessUserEvent(e,t,r){try{const s=await e.client.processUserEvent(t,{timeoutMs:r,maxRetries:0});if(s.success&&s.data?.analysis&&!s.failedOpen&&e.cache.update(t.userId,{isFlagged:s.data.analysis.is_user_flagged}),s.failedOpen)return void e.backoff.noteFailure();s.success?e.backoff.noteSuccess():s.error?.retryAfter?e.backoff.pause(1e3*s.error.retryAfter):e.backoff.noteFailure()}catch(r){e.backoff.noteFailure();try{e.onError&&e.onError(r,{operation:"processUserEvent",userId:t.userId,emailAddress:t.emailAddress})}catch{}}}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import type { UnsharedRequest, UnsharedResponse, UnsharedNextFunction } from '../types';
|
|
2
|
-
import type { UnsharedClient } from '../client';
|
|
3
|
-
import { VerdictCache } from './verdict-cache';
|
|
4
|
-
import type { Verdict } from './verdict-cache';
|
|
1
|
+
import type { UnsharedRequest, UnsharedResponse, UnsharedNextFunction } from '../types.mjs';
|
|
2
|
+
import type { UnsharedClient } from '../client.mjs';
|
|
3
|
+
import { VerdictCache } from './verdict-cache.mjs';
|
|
4
|
+
import type { Verdict } from './verdict-cache.mjs';
|
|
5
5
|
export interface ProtectionConfig<TReq extends UnsharedRequest = UnsharedRequest> {
|
|
6
6
|
/**
|
|
7
7
|
* Required. Resolves the current user's ID from the request.
|
|
@@ -38,6 +38,8 @@ export interface ProtectionConfig<TReq extends UnsharedRequest = UnsharedRequest
|
|
|
38
38
|
includePathPrefix?: string[];
|
|
39
39
|
/** Skip the bot/crawler UA filter. Set to true in test environments so automated browsers (Playwright, Puppeteer, etc.) can observe verdicts. @default false */
|
|
40
40
|
disableBotFilter?: boolean;
|
|
41
|
+
/** @internal Serve only the bundled fingerprint library, without S3 fetches. @default false */
|
|
42
|
+
disableS3Fingerprint?: boolean;
|
|
41
43
|
/** Hard timeout (ms) for checkUser on cache miss. Fails open on timeout. @default 1500 */
|
|
42
44
|
checkUserTimeoutMs?: number;
|
|
43
45
|
/**
|
|
@@ -168,7 +170,7 @@ export interface FailOpenContext {
|
|
|
168
170
|
}
|
|
169
171
|
export type { Verdict };
|
|
170
172
|
export { VerdictCache };
|
|
171
|
-
export { flaggedResponse, ACCOUNT_FLAGGED_ERROR } from './utils/flagged-response';
|
|
173
|
+
export { flaggedResponse, ACCOUNT_FLAGGED_ERROR } from './utils/flagged-response.mjs';
|
|
172
174
|
export declare function unsharedBoundToUser<TReq extends UnsharedRequest = UnsharedRequest>(client: UnsharedClient, config: ProtectionConfig<TReq>): ((req: TReq, res: UnsharedResponse, next: UnsharedNextFunction) => void) & {
|
|
173
175
|
destroy: () => void;
|
|
174
176
|
};
|
|
@@ -1 +1 @@
|
|
|
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{createFingerprintBundleSource}from"./injection/fp-bundle-source";import{handleSubmitFingerprint}from"./routes/submit-fp";import{handleVerifyTrigger,handleVerify}from"./routes/verify";import{handleGetInterstitialFlow}from"./routes/interstitial";import{generateGatePage}from"./injection/gate-page";import{runProcessUserEvent,DISPATCH_TIMEOUT_MS}from"./dispatch-processing";import{sendJson,sendEmpty,sendBodyForMethod,getRequestPath}from"./utils/http-helpers";import{isHtmlContentType,isHtmlNavigation}from"./utils/content-type";import{flaggedResponse}from"./utils/flagged-response";import{maskEmail}from"./utils/mask-email";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};export{flaggedResponse,ACCOUNT_FLAGGED_ERROR}from"./utils/flagged-response";const CHECK_USER_TIMEOUT_MS=1500,FAIL_OPEN_TTL_MS=5e3,_lastFailOpenWarn={};function warnFailOpenThrottled(e){const t=Date.now();t-(_lastFailOpenWarn[e.reason]??0)<6e4||(_lastFailOpenWarn[e.reason]=t,console.warn(`[Unshared] Failing open (reason=${e.reason}${null!=e.status?`, status=${e.status}`:""}). Detection is not enforced for this request. Configure onFailOpen to observe this directly.`))}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:o,cacheTTL:s=6e4,maxCacheSize:a,streamingIdleMs:d=50,skipPaths:c,includePathPrefix:l,disableBotFilter:u=!1,checkUserTimeoutMs:p=CHECK_USER_TIMEOUT_MS,cacheMissStrategy:f="block",sessionId:m,deviceId:h,onFlagged:g,onError:I,onFailOpen:S,blockFlagged:v=!1,flaggedMode:_,autoInterstitial:C=!1,interstitialFlowType:y="email_verification"}=t,k=_??(v?"gate":void 0),T=C||"overlay"===k,A=e=>{if(S)try{S(e)}catch{}else"async_miss"!==e.reason&&warnFailOpenThrottled(e)},E=new VerdictCache(s,a),F=new RateLimitBackoff,U=new Map,x=new DispatchDedupe,w=Date.now().toString(36),O=generateFingerprintScript(n,w,{autoInterstitial:T,interstitialFlowType:y});let M="";try{const e=require.resolve("unshared-frontend-sdk/dist/index.umd.js");M=readFileSync(e,"utf8")}catch{}const P=createFingerprintBundleSource(),b=void 0!==k||T,D="gate"===k?"flaggedMode 'gate' (blockFlagged)":"overlay"===k?"flaggedMode 'overlay'":"autoInterstitial";if(b&&!M)throw new Error(`[Unshared] ${D} requires unshared-frontend-sdk to be installed (its UMD bundle renders the interstitial).`);if(b&&"/__unshared"!==n)throw new Error(`[Unshared] ${D} requires the default routePrefix ("/__unshared"); the browser SDK proxy routes are fixed to that prefix.`);const R="gate"===k?generateGatePage(n,w):"",N=handleSubmitFingerprint({client:e,verdictCache:E,rateLimitBackoff:F,dispatchDedupe:x,resolveUserId:r,resolveEmailAddress:i,resolveSessionId:m,resolveDeviceId:h,disableBotFilter:u,onError:I}),$=handleVerifyTrigger({client:e,verdictCache:E,resolveEmailAddress:i,resolveDeviceId:h,onError:I}),V=handleVerify({client:e,verdictCache:E,resolveUserId:r,resolveEmailAddress:i,resolveDeviceId:h,onError:I}),L=handleGetInterstitialFlow({client:e}),j=o?Array.isArray(o)?o:[o]:null,H=`${n}/fp.js`,B=`${n}/fingerprint.js`,q=`${n}/submit-fp`,G=`${n}/verify-trigger`,J=`${n}/verify`,K=`${n}/status`,X=`${n}/interstitial-flow`,W=function(t,o,s){const a=getRequestPath(t.url),S=t.url||a;if(a.startsWith(n+"/")){if(function(e,t){if(!j)return;const r=e.headers.origin??"";j.includes("*")?(t.setHeader("Access-Control-Allow-Origin","*"),t.setHeader("Access-Control-Allow-Methods","GET, POST, OPTIONS"),t.setHeader("Access-Control-Allow-Headers","Content-Type, X-Idempotency-Key, X-Session-Id, X-Device-Id")):j.includes(r)&&(t.setHeader("Access-Control-Allow-Origin",r),t.setHeader("Vary","Origin"),t.setHeader("Access-Control-Allow-Methods","GET, 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)return void sendEmpty(o,204);if(("GET"===t.method||"HEAD"===t.method)&&a===H)return o.setHeader("Content-Type","application/javascript"),o.setHeader("Cache-Control","public, max-age=3600"),void sendBodyForMethod(o,t.method,200,M);if(("GET"===t.method||"HEAD"===t.method)&&a===B){const e=P.get();return e?(o.setHeader("Content-Type","application/javascript"),o.setHeader("Cache-Control","public, max-age=3600"),void sendBodyForMethod(o,t.method,200,e)):void sendBodyForMethod(o,t.method,503,"/* fingerprint agent unavailable */")}if("POST"===t.method&&(a===q||a===G||a===J))return void 0===t.body?void sendJson(o,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."}}):a===q?void N(t,o):a===G?void $(t,o):void V(t,o);if("GET"===t.method&&a===X)return void L(t,o);if("GET"===t.method&&a===K){let n;try{n=r(t)}catch{}if(!n)return void sendJson(o,200,{status:"anonymous"});const s=resolveEmail(t,i);return void(async()=>{let r=E.get(n);if((!r||E.isStale(n))&&s&&!F.isPaused()&&!E.isRefreshing(n)){E.markRefreshing(n);try{const i=extractDeviceIdOrUndefined(t,h),o=extractFingerprintId(t),a=extractSessionId(t,m),d=i??o??"unknown";await fetchAndCacheVerdict(e,E,n,s,d,o,a,p,(e,t)=>A({operation:"checkUser",reason:e,status:t,userId:n,emailAddress:s})),r=E.get(n)}catch(e){I&&I(e,{operation:"checkUser",userId:n,emailAddress:s}),A({operation:"checkUser",reason:"exception",userId:n,emailAddress:s})}finally{E.clearRefreshing(n)}}r&&r.isFlagged&&!r.isVerified&&(void 0!==k||g)&&s?sendJson(o,200,{status:"flagged",email:maskEmail(s)}):sendJson(o,200,{status:"ok"})})()}return void sendJson(o,404,{success:!1,error:{code:"NOT_FOUND",message:"Unknown route"}})}if(shouldSkipPath(a,c,void 0!==k))return void s();if(!shouldIncludePath(a,l))return interceptForInjection(t,o,O,d),void s();let v;try{v=r(t)}catch{}if(isSentinelUserId(v)){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;v=e&&n?e:void 0}if(!v){const e=isSecureRequest(t)?"; Secure":"";return appendSetCookie(o,`__unshared_uid=; Path=/; SameSite=Lax; Max-Age=0${e}`),appendSetCookie(o,`__unshared_uid_at=; Path=/; SameSite=Lax; Max-Age=0${e}`),appendSetCookie(o,`__unshared_sid=; Path=/; SameSite=Lax; Max-Age=0${e}`),appendSetCookie(o,`__unshared_email=; Path=/; SameSite=Lax; Max-Age=0${e}`),interceptForInjection(t,o,O,d),void s()}const _=resolveEmail(t,i);if(setUserIdCookie(t,o,v),_&&setEmailCookie(t,o,_),!_)return interceptForInjection(t,o,O,d),void s();const C=extractSessionId(t,m),y=extractDeviceIdOrUndefined(t,h),T=extractFingerprintId(t),w=t.headers["user-agent"]??"",b=extractClientIp(t),D=y??T;if(!u&&isBot(w))return void s();const W=E.get(v);if(!W){if("async"===f)return E.isRefreshing(v)||(E.markRefreshing(v),fetchAndCacheVerdict(e,E,v,_,D??"unknown",T,C,p,(e,t)=>A({operation:"checkUser",reason:e,status:t,userId:v,emailAddress:_})).catch(()=>A({operation:"checkUser",reason:"exception",userId:v,emailAddress:_})).finally(()=>E.clearRefreshing(v))),A({operation:"checkUser",reason:"async_miss",userId:v,emailAddress:_}),z(),interceptForInjection(t,o,O,d),void s();let r=U.get(v);return r||(r=fetchAndCacheVerdict(e,E,v,_,D??"unknown",T,C,p,(e,t)=>A({operation:"checkUser",reason:e,status:t,userId:v,emailAddress:_})).finally(()=>U.delete(v)),U.set(v,r)),void r.then(e=>{e.isFlagged||z(),applyVerdict(e,v,_,t,o,s,O,g,k,R,d)}).catch(()=>{A({operation:"checkUser",reason:"exception",userId:v,emailAddress:_}),z(),interceptForInjection(t,o,O,d),s()})}function z(){"unknown"!==C&&D&&(F.isPaused()||dispatchUserEvent(e,E,F,x,{userId:v,emailAddress:_,sessionId:C,deviceId:D,fingerprintId:T,userAgent:w,ipAddress:b,eventType:S},I))}E.isStale(v)&&!E.isRefreshing(v)&&(E.markRefreshing(v),fetchAndCacheVerdict(e,E,v,_,D??"unknown",T,C,p,(e,t)=>A({operation:"checkUser",reason:e,status:t,userId:v,emailAddress:_})).catch(()=>A({operation:"checkUser",reason:"exception",userId:v,emailAddress:_})).finally(()=>E.clearRefreshing(v))),W.isFlagged||z(),applyVerdict(W,v,_,t,o,s,O,g,k,R,d)};return W.destroy=()=>{E.destroy(),P.stop()},W}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,o,s,a,d,c,l){const u=e.isFlagged&&!e.isVerified;if("gate"===d&&u)isHtmlNavigation(i.method,i.headers.accept)?(n.statusCode=200,n.setHeader("Content-Type","text/html; charset=utf-8"),n.setHeader("Cache-Control","no-store"),n.end(c)):sendJson(n,403,flaggedResponse(r));else if("overlay"===d&&u)isHtmlNavigation(i.method,i.headers.accept)?(interceptForInjection(i,n,s,l),o()):sendJson(n,403,flaggedResponse(r));else if(interceptForInjection(i,n,s,l),e.isFlagged&&!e.isVerified&&a)try{a({userId:t,emailAddress:r,verdict:e,req:i,res:n,next:o})}catch{o()}else o()}function interceptForInjection(e,t,r,i){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,streamingIdleMs:i})}function dispatchUserEvent(e,t,r,i,n,o){i.mark(n.userId,n.eventType),runProcessUserEvent({client:e,cache:t,backoff:r,onError:o},{eventType:n.eventType,userId:n.userId,emailAddress:n.emailAddress,ipAddress:n.ipAddress,deviceId:n.deviceId,fingerprintId:n.fingerprintId,sessionHash:n.sessionId,userAgent:n.userAgent},DISPATCH_TIMEOUT_MS)}async function fetchAndCacheVerdict(e,t,r,i,n,o,s,a=CHECK_USER_TIMEOUT_MS,d){const c={timeoutMs:a,maxRetries:0},l=n&&"unknown"!==n?n:void 0,u=l??o,p=o??l;let f;u&&(c.deviceId=u),p&&(c.fingerprintId=p);const m=await Promise.race([e.checkUser(i,c),new Promise(e=>{f=setTimeout(()=>e(null),a)})]);return clearTimeout(f),m?m.failedOpen?(d?.(m.failedOpen.reason??"http_error",m.failedOpen.status),cacheFailOpenVerdict(t,r,i,s)):(t.set(r,{isFlagged:m.data?.is_user_flagged??!1,isVerified:!1,emailAddress:i,sessionId:s}),t.get(r)):(d?.("timeout"),cacheFailOpenVerdict(t,r,i,s))}function cacheFailOpenVerdict(e,t,r,i){const n=e.get(t);return e.set(t,{isFlagged:n?.isFlagged??!1,isVerified:n?.isVerified??!1,emailAddress:r,sessionId:i},5e3),e.get(t)}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
|
+
import{readFileSync}from"fs";import{readJsonBody}from"./utils/read-json-body.mjs";import{VerdictCache}from"./verdict-cache.mjs";import{RateLimitBackoff}from"./rate-limit-backoff.mjs";import{DispatchDedupe}from"./dispatch-dedupe.mjs";import{interceptResponse}from"./response-interceptor.mjs";import{generateFingerprintScript}from"./injection/fingerprint-script.mjs";import{createFingerprintBundleSource}from"./injection/fp-bundle-source.mjs";import{handleSubmitFingerprint}from"./routes/submit-fp.mjs";import{handleVerifyTrigger,handleVerify}from"./routes/verify.mjs";import{handleGetInterstitialFlow}from"./routes/interstitial.mjs";import{generateGatePage}from"./injection/gate-page.mjs";import{runProcessUserEvent,DISPATCH_TIMEOUT_MS}from"./dispatch-processing.mjs";import{sendJson,sendEmpty,sendBodyForMethod,getRequestPath}from"./utils/http-helpers.mjs";import{isHtmlContentType,isHtmlNavigation}from"./utils/content-type.mjs";import{flaggedResponse}from"./utils/flagged-response.mjs";import{maskEmail}from"./utils/mask-email.mjs";import{shouldSkipPath}from"./utils/skip-paths.mjs";import{shouldIncludePath}from"./utils/include-path.mjs";import{isBot}from"./utils/is-bot.mjs";import{extractClientIp}from"./utils/client-ip.mjs";import{parseCookie}from"./utils/cookies.mjs";import{extractDeviceIdOrUndefined}from"./utils/device-id.mjs";import{extractPermanentDeviceId}from"./utils/permanent-device-id.mjs";import{isSecureRequest}from"./utils/secure.mjs";import{isSentinelUserId,SENTINEL_STICKINESS_TTL_MS}from"./utils/sentinel-user-id.mjs";export{VerdictCache};export{flaggedResponse,ACCOUNT_FLAGGED_ERROR}from"./utils/flagged-response.mjs";const CHECK_USER_TIMEOUT_MS=1500,FAIL_OPEN_TTL_MS=5e3,_lastFailOpenWarn={};function warnFailOpenThrottled(e){const t=Date.now();t-(_lastFailOpenWarn[e.reason]??0)<6e4||(_lastFailOpenWarn[e.reason]=t,console.warn(`[Unshared] Failing open (reason=${e.reason}${null!=e.status?`, status=${e.status}`:""}). Detection is not enforced for this request. Configure onFailOpen to observe this directly.`))}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,maxCacheSize:a,streamingIdleMs:d=50,skipPaths:c,includePathPrefix:l,disableBotFilter:u=!1,checkUserTimeoutMs:p=CHECK_USER_TIMEOUT_MS,cacheMissStrategy:m="block",sessionId:f,deviceId:h,onFlagged:g,onError:I,onFailOpen:v,blockFlagged:S=!1,flaggedMode:_,autoInterstitial:C=!1,interstitialFlowType:y="email_verification"}=t,k=_??(S?"gate":void 0),A=C||"overlay"===k,T=e=>{if(v)try{v(e)}catch{}else"async_miss"!==e.reason&&warnFailOpenThrottled(e)},E=new VerdictCache(o,a),x=new RateLimitBackoff,F=new Map,U=new DispatchDedupe,j=Date.now().toString(36),P=generateFingerprintScript(n,j,{autoInterstitial:A,interstitialFlowType:y});let w="";try{const e=require.resolve("unshared-frontend-sdk/dist/index.umd.js");w=readFileSync(e,"utf8")}catch{}const O=createFingerprintBundleSource({disabled:t.disableS3Fingerprint}),M=void 0!==k||A,D="gate"===k?"flaggedMode 'gate' (blockFlagged)":"overlay"===k?"flaggedMode 'overlay'":"autoInterstitial";if(M&&!w)throw new Error(`[Unshared] ${D} requires unshared-frontend-sdk to be installed (its UMD bundle renders the interstitial).`);if(M&&"/__unshared"!==n)throw new Error(`[Unshared] ${D} requires the default routePrefix ("/__unshared"); the browser SDK proxy routes are fixed to that prefix.`);const b="gate"===k?generateGatePage(n,j):"",R=handleSubmitFingerprint({client:e,verdictCache:E,rateLimitBackoff:x,dispatchDedupe:U,resolveUserId:r,resolveEmailAddress:i,resolveSessionId:f,resolveDeviceId:h,disableBotFilter:u,onError:I}),$=handleVerifyTrigger({client:e,verdictCache:E,resolveEmailAddress:i,resolveDeviceId:h,onError:I}),N=handleVerify({client:e,verdictCache:E,resolveUserId:r,resolveEmailAddress:i,resolveDeviceId:h,onError:I}),V=handleGetInterstitialFlow({client:e}),H=s?Array.isArray(s)?s:[s]:null,L=`${n}/fp.js`,B=`${n}/fingerprint.js`,q=`${n}/submit-fp`,G=`${n}/verify-trigger`,X=`${n}/verify`,J=`${n}/status`,K=`${n}/interstitial-flow`,W=function(t,s,o){const a=getRequestPath(t.url),v=t.url||a;if(a.startsWith(n+"/")){if(function(e,t){if(!H)return;const r=e.headers.origin??"";H.includes("*")?(t.setHeader("Access-Control-Allow-Origin","*"),t.setHeader("Access-Control-Allow-Methods","GET, POST, OPTIONS"),t.setHeader("Access-Control-Allow-Headers","Content-Type, X-Idempotency-Key, X-Session-Id, X-Device-Id, X-Permanent-Device-Id"),t.setHeader("Access-Control-Expose-Headers","X-Unshared-Enforcement-Reconciled")):H.includes(r)&&(t.setHeader("Access-Control-Allow-Origin",r),t.setHeader("Vary","Origin"),t.setHeader("Access-Control-Allow-Methods","GET, POST, OPTIONS"),t.setHeader("Access-Control-Allow-Headers","Content-Type, X-Idempotency-Key, X-Session-Id, X-Device-Id, X-Permanent-Device-Id"),t.setHeader("Access-Control-Expose-Headers","X-Unshared-Enforcement-Reconciled"),t.setHeader("Access-Control-Allow-Credentials","true"))}(t,s),"OPTIONS"===t.method)return void sendEmpty(s,204);if(("GET"===t.method||"HEAD"===t.method)&&a===L)return s.setHeader("Content-Type","application/javascript"),s.setHeader("Cache-Control","public, max-age=3600"),void sendBodyForMethod(s,t.method,200,w);if(("GET"===t.method||"HEAD"===t.method)&&a===B)return void O.get().then(e=>{e?(s.setHeader("Content-Type","application/javascript"),s.setHeader("Cache-Control","public, max-age=3600"),sendBodyForMethod(s,t.method,200,e)):sendBodyForMethod(s,t.method,503,"/* fingerprint agent unavailable */")});if("POST"===t.method&&(a===q||a===G||a===X))return void readJsonBody(t,s).then(e=>{if(e)return a===q?R(t,s):a===G?$(t,s):N(t,s)}).catch(o);if("GET"===t.method&&a===K)return void V(t,s);if("GET"===t.method&&a===J){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=E.get(n);if((!r||E.isStale(n))&&o&&!x.isPaused()&&!E.isRefreshing(n)){E.markRefreshing(n);try{const i=extractDeviceIdOrUndefined(t,h),s=extractFingerprintId(t),a=extractPermanentDeviceId(t),d=extractSessionId(t,f),c=i??s??"unknown";await fetchAndCacheVerdict(e,E,n,o,c,s,a,d,p,(e,t)=>T({operation:"checkUser",reason:e,status:t,userId:n,emailAddress:o})),r=E.get(n)}catch(e){I&&I(e,{operation:"checkUser",userId:n,emailAddress:o}),T({operation:"checkUser",reason:"exception",userId:n,emailAddress:o})}finally{E.clearRefreshing(n)}}r&&r.isFlagged&&!r.isVerified&&(void 0!==k||g)&&o?sendJson(s,200,{status:"flagged",email:maskEmail(o)}):sendJson(s,200,{status:"ok"})})()}return void sendJson(s,404,{success:!1,error:{code:"NOT_FOUND",message:"Unknown route"}})}if(shouldSkipPath(a,c,void 0!==k))return void o();if(!shouldIncludePath(a,l))return interceptForInjection(t,s,P,d),void o();let S;try{S=r(t)}catch{}if(isSentinelUserId(S)){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;S=e&&n?e:void 0}if(!S){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}`),appendSetCookie(s,`__unshared_verification_id=; HttpOnly; Path=/; SameSite=Lax; Max-Age=0${e}`),interceptForInjection(t,s,P,d),void o()}const _=resolveEmail(t,i);if(setUserIdCookie(t,s,S),_&&setEmailCookie(t,s,_),!_)return interceptForInjection(t,s,P,d),void o();const C=extractSessionId(t,f),y=extractDeviceIdOrUndefined(t,h),A=extractFingerprintId(t),j=extractPermanentDeviceId(t),M=t.headers["user-agent"]??"",D=extractClientIp(t),W=y??A;if(!u&&isBot(M))return void o();const z=E.get(S);if(!z){if("async"===m)return E.isRefreshing(S)||(E.markRefreshing(S),fetchAndCacheVerdict(e,E,S,_,W??"unknown",A,j,C,p,(e,t)=>T({operation:"checkUser",reason:e,status:t,userId:S,emailAddress:_})).catch(()=>T({operation:"checkUser",reason:"exception",userId:S,emailAddress:_})).finally(()=>E.clearRefreshing(S))),T({operation:"checkUser",reason:"async_miss",userId:S,emailAddress:_}),Q(),interceptForInjection(t,s,P,d),void o();let r=F.get(S);return r||(r=fetchAndCacheVerdict(e,E,S,_,W??"unknown",A,j,C,p,(e,t)=>T({operation:"checkUser",reason:e,status:t,userId:S,emailAddress:_})).finally(()=>F.delete(S)),F.set(S,r)),void r.then(e=>{e.isFlagged||Q(),applyVerdict(e,S,_,t,s,o,P,g,k,b,d)}).catch(()=>{T({operation:"checkUser",reason:"exception",userId:S,emailAddress:_}),Q(),interceptForInjection(t,s,P,d),o()})}function Q(){"unknown"!==C&&W&&(x.isPaused()||dispatchUserEvent(e,E,x,U,{userId:S,emailAddress:_,sessionId:C,deviceId:W,fingerprintId:A,permanentDeviceId:j,userAgent:M,ipAddress:D,eventType:v},I))}E.isStale(S)&&!E.isRefreshing(S)&&(E.markRefreshing(S),fetchAndCacheVerdict(e,E,S,_,W??"unknown",A,j,C,p,(e,t)=>T({operation:"checkUser",reason:e,status:t,userId:S,emailAddress:_})).catch(()=>T({operation:"checkUser",reason:"exception",userId:S,emailAddress:_})).finally(()=>E.clearRefreshing(S))),z.isFlagged||Q(),applyVerdict(z,S,_,t,s,o,P,g,k,b,d)};return W.destroy=()=>{E.destroy(),O.stop()},W}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,a,d,c,l){const u=e.isFlagged&&!e.isVerified;if("gate"===d&&u)isHtmlNavigation(i.method,i.headers.accept)?(n.statusCode=200,n.setHeader("Content-Type","text/html; charset=utf-8"),n.setHeader("Cache-Control","no-store"),n.end(c)):sendJson(n,403,flaggedResponse(r));else if("overlay"===d&&u)isHtmlNavigation(i.method,i.headers.accept)?(interceptForInjection(i,n,o,l),s()):sendJson(n,403,flaggedResponse(r));else if(interceptForInjection(i,n,o,l),e.isFlagged&&!e.isVerified&&a)try{a({userId:t,emailAddress:r,verdict:e,req:i,res:n,next:s})}catch{s()}else s()}function interceptForInjection(e,t,r,i){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,streamingIdleMs:i})}function dispatchUserEvent(e,t,r,i,n,s){i.mark(n.userId,n.eventType),runProcessUserEvent({client:e,cache:t,backoff:r,onError:s},{eventType:n.eventType,userId:n.userId,emailAddress:n.emailAddress,ipAddress:n.ipAddress,deviceId:n.deviceId,fingerprintId:n.fingerprintId,permanentDeviceId:n.permanentDeviceId,sessionHash:n.sessionId,userAgent:n.userAgent},DISPATCH_TIMEOUT_MS)}async function fetchAndCacheVerdict(e,t,r,i,n,s,o,a,d=CHECK_USER_TIMEOUT_MS,c){const l={timeoutMs:d,maxRetries:0},u=n&&"unknown"!==n?n:void 0,p=u??s,m=s??u;let f;p&&(l.deviceId=p),m&&(l.fullHash=m),o&&(l.permanentDeviceId=o);const h=await Promise.race([e.checkUser(i,l),new Promise(e=>{f=setTimeout(()=>e(null),d)})]);return clearTimeout(f),h?h.failedOpen?(c?.(h.failedOpen.reason??"http_error",h.failedOpen.status),cacheFailOpenVerdict(t,r,i,a)):(t.set(r,{isFlagged:h.data?.is_user_flagged??!1,isVerified:!1,emailAddress:i,sessionId:a}),t.get(r)):(c?.("timeout"),cacheFailOpenVerdict(t,r,i,a))}function cacheFailOpenVerdict(e,t,r,i){const n=e.get(t);return e.set(t,{isFlagged:n?.isFlagged??!1,isVerified:n?.isVerified??!1,emailAddress:r,sessionId:i},5e3),e.get(t)}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; Max-Age=31536000${i}`),appendSetCookie(t,`__unshared_uid_at=${Date.now()}; Path=/; SameSite=Lax; Max-Age=31536000${i}`)}function setEmailCookie(e,t,r){const i=isSecureRequest(e)?"; Secure":"";appendSetCookie(t,`__unshared_email=${encodeURIComponent(r)}; HttpOnly; Path=/; SameSite=Lax; Max-Age=31536000${i}`)}
|
|
@@ -12,6 +12,13 @@
|
|
|
12
12
|
* window.addEventListener("unshared:flagged", (e) => {
|
|
13
13
|
* e.detail.email — the flagged user's email (from 403 response body)
|
|
14
14
|
* });
|
|
15
|
+
*
|
|
16
|
+
* Sticky-signal contract (ordering-proof alternative to the event): the signal
|
|
17
|
+
* is also recorded on the shared namespace, so listeners that attach AFTER the
|
|
18
|
+
* CustomEvent fired (late hydration, app crash-and-remount) can recover it:
|
|
19
|
+
* window.__unshared.flagged — { email } once flagged, else undefined
|
|
20
|
+
* window.__unshared.whenFlagged(cb) — subscribes cb AND replays immediately
|
|
21
|
+
* if the signal already fired
|
|
15
22
|
*/
|
|
16
23
|
export declare function generateFingerprintScript(routePrefix: string, version?: string, opts?: {
|
|
17
24
|
autoInterstitial?: boolean;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
export function generateFingerprintScript(e,t,n){const r=t?`?v=${escapeJavaScript(t)}`:"",s=n?.autoInterstitial?`\nvar _isdk=new UnsharedBrowser.UnsharedBrowser({baseUrl:""});\nwindow.addEventListener("unshared:flagged",function(){try{_isdk.showInterstitial({flowType:"${escapeJavaScript(n?.interstitialFlowType??"email_verification")}",onComplete:function(){try{location.reload()}catch(e){}}})}catch(e){}});`:"";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// Page-scoped dedup state SHARED with the frontend SDK (browser.ts getSharedDedup).\n// On a Tier 1 page both this inline script and the SDK submit; each holds its own\n// in-memory lastSubmitKey, so both could pass the check below and POST. window.__unshared\n// is read+written synchronously in submitFP (no await between), so whichever fires\n// first claims the uid|route key and the other no-ops — killing the read-before-write\n// race that doubled events. KEEP IN SYNC with browser.ts: namespace, lastKey, key formula.\nvar shared=(window.__unshared=window.__unshared||{});\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"+(location.protocol==="https:"?"; Secure":"")}\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 // Check the shared window guard AND the in-memory key (last-key semantics, so an\n // SPA A->B->A revisit still submits the second A). The shared guard makes this\n // submitter and the frontend SDK see each other within the page.\n if(key===lastSubmitKey||shared.lastKey===key)return;\n shared.lastKey=key;\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,source:fp.source,session_id:sid,user_id:uid,event_type:route};\n // Idempotency key derived from (stable_hash, user_id, route), SHA-256-hashed\n // when WebCrypto is available so the user_id never appears in the platform's\n // stored idempotency_key column (raw fallback on non-secure contexts — the\n // middleware accepts both). NOTE: the middleware appends |Date.now() before\n // forwarding (submit-fp.ts), so the backend sees a unique value per\n // submission and dedups only PubSub redeliveries — NOT two distinct POSTs.\n // Cross-submitter / cross-reload dedup is client-side (the shared window\n // guard + sessionStorage above).\n var idem=fp.fingerprint_id+"|"+uid+"|"+route;\n function sendFP(idemKey){\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",idemKey);\n xhr.send(JSON.stringify(body));\n }\n if(window.crypto&&crypto.subtle&&window.TextEncoder){\n crypto.subtle.digest("SHA-256",new TextEncoder().encode(idem)).then(function(d){\n var a=new Uint8Array(d),s="";\n for(var i=0;i<a.length;i++){s+=("0"+a[i].toString(16)).slice(-2)}\n sendFP(s);\n }).catch(function(){sendFP(idem)});\n }else{sendFP(idem)}\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${r}";\ns.onload=function(){fpReady=true;${s}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 JSON.stringify(e).slice(1,-1).replace(/<\/(script)/gi,"<\\/$1").replace(new RegExp("\u2028","g"),"\\u2028").replace(new RegExp("\u2029","g"),"\\u2029")}
|
|
1
|
+
export function generateFingerprintScript(e,t,n){const r=t?`?v=${escapeJavaScript(t)}`:"",a=n?.autoInterstitial?`\nvar _isdk=new UnsharedBrowser.UnsharedBrowser({baseUrl:""});\nvar _isdkShow=function(){try{_isdk.showInterstitial({flowType:"${escapeJavaScript(n?.interstitialFlowType??"email_verification")}",onComplete:function(){try{location.reload()}catch(e){}}})}catch(e){}};\nwindow.addEventListener("unshared:flagged",_isdkShow);\nif(shared.flagged){_isdkShow()}`:"";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_FP_RAW="__unshared_fp_raw";\nvar SS_LAST_SUBMIT="__unshared_last_submit";\nvar CAPTURE_SESSION_KEYS=["__unshared_email","__unshared_last_submit","__unshared_verification_id"];\nvar MAX_REQUEST_BYTES=102400;\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// Page-scoped dedup state SHARED with the frontend SDK (browser.ts getSharedDedup).\n// On a Tier 1 page both this inline script and the SDK submit; each holds its own\n// in-memory lastSubmitKey, so both could pass the check below and POST. window.__unshared\n// is read+written synchronously in submitFP (no await between), so whichever fires\n// first claims the uid|route key and the other no-ops — killing the read-before-write\n// race that doubled events. KEEP IN SYNC with browser.ts: namespace, lastKey, key formula.\nvar shared=(window.__unshared=window.__unshared||{});\n// Sticky flagged signal: emitFlagged() records shared.flagged and drains\n// shared.flaggedCbs, so listeners that attach AFTER the CustomEvent fired\n// (late hydration, fp.js still loading) can recover it via whenFlagged().\n// KEEP IN SYNC with browser.ts getSharedDedup: flagged, flaggedCbs, whenFlagged.\nshared.whenFlagged=shared.whenFlagged||function(cb){(shared.flaggedCbs=shared.flaggedCbs||[]).push(cb);if(shared.flagged){try{cb(shared.flagged)}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"+(location.protocol==="https:"?"; Secure":"")}\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)}))}\nfunction validPid(v){return typeof v==="string"&&/^upid_[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(v)}\nfunction bLen(v){return typeof TextEncoder!=="undefined"?new TextEncoder().encode(v).length:encodeURIComponent(v).replace(/%[0-9A-F]{2}/g,"x").length}\nfunction cCookies(){try{return document.cookie.split(";").map(function(v){return v.trim()}).filter(Boolean).map(function(v){var i=v.indexOf("="),n=i<0?v:v.slice(0,i),r=i<0?"":v.slice(i+1),d=r;try{d=decodeURIComponent(r)}catch(e){}return{name:n,value:d}}).sort(function(a,b){return a.name<b.name?-1:a.name>b.name?1:0})}catch(e){return[]}}\nfunction cStorage(s,keys){try{var out=[];for(var i=0;i<keys.length;i++){var v=s.getItem(keys[i]);if(v!==null)out.push({name:keys[i],value:v})}return out.sort(function(a,b){return a.name<b.name?-1:a.name>b.name?1:0})}catch(e){return[]}}\nfunction browserStorage(){var l=[],s=[];try{l=cStorage(localStorage,["__unshared_device_id"])}catch(e){}try{s=cStorage(sessionStorage,CAPTURE_SESSION_KEYS)}catch(e){}return{cookies:cCookies(),local_storage:l,session_storage:s}}\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="",pid="";\n\n// --- Fingerprint cache (sessionStorage) ---\n// Old normalized-only caches cannot recover overwritten raw fields; recollect them.\nfunction getFP(){try{var w=sessionStorage.getItem(SS_FP),r=sessionStorage.getItem(SS_FP_RAW);return w&&r?{wire:JSON.parse(w),raw:JSON.parse(r)}:null}catch(e){return null}}\nfunction setFP(collected){try{sessionStorage.removeItem(SS_FP_RAW);sessionStorage.setItem(SS_FP,JSON.stringify(collected.wire));sessionStorage.setItem(SS_FP_RAW,JSON.stringify(collected.raw))}catch(e){}}\n\n// --- Submit fingerprint to backend ---\nfunction submitFP(collected){\n var fp=collected.wire;\n var uid=gC("__unshared_uid");\n if(!uid||isSentinelUid(uid))return;\n try{pid=localStorage.getItem("__unshared_device_id")||""}catch(e){}\n if(!validPid(pid)){pid="upid_"+uuid();try{localStorage.setItem("__unshared_device_id",pid)}catch(e){}}\n sC("__unshared_device_id",pid,400);\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 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 // Check the shared window guard AND the in-memory key (last-key semantics, so an\n // SPA A->B->A revisit still submits the second A). The shared guard makes this\n // submitter and the frontend SDK see each other within the page.\n if(key===lastSubmitKey||shared.lastKey===key)return;\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={fingerprint:collected.raw,context:{sdk:{name:"unshared-inline-sdk",version:"3.0.0",agent_source:fp.source||"unknown"},identity:{user_id:uid},identifiers:{unshared_device_id:pid,device_id:did,stable_hash:fp.fingerprint_id||"",full_hash:fp.full_hash||"",session_hash:sid},browser_storage:browserStorage(),event:{type:route,collected_at:(new Date()).toISOString()}}};\n var payload=JSON.stringify(body);\n // Keep in sync with the structured browser builder: full, cookies only, core only.\n if(bLen(payload)>MAX_REQUEST_BYTES){body.context.browser_storage.local_storage=[];body.context.browser_storage.session_storage=[];payload=JSON.stringify(body);}\n if(bLen(payload)>MAX_REQUEST_BYTES){delete body.context.browser_storage;payload=JSON.stringify(body);}\n if(bLen(payload)>MAX_REQUEST_BYTES){if(window.console&&console.warn)console.warn("[Unshared] REQUEST_TOO_LARGE: Request body exceeds 100 KiB");return;}\n shared.lastKey=key;\n lastSubmitKey=key;\n try{sessionStorage.setItem(SS_LAST_SUBMIT,key)}catch(e){}\n // Idempotency key derived from (stable_hash, user_id, route), SHA-256-hashed\n // when WebCrypto is available so the user_id never appears in the platform's\n // stored idempotency_key column (raw fallback on non-secure contexts — the\n // middleware accepts both). NOTE: the middleware appends |Date.now() before\n // forwarding (submit-fp.ts), so the backend sees a unique value per\n // submission and dedups only PubSub redeliveries — NOT two distinct POSTs.\n // Cross-submitter / cross-reload dedup is client-side (the shared window\n // guard + sessionStorage above).\n var idem=fp.fingerprint_id+"|"+uid+"|"+route;\n function sendFP(idemKey){\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 if(pid)xhr.setRequestHeader("X-Permanent-Device-Id",pid);\n xhr.setRequestHeader("X-Idempotency-Key",idemKey);\n xhr.send(payload);\n }\n if(window.crypto&&crypto.subtle&&window.TextEncoder){\n crypto.subtle.digest("SHA-256",new TextEncoder().encode(idem)).then(function(d){\n var a=new Uint8Array(d),s="";\n for(var i=0;i<a.length;i++){s+=("0"+a[i].toString(16)).slice(-2)}\n sendFP(s);\n }).catch(function(){sendFP(idem)});\n }else{sendFP(idem)}\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.collectWithMetadata({exclude:["timing","speech"]}).then(function(fp){setFP(fp);submitFP(fp)}).catch(function(){});\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${r}";\ns.onload=function(){fpReady=true;${a}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: record sticky state + dispatch "unshared:flagged" ---\nfunction emitFlagged(body){\n var d={email:body.email||""};\n shared.flagged=d;\n var cbs=shared.flaggedCbs||[];\n for(var i=0;i<cbs.length;i++){try{cbs[i](d)}catch(e){}}\n try{window.dispatchEvent(new CustomEvent("unshared:flagged",{detail:d}))}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 JSON.stringify(e).slice(1,-1).replace(/<\/(script)/gi,"<\\/$1").replace(new RegExp("\u2028","g"),"\\u2028").replace(new RegExp("\u2029","g"),"\\u2029")}
|
|
@@ -1,13 +1,15 @@
|
|
|
1
1
|
export declare const S3_FINGERPRINT_URL = "https://unshared-public.s3.us-east-2.amazonaws.com/fingerprint@1/fingerprint.umd.js";
|
|
2
2
|
export interface FingerprintBundleSource {
|
|
3
|
-
/**
|
|
4
|
-
get(): string
|
|
3
|
+
/** Wait for the initial bounded S3 attempt, then return the cached UMD or fallback. */
|
|
4
|
+
get(): Promise<string>;
|
|
5
5
|
/** Run one refresh cycle now (also called by the background timer). */
|
|
6
6
|
refresh(): Promise<void>;
|
|
7
7
|
/** Stop the background timer. */
|
|
8
8
|
stop(): void;
|
|
9
9
|
}
|
|
10
10
|
export interface FingerprintBundleSourceOptions {
|
|
11
|
+
/** @internal Disable all remote fetches and serve only the bundled fallback. */
|
|
12
|
+
disabled?: boolean;
|
|
11
13
|
url?: string;
|
|
12
14
|
intervalMs?: number;
|
|
13
15
|
fetchTimeoutMs?: number;
|
|
@@ -18,8 +20,7 @@ export interface FingerprintBundleSourceOptions {
|
|
|
18
20
|
/**
|
|
19
21
|
* Start the background refresh timer + immediate boot fetch.
|
|
20
22
|
* @default true in production, false under NODE_ENV==='test' (so unit tests
|
|
21
|
-
*
|
|
22
|
-
* fallback and drive `refresh()` with an injected fetch).
|
|
23
|
+
* drive `get()` / `refresh()` with an injected fetch).
|
|
23
24
|
*/
|
|
24
25
|
autoStart?: boolean;
|
|
25
26
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
import{readFileSync}from"fs";import{createRequire}from"module";import{dirname,join}from"path";export const S3_FINGERPRINT_URL="https://unshared-public.s3.us-east-2.amazonaws.com/fingerprint@1/fingerprint.umd.js";const DEFAULT_REFRESH_INTERVAL_MS=9e5,DEFAULT_FETCH_TIMEOUT_MS=
|
|
1
|
+
import{readFileSync}from"fs";import{createRequire}from"module";import{dirname,join}from"path";export const S3_FINGERPRINT_URL="https://unshared-public.s3.us-east-2.amazonaws.com/fingerprint@1/fingerprint.umd.js";const DEFAULT_REFRESH_INTERVAL_MS=9e5,DEFAULT_FETCH_TIMEOUT_MS=1e3;function defaultLoadFallback(){try{const e=createRequire(import.meta.url).resolve("unshared-frontend-sdk/package.json"),t=createRequire(e).resolve("unshared-fingerprint-lib"),r=join(dirname(t),"fingerprint.umd.js");return readFileSync(r,"utf8")}catch{return""}}export function createFingerprintBundleSource(e={}){const t=e.disabled??!1,r=e.url??S3_FINGERPRINT_URL,n=e.intervalMs??9e5,o=e.fetchTimeoutMs??1e3,i=e.fetchImpl??globalThis.fetch;let a,s,c,u,l=(e.loadFallback??defaultLoadFallback)();function f(){if(t)return Promise.resolve();if(u)return u;const e=new AbortController;let n;const s=new Promise(t=>{n=setTimeout(()=>{e.abort(),t()},o)});return u=Promise.race([(async()=>{try{if(!i)return;const t={};a&&(t["If-None-Match"]=a);const n=await i(r,{headers:t,signal:e.signal});if(304===n.status||!n.ok)return;const o=await n.text();o&&!e.signal.aborted&&(l=o,a=n.headers.get("etag")??void 0)}catch{}})(),s]).finally(()=>{clearTimeout(n),u=void 0}),c??(c=u),u}return(e.autoStart??"test"!==process.env.NODE_ENV)&&!t&&(f(),s=setInterval(()=>{f()},n),"function"==typeof s.unref&&s.unref()),{get:async()=>(await(c??f()),l),refresh:f,stop:()=>{s&&clearInterval(s),s=void 0}}}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import type { UnsharedRequest, UnsharedResponse } from '../../types';
|
|
2
|
-
import type { UnsharedClient } from '../../client';
|
|
1
|
+
import type { UnsharedRequest, UnsharedResponse } from '../../types.mjs';
|
|
2
|
+
import type { UnsharedClient } from '../../client.mjs';
|
|
3
3
|
export interface InterstitialDependencies {
|
|
4
4
|
client: UnsharedClient;
|
|
5
5
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
import{sendJson}from"../utils/http-helpers";function isPublishedFlow(s){return!!s&&"object"==typeof s&&Object.keys(s).length>0}const FLOW_NOT_PUBLISHED={code:"FLOW_NOT_PUBLISHED",message:"No interstitial flow is published for this company."};export function handleGetInterstitialFlow(s){return async(e,o)=>{try{const t=e.url&&e.url.includes("?")?e.url.slice(e.url.indexOf("?")+1):"",r=new URLSearchParams(t),n=r.get("flow_type")||void 0,i=r.get("platform")||void 0,a=await s.client.getInterstitialFlow({flowType:n,platform:i});a.success?isPublishedFlow(a.data)?sendJson(o,200,{success:!0,data:a.data}):sendJson(o,200,{success:!1,error:{...FLOW_NOT_PUBLISHED}}):404===a.status?sendJson(o,200,{success:!1,error:{...FLOW_NOT_PUBLISHED}}):sendJson(o,200,{success:!1,error:a.error??{code:"FLOW_FETCH_FAILED",message:"Failed to load interstitial flow"}})}catch{sendJson(o,200,{success:!1,error:{code:"INTERNAL_ERROR",message:"Failed to load interstitial flow"}})}}}
|
|
1
|
+
import{sendJson}from"../utils/http-helpers.mjs";function isPublishedFlow(s){return!!s&&"object"==typeof s&&Object.keys(s).length>0}const FLOW_NOT_PUBLISHED={code:"FLOW_NOT_PUBLISHED",message:"No interstitial flow is published for this company."};export function handleGetInterstitialFlow(s){return async(e,o)=>{try{const t=e.url&&e.url.includes("?")?e.url.slice(e.url.indexOf("?")+1):"",r=new URLSearchParams(t),n=r.get("flow_type")||void 0,i=r.get("platform")||void 0,a=await s.client.getInterstitialFlow({flowType:n,platform:i});a.success?isPublishedFlow(a.data)?sendJson(o,200,{success:!0,data:a.data}):sendJson(o,200,{success:!1,error:{...FLOW_NOT_PUBLISHED}}):404===a.status?sendJson(o,200,{success:!1,error:{...FLOW_NOT_PUBLISHED}}):sendJson(o,200,{success:!1,error:a.error??{code:"FLOW_FETCH_FAILED",message:"Failed to load interstitial flow"}})}catch{sendJson(o,200,{success:!1,error:{code:"INTERNAL_ERROR",message:"Failed to load interstitial flow"}})}}}
|