unshared-clientjs-sdk 2.0.0-rc.2 → 2.0.0-rc.21
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 +100 -102
- package/dist/client.d.ts +57 -12
- package/dist/client.js +1 -1
- package/dist/esm/client.d.mts +57 -12
- package/dist/esm/client.mjs +1 -1
- package/dist/esm/index.d.mts +5 -1
- package/dist/esm/index.mjs +1 -1
- package/dist/esm/middleware/index.d.mts +50 -0
- package/dist/esm/middleware/index.mjs +1 -0
- package/dist/esm/middleware/injection/fingerprint-script.d.mts +16 -0
- package/dist/esm/middleware/injection/fingerprint-script.mjs +1 -0
- package/dist/esm/middleware/rate-limit-backoff.d.mts +14 -0
- package/dist/esm/middleware/rate-limit-backoff.mjs +1 -0
- package/dist/esm/middleware/response-interceptor.d.mts +15 -0
- package/dist/esm/middleware/response-interceptor.mjs +1 -0
- package/dist/esm/middleware/routes/submit-fp.d.mts +24 -0
- package/dist/esm/middleware/routes/submit-fp.mjs +1 -0
- package/dist/esm/middleware/routes/verify.d.mts +28 -0
- package/dist/esm/middleware/routes/verify.mjs +1 -0
- package/dist/esm/middleware/utils/client-ip.d.mts +6 -0
- package/dist/esm/middleware/utils/client-ip.mjs +1 -0
- package/dist/esm/middleware/utils/content-type.d.mts +6 -0
- package/dist/esm/middleware/utils/content-type.mjs +1 -0
- package/dist/esm/middleware/utils/cookies.d.mts +6 -0
- package/dist/esm/middleware/utils/cookies.mjs +1 -0
- package/dist/esm/middleware/utils/device-id.d.mts +5 -0
- package/dist/esm/middleware/utils/device-id.mjs +1 -0
- package/dist/esm/middleware/utils/is-bot.d.mts +5 -0
- package/dist/esm/middleware/utils/is-bot.mjs +1 -0
- package/dist/esm/middleware/utils/secure.d.mts +3 -0
- package/dist/esm/middleware/utils/secure.mjs +1 -0
- package/dist/esm/middleware/utils/skip-paths.d.mts +5 -0
- package/dist/esm/middleware/utils/skip-paths.mjs +1 -0
- package/dist/esm/middleware/verdict-cache.d.mts +47 -0
- package/dist/esm/middleware/verdict-cache.mjs +1 -0
- package/dist/esm/middleware.d.mts +30 -5
- package/dist/esm/middleware.mjs +1 -1
- package/dist/index.d.ts +5 -1
- package/dist/index.js +1 -1
- package/dist/middleware/index.d.ts +50 -0
- package/dist/middleware/index.js +1 -0
- package/dist/middleware/injection/fingerprint-script.d.ts +16 -0
- package/dist/middleware/injection/fingerprint-script.js +1 -0
- package/dist/middleware/rate-limit-backoff.d.ts +14 -0
- package/dist/middleware/rate-limit-backoff.js +1 -0
- package/dist/middleware/response-interceptor.d.ts +15 -0
- package/dist/middleware/response-interceptor.js +1 -0
- package/dist/middleware/routes/submit-fp.d.ts +24 -0
- package/dist/middleware/routes/submit-fp.js +1 -0
- package/dist/middleware/routes/verify.d.ts +28 -0
- package/dist/middleware/routes/verify.js +1 -0
- package/dist/middleware/utils/client-ip.d.ts +6 -0
- package/dist/middleware/utils/client-ip.js +1 -0
- package/dist/middleware/utils/content-type.d.ts +6 -0
- package/dist/middleware/utils/content-type.js +1 -0
- package/dist/middleware/utils/cookies.d.ts +6 -0
- package/dist/middleware/utils/cookies.js +1 -0
- package/dist/middleware/utils/device-id.d.ts +5 -0
- package/dist/middleware/utils/device-id.js +1 -0
- package/dist/middleware/utils/is-bot.d.ts +5 -0
- package/dist/middleware/utils/is-bot.js +1 -0
- package/dist/middleware/utils/secure.d.ts +3 -0
- package/dist/middleware/utils/secure.js +1 -0
- package/dist/middleware/utils/skip-paths.d.ts +5 -0
- package/dist/middleware/utils/skip-paths.js +1 -0
- package/dist/middleware/verdict-cache.d.ts +47 -0
- package/dist/middleware/verdict-cache.js +1 -0
- package/dist/middleware.d.ts +30 -5
- package/dist/middleware.js +1 -1
- package/package.json +14 -1
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{readFileSync}from"fs";import{VerdictCache}from"./verdict-cache";import{RateLimitBackoff}from"./rate-limit-backoff";import{interceptResponse}from"./response-interceptor";import{generateFingerprintScript}from"./injection/fingerprint-script";import{handleSubmitFingerprint}from"./routes/submit-fp";import{handleVerifyTrigger,handleVerify}from"./routes/verify";import{isHtmlContentType}from"./utils/content-type";import{shouldSkipPath}from"./utils/skip-paths";import{isBot}from"./utils/is-bot";import{extractClientIp}from"./utils/client-ip";import{parseCookie}from"./utils/cookies";import{extractDeviceId}from"./utils/device-id";import{isSecureRequest}from"./utils/secure";export{VerdictCache};const CHECK_USER_TIMEOUT_MS=500;export function unsharedBoundToUser(e,r){if(!r.userId)throw new Error("[Unshared] userId resolver is required");if(!r.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:t,emailAddress:i,routePrefix:n="/__unshared",corsOrigins:o,cacheTTL:s=6e4,skipPaths:c,sessionId:d,deviceId:a,onFlagged:u}=r,l=new VerdictCache(s),f=new RateLimitBackoff,p=Date.now().toString(36),m=generateFingerprintScript(n,p);let h="";try{const e=require.resolve("unshared-frontend-sdk/dist/index.umd.js");h=readFileSync(e,"utf8")}catch{}const v=handleSubmitFingerprint({client:e,verdictCache:l,rateLimitBackoff:f,resolveUserId:t,resolveEmailAddress:i,resolveSessionId:d,resolveDeviceId:a}),C=handleVerifyTrigger({client:e,verdictCache:l,resolveEmailAddress:i,resolveDeviceId:a}),I=handleVerify({client:e,verdictCache:l,resolveEmailAddress:i,resolveDeviceId:a}),S=o?Array.isArray(o)?o:[o]:null,g=`${n}/fp.js`,k=`${n}/submit-fp`,y=`${n}/verify-trigger`,_=`${n}/verify`,A=`${n}/status`;return function(r,o,s){const p=r.path;if(p.startsWith(n+"/")){if(function(e,r){if(!S)return;const t=e.headers.origin??"",i=S.includes("*");(i||S.includes(t))&&(r.setHeader("Access-Control-Allow-Origin",i?"*":t),r.setHeader("Access-Control-Allow-Methods","POST, OPTIONS"),r.setHeader("Access-Control-Allow-Headers","Content-Type, X-Idempotency-Key, X-Session-Id, X-Device-Id"),r.setHeader("Access-Control-Allow-Credentials","true"))}(r,o),"OPTIONS"===r.method)return void o.status(204).end();if("GET"===r.method&&p===g)return o.setHeader("Content-Type","application/javascript"),o.setHeader("Cache-Control","public, max-age=3600"),void o.status(200).end(h);if("POST"===r.method&&p===k)return void v(r,o);if("POST"===r.method&&p===y)return void C(r,o);if("POST"===r.method&&p===_)return void I(r,o);if("GET"===r.method&&p===A){let e;try{e=t(r)}catch{}if(!e)return void o.status(200).json({status:"anonymous"});const n=resolveEmail(r,i),s=l.get(e);return void(s&&s.isFlagged&&!s.isVerified&&u&&n?o.status(403).json({error:"account_flagged",email:n}):o.status(200).json({status:"ok"}))}return void o.status(404).json({success:!1,error:{code:"NOT_FOUND",message:"Unknown route"}})}if(shouldSkipPath(p,c))return void s();let T;try{T=t(r)}catch{}if(!T)return clearUserIdCookieIfPresent(r,o),clearEmailCookieIfPresent(r,o),interceptForInjection(r,o,m),void s();const x=resolveEmail(r,i);if(setUserIdCookie(r,o,T),x&&setEmailCookie(r,o,x),!x)return interceptForInjection(r,o,m),void s();const P=extractSessionId(r,d),E=extractDeviceId(r,a),F=extractFingerprintId(r),w=r.headers["user-agent"]??"",U=extractClientIp(r);if(isBot(w))return void s();if("unknown"===P)return interceptForInjection(r,o,m),void s();f.isPaused()||dispatchUserEvent(e,l,f,{userId:T,emailAddress:x,sessionId:P,deviceId:E,fingerprintId:F,userAgent:w,ipAddress:U,eventType:`${r.method} ${r.path}`});const O=l.get(T);O?(l.isStale(T)&&!l.isRefreshing(T)&&(l.markRefreshing(T),fetchAndCacheVerdict(e,l,T,x,E,F,P).finally(()=>l.clearRefreshing(T))),applyVerdict(O,T,x,r,o,s,m,u)):fetchAndCacheVerdict(e,l,T,x,E,F,P).then(e=>{applyVerdict(e,T,x,r,o,s,m,u)}).catch(()=>{interceptForInjection(r,o,m),s()})}}function resolveEmail(e,r){if(r)try{const t=r(e);if(t)return t}catch{}const t=parseCookie(e,"__unshared_email");if(t)return t;const i=e.body?.email;return"string"==typeof i&&i?i:void 0}function applyVerdict(e,r,t,i,n,o,s,c){if(interceptForInjection(i,n,s),e.isFlagged&&!e.isVerified&&c)try{c({userId:r,emailAddress:t,verdict:e,req:i,res:n,next:o})}catch{o()}else o()}function interceptForInjection(e,r,t){delete e.headers["if-none-match"],delete e.headers["if-modified-since"],interceptResponse(r,(e,r)=>{if(!isHtmlContentType(r))return null;const i=e.toString("utf8"),n=i.lastIndexOf("</body>");return-1===n?i+t:i.slice(0,n)+t+i.slice(n)},{preventCaching:!0})}function dispatchUserEvent(e,r,t,i){e.processUserEvent({eventType:i.eventType,userId:i.userId,emailAddress:i.emailAddress,ipAddress:i.ipAddress,deviceId:i.deviceId,fingerprintId:i.fingerprintId,sessionHash:i.sessionId,userAgent:i.userAgent}).then(e=>{e.success&&e.data?.analysis&&r.update(i.userId,{isFlagged:e.data.analysis.is_user_flagged}),!e.success&&e.error?.retryAfter&&t.pause(1e3*e.error.retryAfter)}).catch(()=>{})}async function fetchAndCacheVerdict(e,r,t,i,n,o,s){const c={};let d;n&&"unknown"!==n&&(c.deviceId=n),o&&(c.fingerprintId=o);const a=await Promise.race([e.checkUser(i,c),new Promise(e=>{d=setTimeout(()=>e(null),500)})]);if(clearTimeout(d),!a)return{isFlagged:!1,isVerified:!1,emailAddress:i,sessionId:s,cachedAt:0,ttl:0};const u=a.data?.is_user_flagged??!1;return r.set(t,{isFlagged:u,isVerified:!1,emailAddress:i,sessionId:s}),r.get(t)}function extractSessionId(e,r){if(r)try{const t=r(e);if(t)return t}catch{}return parseCookie(e,"__unshared_sid")??"unknown"}function extractFingerprintId(e){return parseCookie(e,"__unshared_fingerprint_id")||void 0}function appendSetCookie(e,r){const t=e.getHeader("Set-Cookie");if(t){const i=Array.isArray(t)?[...t]:[String(t)];i.push(r),e.setHeader("Set-Cookie",i)}else e.setHeader("Set-Cookie",r)}function setUserIdCookie(e,r,t){const i=isSecureRequest(e)?"; Secure":"";appendSetCookie(r,`__unshared_uid=${encodeURIComponent(t)}; Path=/; SameSite=Lax${i}`)}function setEmailCookie(e,r,t){const i=isSecureRequest(e)?"; Secure":"";appendSetCookie(r,`__unshared_email=${encodeURIComponent(t)}; HttpOnly; Path=/; SameSite=Lax${i}`)}function clearUserIdCookieIfPresent(e,r){parseCookie(e,"__unshared_uid")&&appendSetCookie(r,"__unshared_uid=; Path=/; SameSite=Lax; Max-Age=0"+(isSecureRequest(e)?"; Secure":""))}function clearEmailCookieIfPresent(e,r){parseCookie(e,"__unshared_email")&&appendSetCookie(r,"__unshared_email=; HttpOnly; Path=/; SameSite=Lax; Max-Age=0"+(isSecureRequest(e)?"; Secure":""))}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Generates an inline loader script that:
|
|
3
|
+
* 1. Loads the fingerprint SDK from /__unshared/fp.js
|
|
4
|
+
* 2. Collects a fingerprint and POSTs to /__unshared/submit-fp
|
|
5
|
+
* 3. Caches fingerprint in sessionStorage for reuse on SPA navigations
|
|
6
|
+
* 4. Patches History API to detect SPA route changes → re-submits fingerprint
|
|
7
|
+
* 5. Patches fetch/XHR to detect 403 account_flagged → dispatches "unshared:flagged" event
|
|
8
|
+
*
|
|
9
|
+
* The actual SDK UMD bundle is served by the middleware at /__unshared/fp.js.
|
|
10
|
+
*
|
|
11
|
+
* Event contract:
|
|
12
|
+
* window.addEventListener("unshared:flagged", (e) => {
|
|
13
|
+
* e.detail.email — the flagged user's email (from 403 response body)
|
|
14
|
+
* });
|
|
15
|
+
*/
|
|
16
|
+
export declare function generateFingerprintScript(routePrefix: string, version?: string): string;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export function generateFingerprintScript(e,n){const t=n?`?v=${escapeJavaScript(n)}`:"";return`<script>\n(function(){\ntry{\nvar pfx="${escapeJavaScript(e)}";\nvar SS_FP="__unshared_fp";\n\n// --- Helpers ---\nfunction gC(n){var m=document.cookie.match(new RegExp("(?:^|; )"+n+"=([^;]*)"));return m?decodeURIComponent(m[1]):null}\nfunction sC(n,v,d){var e="";if(d){var dt=new Date();dt.setTime(dt.getTime()+d*864e5);e="; expires="+dt.toUTCString()}document.cookie=n+"="+encodeURIComponent(v)+e+"; path=/; SameSite=Lax"}\nfunction uuid(){return(typeof crypto!=="undefined"&&crypto.randomUUID)?crypto.randomUUID():("xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g,function(c){var r=Math.random()*16|0;return(c==="x"?r:r&0x3|0x8).toString(16)}))}\n\n// --- Session + device IDs ---\nvar sid=gC("__unshared_sid");\nif(!sid){sid=uuid();sC("__unshared_sid",sid,365)}\nvar did="";\ntry{did=localStorage.getItem("__unshared_device_id")||"";if(!did){did=uuid();localStorage.setItem("__unshared_device_id",did)}}catch(e){did=did||uuid()}\nsC("__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,evType){\n var uid=gC("__unshared_uid");\n if(!uid)return;\n var body={hash:fp.full_hash,stable_hash:fp.fingerprint_id,collected_at:fp.timestamp,is_incognito:fp.isIncognito,components:fp.components,version:fp.version,session_id:sid,user_id:uid,event_type:evType||"page_load"};\n var xhr=new XMLHttpRequest();\n xhr.open("POST",pfx+"/submit-fp",true);\n xhr.setRequestHeader("Content-Type","application/json");\n xhr.setRequestHeader("X-Session-Id",sid);\n xhr.setRequestHeader("X-Device-Id",did);\n xhr.send(JSON.stringify(body));\n}\n\n// --- Collect fingerprint (loads fp.js if needed) then submit ---\nvar fpReady=false;\nfunction collectAndSubmit(evType){\n var uid=gC("__unshared_uid");\n if(!uid)return;\n var cached=getFP();\n if(cached){submitFP(cached,evType);return}\n if(!fpReady)return;\n try{\n var c=new UnsharedLabsBrowser.UnsharedLabsBrowser({baseUrl:""});\n c.collect({exclude:["timing","navigatorConnection","speech"]}).then(function(fp){setFP(fp);submitFP(fp,evType)});\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;\nif(getFP()&&gC("__unshared_uid")){submitFP(getFP(),"page_load");pageLoadSubmitted=true;deferredCheck()}\nvar s=document.createElement("script");\ns.src=pfx+"/fp.js${t}";\ns.onload=function(){fpReady=true;if(!pageLoadSubmitted){collectAndSubmit("page_load");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// Uses the patched fetch so the 403 interceptor picks up the response.\nfunction deferredCheck(){\n var uid=gC("__unshared_uid");\n if(!uid)return;\n setTimeout(function(){\n try{fetch(pfx+"/status",{method:"GET",credentials:"same-origin"}).catch(function(){})}catch(e){}\n },5000);\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("route_change")}catch(e){}};\nhistory.replaceState=function(){oReplace.apply(this,arguments);try{collectAndSubmit("route_change")}catch(e){}};\nwindow.addEventListener("popstate",function(){try{collectAndSubmit("route_change")}catch(e){}});\n\n// --- 403 interception: dispatch "unshared:flagged" event ---\nfunction emitFlagged(body){\n try{window.dispatchEvent(new CustomEvent("unshared:flagged",{detail:{email:body.email||""}}))}catch(e){}\n}\n\n// Patch fetch\nvar oFetch=window.fetch;\nif(oFetch){window.fetch=function(){return oFetch.apply(this,arguments).then(function(r){if(r.status===403){try{var cl=r.clone();cl.json().then(function(b){if(b&&b.error==="account_flagged")emitFlagged(b)}).catch(function(){})}catch(e){}}return r})}}\n\n// Patch XMLHttpRequest\nvar oXSend=XMLHttpRequest.prototype.send;\nXMLHttpRequest.prototype.send=function(){var x=this;x.addEventListener("load",function(){if(x.status===403){try{var b=JSON.parse(x.responseText);if(b&&b.error==="account_flagged")emitFlagged(b)}catch(e){}}});return oXSend.apply(this,arguments)};\n\n}catch(e){}\n})();\n<\/script>`}function escapeJavaScript(e){return e.replace(/\\/g,"\\\\").replace(/"/g,'\\"').replace(/'/g,"\\'")}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Global rate-limit backoff for processUserEvent calls.
|
|
3
|
+
*
|
|
4
|
+
* When the Unshared API returns 429 with a Retry-After header,
|
|
5
|
+
* the middleware pauses processUserEvent calls for the specified duration.
|
|
6
|
+
* checkUser calls are not affected — enforcement takes priority.
|
|
7
|
+
*/
|
|
8
|
+
export declare class RateLimitBackoff {
|
|
9
|
+
private _resumeAtTimestamp;
|
|
10
|
+
/** Pause processUserEvent calls for the given duration. */
|
|
11
|
+
pause(durationMs: number): void;
|
|
12
|
+
/** Returns true if processUserEvent calls should be skipped. */
|
|
13
|
+
isPaused(): boolean;
|
|
14
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export class RateLimitBackoff{constructor(){this.t=0}pause(t){const s=Date.now()+t;s>this.t&&(this.t=s)}isPaused(){return Date.now()<this.t}}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { Response } from 'express';
|
|
2
|
+
/**
|
|
3
|
+
* Intercepts the response body by wrapping res.write() and res.end().
|
|
4
|
+
*
|
|
5
|
+
* Only buffers HTML responses (text/html). Non-HTML responses (JSON, images,
|
|
6
|
+
* CSS, JS, etc.) pass through to the original write/end without buffering,
|
|
7
|
+
* avoiding unnecessary memory usage on large payloads.
|
|
8
|
+
*
|
|
9
|
+
* When res.end() is called on an HTML response, invokes the `transform`
|
|
10
|
+
* callback with the complete body buffer and the Content-Type header.
|
|
11
|
+
* The transform can return modified content or null to pass through unchanged.
|
|
12
|
+
*/
|
|
13
|
+
export declare function interceptResponse(res: Response, transform: (body: Buffer, contentType: string | undefined) => Buffer | string | null, options?: {
|
|
14
|
+
preventCaching?: boolean;
|
|
15
|
+
}): void;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export function interceptResponse(n,t,f){const e=f?.preventCaching??!1,o=n.write.bind(n),u=n.end.bind(n),r=n.writeHead.bind(n),c=[];let i=!1,l=null;function s(){if(null!==l)return;const t=n.getHeader("content-type");null!=t&&(l=String(t).includes("text/html"),l||function(){n.write=o,n.end=u;for(const n of c)o(n);c.length=0}())}n.writeHead=function(t,...f){return s(),e&&l&&(n.setHeader("Cache-Control","no-store"),n.removeHeader("ETag"),n.removeHeader("Last-Modified")),r(t,...f)},n.write=function(n,t,f){if(s(),!1===l)return o(n,t,f);if(null!=n){const f=Buffer.isBuffer(n)?n:Buffer.from(n,"string"==typeof t?t:"utf8");c.push(f)}return"function"==typeof t&&t(null),"function"==typeof f&&f(null),!0},n.end=function(f,e,r){if(i)return n;if(i=!0,s(),!1===l)return u(f,e,r);if(null!=f){const n=Buffer.isBuffer(f)?f:Buffer.from(f,"string"==typeof e?e:"utf8");c.push(n)}const p=Buffer.concat(c),y=n.getHeader("content-type");let B;try{B=t(p,y)}catch{B=null}if(null!=B){const t=Buffer.isBuffer(B)?B:Buffer.from(B,"utf8");n.setHeader("Content-Length",t.length),n.removeHeader("Content-Encoding"),o(t)}else p.length>0&&o(p);const g="function"==typeof e?e:r;return g?u(g):u(),n}}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { Request, Response } from 'express';
|
|
2
|
+
import type { UnsharedLabsClient } from '../../client';
|
|
3
|
+
import type { VerdictCache } from '../verdict-cache';
|
|
4
|
+
import type { RateLimitBackoff } from '../rate-limit-backoff';
|
|
5
|
+
export interface SubmitFingerprintDependencies {
|
|
6
|
+
client: UnsharedLabsClient;
|
|
7
|
+
verdictCache: VerdictCache;
|
|
8
|
+
rateLimitBackoff: RateLimitBackoff;
|
|
9
|
+
resolveUserId?: (req: Request) => string | undefined;
|
|
10
|
+
resolveEmailAddress?: (req: Request) => string | undefined;
|
|
11
|
+
resolveSessionId?: (req: Request) => string | undefined;
|
|
12
|
+
resolveDeviceId?: (req: Request) => string | undefined;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Handles POST /__unshared/submit-fp
|
|
16
|
+
*
|
|
17
|
+
* Receives fingerprint data from the injected inline script and:
|
|
18
|
+
* 1. Forwards to Unshared API via client.submitFingerprintEvent() (fire-and-forget)
|
|
19
|
+
* 2. Calls processUserEvent with cache side-effect (fire-and-forget)
|
|
20
|
+
* 3. Sets __unshared_email HttpOnly cookie when email is resolved from body
|
|
21
|
+
*
|
|
22
|
+
* Always returns 200 (fire-and-forget from browser's perspective).
|
|
23
|
+
*/
|
|
24
|
+
export declare function handleSubmitFingerprint(dependencies: SubmitFingerprintDependencies): (req: Request, res: Response) => Promise<void>;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{isBot}from"../utils/is-bot";import{extractClientIp}from"../utils/client-ip";import{parseCookie}from"../utils/cookies";import{extractDeviceId}from"../utils/device-id";import{isSecureRequest}from"../utils/secure";export function handleSubmitFingerprint(e){return async(i,t)=>{try{const s=i.body??{},o={full_hash:s.hash??"",fingerprint_id:s.stable_hash??"",timestamp:s.collected_at??(new Date).toISOString(),isIncognito:s.is_incognito??!1,components:s.components??{},version:s.version??"inline-1.0.0"};let r,n,c;try{r=e.resolveUserId?e.resolveUserId(i):void 0}catch{}r=r??s.user_id??void 0;try{n=e.resolveEmailAddress?e.resolveEmailAddress(i):void 0}catch{}n=n??parseCookie(i,"__unshared_email")??s.email??void 0;try{c=e.resolveSessionId?e.resolveSessionId(i):void 0}catch{}c=c??s.session_id??parseCookie(i,"__unshared_sid");const a=extractClientIp(i),d=i.headers["user-agent"]??"";if(isBot(d))return void t.status(200).json({success:!0});const u=extractDeviceId(i,e.resolveDeviceId),p=o.fingerprint_id||void 0,l=isSecureRequest(i)?"; Secure":"",_=[];if(p&&!parseCookie(i,"__unshared_fingerprint_id")&&_.push(`__unshared_fingerprint_id=${encodeURIComponent(p)}; HttpOnly; Path=/; SameSite=Lax${l}`),n&&!parseCookie(i,"__unshared_email")&&_.push(`__unshared_email=${encodeURIComponent(n)}; HttpOnly; Path=/; SameSite=Lax${l}`),_.length>0){const e=t.getHeader("Set-Cookie");if(e){const i=Array.isArray(e)?[...e]:[String(e)];i.push(..._),t.setHeader("Set-Cookie",i)}else t.setHeader("Set-Cookie",_)}r&&e.client.submitFingerprintEvent(o,{userId:r,emailAddress:n,sessionHash:c,eventType:"auto_collect",ipAddress:a,userAgent:d}).catch(()=>{}),r&&n&&!e.rateLimitBackoff.isPaused()&&e.client.processUserEvent({eventType:"auto_collect",userId:r,emailAddress:n,ipAddress:a,deviceId:u,fingerprintId:p,sessionHash:c??"unknown",userAgent:d}).then(i=>{i.success&&i.data?.analysis&&e.verdictCache.update(r,{isFlagged:i.data.analysis.is_user_flagged}),!i.success&&i.error?.retryAfter&&e.rateLimitBackoff.pause(1e3*i.error.retryAfter)}).catch(()=>{}),t.status(200).json({success:!0})}catch{t.status(200).json({success:!0})}}}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { Request, Response } from 'express';
|
|
2
|
+
import type { UnsharedLabsClient } from '../../client';
|
|
3
|
+
import type { VerdictCache } from '../verdict-cache';
|
|
4
|
+
export interface VerificationDependencies {
|
|
5
|
+
client: UnsharedLabsClient;
|
|
6
|
+
verdictCache: VerdictCache;
|
|
7
|
+
resolveEmailAddress?: (req: Request) => string | undefined;
|
|
8
|
+
resolveDeviceId?: (req: Request) => string | undefined;
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* POST /__unshared/verify-trigger
|
|
12
|
+
* Triggers email verification. Called by the blocker overlay UI.
|
|
13
|
+
*
|
|
14
|
+
* Body: { email: string }
|
|
15
|
+
*
|
|
16
|
+
* The deviceId is resolved via extractDeviceId (same as the middleware).
|
|
17
|
+
*/
|
|
18
|
+
export declare function handleVerifyTrigger(dependencies: VerificationDependencies): (req: Request, res: Response) => Promise<void>;
|
|
19
|
+
/**
|
|
20
|
+
* POST /__unshared/verify
|
|
21
|
+
* Validates OTP code. Called by the blocker overlay UI.
|
|
22
|
+
*
|
|
23
|
+
* Body: { email: string, code: string }
|
|
24
|
+
*
|
|
25
|
+
* On successful verification, updates the verdict cache to mark
|
|
26
|
+
* the user as verified so subsequent requests pass through.
|
|
27
|
+
*/
|
|
28
|
+
export declare function handleVerify(dependencies: VerificationDependencies): (req: Request, res: Response) => Promise<void>;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{parseCookie}from"../utils/cookies";import{extractDeviceId}from"../utils/device-id";export function handleVerifyTrigger(e){return async(r,i)=>{try{const s=resolveEmail(r,r.body??{},e.resolveEmailAddress);if(!s)return void i.status(400).json({success:!1,error:{code:"VALIDATION_ERROR",message:"Email is required"}});const o=extractDeviceId(r,e.resolveDeviceId),c=parseCookie(r,"__unshared_fingerprint_id")||void 0,t=await e.client.triggerEmailVerification(s,o,{fingerprintId:c});t.success?i.status(200).json({success:!0,data:t.data}):i.status(200).json({success:!1,error:t.error??{code:"TRIGGER_FAILED",message:"Failed to send verification email"}})}catch{i.status(200).json({success:!1,error:{code:"INTERNAL_ERROR",message:"Failed to trigger verification"}})}}}export function handleVerify(e){return async(r,i)=>{try{const s=r.body??{},o=resolveEmail(r,s,e.resolveEmailAddress),c=s.code;if(!o||!c)return void i.status(400).json({success:!1,error:{code:"VALIDATION_ERROR",message:"Email and code are required"}});const t=extractDeviceId(r,e.resolveDeviceId),a=parseCookie(r,"__unshared_fingerprint_id")||void 0,n=await e.client.verify(o,t,c,{fingerprintId:a});if(n.success){const s=parseCookie(r,"__unshared_uid");s&&e.verdictCache.update(s,{isVerified:!0}),i.status(200).json({success:!0,data:{verified:!0}})}else i.status(200).json({success:!1,error:n.error??{code:"VERIFICATION_FAILED",message:"Verification failed"}})}catch{i.status(200).json({success:!1,error:{code:"INTERNAL_ERROR",message:"Verification failed"}})}}}function resolveEmail(e,r,i){if(i)try{const r=i(e);if(r)return r}catch{}const s=parseCookie(e,"__unshared_email");if(s)return s;const o=r.email;return"string"==typeof o&&o?o:void 0}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { Request } from 'express';
|
|
2
|
+
/**
|
|
3
|
+
* Extract the real client IP from proxy headers, falling back to req.ip.
|
|
4
|
+
* Checked in order: CF-Connecting-IP (Cloudflare) → X-Real-IP (nginx/ALB) → req.ip.
|
|
5
|
+
*/
|
|
6
|
+
export declare function extractClientIp(req: Request): string;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export function extractClientIp(t){const n=t.headers["cf-connecting-ip"];if("string"==typeof n&&n)return n;const r=t.headers["x-real-ip"];return"string"==typeof r&&r?r:t.ip??""}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/** Check if a Content-Type header value indicates HTML. */
|
|
2
|
+
export declare function isHtmlContentType(contentType: string | undefined): boolean;
|
|
3
|
+
/** Check if a Content-Type header value indicates JSON. */
|
|
4
|
+
export declare function isJsonContentType(contentType: string | undefined): boolean;
|
|
5
|
+
/** Check if a Content-Type indicates a static asset (images, fonts, etc). */
|
|
6
|
+
export declare function isStaticContentType(contentType: string | undefined): boolean;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export function isHtmlContentType(t){return!!t&&t.includes("text/html")}export function isJsonContentType(t){return!!t&&t.includes("application/json")}export function isStaticContentType(t){return!!t&&["image/","font/","audio/","video/","application/javascript","text/javascript","text/css","application/wasm"].some(n=>t.includes(n))}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export function parseCookie(e,o){const n=e.headers.cookie;if(!n)return;const t=n.match(new RegExp(`(?:^|; )${o}=([^;]*)`));return t?decodeURIComponent(t[1]):void 0}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{parseCookie}from"./cookies";export function extractDeviceId(o,r){if(r)try{const t=r(o);if(t)return t}catch{}const t=parseCookie(o,"__unshared_fp_id");if(t)return t;const e=o.headers["x-device-id"];return"string"==typeof e&&e?e:"unknown"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
const BOT_PATTERNS=["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","nessus","nikto","sqlmap","burp","zap","qualys","openvas","nmap","masscan","facebookexternalhit","twitterbot","linkedinbot","whatsapp","telegrambot","slackbot","discordbot","bot","crawl","spider","scrape","fetch","scan"],BOT_RE=new RegExp(BOT_PATTERNS.join("|"),"i");export function isBot(t){return!!t&&BOT_RE.test(t)}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export function isSecureRequest(e){return e.secure||"https"===e.headers["x-forwarded-proto"]}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
const STATIC_EXTENSIONS=new Set([".js",".mjs",".cjs",".css",".map",".png",".jpg",".jpeg",".gif",".svg",".ico",".webp",".avif",".woff",".woff2",".ttf",".otf",".eot",".mp3",".mp4",".webm",".ogg",".wasm",".xml",".txt",".pdf"]),STATIC_PATH_PREFIXES=["/static/","/assets/","/public/","/_next/","/__vite/","/favicon"];export function shouldSkipPath(t,f){if(f)for(const o of f)if(t.startsWith(o))return!0;const o=t.lastIndexOf(".");if(-1!==o){const f=t.slice(o).toLowerCase().split("?")[0];if(STATIC_EXTENSIONS.has(f))return!0}for(const f of STATIC_PATH_PREFIXES)if(t.startsWith(f))return!0;return!1}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
export interface Verdict {
|
|
2
|
+
isFlagged: boolean;
|
|
3
|
+
isVerified: boolean;
|
|
4
|
+
emailAddress: string;
|
|
5
|
+
sessionId: string;
|
|
6
|
+
cachedAt: number;
|
|
7
|
+
ttl: number;
|
|
8
|
+
}
|
|
9
|
+
export declare class VerdictCache {
|
|
10
|
+
private readonly _entries;
|
|
11
|
+
private readonly _activeRefreshes;
|
|
12
|
+
private readonly _defaultTtlMs;
|
|
13
|
+
private readonly _maxSize;
|
|
14
|
+
private _sweepTimer;
|
|
15
|
+
constructor(defaultTTL?: number, maxSize?: number);
|
|
16
|
+
get(userId: string): Verdict | undefined;
|
|
17
|
+
set(userId: string, verdict: Omit<Verdict, 'cachedAt' | 'ttl'>, ttl?: number): void;
|
|
18
|
+
/**
|
|
19
|
+
* Update an existing cache entry (e.g. from webhook).
|
|
20
|
+
* If the user is not in cache, creates a new entry.
|
|
21
|
+
*/
|
|
22
|
+
update(userId: string, partial: Partial<Pick<Verdict, 'isFlagged' | 'isVerified'>>): void;
|
|
23
|
+
delete(userId: string): void;
|
|
24
|
+
/**
|
|
25
|
+
* Returns true if the entry exists but is past its TTL.
|
|
26
|
+
* Stale entries are served while a background refresh happens.
|
|
27
|
+
*/
|
|
28
|
+
isStale(userId: string): boolean;
|
|
29
|
+
/**
|
|
30
|
+
* Returns true if a background refresh is already in flight for this user.
|
|
31
|
+
*/
|
|
32
|
+
isRefreshing(userId: string): boolean;
|
|
33
|
+
markRefreshing(userId: string): void;
|
|
34
|
+
clearRefreshing(userId: string): void;
|
|
35
|
+
/** Number of cached entries. */
|
|
36
|
+
get size(): number;
|
|
37
|
+
clear(): void;
|
|
38
|
+
/**
|
|
39
|
+
* Stops the periodic sweep timer. Call this when shutting down
|
|
40
|
+
* or when the middleware is no longer needed (e.g., in tests).
|
|
41
|
+
*/
|
|
42
|
+
destroy(): void;
|
|
43
|
+
/** Remove all entries that are past their TTL + a 2x grace period. */
|
|
44
|
+
private _sweep;
|
|
45
|
+
/** Evict the oldest entry by cachedAt to make room. */
|
|
46
|
+
private _evictOldest;
|
|
47
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
const DEFAULT_TTL_MS=6e4,DEFAULT_MAX_SIZE=1e4,SWEEP_INTERVAL_MS=3e5;export class VerdictCache{constructor(t=6e4,s=1e4){this.t=new Map,this.i=new Set,this.h=null,this.l=t,this.o=s,this.h=setInterval(()=>this.u(),3e5),this.h&&"function"==typeof this.h.unref&&this.h.unref()}get(t){return this.t.get(t)}set(t,s,i){!this.t.has(t)&&this.t.size>=this.o&&this._(),this.t.set(t,{...s,cachedAt:Date.now(),ttl:i??this.l})}update(t,s){const i=this.t.get(t);i?(void 0!==s.isFlagged&&(i.isFlagged=s.isFlagged),void 0!==s.isVerified&&(i.isVerified=s.isVerified),i.cachedAt=Date.now()):(this.t.size>=this.o&&this._(),this.t.set(t,{isFlagged:s.isFlagged??!1,isVerified:s.isVerified??!1,emailAddress:"",sessionId:"",cachedAt:Date.now(),ttl:this.l}))}delete(t){this.t.delete(t),this.i.delete(t)}isStale(t){const s=this.t.get(t);return!!s&&Date.now()-s.cachedAt>s.ttl}isRefreshing(t){return this.i.has(t)}markRefreshing(t){this.i.add(t)}clearRefreshing(t){this.i.delete(t)}get size(){return this.t.size}clear(){this.t.clear(),this.i.clear()}destroy(){this.h&&(clearInterval(this.h),this.h=null),this.clear()}u(){const t=Date.now();for(const[s,i]of this.t)t-i.cachedAt>2*i.ttl&&(this.t.delete(s),this.i.delete(s))}_(){let t=null,s=1/0;for(const[i,e]of this.t)e.cachedAt<s&&(s=e.cachedAt,t=i);t&&(this.t.delete(t),this.i.delete(t))}}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { Request, Response, NextFunction } from 'express';
|
|
1
|
+
import type { Request, Response, NextFunction, Application } from 'express';
|
|
2
2
|
import type { UnsharedLabsClient } from './client';
|
|
3
3
|
export interface MiddlewareOptions {
|
|
4
4
|
/** Override userId extractor. Falls back to req.body.user_id. */
|
|
@@ -7,6 +7,8 @@ export interface MiddlewareOptions {
|
|
|
7
7
|
eventTypeExtractor?: (req: Request) => string | undefined;
|
|
8
8
|
/** Override sessionId extractor. Falls back to X-Session-Id header, then req.body.session_id. */
|
|
9
9
|
sessionIdExtractor?: (req: Request) => string | undefined;
|
|
10
|
+
/** Override IP address extractor. Falls back to req.ip. */
|
|
11
|
+
ipAddressExtractor?: (req: Request) => string | undefined;
|
|
10
12
|
/** Default event type when none is extractable. @default "browser_event" */
|
|
11
13
|
defaultEventType?: string;
|
|
12
14
|
/**
|
|
@@ -14,7 +16,30 @@ export interface MiddlewareOptions {
|
|
|
14
16
|
* @default "/unshared"
|
|
15
17
|
*/
|
|
16
18
|
routePrefix?: string;
|
|
19
|
+
/**
|
|
20
|
+
* Allowed CORS origins for the fingerprint route.
|
|
21
|
+
* Use `"*"` to allow all origins, or pass a specific origin / array of origins.
|
|
22
|
+
* The middleware handles OPTIONS preflight automatically when this is set.
|
|
23
|
+
* @example corsOrigins: "https://app.example.com"
|
|
24
|
+
* @example corsOrigins: ["https://app.example.com", "https://staging.example.com"]
|
|
25
|
+
*/
|
|
26
|
+
corsOrigins?: string | string[];
|
|
17
27
|
}
|
|
28
|
+
/**
|
|
29
|
+
* Asserts that Express `trust proxy` is configured on the app.
|
|
30
|
+
* Call this once during application startup, before mounting any middleware.
|
|
31
|
+
*
|
|
32
|
+
* Throws synchronously if the setting is missing, killing the process before
|
|
33
|
+
* any requests are served.
|
|
34
|
+
*
|
|
35
|
+
* @example
|
|
36
|
+
* ```typescript
|
|
37
|
+
* assertTrustProxy(app); // throws at startup if not set
|
|
38
|
+
* app.use(express.json());
|
|
39
|
+
* app.use(createUnsharedMiddleware(client, options));
|
|
40
|
+
* ```
|
|
41
|
+
*/
|
|
42
|
+
export declare function assertTrustProxy(app: Application): void;
|
|
18
43
|
/**
|
|
19
44
|
* Creates an Express middleware that proxies browser fingerprint events to
|
|
20
45
|
* Unshared Labs. Mount this to handle the browser fingerprint route contract (§4 of spec).
|
|
@@ -25,10 +50,10 @@ export interface MiddlewareOptions {
|
|
|
25
50
|
* **Prerequisites:**
|
|
26
51
|
* - Mount `express.json()` (or equivalent body-parser) **before** this middleware,
|
|
27
52
|
* otherwise `req.body` will be undefined and every request will return 400.
|
|
28
|
-
* -
|
|
29
|
-
*
|
|
30
|
-
* -
|
|
31
|
-
*
|
|
53
|
+
* - For cross-origin frontends, pass `corsOrigins` instead of configuring CORS
|
|
54
|
+
* separately — the middleware handles OPTIONS preflight automatically.
|
|
55
|
+
* - `user_id` is automatically scrubbed from `req.body` after it is read, so
|
|
56
|
+
* downstream logging middleware will not capture plaintext PII.
|
|
32
57
|
*
|
|
33
58
|
* **Error contract:** Never returns 5xx to the browser. Upstream failures are
|
|
34
59
|
* returned as HTTP 200 with { success: false, error: { code: "UPSTREAM_ERROR" } }.
|
package/dist/esm/middleware.mjs
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export function createUnsharedMiddleware(e,r){const{userIdExtractor:s,eventTypeExtractor:t,sessionIdExtractor:
|
|
1
|
+
export function assertTrustProxy(e){if(!e.get("trust proxy"))throw new Error('[unshared-labs] Express "trust proxy" is not set. Add `app.set("trust proxy", 1)` before calling assertTrustProxy, otherwise req.ip will reflect the proxy\'s IP instead of the real client IP.')}export function createUnsharedMiddleware(e,r){const{userIdExtractor:s,eventTypeExtractor:t,sessionIdExtractor:o,ipAddressExtractor:i,defaultEventType:n="browser_event",routePrefix:c="/unshared",corsOrigins:d}=r??{},a=`${c}/submit-fingerprint-event`,l=d?Array.isArray(d)?d:[d]:null;let u=!1;return async(r,c,d)=>{if(!u&&(u=!0,r.app&&!r.app.get("trust proxy")))throw new Error('[unshared-labs] Express "trust proxy" is not set. Add `app.set("trust proxy", 1)` before mounting this middleware, otherwise req.ip will reflect the proxy\'s IP instead of the real client IP.');if(l&&r.path===a){const e=r.headers.origin??"",s=l.includes("*");if((s||l.includes(e))&&(c.setHeader("Access-Control-Allow-Origin",s?"*":e),c.setHeader("Access-Control-Allow-Methods","POST, OPTIONS"),c.setHeader("Access-Control-Allow-Headers","Content-Type, X-Idempotency-Key, X-Session-Id")),"OPTIONS"===r.method)return void c.status(204).end()}if("POST"===r.method&&r.path===a)try{const d=r.body??{};if(!d.hash||!d.stable_hash||!d.collected_at)return void c.status(400).json({success:!1,error:{code:"VALIDATION_ERROR",message:"Missing required fingerprint fields: hash, stable_hash, collected_at"}});if(!r.headers["x-session-id"])return void c.status(400).json({success:!1,error:{code:"VALIDATION_ERROR",message:"Missing required header: X-Session-Id"}});const a={full_hash:d.hash,fingerprint_id:d.stable_hash,timestamp:d.collected_at,isIncognito:d.is_incognito??!1,components:d.components??{},version:d.version??"unknown"};let l,u,p,f;try{l=(s?s(r):void 0)??d.user_id}catch{l=d.user_id}if(r.body&&"object"==typeof r.body&&"user_id"in r.body&&delete r.body.user_id,!l)return void c.status(400).json({success:!1,error:{code:"VALIDATION_ERROR",message:"Missing required field: user_id"}});try{u=(t?t(r):void 0)??d.event_type??n}catch{u=d.event_type??n}try{p=(o?o(r):void 0)??r.headers["x-session-id"]?.toString()??d.session_id}catch{p=r.headers["x-session-id"]?.toString()??d.session_id}try{f=(i?i(r):void 0)??r.ip}catch{f=r.ip}const h=await e.submitFingerprintEvent(a,{userId:l,sessionHash:p,eventType:u,ipAddress:f});if(!h.success)return void c.status(200).json({success:!1,error:{code:"UPSTREAM_ERROR",message:h.error?.message??"Upstream request failed"}});c.status(202).json({success:!0,data:h.data})}catch(e){c.status(200).json({success:!1,error:{code:"MIDDLEWARE_ERROR",message:e instanceof Error?e.message:"Middleware error"}})}else d()}}
|
package/dist/index.d.ts
CHANGED
|
@@ -1,2 +1,6 @@
|
|
|
1
1
|
export { UnsharedLabsClient } from './client';
|
|
2
|
-
export
|
|
2
|
+
export { createUnsharedMiddleware, assertTrustProxy } from './middleware';
|
|
3
|
+
export type { MiddlewareOptions } from './middleware';
|
|
4
|
+
export { unsharedBoundToUser, VerdictCache, } from './middleware/index';
|
|
5
|
+
export type { ProtectionConfig, Verdict } from './middleware/index';
|
|
6
|
+
export type { UnsharedLabsClientConfig, ApiResult, UnsharedLabsError, SubmitFingerprintOptions, SubmitFingerprintResult, ProcessUserEventParams, ProcessUserEventResult, CheckUserResult, TriggerEmailVerificationResult, VerifyResult, VerificationFlowStep, VerificationFlowConfigResult, } from './client';
|
package/dist/index.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
"use strict";Object.defineProperty(exports,"t",{value:!0}),exports.UnsharedLabsClient=void 0;var client_1=require("./client");Object.defineProperty(exports,"UnsharedLabsClient",{enumerable:!0,get:function(){return client_1.UnsharedLabsClient}});
|
|
1
|
+
"use strict";Object.defineProperty(exports,"t",{value:!0}),exports.VerdictCache=exports.unsharedBoundToUser=exports.assertTrustProxy=exports.createUnsharedMiddleware=exports.UnsharedLabsClient=void 0;var client_1=require("./client");Object.defineProperty(exports,"UnsharedLabsClient",{enumerable:!0,get:function(){return client_1.UnsharedLabsClient}});var middleware_1=require("./middleware");Object.defineProperty(exports,"createUnsharedMiddleware",{enumerable:!0,get:function(){return middleware_1.createUnsharedMiddleware}}),Object.defineProperty(exports,"assertTrustProxy",{enumerable:!0,get:function(){return middleware_1.assertTrustProxy}});var index_1=require("./middleware/index");Object.defineProperty(exports,"unsharedBoundToUser",{enumerable:!0,get:function(){return index_1.unsharedBoundToUser}}),Object.defineProperty(exports,"VerdictCache",{enumerable:!0,get:function(){return index_1.VerdictCache}});
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import type { Request, Response, NextFunction } from 'express';
|
|
2
|
+
import type { UnsharedLabsClient } from '../client';
|
|
3
|
+
import { VerdictCache } from './verdict-cache';
|
|
4
|
+
import type { Verdict } from './verdict-cache';
|
|
5
|
+
export interface ProtectionConfig {
|
|
6
|
+
/**
|
|
7
|
+
* Required. Resolves the current user's ID from the request.
|
|
8
|
+
* Return undefined for anonymous/logged-out visitors.
|
|
9
|
+
*/
|
|
10
|
+
userId: (req: Request) => string | undefined;
|
|
11
|
+
/**
|
|
12
|
+
* Resolves the current user's email address from the request.
|
|
13
|
+
* Required in Tier 2 (backend-only). Recommended in Tier 1.
|
|
14
|
+
* Falls back to HttpOnly cookie → req.body.email when not configured.
|
|
15
|
+
*/
|
|
16
|
+
emailAddress?: (req: Request) => string | undefined;
|
|
17
|
+
/** Route prefix for internal routes. @default "/__unshared" */
|
|
18
|
+
routePrefix?: string;
|
|
19
|
+
/** Allowed CORS origins for /__unshared/* routes. */
|
|
20
|
+
corsOrigins?: string | string[];
|
|
21
|
+
/** Verdict cache TTL in ms. @default 60000 */
|
|
22
|
+
cacheTTL?: number;
|
|
23
|
+
/** Paths to skip entirely (static assets, health checks). */
|
|
24
|
+
skipPaths?: string[];
|
|
25
|
+
/** Resolves a custom session ID. Falls back to __unshared_sid cookie. */
|
|
26
|
+
sessionId?: (req: Request) => string | undefined;
|
|
27
|
+
/**
|
|
28
|
+
* Resolves a device ID from the request.
|
|
29
|
+
* Falls back to __unshared_fp_id cookie → X-Device-Id header.
|
|
30
|
+
*/
|
|
31
|
+
deviceId?: (req: Request) => string | undefined;
|
|
32
|
+
/**
|
|
33
|
+
* Called when a flagged, unverified user makes a request.
|
|
34
|
+
* You own the response — block, redirect, or call next() to let it through.
|
|
35
|
+
*
|
|
36
|
+
* If not provided, flagged requests pass through (data collection only).
|
|
37
|
+
* Exceptions are caught and swallowed — the request passes through on error.
|
|
38
|
+
*/
|
|
39
|
+
onFlagged?: (context: {
|
|
40
|
+
userId: string;
|
|
41
|
+
emailAddress: string;
|
|
42
|
+
verdict: Verdict;
|
|
43
|
+
req: Request;
|
|
44
|
+
res: Response;
|
|
45
|
+
next: NextFunction;
|
|
46
|
+
}) => void;
|
|
47
|
+
}
|
|
48
|
+
export type { Verdict };
|
|
49
|
+
export { VerdictCache };
|
|
50
|
+
export declare function unsharedBoundToUser(client: UnsharedLabsClient, config: ProtectionConfig): (req: Request, res: Response, next: NextFunction) => void;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"use strict";Object.defineProperty(exports,"i",{value:!0}),exports.VerdictCache=void 0,exports.unsharedBoundToUser=unsharedBoundToUser;const fs_1=require("fs"),verdict_cache_1=require("./verdict-cache");Object.defineProperty(exports,"VerdictCache",{enumerable:!0,get:function(){return verdict_cache_1.VerdictCache}});const rate_limit_backoff_1=require("./rate-limit-backoff"),response_interceptor_1=require("./response-interceptor"),fingerprint_script_1=require("./injection/fingerprint-script"),submit_fp_1=require("./routes/submit-fp"),verify_1=require("./routes/verify"),content_type_1=require("./utils/content-type"),skip_paths_1=require("./utils/skip-paths"),is_bot_1=require("./utils/is-bot"),client_ip_1=require("./utils/client-ip"),cookies_1=require("./utils/cookies"),device_id_1=require("./utils/device-id"),secure_1=require("./utils/secure"),CHECK_USER_TIMEOUT_MS=500;function unsharedBoundToUser(e,r){if(!r.userId)throw new Error("[Unshared] userId resolver is required");if(!r.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:i,emailAddress:t,routePrefix:n="/__unshared",corsOrigins:s,cacheTTL:o=6e4,skipPaths:c,sessionId:d,deviceId:a,onFlagged:u}=r,l=new verdict_cache_1.VerdictCache(o),_=new rate_limit_backoff_1.RateLimitBackoff,f=Date.now().toString(36),p=(0,fingerprint_script_1.generateFingerprintScript)(n,f);let v="";try{const e=require.resolve("unshared-frontend-sdk/dist/index.umd.js");v=(0,fs_1.readFileSync)(e,"utf8")}catch{}const h=(0,submit_fp_1.handleSubmitFingerprint)({client:e,verdictCache:l,rateLimitBackoff:_,resolveUserId:i,resolveEmailAddress:t,resolveSessionId:d,resolveDeviceId:a}),m=(0,verify_1.handleVerifyTrigger)({client:e,verdictCache:l,resolveEmailAddress:t,resolveDeviceId:a}),I=(0,verify_1.handleVerify)({client:e,verdictCache:l,resolveEmailAddress:t,resolveDeviceId:a}),k=s?Array.isArray(s)?s:[s]:null,C=`${n}/fp.js`,g=`${n}/submit-fp`,y=`${n}/verify-trigger`,S=`${n}/verify`,A=`${n}/status`;return function(r,s,o){const f=r.path;if(f.startsWith(n+"/")){if(function(e,r){if(!k)return;const i=e.headers.origin??"",t=k.includes("*");(t||k.includes(i))&&(r.setHeader("Access-Control-Allow-Origin",t?"*":i),r.setHeader("Access-Control-Allow-Methods","POST, OPTIONS"),r.setHeader("Access-Control-Allow-Headers","Content-Type, X-Idempotency-Key, X-Session-Id, X-Device-Id"),r.setHeader("Access-Control-Allow-Credentials","true"))}(r,s),"OPTIONS"===r.method)return void s.status(204).end();if("GET"===r.method&&f===C)return s.setHeader("Content-Type","application/javascript"),s.setHeader("Cache-Control","public, max-age=3600"),void s.status(200).end(v);if("POST"===r.method&&f===g)return void h(r,s);if("POST"===r.method&&f===y)return void m(r,s);if("POST"===r.method&&f===S)return void I(r,s);if("GET"===r.method&&f===A){let e;try{e=i(r)}catch{}if(!e)return void s.status(200).json({status:"anonymous"});const n=resolveEmail(r,t),o=l.get(e);return void(o&&o.isFlagged&&!o.isVerified&&u&&n?s.status(403).json({error:"account_flagged",email:n}):s.status(200).json({status:"ok"}))}return void s.status(404).json({success:!1,error:{code:"NOT_FOUND",message:"Unknown route"}})}if((0,skip_paths_1.shouldSkipPath)(f,c))return void o();let T;try{T=i(r)}catch{}if(!T)return clearUserIdCookieIfPresent(r,s),clearEmailCookieIfPresent(r,s),interceptForInjection(r,s,p),void o();const E=resolveEmail(r,t);if(setUserIdCookie(r,s,T),E&&setEmailCookie(r,s,E),!E)return interceptForInjection(r,s,p),void o();const q=extractSessionId(r,d),x=(0,device_id_1.extractDeviceId)(r,a),P=extractFingerprintId(r),b=r.headers["user-agent"]??"",O=(0,client_ip_1.extractClientIp)(r);if((0,is_bot_1.isBot)(b))return void o();if("unknown"===q)return interceptForInjection(r,s,p),void o();_.isPaused()||dispatchUserEvent(e,l,_,{userId:T,emailAddress:E,sessionId:q,deviceId:x,fingerprintId:P,userAgent:b,ipAddress:O,eventType:`${r.method} ${r.path}`});const U=l.get(T);U?(l.isStale(T)&&!l.isRefreshing(T)&&(l.markRefreshing(T),fetchAndCacheVerdict(e,l,T,E,x,P,q).finally(()=>l.clearRefreshing(T))),applyVerdict(U,T,E,r,s,o,p,u)):fetchAndCacheVerdict(e,l,T,E,x,P,q).then(e=>{applyVerdict(e,T,E,r,s,o,p,u)}).catch(()=>{interceptForInjection(r,s,p),o()})}}function resolveEmail(e,r){if(r)try{const i=r(e);if(i)return i}catch{}const i=(0,cookies_1.parseCookie)(e,"__unshared_email");if(i)return i;const t=e.body?.email;return"string"==typeof t&&t?t:void 0}function applyVerdict(e,r,i,t,n,s,o,c){if(interceptForInjection(t,n,o),e.isFlagged&&!e.isVerified&&c)try{c({userId:r,emailAddress:i,verdict:e,req:t,res:n,next:s})}catch{s()}else s()}function interceptForInjection(e,r,i){delete e.headers["if-none-match"],delete e.headers["if-modified-since"],(0,response_interceptor_1.interceptResponse)(r,(e,r)=>{if(!(0,content_type_1.isHtmlContentType)(r))return null;const t=e.toString("utf8"),n=t.lastIndexOf("</body>");return-1===n?t+i:t.slice(0,n)+i+t.slice(n)},{preventCaching:!0})}function dispatchUserEvent(e,r,i,t){e.processUserEvent({eventType:t.eventType,userId:t.userId,emailAddress:t.emailAddress,ipAddress:t.ipAddress,deviceId:t.deviceId,fingerprintId:t.fingerprintId,sessionHash:t.sessionId,userAgent:t.userAgent}).then(e=>{e.success&&e.data?.analysis&&r.update(t.userId,{isFlagged:e.data.analysis.is_user_flagged}),!e.success&&e.error?.retryAfter&&i.pause(1e3*e.error.retryAfter)}).catch(()=>{})}async function fetchAndCacheVerdict(e,r,i,t,n,s,o){const c={};let d;n&&"unknown"!==n&&(c.deviceId=n),s&&(c.fingerprintId=s);const a=await Promise.race([e.checkUser(t,c),new Promise(e=>{d=setTimeout(()=>e(null),500)})]);if(clearTimeout(d),!a)return{isFlagged:!1,isVerified:!1,emailAddress:t,sessionId:o,cachedAt:0,ttl:0};const u=a.data?.is_user_flagged??!1;return r.set(i,{isFlagged:u,isVerified:!1,emailAddress:t,sessionId:o}),r.get(i)}function extractSessionId(e,r){if(r)try{const i=r(e);if(i)return i}catch{}return(0,cookies_1.parseCookie)(e,"__unshared_sid")??"unknown"}function extractFingerprintId(e){return(0,cookies_1.parseCookie)(e,"__unshared_fingerprint_id")||void 0}function appendSetCookie(e,r){const i=e.getHeader("Set-Cookie");if(i){const t=Array.isArray(i)?[...i]:[String(i)];t.push(r),e.setHeader("Set-Cookie",t)}else e.setHeader("Set-Cookie",r)}function setUserIdCookie(e,r,i){const t=(0,secure_1.isSecureRequest)(e)?"; Secure":"";appendSetCookie(r,`__unshared_uid=${encodeURIComponent(i)}; Path=/; SameSite=Lax${t}`)}function setEmailCookie(e,r,i){const t=(0,secure_1.isSecureRequest)(e)?"; Secure":"";appendSetCookie(r,`__unshared_email=${encodeURIComponent(i)}; HttpOnly; Path=/; SameSite=Lax${t}`)}function clearUserIdCookieIfPresent(e,r){(0,cookies_1.parseCookie)(e,"__unshared_uid")&&appendSetCookie(r,"__unshared_uid=; Path=/; SameSite=Lax; Max-Age=0"+((0,secure_1.isSecureRequest)(e)?"; Secure":""))}function clearEmailCookieIfPresent(e,r){(0,cookies_1.parseCookie)(e,"__unshared_email")&&appendSetCookie(r,"__unshared_email=; HttpOnly; Path=/; SameSite=Lax; Max-Age=0"+((0,secure_1.isSecureRequest)(e)?"; Secure":""))}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Generates an inline loader script that:
|
|
3
|
+
* 1. Loads the fingerprint SDK from /__unshared/fp.js
|
|
4
|
+
* 2. Collects a fingerprint and POSTs to /__unshared/submit-fp
|
|
5
|
+
* 3. Caches fingerprint in sessionStorage for reuse on SPA navigations
|
|
6
|
+
* 4. Patches History API to detect SPA route changes → re-submits fingerprint
|
|
7
|
+
* 5. Patches fetch/XHR to detect 403 account_flagged → dispatches "unshared:flagged" event
|
|
8
|
+
*
|
|
9
|
+
* The actual SDK UMD bundle is served by the middleware at /__unshared/fp.js.
|
|
10
|
+
*
|
|
11
|
+
* Event contract:
|
|
12
|
+
* window.addEventListener("unshared:flagged", (e) => {
|
|
13
|
+
* e.detail.email — the flagged user's email (from 403 response body)
|
|
14
|
+
* });
|
|
15
|
+
*/
|
|
16
|
+
export declare function generateFingerprintScript(routePrefix: string, version?: string): string;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"use strict";function generateFingerprintScript(e,t){const n=t?`?v=${escapeJavaScript(t)}`:"";return`<script>\n(function(){\ntry{\nvar pfx="${escapeJavaScript(e)}";\nvar SS_FP="__unshared_fp";\n\n// --- Helpers ---\nfunction gC(n){var m=document.cookie.match(new RegExp("(?:^|; )"+n+"=([^;]*)"));return m?decodeURIComponent(m[1]):null}\nfunction sC(n,v,d){var e="";if(d){var dt=new Date();dt.setTime(dt.getTime()+d*864e5);e="; expires="+dt.toUTCString()}document.cookie=n+"="+encodeURIComponent(v)+e+"; path=/; SameSite=Lax"}\nfunction uuid(){return(typeof crypto!=="undefined"&&crypto.randomUUID)?crypto.randomUUID():("xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g,function(c){var r=Math.random()*16|0;return(c==="x"?r:r&0x3|0x8).toString(16)}))}\n\n// --- Session + device IDs ---\nvar sid=gC("__unshared_sid");\nif(!sid){sid=uuid();sC("__unshared_sid",sid,365)}\nvar did="";\ntry{did=localStorage.getItem("__unshared_device_id")||"";if(!did){did=uuid();localStorage.setItem("__unshared_device_id",did)}}catch(e){did=did||uuid()}\nsC("__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,evType){\n var uid=gC("__unshared_uid");\n if(!uid)return;\n var body={hash:fp.full_hash,stable_hash:fp.fingerprint_id,collected_at:fp.timestamp,is_incognito:fp.isIncognito,components:fp.components,version:fp.version,session_id:sid,user_id:uid,event_type:evType||"page_load"};\n var xhr=new XMLHttpRequest();\n xhr.open("POST",pfx+"/submit-fp",true);\n xhr.setRequestHeader("Content-Type","application/json");\n xhr.setRequestHeader("X-Session-Id",sid);\n xhr.setRequestHeader("X-Device-Id",did);\n xhr.send(JSON.stringify(body));\n}\n\n// --- Collect fingerprint (loads fp.js if needed) then submit ---\nvar fpReady=false;\nfunction collectAndSubmit(evType){\n var uid=gC("__unshared_uid");\n if(!uid)return;\n var cached=getFP();\n if(cached){submitFP(cached,evType);return}\n if(!fpReady)return;\n try{\n var c=new UnsharedLabsBrowser.UnsharedLabsBrowser({baseUrl:""});\n c.collect({exclude:["timing","navigatorConnection","speech"]}).then(function(fp){setFP(fp);submitFP(fp,evType)});\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;\nif(getFP()&&gC("__unshared_uid")){submitFP(getFP(),"page_load");pageLoadSubmitted=true;deferredCheck()}\nvar s=document.createElement("script");\ns.src=pfx+"/fp.js${n}";\ns.onload=function(){fpReady=true;if(!pageLoadSubmitted){collectAndSubmit("page_load");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// Uses the patched fetch so the 403 interceptor picks up the response.\nfunction deferredCheck(){\n var uid=gC("__unshared_uid");\n if(!uid)return;\n setTimeout(function(){\n try{fetch(pfx+"/status",{method:"GET",credentials:"same-origin"}).catch(function(){})}catch(e){}\n },5000);\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("route_change")}catch(e){}};\nhistory.replaceState=function(){oReplace.apply(this,arguments);try{collectAndSubmit("route_change")}catch(e){}};\nwindow.addEventListener("popstate",function(){try{collectAndSubmit("route_change")}catch(e){}});\n\n// --- 403 interception: dispatch "unshared:flagged" event ---\nfunction emitFlagged(body){\n try{window.dispatchEvent(new CustomEvent("unshared:flagged",{detail:{email:body.email||""}}))}catch(e){}\n}\n\n// Patch fetch\nvar oFetch=window.fetch;\nif(oFetch){window.fetch=function(){return oFetch.apply(this,arguments).then(function(r){if(r.status===403){try{var cl=r.clone();cl.json().then(function(b){if(b&&b.error==="account_flagged")emitFlagged(b)}).catch(function(){})}catch(e){}}return r})}}\n\n// Patch XMLHttpRequest\nvar oXSend=XMLHttpRequest.prototype.send;\nXMLHttpRequest.prototype.send=function(){var x=this;x.addEventListener("load",function(){if(x.status===403){try{var b=JSON.parse(x.responseText);if(b&&b.error==="account_flagged")emitFlagged(b)}catch(e){}}});return oXSend.apply(this,arguments)};\n\n}catch(e){}\n})();\n<\/script>`}function escapeJavaScript(e){return e.replace(/\\/g,"\\\\").replace(/"/g,'\\"').replace(/'/g,"\\'")}Object.defineProperty(exports,"t",{value:!0}),exports.generateFingerprintScript=generateFingerprintScript;
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Global rate-limit backoff for processUserEvent calls.
|
|
3
|
+
*
|
|
4
|
+
* When the Unshared API returns 429 with a Retry-After header,
|
|
5
|
+
* the middleware pauses processUserEvent calls for the specified duration.
|
|
6
|
+
* checkUser calls are not affected — enforcement takes priority.
|
|
7
|
+
*/
|
|
8
|
+
export declare class RateLimitBackoff {
|
|
9
|
+
private _resumeAtTimestamp;
|
|
10
|
+
/** Pause processUserEvent calls for the given duration. */
|
|
11
|
+
pause(durationMs: number): void;
|
|
12
|
+
/** Returns true if processUserEvent calls should be skipped. */
|
|
13
|
+
isPaused(): boolean;
|
|
14
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"use strict";Object.defineProperty(exports,"t",{value:!0}),exports.RateLimitBackoff=void 0;class RateLimitBackoff{constructor(){this.i=0}pause(t){const s=Date.now()+t;s>this.i&&(this.i=s)}isPaused(){return Date.now()<this.i}}exports.RateLimitBackoff=RateLimitBackoff;
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { Response } from 'express';
|
|
2
|
+
/**
|
|
3
|
+
* Intercepts the response body by wrapping res.write() and res.end().
|
|
4
|
+
*
|
|
5
|
+
* Only buffers HTML responses (text/html). Non-HTML responses (JSON, images,
|
|
6
|
+
* CSS, JS, etc.) pass through to the original write/end without buffering,
|
|
7
|
+
* avoiding unnecessary memory usage on large payloads.
|
|
8
|
+
*
|
|
9
|
+
* When res.end() is called on an HTML response, invokes the `transform`
|
|
10
|
+
* callback with the complete body buffer and the Content-Type header.
|
|
11
|
+
* The transform can return modified content or null to pass through unchanged.
|
|
12
|
+
*/
|
|
13
|
+
export declare function interceptResponse(res: Response, transform: (body: Buffer, contentType: string | undefined) => Buffer | string | null, options?: {
|
|
14
|
+
preventCaching?: boolean;
|
|
15
|
+
}): void;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"use strict";function interceptResponse(t,n,e){const f=e?.preventCaching??!1,o=t.write.bind(t),u=t.end.bind(t),r=t.writeHead.bind(t),c=[];let i=!1,l=null;function s(){if(null!==l)return;const n=t.getHeader("content-type");null!=n&&(l=String(n).includes("text/html"),l||function(){t.write=o,t.end=u;for(const t of c)o(t);c.length=0}())}t.writeHead=function(n,...e){return s(),f&&l&&(t.setHeader("Cache-Control","no-store"),t.removeHeader("ETag"),t.removeHeader("Last-Modified")),r(n,...e)},t.write=function(t,n,e){if(s(),!1===l)return o(t,n,e);if(null!=t){const e=Buffer.isBuffer(t)?t:Buffer.from(t,"string"==typeof n?n:"utf8");c.push(e)}return"function"==typeof n&&n(null),"function"==typeof e&&e(null),!0},t.end=function(e,f,r){if(i)return t;if(i=!0,s(),!1===l)return u(e,f,r);if(null!=e){const t=Buffer.isBuffer(e)?e:Buffer.from(e,"string"==typeof f?f:"utf8");c.push(t)}const p=Buffer.concat(c),y=t.getHeader("content-type");let B;try{B=n(p,y)}catch{B=null}if(null!=B){const n=Buffer.isBuffer(B)?B:Buffer.from(B,"utf8");t.setHeader("Content-Length",n.length),t.removeHeader("Content-Encoding"),o(n)}else p.length>0&&o(p);const g="function"==typeof f?f:r;return g?u(g):u(),t}}Object.defineProperty(exports,"t",{value:!0}),exports.interceptResponse=interceptResponse;
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { Request, Response } from 'express';
|
|
2
|
+
import type { UnsharedLabsClient } from '../../client';
|
|
3
|
+
import type { VerdictCache } from '../verdict-cache';
|
|
4
|
+
import type { RateLimitBackoff } from '../rate-limit-backoff';
|
|
5
|
+
export interface SubmitFingerprintDependencies {
|
|
6
|
+
client: UnsharedLabsClient;
|
|
7
|
+
verdictCache: VerdictCache;
|
|
8
|
+
rateLimitBackoff: RateLimitBackoff;
|
|
9
|
+
resolveUserId?: (req: Request) => string | undefined;
|
|
10
|
+
resolveEmailAddress?: (req: Request) => string | undefined;
|
|
11
|
+
resolveSessionId?: (req: Request) => string | undefined;
|
|
12
|
+
resolveDeviceId?: (req: Request) => string | undefined;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Handles POST /__unshared/submit-fp
|
|
16
|
+
*
|
|
17
|
+
* Receives fingerprint data from the injected inline script and:
|
|
18
|
+
* 1. Forwards to Unshared API via client.submitFingerprintEvent() (fire-and-forget)
|
|
19
|
+
* 2. Calls processUserEvent with cache side-effect (fire-and-forget)
|
|
20
|
+
* 3. Sets __unshared_email HttpOnly cookie when email is resolved from body
|
|
21
|
+
*
|
|
22
|
+
* Always returns 200 (fire-and-forget from browser's perspective).
|
|
23
|
+
*/
|
|
24
|
+
export declare function handleSubmitFingerprint(dependencies: SubmitFingerprintDependencies): (req: Request, res: Response) => Promise<void>;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"use strict";Object.defineProperty(exports,"i",{value:!0}),exports.handleSubmitFingerprint=handleSubmitFingerprint;const is_bot_1=require("../utils/is-bot"),client_ip_1=require("../utils/client-ip"),cookies_1=require("../utils/cookies"),device_id_1=require("../utils/device-id"),secure_1=require("../utils/secure");function handleSubmitFingerprint(e){return async(i,s)=>{try{const t=i.body??{},n={full_hash:t.hash??"",fingerprint_id:t.stable_hash??"",timestamp:t.collected_at??(new Date).toISOString(),isIncognito:t.is_incognito??!1,components:t.components??{},version:t.version??"inline-1.0.0"};let o,r,c;try{o=e.resolveUserId?e.resolveUserId(i):void 0}catch{}o=o??t.user_id??void 0;try{r=e.resolveEmailAddress?e.resolveEmailAddress(i):void 0}catch{}r=r??(0,cookies_1.parseCookie)(i,"__unshared_email")??t.email??void 0;try{c=e.resolveSessionId?e.resolveSessionId(i):void 0}catch{}c=c??t.session_id??(0,cookies_1.parseCookie)(i,"__unshared_sid");const _=(0,client_ip_1.extractClientIp)(i),d=i.headers["user-agent"]??"";if((0,is_bot_1.isBot)(d))return void s.status(200).json({success:!0});const u=(0,device_id_1.extractDeviceId)(i,e.resolveDeviceId),a=n.fingerprint_id||void 0,l=(0,secure_1.isSecureRequest)(i)?"; Secure":"",p=[];if(a&&!(0,cookies_1.parseCookie)(i,"__unshared_fingerprint_id")&&p.push(`__unshared_fingerprint_id=${encodeURIComponent(a)}; HttpOnly; Path=/; SameSite=Lax${l}`),r&&!(0,cookies_1.parseCookie)(i,"__unshared_email")&&p.push(`__unshared_email=${encodeURIComponent(r)}; HttpOnly; Path=/; SameSite=Lax${l}`),p.length>0){const e=s.getHeader("Set-Cookie");if(e){const i=Array.isArray(e)?[...e]:[String(e)];i.push(...p),s.setHeader("Set-Cookie",i)}else s.setHeader("Set-Cookie",p)}o&&e.client.submitFingerprintEvent(n,{userId:o,emailAddress:r,sessionHash:c,eventType:"auto_collect",ipAddress:_,userAgent:d}).catch(()=>{}),o&&r&&!e.rateLimitBackoff.isPaused()&&e.client.processUserEvent({eventType:"auto_collect",userId:o,emailAddress:r,ipAddress:_,deviceId:u,fingerprintId:a,sessionHash:c??"unknown",userAgent:d}).then(i=>{i.success&&i.data?.analysis&&e.verdictCache.update(o,{isFlagged:i.data.analysis.is_user_flagged}),!i.success&&i.error?.retryAfter&&e.rateLimitBackoff.pause(1e3*i.error.retryAfter)}).catch(()=>{}),s.status(200).json({success:!0})}catch{s.status(200).json({success:!0})}}}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { Request, Response } from 'express';
|
|
2
|
+
import type { UnsharedLabsClient } from '../../client';
|
|
3
|
+
import type { VerdictCache } from '../verdict-cache';
|
|
4
|
+
export interface VerificationDependencies {
|
|
5
|
+
client: UnsharedLabsClient;
|
|
6
|
+
verdictCache: VerdictCache;
|
|
7
|
+
resolveEmailAddress?: (req: Request) => string | undefined;
|
|
8
|
+
resolveDeviceId?: (req: Request) => string | undefined;
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* POST /__unshared/verify-trigger
|
|
12
|
+
* Triggers email verification. Called by the blocker overlay UI.
|
|
13
|
+
*
|
|
14
|
+
* Body: { email: string }
|
|
15
|
+
*
|
|
16
|
+
* The deviceId is resolved via extractDeviceId (same as the middleware).
|
|
17
|
+
*/
|
|
18
|
+
export declare function handleVerifyTrigger(dependencies: VerificationDependencies): (req: Request, res: Response) => Promise<void>;
|
|
19
|
+
/**
|
|
20
|
+
* POST /__unshared/verify
|
|
21
|
+
* Validates OTP code. Called by the blocker overlay UI.
|
|
22
|
+
*
|
|
23
|
+
* Body: { email: string, code: string }
|
|
24
|
+
*
|
|
25
|
+
* On successful verification, updates the verdict cache to mark
|
|
26
|
+
* the user as verified so subsequent requests pass through.
|
|
27
|
+
*/
|
|
28
|
+
export declare function handleVerify(dependencies: VerificationDependencies): (req: Request, res: Response) => Promise<void>;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"use strict";Object.defineProperty(exports,"i",{value:!0}),exports.handleVerifyTrigger=handleVerifyTrigger,exports.handleVerify=handleVerify;const cookies_1=require("../utils/cookies"),device_id_1=require("../utils/device-id");function handleVerifyTrigger(e){return async(i,r)=>{try{const s=resolveEmail(i,i.body??{},e.resolveEmailAddress);if(!s)return void r.status(400).json({success:!1,error:{code:"VALIDATION_ERROR",message:"Email is required"}});const c=(0,device_id_1.extractDeviceId)(i,e.resolveDeviceId),o=(0,cookies_1.parseCookie)(i,"__unshared_fingerprint_id")||void 0,t=await e.client.triggerEmailVerification(s,c,{fingerprintId:o});t.success?r.status(200).json({success:!0,data:t.data}):r.status(200).json({success:!1,error:t.error??{code:"TRIGGER_FAILED",message:"Failed to send verification email"}})}catch{r.status(200).json({success:!1,error:{code:"INTERNAL_ERROR",message:"Failed to trigger verification"}})}}}function handleVerify(e){return async(i,r)=>{try{const s=i.body??{},c=resolveEmail(i,s,e.resolveEmailAddress),o=s.code;if(!c||!o)return void r.status(400).json({success:!1,error:{code:"VALIDATION_ERROR",message:"Email and code are required"}});const t=(0,device_id_1.extractDeviceId)(i,e.resolveDeviceId),n=(0,cookies_1.parseCookie)(i,"__unshared_fingerprint_id")||void 0,d=await e.client.verify(c,t,o,{fingerprintId:n});if(d.success){const s=(0,cookies_1.parseCookie)(i,"__unshared_uid");s&&e.verdictCache.update(s,{isVerified:!0}),r.status(200).json({success:!0,data:{verified:!0}})}else r.status(200).json({success:!1,error:d.error??{code:"VERIFICATION_FAILED",message:"Verification failed"}})}catch{r.status(200).json({success:!1,error:{code:"INTERNAL_ERROR",message:"Verification failed"}})}}}function resolveEmail(e,i,r){if(r)try{const i=r(e);if(i)return i}catch{}const s=(0,cookies_1.parseCookie)(e,"__unshared_email");if(s)return s;const c=i.email;return"string"==typeof c&&c?c:void 0}
|