unshared-clientjs-sdk 2.0.0-rc.9 → 2.0.1

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.
Files changed (104) hide show
  1. package/README.md +54 -16
  2. package/dist/client.d.ts +43 -35
  3. package/dist/client.js +1 -1
  4. package/dist/esm/client.d.mts +43 -35
  5. package/dist/esm/client.mjs +1 -1
  6. package/dist/esm/index.d.mts +5 -3
  7. package/dist/esm/index.mjs +1 -1
  8. package/dist/esm/middleware/dispatch-dedupe.d.mts +11 -0
  9. package/dist/esm/middleware/dispatch-dedupe.mjs +1 -0
  10. package/dist/esm/middleware/index.d.mts +32 -12
  11. package/dist/esm/middleware/index.mjs +1 -1
  12. package/dist/esm/middleware/injection/fingerprint-script.d.mts +11 -5
  13. package/dist/esm/middleware/injection/fingerprint-script.mjs +1 -1
  14. package/dist/esm/middleware/response-interceptor.d.mts +10 -8
  15. package/dist/esm/middleware/response-interceptor.mjs +1 -1
  16. package/dist/esm/middleware/routes/submit-fp.d.mts +16 -9
  17. package/dist/esm/middleware/routes/submit-fp.mjs +1 -1
  18. package/dist/esm/middleware/routes/verify.d.mts +13 -8
  19. package/dist/esm/middleware/routes/verify.mjs +1 -1
  20. package/dist/esm/middleware/utils/client-ip.d.mts +6 -0
  21. package/dist/esm/middleware/utils/client-ip.mjs +1 -0
  22. package/dist/esm/middleware/utils/cookies.d.mts +6 -0
  23. package/dist/esm/middleware/utils/cookies.mjs +1 -0
  24. package/dist/esm/middleware/utils/device-id.d.mts +19 -0
  25. package/dist/esm/middleware/utils/device-id.mjs +1 -0
  26. package/dist/esm/middleware/utils/flagged-response.d.mts +5 -0
  27. package/dist/esm/middleware/utils/flagged-response.mjs +1 -0
  28. package/dist/esm/middleware/utils/http-helpers.d.mts +21 -0
  29. package/dist/esm/middleware/utils/http-helpers.mjs +1 -0
  30. package/dist/esm/middleware/utils/include-path.d.mts +6 -0
  31. package/dist/esm/middleware/utils/include-path.mjs +1 -0
  32. package/dist/esm/middleware/utils/is-bot.d.mts +5 -0
  33. package/dist/esm/middleware/utils/is-bot.mjs +1 -0
  34. package/dist/esm/middleware/utils/secure.d.mts +3 -0
  35. package/dist/esm/middleware/utils/secure.mjs +1 -0
  36. package/dist/esm/middleware/utils/sentinel-user-id.d.mts +10 -0
  37. package/dist/esm/middleware/utils/sentinel-user-id.mjs +1 -0
  38. package/dist/esm/middleware/utils/skip-paths.mjs +1 -1
  39. package/dist/esm/middleware/verdict-cache.d.mts +12 -1
  40. package/dist/esm/middleware/verdict-cache.mjs +1 -1
  41. package/dist/esm/middleware.d.mts +13 -10
  42. package/dist/esm/middleware.mjs +1 -1
  43. package/dist/esm/types.d.mts +44 -0
  44. package/dist/esm/types.mjs +1 -0
  45. package/dist/esm/web/index.d.mts +17 -0
  46. package/dist/esm/web/index.mjs +1 -0
  47. package/dist/esm/web/protection-handler.d.mts +28 -0
  48. package/dist/esm/web/protection-handler.mjs +1 -0
  49. package/dist/esm/web/submit-handler.d.mts +27 -0
  50. package/dist/esm/web/submit-handler.mjs +1 -0
  51. package/dist/esm/web/types.d.mts +110 -0
  52. package/dist/esm/web/types.mjs +1 -0
  53. package/dist/esm/web/web-helpers.d.mts +55 -0
  54. package/dist/esm/web/web-helpers.mjs +1 -0
  55. package/dist/index.d.ts +5 -3
  56. package/dist/index.js +1 -1
  57. package/dist/middleware/dispatch-dedupe.d.ts +11 -0
  58. package/dist/middleware/dispatch-dedupe.js +1 -0
  59. package/dist/middleware/index.d.ts +32 -12
  60. package/dist/middleware/index.js +1 -1
  61. package/dist/middleware/injection/fingerprint-script.d.ts +11 -5
  62. package/dist/middleware/injection/fingerprint-script.js +1 -1
  63. package/dist/middleware/response-interceptor.d.ts +10 -8
  64. package/dist/middleware/response-interceptor.js +1 -1
  65. package/dist/middleware/routes/submit-fp.d.ts +16 -9
  66. package/dist/middleware/routes/submit-fp.js +1 -1
  67. package/dist/middleware/routes/verify.d.ts +13 -8
  68. package/dist/middleware/routes/verify.js +1 -1
  69. package/dist/middleware/utils/client-ip.d.ts +6 -0
  70. package/dist/middleware/utils/client-ip.js +1 -0
  71. package/dist/middleware/utils/cookies.d.ts +6 -0
  72. package/dist/middleware/utils/cookies.js +1 -0
  73. package/dist/middleware/utils/device-id.d.ts +19 -0
  74. package/dist/middleware/utils/device-id.js +1 -0
  75. package/dist/middleware/utils/flagged-response.d.ts +5 -0
  76. package/dist/middleware/utils/flagged-response.js +1 -0
  77. package/dist/middleware/utils/http-helpers.d.ts +21 -0
  78. package/dist/middleware/utils/http-helpers.js +1 -0
  79. package/dist/middleware/utils/include-path.d.ts +6 -0
  80. package/dist/middleware/utils/include-path.js +1 -0
  81. package/dist/middleware/utils/is-bot.d.ts +5 -0
  82. package/dist/middleware/utils/is-bot.js +1 -0
  83. package/dist/middleware/utils/secure.d.ts +3 -0
  84. package/dist/middleware/utils/secure.js +1 -0
  85. package/dist/middleware/utils/sentinel-user-id.d.ts +10 -0
  86. package/dist/middleware/utils/sentinel-user-id.js +1 -0
  87. package/dist/middleware/utils/skip-paths.js +1 -1
  88. package/dist/middleware/verdict-cache.d.ts +12 -1
  89. package/dist/middleware/verdict-cache.js +1 -1
  90. package/dist/middleware.d.ts +13 -10
  91. package/dist/middleware.js +1 -1
  92. package/dist/types.d.ts +44 -0
  93. package/dist/types.js +1 -0
  94. package/dist/web/index.d.ts +17 -0
  95. package/dist/web/index.js +1 -0
  96. package/dist/web/protection-handler.d.ts +28 -0
  97. package/dist/web/protection-handler.js +1 -0
  98. package/dist/web/submit-handler.d.ts +27 -0
  99. package/dist/web/submit-handler.js +1 -0
  100. package/dist/web/types.d.ts +110 -0
  101. package/dist/web/types.js +1 -0
  102. package/dist/web/web-helpers.d.ts +55 -0
  103. package/dist/web/web-helpers.js +1 -0
  104. package/package.json +7 -10
@@ -1 +1 @@
1
- export function generateFingerprintScript(e,n){const t=n?`?v=${escapeJavaScript(n)}`:"";return`<script>\n(function(){\ntry{\nvar pfx="${escapeJavaScript(e)}";\n\n// Session cookie helpers\nfunction getCookie(n){var m=document.cookie.match(new RegExp("(?:^|; )"+n+"=([^;]*)"));return m?decodeURIComponent(m[1]):null}\nfunction setCookie(n,v,d){var e="";if(d){var dt=new Date();dt.setTime(dt.getTime()+d*864e5);e="; expires="+dt.toUTCString()}document.cookie=n+"="+encodeURIComponent(v)+e+"; path=/; SameSite=Lax"}\n\n// UUID helper\nfunction uuid(){return(typeof crypto!=="undefined"&&crypto.randomUUID)?crypto.randomUUID():("xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g,function(c){var r=Math.random()*16|0;return(c==="x"?r:r&0x3|0x8).toString(16)}))}\n\n// Ensure session ID cookie exists\nvar sid=getCookie("__unshared_sid");\nif(!sid){sid=uuid();setCookie("__unshared_sid",sid,365)}\n\n// Persistent device ID (survives across sessions via localStorage)\nvar did="";\ntry{did=localStorage.getItem("__unshared_device_id")||"";if(!did){did=uuid();localStorage.setItem("__unshared_device_id",did)}}catch(e){did=did||uuid()}\n\nvar uid=getCookie("__unshared_uid")||"";\n\n// Collect on every page load if a userId is present\nif(uid){\n var s=document.createElement("script");\n s.src=pfx+"/fp.js${t}";\n s.onload=function(){\n try{\n var client=new UnsharedLabsBrowser.UnsharedLabsBrowser({baseUrl:""});\n client.collect({exclude:["timing","navigatorConnection"]}).then(function(fp){\n var body={\n hash:fp.full_hash,\n stable_hash:fp.fingerprint_id,\n collected_at:fp.timestamp,\n is_incognito:fp.isIncognito,\n components:fp.components,\n version:fp.version,\n session_id:sid,\n user_id:uid\n };\n var xhr=new XMLHttpRequest();\n xhr.open("POST",pfx+"/submit-fp",true);\n xhr.setRequestHeader("Content-Type","application/json");\n xhr.setRequestHeader("X-Session-Id",sid);\n xhr.setRequestHeader("X-Device-Id",did);\n xhr.send(JSON.stringify(body));\n });\n }catch(e){}\n };\n document.head.appendChild(s);\n}\n}catch(e){}\n})();\n<\/script>`}function escapeJavaScript(e){return e.replace(/\\/g,"\\\\").replace(/"/g,'\\"').replace(/'/g,"\\'")}
1
+ export function generateFingerprintScript(e,t){const n=t?`?v=${escapeJavaScript(t)}`:"";return`<script>\n(function(){\ntry{\n// --- Bot drop (defense-in-depth) ---\n// Must be the first statement: we do not want to write cookies, localStorage,\n// session IDs, or any network requests for known-bot traffic. Mirrors the\n// regex in unshared-fingerprint-lib/src/detect/bot.ts and Node middleware\n// utils/is-bot.ts. Keep all three in sync.\nvar BOT_RE=/googlebot|bingbot|slurp|baiduspider|duckduckbot|yandex|sogou|exabot|ia_archiver|curl|wget|python-requests|python-urllib|axios|node-fetch|go-http-client|java\\/|libwww-perl|okhttp|apache-httpclient|http_request|httpie|headlesschrome|phantomjs|puppeteer|playwright|cypress|selenium|webdriver|electron|jsdom|vercel-screenshot|screenshot|prerender|lighthouse|chrome-lighthouse|pagespeed|gtmetrix|pingdom|nessus|nikto|sqlmap|burp|zap|qualys|openvas|nmap|masscan|facebookexternalhit|twitterbot|linkedinbot|whatsapp|telegrambot|slackbot|discordbot|bot|crawl|spider|scrape|fetch|scan/i;\nif(typeof navigator!=="undefined"&&navigator.userAgent&&BOT_RE.test(navigator.userAgent))return;\n\nvar pfx="${escapeJavaScript(e)}";\nvar SS_FP="__unshared_fp";\nvar SS_LAST_SUBMIT="__unshared_last_submit";\n\n// Dedup state: skip submit if (user_id + URL) matches last submission.\n// Modern SPAs (Next.js App Router, React Router, etc.) call replaceState\n// 3-5 times during hydration with the same URL — without this guard,\n// each call generates a redundant FP row with identical stable_hash.\n// Persisted to sessionStorage so hard reloads and framework double-boots\n// inside the same tab still dedupe (the in-memory value resets on reload).\nvar lastSubmitKey="";\ntry{lastSubmitKey=sessionStorage.getItem(SS_LAST_SUBMIT)||""}catch(e){}\n\n// --- Helpers ---\nfunction gC(n){var m=document.cookie.match(new RegExp("(?:^|; )"+n+"=([^;]*)"));return m?decodeURIComponent(m[1]):null}\nfunction sC(n,v,d){var e="";if(d){var dt=new Date();dt.setTime(dt.getTime()+d*864e5);e="; expires="+dt.toUTCString()}document.cookie=n+"="+encodeURIComponent(v)+e+"; path=/; SameSite=Lax"}\nfunction uuid(){return(typeof crypto!=="undefined"&&crypto.randomUUID)?crypto.randomUUID():("xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g,function(c){var r=Math.random()*16|0;return(c==="x"?r:r&0x3|0x8).toString(16)}))}\n// Sentinel user IDs that must never be treated as real users. Mirrors\n// the Set in sentinel-user-id.ts — keep in sync. Empty string is handled\n// by the separate !uid checks below.\nvar SENTINEL_UIDS={"__pre_auth__":1,"anonymous":1,"guest":1,"undefined":1,"null":1};\nfunction isSentinelUid(v){return typeof v==="string"&&SENTINEL_UIDS.hasOwnProperty(v)}\n\n// --- Session + device IDs ---\n// Session ID is a UUID because it's supposed to be tab-scoped and random.\n// Device ID is intentionally NOT a UUID — Issue 9: random UUIDs wrote\n// meaningless device_ids to every fingerprint row. Instead we read the\n// stable fingerprint hash from localStorage if a previous submission\n// already persisted it; otherwise we leave did empty and let submitFP()\n// reconcile on the first successful collection. The Node middleware's\n// Issue 8 bootstrap-skip branch handles the empty-device_id window so we\n// never dispatch with a random or "unknown" value.\nvar sid=gC("__unshared_sid");\nif(!sid){sid=uuid();sC("__unshared_sid",sid,365)}\nvar did="";\ntry{did=localStorage.getItem("__unshared_device_id")||""}catch(e){}\nif(did){sC("__unshared_fp_id",did,365)}\n\n// --- Fingerprint cache (sessionStorage) ---\nfunction getFP(){try{var r=sessionStorage.getItem(SS_FP);return r?JSON.parse(r):null}catch(e){return null}}\nfunction setFP(fp){try{sessionStorage.setItem(SS_FP,JSON.stringify(fp))}catch(e){}}\n\n// --- Submit fingerprint to backend ---\nfunction submitFP(fp){\n var uid=gC("__unshared_uid");\n if(!uid||isSentinelUid(uid))return;\n // Issue 9: reconcile device_id to the stable fingerprint hash. This runs\n // before we send the X-Device-Id header so the very first submission\n // already carries the real value. Persist to localStorage so other tabs\n // (and future reloads) pick up the same stable ID without needing to\n // re-collect the fingerprint.\n if(fp.fingerprint_id){\n did=fp.fingerprint_id;\n try{localStorage.setItem("__unshared_device_id",did)}catch(e){}\n sC("__unshared_fp_id",did,365);\n }\n // event_type is the SPA route, not a fixed enum. Page-level event names\n // (page_load/route_change) collapsed every row into one of two buckets;\n // the URL is more useful for analytics and matches the frontend SDK.\n var route=(location.pathname||"/")+(location.search||"");\n var key=uid+"|"+route;\n if(key===lastSubmitKey)return;\n lastSubmitKey=key;\n try{sessionStorage.setItem(SS_LAST_SUBMIT,key)}catch(e){}\n // collected_at is stamped fresh at submit time rather than carried from fp.timestamp,\n // because fp is cached per-tab in sessionStorage — reusing its original timestamp would\n // freeze collected_at at first load and drift against server created_at as the tab ages.\n // The server authoritatively overwrites this value again on ingress.\n var body={hash:fp.full_hash,stable_hash:fp.fingerprint_id,collected_at:(new Date()).toISOString(),is_incognito:fp.isIncognito,components:fp.components,version:fp.version,session_id:sid,user_id:uid,event_type:route};\n // Deterministic idempotency key: (stable_hash, user_id, event_type) fully\n // identifies one logical submission. A stable key lets the backend's\n // ON CONFLICT (idempotency_key) DO NOTHING actually catch duplicates across\n // reloads, tabs, and concurrent SDK instances — a fresh UUID could not.\n var idem=fp.fingerprint_id+"|"+uid+"|"+route;\n var xhr=new XMLHttpRequest();\n xhr.open("POST",pfx+"/submit-fp",true);\n xhr.setRequestHeader("Content-Type","application/json");\n xhr.setRequestHeader("X-Session-Id",sid);\n if(did)xhr.setRequestHeader("X-Device-Id",did);\n xhr.setRequestHeader("X-Idempotency-Key",idem);\n xhr.send(JSON.stringify(body));\n}\n\n// --- Collect fingerprint (loads fp.js if needed) then submit ---\nvar fpReady=false;\nfunction collectAndSubmit(){\n var uid=gC("__unshared_uid");\n if(!uid||isSentinelUid(uid))return;\n var cached=getFP();\n if(cached){submitFP(cached);return}\n if(!fpReady)return;\n try{\n var c=new UnsharedBrowser.UnsharedBrowser({baseUrl:""});\n c.collect({exclude:["timing","speech"]}).then(function(fp){setFP(fp);submitFP(fp)});\n }catch(e){}\n}\n\n// --- Load fp.js (always — browser caches it for 1h) ---\n// Submit cached FP immediately if available; load fp.js for fresh collection\nvar pageLoadSubmitted=false;\nvar _boot_uid=gC("__unshared_uid");\nif(getFP()&&_boot_uid&&!isSentinelUid(_boot_uid)){submitFP(getFP());pageLoadSubmitted=true;deferredCheck()}\nvar s=document.createElement("script");\ns.src=pfx+"/fp.js${n}";\ns.onload=function(){fpReady=true;if(!pageLoadSubmitted){collectAndSubmit();deferredCheck()}};\ndocument.head.appendChild(s);\n\n// --- Deferred verdict check ---\n// After fingerprint submission, the backend processes the event async.\n// If the user was just flagged, the initial page load may have beaten\n// the verdict update. Re-check after a delay so newly flagged sessions\n// get caught without waiting for user interaction.\n// The endpoint always returns 200 so browsers don't log a scary red\n// network error — we inspect the body and dispatch the flagged event\n// ourselves when status==="flagged".\nfunction deferredCheck(){\n var uid=gC("__unshared_uid");\n if(!uid||isSentinelUid(uid))return;\n setTimeout(function(){\n try{fetch(pfx+"/status",{method:"GET",credentials:"same-origin"}).then(function(r){return r.json()}).then(function(b){if(b&&b.status==="flagged")emitFlagged(b)}).catch(function(){})}catch(e){}\n },500);\n}\n\n// --- SPA route change tracking (History API + popstate) ---\nvar oPush=history.pushState,oReplace=history.replaceState;\nhistory.pushState=function(){oPush.apply(this,arguments);try{collectAndSubmit()}catch(e){}};\nhistory.replaceState=function(){oReplace.apply(this,arguments);try{collectAndSubmit()}catch(e){}};\nwindow.addEventListener("popstate",function(){try{collectAndSubmit()}catch(e){}});\n\n// --- 403 interception: dispatch "unshared:flagged" event ---\nfunction emitFlagged(body){\n try{window.dispatchEvent(new CustomEvent("unshared:flagged",{detail:{email:body.email||""}}))}catch(e){}\n}\n\n// Patch fetch\nvar oFetch=window.fetch;\nif(oFetch){window.fetch=function(){return oFetch.apply(this,arguments).then(function(r){if(r.status===403){try{var cl=r.clone();cl.json().then(function(b){if(b&&b.error==="account_flagged")emitFlagged(b)}).catch(function(){})}catch(e){}}return r})}}\n\n// Patch XMLHttpRequest\nvar oXSend=XMLHttpRequest.prototype.send;\nXMLHttpRequest.prototype.send=function(){var x=this;x.addEventListener("load",function(){if(x.status===403){try{var b=JSON.parse(x.responseText);if(b&&b.error==="account_flagged")emitFlagged(b)}catch(e){}}});return oXSend.apply(this,arguments)};\n\n}catch(e){}\n})();\n<\/script>`}function escapeJavaScript(e){return e.replace(/\\/g,"\\\\").replace(/"/g,'\\"').replace(/'/g,"\\'")}
@@ -1,13 +1,15 @@
1
- import type { Response } from 'express';
1
+ import type { UnsharedResponse } from '../types';
2
2
  /**
3
3
  * Intercepts the response body by wrapping res.write() and res.end().
4
4
  *
5
- * Collects all chunks written to the response. When res.end() is called,
6
- * invokes the `transform` callback with the complete body buffer and the
7
- * Content-Type header. The transform can return modified content or null
8
- * to pass through unchanged.
5
+ * Only buffers HTML responses (text/html). Non-HTML responses (JSON, images,
6
+ * CSS, JS, etc.) pass through to the original write/end without buffering,
7
+ * avoiding unnecessary memory usage on large payloads.
9
8
  *
10
- * Does NOT monkey-patch res.send uses the lower-level write/end API
11
- * as required by the spec.
9
+ * When res.end() is called on an HTML response, invokes the `transform`
10
+ * callback with the complete body buffer and the Content-Type header.
11
+ * The transform can return modified content or null to pass through unchanged.
12
12
  */
13
- export declare function interceptResponse(res: Response, transform: (body: Buffer, contentType: string | undefined) => Buffer | string | null): void;
13
+ export declare function interceptResponse(res: UnsharedResponse, transform: (body: Buffer, contentType: string | undefined) => Buffer | string | null, options?: {
14
+ preventCaching?: boolean;
15
+ }): void;
@@ -1 +1 @@
1
- export function interceptResponse(n,t){const f=[],e=n.write.bind(n),u=n.end.bind(n);let o=!1;n.write=function(n,t,e){if(null!=n){const e=Buffer.isBuffer(n)?n:Buffer.from(n,"string"==typeof t?t:"utf8");f.push(e)}return"function"==typeof t&&t(null),"function"==typeof e&&e(null),!0},n.end=function(r,c,l){if(o)return n;if(o=!0,null!=r){const n=Buffer.isBuffer(r)?r:Buffer.from(r,"string"==typeof c?c:"utf8");f.push(n)}const i=Buffer.concat(f),s=n.getHeader("content-type");let p;try{p=t(i,s)}catch{p=null}if(null!=p){const t=Buffer.isBuffer(p)?p:Buffer.from(p,"utf8");n.setHeader("Content-Length",t.length),n.removeHeader("Content-Encoding"),e(t)}else i.length>0&&e(i);const y="function"==typeof c?c:l;return y?u(y):u(),n}}
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}}
@@ -1,15 +1,22 @@
1
- import type { Request, Response } from 'express';
2
- import type { UnsharedLabsClient } from '../../client';
1
+ import type { UnsharedRequest, UnsharedResponse } from '../../types';
2
+ import type { UnsharedClient } from '../../client';
3
3
  import type { VerdictCache } from '../verdict-cache';
4
4
  import type { RateLimitBackoff } from '../rate-limit-backoff';
5
- export interface SubmitFingerprintDependencies {
6
- client: UnsharedLabsClient;
5
+ import type { DispatchDedupe } from '../dispatch-dedupe';
6
+ export interface SubmitFingerprintDependencies<TReq extends UnsharedRequest = UnsharedRequest> {
7
+ client: UnsharedClient;
7
8
  verdictCache: VerdictCache;
8
9
  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;
10
+ dispatchDedupe: DispatchDedupe;
11
+ resolveUserId?: (req: TReq) => string | undefined;
12
+ resolveEmailAddress?: (req: TReq) => string | undefined;
13
+ resolveSessionId?: (req: TReq) => string | undefined;
14
+ resolveDeviceId?: (req: TReq) => string | undefined;
15
+ onError?: (error: unknown, context: {
16
+ operation: 'processUserEvent' | 'submitFingerprintEvent' | 'checkUser' | 'verifyTrigger' | 'verify';
17
+ userId?: string;
18
+ emailAddress?: string;
19
+ }) => void;
13
20
  }
14
21
  /**
15
22
  * Handles POST /__unshared/submit-fp
@@ -21,4 +28,4 @@ export interface SubmitFingerprintDependencies {
21
28
  *
22
29
  * Always returns 200 (fire-and-forget from browser's perspective).
23
30
  */
24
- export declare function handleSubmitFingerprint(dependencies: SubmitFingerprintDependencies): (req: Request, res: Response) => Promise<void>;
31
+ export declare function handleSubmitFingerprint<TReq extends UnsharedRequest = UnsharedRequest>(dependencies: SubmitFingerprintDependencies<TReq>): (req: TReq, res: UnsharedResponse) => Promise<void>;
@@ -1 +1 @@
1
- export function handleSubmitFingerprint(e){return async(n,t)=>{try{const i=n.body??{},o={full_hash:i.hash??"",fingerprint_id:i.stable_hash??"",timestamp:i.collected_at??(new Date).toISOString(),isIncognito:i.is_incognito??!1,components:i.components??{},version:i.version??"inline-1.0.0"};let s,r,a;try{s=e.resolveUserId?e.resolveUserId(n):void 0}catch{}s=s??i.user_id??void 0;try{r=e.resolveEmailAddress?e.resolveEmailAddress(n):void 0}catch{}r=r??parseCookie(n,"__unshared_email")??i.email??void 0;try{a=e.resolveSessionId?e.resolveSessionId(n):void 0}catch{}a=a??i.session_id??parseCookie(n,"__unshared_sid");const c=n.ip??"",d=n.headers["user-agent"]??"",_=extractDeviceId(n,e.resolveDeviceId),u=o.fingerprint_id||void 0,p=[];if(u&&!parseCookie(n,"__unshared_fingerprint_id")&&p.push(`__unshared_fingerprint_id=${encodeURIComponent(u)}; HttpOnly; Path=/; SameSite=Lax`),r&&!parseCookie(n,"__unshared_email")&&p.push(`__unshared_email=${encodeURIComponent(r)}; HttpOnly; Path=/; SameSite=Lax`),p.length>0){const e=t.getHeader("Set-Cookie");if(e){const n=Array.isArray(e)?[...e]:[String(e)];n.push(...p),t.setHeader("Set-Cookie",n)}else t.setHeader("Set-Cookie",p)}s&&e.client.submitFingerprintEvent(o,{userId:s,sessionHash:a,eventType:"auto_collect",ipAddress:c}).catch(()=>{}),s&&r&&!e.rateLimitBackoff.isPaused()&&e.client.processUserEvent({eventType:"auto_collect",userId:s,emailAddress:r,ipAddress:c,deviceId:_,fingerprintId:u,sessionHash:a??"unknown",userAgent:d}).then(n=>{n.success&&n.data?.analysis&&e.verdictCache.update(s,{isFlagged:n.data.analysis.is_user_flagged}),!n.success&&n.error?.retryAfter&&e.rateLimitBackoff.pause(1e3*n.error.retryAfter)}).catch(()=>{}),t.status(200).json({success:!0})}catch{t.status(200).json({success:!0})}}}function extractDeviceId(e,n){if(n)try{const t=n(e);if(t)return t}catch{}const t=parseCookie(e,"__unshared_fp_id");if(t)return t;const i=e.headers["x-device-id"];return"string"==typeof i&&i?i:"unknown"}function parseCookie(e,n){const t=e.headers.cookie;if(!t)return;const i=t.match(new RegExp(`(?:^|; )${n}=([^;]*)`));return i?decodeURIComponent(i[1]):void 0}
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";import{isSentinelUserId}from"../utils/sentinel-user-id";import{sendJson}from"../utils/http-helpers";export function handleSubmitFingerprint(e){return async(i,t)=>{try{const s=i.body??{},n={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 o,r,d;try{const t=e.resolveUserId?e.resolveUserId(i):void 0;t&&!isSentinelUserId(t)&&(o=t)}catch{}if(!o){const e="string"==typeof s.user_id?s.user_id:void 0;e&&!isSentinelUserId(e)&&(o=e)}if(!o){const e=parseCookie(i,"__unshared_uid");e&&!isSentinelUserId(e)&&(o=e)}try{r=e.resolveEmailAddress?e.resolveEmailAddress(i):void 0}catch{}r=r??parseCookie(i,"__unshared_email")??s.email??void 0;try{d=e.resolveSessionId?e.resolveSessionId(i):void 0}catch{}d=d??s.session_id??parseCookie(i,"__unshared_sid");const a=extractClientIp(i),c=i.headers["user-agent"]??"";if(isBot(c))return void sendJson(t,200,{success:!0});const p=(n.fingerprint_id&&n.fingerprint_id.length>0?n.fingerprint_id:void 0)??extractDeviceId(i,e.resolveDeviceId),u=n.fingerprint_id||void 0,l=n.full_hash||void 0,m=isSecureRequest(i)?"; Secure":"",_=[];if(l&&!parseCookie(i,"__unshared_fingerprint_id")&&_.push(`__unshared_fingerprint_id=${encodeURIComponent(l)}; HttpOnly; Path=/; SameSite=Lax${m}`),u){const e=parseCookie(i,"__unshared_fp_id");e&&e===u||_.push(`__unshared_fp_id=${encodeURIComponent(u)}; Path=/; SameSite=Lax; Max-Age=31536000${m}`)}if(r&&!parseCookie(i,"__unshared_email")&&_.push(`__unshared_email=${encodeURIComponent(r)}; HttpOnly; Path=/; SameSite=Lax${m}`),_.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",_)}let f;if("string"==typeof s.event_type&&s.event_type)f=s.event_type;else{const e=i.headers.referer??i.headers.referrer;let t="unknown";if("string"==typeof e&&e.length>0)try{const i=new URL(e);t=(i.pathname||"/")+(i.search||"")}catch{}f=t}const h=i.headers["x-idempotency-key"],v="string"==typeof h&&h.length>0?h:void 0,y=Date.now(),I=v?`${v}|${y}`:u&&o?`${u}|${o}|${f}|${y}`:void 0;if(o&&e.client.submitFingerprintEvent(n,{userId:o,emailAddress:r,sessionHash:d,eventType:f,ipAddress:a,userAgent:c,idempotencyKey:I}).catch(i=>{e.onError&&e.onError(i,{operation:"submitFingerprintEvent",userId:o,emailAddress:r})}),o&&r&&!e.rateLimitBackoff.isPaused()&&!e.dispatchDedupe.wasRecentlyDispatched(o,f))try{const i=await e.client.processUserEvent({eventType:f,userId:o,emailAddress:r,ipAddress:a,deviceId:p,fingerprintId:u,sessionHash:d??"unknown",userAgent:c});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(i){e.onError&&e.onError(i,{operation:"processUserEvent",userId:o,emailAddress:r})}sendJson(t,200,{success:!0})}catch{sendJson(t,200,{success:!0})}}}
@@ -1,11 +1,16 @@
1
- import type { Request, Response } from 'express';
2
- import type { UnsharedLabsClient } from '../../client';
1
+ import type { UnsharedRequest, UnsharedResponse } from '../../types';
2
+ import type { UnsharedClient } from '../../client';
3
3
  import type { VerdictCache } from '../verdict-cache';
4
- export interface VerificationDependencies {
5
- client: UnsharedLabsClient;
4
+ export interface VerificationDependencies<TReq extends UnsharedRequest = UnsharedRequest> {
5
+ client: UnsharedClient;
6
6
  verdictCache: VerdictCache;
7
- resolveEmailAddress?: (req: Request) => string | undefined;
8
- resolveDeviceId?: (req: Request) => string | undefined;
7
+ resolveEmailAddress?: (req: TReq) => string | undefined;
8
+ resolveDeviceId?: (req: TReq) => string | undefined;
9
+ onError?: (error: unknown, context: {
10
+ operation: 'processUserEvent' | 'submitFingerprintEvent' | 'checkUser' | 'verifyTrigger' | 'verify';
11
+ userId?: string;
12
+ emailAddress?: string;
13
+ }) => void;
9
14
  }
10
15
  /**
11
16
  * POST /__unshared/verify-trigger
@@ -15,7 +20,7 @@ export interface VerificationDependencies {
15
20
  *
16
21
  * The deviceId is resolved via extractDeviceId (same as the middleware).
17
22
  */
18
- export declare function handleVerifyTrigger(dependencies: VerificationDependencies): (req: Request, res: Response) => Promise<void>;
23
+ export declare function handleVerifyTrigger<TReq extends UnsharedRequest = UnsharedRequest>(dependencies: VerificationDependencies<TReq>): (req: TReq, res: UnsharedResponse) => Promise<void>;
19
24
  /**
20
25
  * POST /__unshared/verify
21
26
  * Validates OTP code. Called by the blocker overlay UI.
@@ -25,4 +30,4 @@ export declare function handleVerifyTrigger(dependencies: VerificationDependenci
25
30
  * On successful verification, updates the verdict cache to mark
26
31
  * the user as verified so subsequent requests pass through.
27
32
  */
28
- export declare function handleVerify(dependencies: VerificationDependencies): (req: Request, res: Response) => Promise<void>;
33
+ export declare function handleVerify<TReq extends UnsharedRequest = UnsharedRequest>(dependencies: VerificationDependencies<TReq>): (req: TReq, res: UnsharedResponse) => Promise<void>;
@@ -1 +1 @@
1
- export function handleVerifyTrigger(e){return async(r,i)=>{try{const s=resolveEmail(r,r.body??{},e.resolveEmailAddress);if(!s)return void i.status(400).json({success:!1,error:{code:"VALIDATION_ERROR",message:"Email is required"}});const n=extractDeviceId(r,e.resolveDeviceId),o=parseCookie(r,"__unshared_fingerprint_id")||void 0,t=await e.client.triggerEmailVerification(s,n,{fingerprintId:o});t.success?i.status(200).json({success:!0,data:t.data}):i.status(200).json({success:!1,error:t.error??{code:"TRIGGER_FAILED",message:"Failed to send verification email"}})}catch{i.status(200).json({success:!1,error:{code:"INTERNAL_ERROR",message:"Failed to trigger verification"}})}}}export function handleVerify(e){return async(r,i)=>{try{const s=r.body??{},n=resolveEmail(r,s,e.resolveEmailAddress),o=s.code;if(!n||!o)return void i.status(400).json({success:!1,error:{code:"VALIDATION_ERROR",message:"Email and code are required"}});const t=extractDeviceId(r,e.resolveDeviceId),c=parseCookie(r,"__unshared_fingerprint_id")||void 0,a=await e.client.verify(n,t,o,{fingerprintId:c});if(a.success){const s=parseCookie(r,"__unshared_uid");s&&e.verdictCache.update(s,{isVerified:!0}),i.status(200).json({success:!0,data:{verified:!0}})}else i.status(200).json({success:!1,error:a.error??{code:"VERIFICATION_FAILED",message:"Verification failed"}})}catch{i.status(200).json({success:!1,error:{code:"INTERNAL_ERROR",message:"Verification failed"}})}}}function resolveEmail(e,r,i){if(i)try{const r=i(e);if(r)return r}catch{}const s=parseCookie(e,"__unshared_email");if(s)return s;const n=r.email;return"string"==typeof n&&n?n:void 0}function extractDeviceId(e,r){if(r)try{const i=r(e);if(i)return i}catch{}const i=parseCookie(e,"__unshared_fp_id");if(i)return i;const s=e.headers["x-device-id"];return"string"==typeof s&&s?s:"unknown"}function parseCookie(e,r){const i=e.headers.cookie;if(!i)return;const s=i.match(new RegExp(`(?:^|; )${r}=([^;]*)`));return s?decodeURIComponent(s[1]):void 0}
1
+ import{parseCookie}from"../utils/cookies";import{extractDeviceId}from"../utils/device-id";import{sendJson}from"../utils/http-helpers";export function handleVerifyTrigger(e){return async(r,s)=>{try{const i=resolveEmail(r,r.body??{},e.resolveEmailAddress);if(!i)return void sendJson(s,400,{success:!1,error:{code:"VALIDATION_ERROR",message:"Email is required"}});const o=extractDeviceId(r,e.resolveDeviceId),n=parseCookie(r,"__unshared_fingerprint_id")||void 0,t=await e.client.triggerEmailVerification(i,o,{fingerprintId:n});t.success?sendJson(s,200,{success:!0,data:t.data}):sendJson(s,200,{success:!1,error:t.error??{code:"TRIGGER_FAILED",message:"Failed to send verification email"}})}catch(r){e.onError&&e.onError(r,{operation:"verifyTrigger"}),sendJson(s,200,{success:!1,error:{code:"INTERNAL_ERROR",message:"Failed to trigger verification"}})}}}export function handleVerify(e){return async(r,s)=>{try{const i=r.body??{},o=resolveEmail(r,i,e.resolveEmailAddress),n=i.code;if(!o||!n)return void sendJson(s,400,{success:!1,error:{code:"VALIDATION_ERROR",message:"Email and code are required"}});const t=extractDeviceId(r,e.resolveDeviceId),c=parseCookie(r,"__unshared_fingerprint_id")||void 0,a=await e.client.verify(o,t,n,{fingerprintId:c});if(a.success){const i=parseCookie(r,"__unshared_uid");i&&e.verdictCache.update(i,{isVerified:!0}),sendJson(s,200,{success:!0,data:{verified:!0}})}else sendJson(s,200,{success:!1,error:a.error??{code:"VERIFICATION_FAILED",message:"Verification failed"}})}catch(r){e.onError&&e.onError(r,{operation:"verify"}),sendJson(s,200,{success:!1,error:{code:"INTERNAL_ERROR",message:"Verification failed"}})}}}function resolveEmail(e,r,s){if(s)try{const r=s(e);if(r)return r}catch{}const i=parseCookie(e,"__unshared_email");if(i)return i;const o=r.email;return"string"==typeof o&&o?o:void 0}
@@ -0,0 +1,6 @@
1
+ import type { UnsharedRequest } from '../../types';
2
+ /**
3
+ * Extract the real client IP from proxy headers, falling back to req.ip or socket address.
4
+ * Checked in order: CF-Connecting-IP (Cloudflare) → X-Real-IP (nginx/ALB) → req.ip → socket.
5
+ */
6
+ export declare function extractClientIp(req: UnsharedRequest): 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??t.socket?.remoteAddress??""}
@@ -0,0 +1,6 @@
1
+ import type { UnsharedRequest } from '../../types';
2
+ /**
3
+ * Reads a single cookie value from the raw Cookie header.
4
+ * Works without cookie-parser middleware.
5
+ */
6
+ export declare function parseCookie(req: UnsharedRequest, name: string): string | undefined;
@@ -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,19 @@
1
+ import type { UnsharedRequest } from '../../types';
2
+ /**
3
+ * Resolves device ID from: custom resolver → X-Device-Id header → __unshared_fp_id cookie.
4
+ *
5
+ * Returns the literal string `"unknown"` when no source provides a value.
6
+ * Callers that can tolerate a tri-state should use `extractDeviceIdOrUndefined`
7
+ * instead — the "unknown" sentinel polluted 19% of FP rows in production
8
+ * because it was being dispatched as-if-real during the first request of a
9
+ * session, before the inline script had a chance to set `__unshared_fp_id`.
10
+ * Kept for API compatibility.
11
+ */
12
+ export declare function extractDeviceId<TReq extends UnsharedRequest = UnsharedRequest>(req: TReq, resolveDeviceId?: (req: TReq) => string | undefined): string;
13
+ /**
14
+ * Resolves device ID from: custom resolver → X-Device-Id header → __unshared_fp_id cookie.
15
+ * Returns `undefined` when nothing is available. Use this at any call site
16
+ * that can take a "skip dispatch" branch during the bootstrap window, so we
17
+ * stop writing `device_id="unknown"` rows to the analytics table on first request.
18
+ */
19
+ export declare function extractDeviceIdOrUndefined<TReq extends UnsharedRequest = UnsharedRequest>(req: TReq, resolveDeviceId?: (req: TReq) => string | undefined): string | undefined;
@@ -0,0 +1 @@
1
+ import{parseCookie}from"./cookies";export function extractDeviceId(e,r){return extractDeviceIdOrUndefined(e,r)??"unknown"}export function extractDeviceIdOrUndefined(e,r){if(r)try{const t=r(e);if(t)return t}catch{}const t=e.headers["x-device-id"];if("string"==typeof t&&t)return t;return parseCookie(e,"__unshared_fp_id")||void 0}
@@ -0,0 +1,5 @@
1
+ export declare const ACCOUNT_FLAGGED_ERROR: "account_flagged";
2
+ export declare function flaggedResponse(email: string): {
3
+ readonly error: "account_flagged";
4
+ readonly email: string;
5
+ };
@@ -0,0 +1 @@
1
+ export const ACCOUNT_FLAGGED_ERROR="account_flagged";export function flaggedResponse(e){return{error:"account_flagged",email:e}}
@@ -0,0 +1,21 @@
1
+ import type { UnsharedResponse } from '../../types';
2
+ /**
3
+ * Send a JSON response. Framework-agnostic replacement for Express's
4
+ * `res.status(code).json(data)`.
5
+ */
6
+ export declare function sendJson(res: UnsharedResponse, statusCode: number, data: unknown): void;
7
+ /**
8
+ * Send an empty response with a status code. Replacement for Express's
9
+ * `res.status(code).end()`.
10
+ */
11
+ export declare function sendEmpty(res: UnsharedResponse, statusCode: number): void;
12
+ /**
13
+ * Send a response with a specific body. Replacement for Express's
14
+ * `res.status(code).end(body)`.
15
+ */
16
+ export declare function sendBody(res: UnsharedResponse, statusCode: number, body: string | Buffer): void;
17
+ /**
18
+ * Extract the pathname from a request URL, stripping query string.
19
+ * Framework-agnostic replacement for Express's `req.path`.
20
+ */
21
+ export declare function getRequestPath(url: string | undefined): string;
@@ -0,0 +1 @@
1
+ export function sendJson(n,t,o){const e=JSON.stringify(o);n.statusCode=t,n.setHeader("Content-Type","application/json"),n.end(e)}export function sendEmpty(n,t){n.statusCode=t,n.end()}export function sendBody(n,t,o){n.statusCode=t,n.end(o)}export function getRequestPath(n){if(!n)return"/";const t=n.indexOf("?");return-1===t?n:n.slice(0,t)}
@@ -0,0 +1,6 @@
1
+ /**
2
+ * Returns true if the path matches at least one of the includePathPrefix entries.
3
+ * When includePathPrefix is undefined, returns true (all paths match — preserves default behavior).
4
+ * When includePathPrefix is an empty array, returns false (no paths match).
5
+ */
6
+ export declare function shouldIncludePath(path: string, includePathPrefix?: string[]): boolean;
@@ -0,0 +1 @@
1
+ export function shouldIncludePath(n,t){return!t||t.some(t=>n.startsWith(t))}
@@ -0,0 +1,5 @@
1
+ /**
2
+ * Returns true if the User-Agent string matches a known bot, crawler,
3
+ * scanner, or HTTP client library pattern.
4
+ */
5
+ export declare function isBot(userAgent: string): boolean;
@@ -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","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"],BOT_RE=new RegExp(BOT_PATTERNS.join("|"),"i");export function isBot(e){return!!e&&BOT_RE.test(e)}
@@ -0,0 +1,3 @@
1
+ import type { UnsharedRequest } from '../../types';
2
+ /** Returns true if the request arrived over HTTPS (direct or via reverse proxy). */
3
+ export declare function isSecureRequest(req: UnsharedRequest): boolean;
@@ -0,0 +1 @@
1
+ export function isSecureRequest(e){return!!e.socket?.encrypted||"https"===e.headers["x-forwarded-proto"]}
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Stickiness window: if the resolver returns a sentinel but the
3
+ * `__unshared_uid` cookie was set within this many milliseconds, we prefer
4
+ * the cookie value (assuming it's a hydration race on the same tab).
5
+ * Outside the window, the cookie is considered stale and we fall through
6
+ * to the "no userId" branch — without clearing the cookie, because clearing
7
+ * on every hydration blip is exactly the bug we're fixing.
8
+ */
9
+ export declare const SENTINEL_STICKINESS_TTL_MS = 30000;
10
+ export declare function isSentinelUserId(value: string | undefined | null): boolean;
@@ -0,0 +1 @@
1
+ const SENTINEL_USER_IDS=new Set(["__pre_auth__","anonymous","guest","undefined","null"]);export const SENTINEL_STICKINESS_TTL_MS=3e4;export function isSentinelUserId(e){return"string"==typeof e&&SENTINEL_USER_IDS.has(e)}
@@ -1 +1 @@
1
- const STATIC_EXTENSIONS=new Set([".js",".mjs",".cjs",".css",".map",".png",".jpg",".jpeg",".gif",".svg",".ico",".webp",".avif",".woff",".woff2",".ttf",".otf",".eot",".mp3",".mp4",".webm",".ogg",".wasm",".json",".xml",".txt",".pdf"]),STATIC_PATH_PREFIXES=["/static/","/assets/","/public/","/_next/","/__vite/","/favicon"];export function shouldSkipPath(t,f){if(f)for(const o of f)if(t.startsWith(o))return!0;const o=t.lastIndexOf(".");if(-1!==o){const f=t.slice(o).toLowerCase().split("?")[0];if(STATIC_EXTENSIONS.has(f))return!0}for(const f of STATIC_PATH_PREFIXES)if(t.startsWith(f))return!0;return!1}
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}
@@ -10,7 +10,9 @@ export declare class VerdictCache {
10
10
  private readonly _entries;
11
11
  private readonly _activeRefreshes;
12
12
  private readonly _defaultTtlMs;
13
- constructor(defaultTTL?: number);
13
+ private readonly _maxSize;
14
+ private _sweepTimer;
15
+ constructor(defaultTTL?: number, maxSize?: number);
14
16
  get(userId: string): Verdict | undefined;
15
17
  set(userId: string, verdict: Omit<Verdict, 'cachedAt' | 'ttl'>, ttl?: number): void;
16
18
  /**
@@ -33,4 +35,13 @@ export declare class VerdictCache {
33
35
  /** Number of cached entries. */
34
36
  get size(): number;
35
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;
36
47
  }
@@ -1 +1 @@
1
- export class VerdictCache{constructor(t=6e4){this.t=new Map,this.i=new Set,this.h=t}get(t){return this.t.get(t)}set(t,e,s){this.t.set(t,{...e,cachedAt:Date.now(),ttl:s??this.h})}update(t,e){const s=this.t.get(t);s?(void 0!==e.isFlagged&&(s.isFlagged=e.isFlagged),void 0!==e.isVerified&&(s.isVerified=e.isVerified),s.cachedAt=Date.now()):this.t.set(t,{isFlagged:e.isFlagged??!1,isVerified:e.isVerified??!1,emailAddress:"",sessionId:"",cachedAt:Date.now(),ttl:this.h})}delete(t){this.t.delete(t),this.i.delete(t)}isStale(t){const e=this.t.get(t);return!!e&&Date.now()-e.cachedAt>e.ttl}isRefreshing(t){return this.i.has(t)}markRefreshing(t){this.i.add(t)}clearRefreshing(t){this.i.delete(t)}get size(){return this.t.size}clear(){this.t.clear(),this.i.clear()}}
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,14 +1,14 @@
1
- import type { Request, Response, NextFunction, Application } from 'express';
2
- import type { UnsharedLabsClient } from './client';
3
- export interface MiddlewareOptions {
1
+ import type { UnsharedRequest, UnsharedResponse, UnsharedNextFunction } from './types';
2
+ import type { UnsharedClient } from './client';
3
+ export interface MiddlewareOptions<TReq extends UnsharedRequest = UnsharedRequest> {
4
4
  /** Override userId extractor. Falls back to req.body.user_id. */
5
- userIdExtractor?: (req: Request) => string | undefined;
5
+ userIdExtractor?: (req: TReq) => string | undefined;
6
6
  /** Override eventType extractor. Falls back to req.body.event_type. */
7
- eventTypeExtractor?: (req: Request) => string | undefined;
7
+ eventTypeExtractor?: (req: TReq) => string | undefined;
8
8
  /** Override sessionId extractor. Falls back to X-Session-Id header, then req.body.session_id. */
9
- sessionIdExtractor?: (req: Request) => string | undefined;
9
+ sessionIdExtractor?: (req: TReq) => string | undefined;
10
10
  /** Override IP address extractor. Falls back to req.ip. */
11
- ipAddressExtractor?: (req: Request) => string | undefined;
11
+ ipAddressExtractor?: (req: TReq) => string | undefined;
12
12
  /** Default event type when none is extractable. @default "browser_event" */
13
13
  defaultEventType?: string;
14
14
  /**
@@ -29,6 +29,9 @@ export interface MiddlewareOptions {
29
29
  * Asserts that Express `trust proxy` is configured on the app.
30
30
  * Call this once during application startup, before mounting any middleware.
31
31
  *
32
+ * **Express-specific utility.** Other frameworks handle proxy trust differently;
33
+ * consult your framework's documentation for equivalent configuration.
34
+ *
32
35
  * Throws synchronously if the setting is missing, killing the process before
33
36
  * any requests are served.
34
37
  *
@@ -39,7 +42,7 @@ export interface MiddlewareOptions {
39
42
  * app.use(createUnsharedMiddleware(client, options));
40
43
  * ```
41
44
  */
42
- export declare function assertTrustProxy(app: Application): void;
45
+ export declare function assertTrustProxy(app: any): void;
43
46
  /**
44
47
  * Creates an Express middleware that proxies browser fingerprint events to
45
48
  * Unshared Labs. Mount this to handle the browser fingerprint route contract (§4 of spec).
@@ -62,7 +65,7 @@ export declare function assertTrustProxy(app: Application): void;
62
65
  *
63
66
  * @example
64
67
  * ```typescript
65
- * import { createUnsharedMiddleware } from "@unshared-labs/sdk/middleware";
68
+ * import { createUnsharedMiddleware } from "unshared-clientjs-sdk";
66
69
  *
67
70
  * app.use(express.json()); // must come first
68
71
  * app.use(createUnsharedMiddleware(client, {
@@ -70,4 +73,4 @@ export declare function assertTrustProxy(app: Application): void;
70
73
  * }));
71
74
  * ```
72
75
  */
73
- export declare function createUnsharedMiddleware(client: UnsharedLabsClient, options?: MiddlewareOptions): (req: Request, res: Response, next: NextFunction) => Promise<void>;
76
+ export declare function createUnsharedMiddleware<TReq extends UnsharedRequest = UnsharedRequest>(client: UnsharedClient, options?: MiddlewareOptions<TReq>): (req: TReq, res: UnsharedResponse, next: UnsharedNextFunction) => Promise<void>;
@@ -1 +1 @@
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()}}
1
+ import{sendJson,sendEmpty,getRequestPath}from"./middleware/utils/http-helpers";export function assertTrustProxy(e){if(!e?.get?.("trust proxy"))throw new Error('[unshared-labs] Express "trust proxy" is not set.\n\n Fix: add this line before mounting any middleware:\n app.set("trust proxy", 1);\n\n Why: without it, req.ip returns the proxy/load-balancer IP instead of\n the real client IP, which degrades account-sharing detection accuracy.\n Set to 1 if behind a single reverse proxy (Nginx, ALB, CloudFront),\n or the number of trusted proxies in your chain.')}export function createUnsharedMiddleware(e,s){const{userIdExtractor:r,eventTypeExtractor:t,sessionIdExtractor:n,ipAddressExtractor:o,defaultEventType:i="browser_event",routePrefix:d="/unshared",corsOrigins:c}=s??{},a=`${d}/submit-fingerprint-event`,u=c?Array.isArray(c)?c:[c]:null;return async(s,d,c)=>{const l=getRequestPath(s.url);if(u&&l===a){const e=s.headers.origin??"",r=u.includes("*");if((r||u.includes(e))&&(d.setHeader("Access-Control-Allow-Origin",r?"*":e),d.setHeader("Access-Control-Allow-Methods","POST, OPTIONS"),d.setHeader("Access-Control-Allow-Headers","Content-Type, X-Idempotency-Key, X-Session-Id")),"OPTIONS"===s.method)return void sendEmpty(d,204)}if("POST"===s.method&&l===a)try{if(void 0===s.body)return void sendJson(d,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."}});const c=s.body??{};if(!c.hash||!c.stable_hash||!c.collected_at)return void sendJson(d,400,{success:!1,error:{code:"VALIDATION_ERROR",message:"Missing required fingerprint fields: hash, stable_hash, collected_at"}});if(!s.headers["x-session-id"])return void sendJson(d,400,{success:!1,error:{code:"VALIDATION_ERROR",message:"Missing required header: X-Session-Id"}});const a={full_hash:c.hash,fingerprint_id:c.stable_hash,timestamp:c.collected_at,isIncognito:c.is_incognito??!1,components:c.components??{},version:c.version??"unknown"};let u,l,h,p;try{u=(r?r(s):void 0)??c.user_id}catch{u=c.user_id}if(s.body&&"object"==typeof s.body&&"user_id"in s.body&&delete s.body.user_id,!u)return void sendJson(d,400,{success:!1,error:{code:"VALIDATION_ERROR",message:"Missing required field: user_id"}});try{l=(t?t(s):void 0)??c.event_type??i}catch{l=c.event_type??i}try{h=(n?n(s):void 0)??s.headers["x-session-id"]?.toString()??c.session_id}catch{h=s.headers["x-session-id"]?.toString()??c.session_id}const f=s.ip??s.socket?.remoteAddress??"";try{p=(o?o(s):void 0)??f}catch{p=f}const y=await e.submitFingerprintEvent(a,{userId:u,sessionHash:h,eventType:l,ipAddress:p});if(!y.success)return void sendJson(d,200,{success:!1,error:{code:"UPSTREAM_ERROR",message:y.error?.message??"Upstream request failed"}});sendJson(d,202,{success:!0,data:y.data})}catch(e){sendJson(d,200,{success:!1,error:{code:"MIDDLEWARE_ERROR",message:e instanceof Error?e.message:"Middleware error"}})}else c()}}
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Framework-agnostic HTTP types for the Unshared Labs middleware.
3
+ *
4
+ * These interfaces are structurally compatible with Express, Fastify (via raw),
5
+ * Koa (via ctx.req/ctx.res), and raw Node.js http.IncomingMessage/ServerResponse.
6
+ */
7
+ import type { IncomingHttpHeaders } from 'http';
8
+ /**
9
+ * Minimal request interface for framework-agnostic middleware.
10
+ *
11
+ * Express, Fastify's `request.raw`, Koa's `ctx.req`, and Node.js
12
+ * `http.IncomingMessage` all satisfy this interface via structural typing.
13
+ */
14
+ export interface UnsharedRequest {
15
+ method?: string;
16
+ url?: string;
17
+ headers: IncomingHttpHeaders;
18
+ body?: any;
19
+ /** Client IP address, if the framework provides it (e.g. Express `req.ip`). */
20
+ ip?: string;
21
+ socket?: {
22
+ remoteAddress?: string;
23
+ encrypted?: boolean;
24
+ };
25
+ }
26
+ /**
27
+ * Minimal response interface for framework-agnostic middleware.
28
+ *
29
+ * Express `Response`, Fastify's `reply.raw`, Koa's `ctx.res`, and Node.js
30
+ * `http.ServerResponse` all satisfy this interface via structural typing.
31
+ */
32
+ export interface UnsharedResponse {
33
+ statusCode: number;
34
+ setHeader(name: string, value: string | number | string[]): any;
35
+ getHeader(name: string): string | number | string[] | undefined;
36
+ removeHeader(name: string): void;
37
+ write(chunk: any, encodingOrCallback?: any, callback?: any): boolean;
38
+ end(chunk?: any, encodingOrCallback?: any, callback?: any): any;
39
+ writeHead(statusCode: number, ...args: any[]): any;
40
+ }
41
+ /**
42
+ * Middleware next function. Compatible with Express, Connect, and Koa.
43
+ */
44
+ export type UnsharedNextFunction = (err?: any) => void;
@@ -0,0 +1 @@
1
+ export{};
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Web Standard Request/Response handlers for serverless/edge environments.
3
+ *
4
+ * Use this entry point in Next.js App Router, Vercel Edge Functions,
5
+ * Cloudflare Workers, Deno Deploy, Bun, or any other runtime that uses
6
+ * the Web Standard Fetch API (`Request`/`Response` globals).
7
+ *
8
+ * For Node.js HTTP frameworks (Express, Fastify, Koa, Next.js Pages Router),
9
+ * use the default `unshared-clientjs-sdk` entry point instead — its middleware
10
+ * is compatible with all frameworks that expose `http.IncomingMessage`/
11
+ * `http.ServerResponse` objects.
12
+ */
13
+ export { createWebSubmitHandler } from './submit-handler';
14
+ export { createWebProtectionMiddleware } from './protection-handler';
15
+ export type { WebHandler, WebMiddleware, WebSubmitOptions, WebProtectionConfig, } from './types';
16
+ export { VerdictCache } from '../middleware/verdict-cache';
17
+ export type { Verdict } from '../middleware/verdict-cache';
@@ -0,0 +1 @@
1
+ export{createWebSubmitHandler}from"./submit-handler";export{createWebProtectionMiddleware}from"./protection-handler";export{VerdictCache}from"../middleware/verdict-cache";
@@ -0,0 +1,28 @@
1
+ import type { UnsharedClient } from '../client';
2
+ import type { WebMiddleware, WebProtectionConfig } from './types';
3
+ /**
4
+ * Web Standard equivalent of `unsharedBoundToUser` from src/middleware/index.ts.
5
+ *
6
+ * Returns a middleware `(request, next) => Promise<Response>` suitable for
7
+ * Next.js App Router, Vercel Edge Functions, Cloudflare Workers, Deno Deploy,
8
+ * and other Web Standard runtimes.
9
+ *
10
+ * Unlike the Node.js middleware which mutates the response object, this
11
+ * handler calls `next(request)` to get the downstream Response, transforms
12
+ * it (injects the fingerprint script into HTML), and returns a new Response.
13
+ *
14
+ * @example Next.js App Router (in middleware.ts)
15
+ * ```typescript
16
+ * import { createWebProtectionMiddleware } from 'unshared-clientjs-sdk/web';
17
+ *
18
+ * const protect = createWebProtectionMiddleware(client, {
19
+ * userId: (req) => parseJwt(req.headers.get('authorization'))?.sub,
20
+ * emailAddress: (req) => parseJwt(req.headers.get('authorization'))?.email,
21
+ * });
22
+ *
23
+ * export async function middleware(request: Request) {
24
+ * return protect(request, async () => NextResponse.next());
25
+ * }
26
+ * ```
27
+ */
28
+ export declare function createWebProtectionMiddleware(client: UnsharedClient, config: WebProtectionConfig): WebMiddleware;
@@ -0,0 +1 @@
1
+ import{VerdictCache}from"../middleware/verdict-cache";import{RateLimitBackoff}from"../middleware/rate-limit-backoff";import{DispatchDedupe}from"../middleware/dispatch-dedupe";import{generateFingerprintScript}from"../middleware/injection/fingerprint-script";import{isHtmlContentType}from"../middleware/utils/content-type";import{shouldSkipPath}from"../middleware/utils/skip-paths";import{shouldIncludePath}from"../middleware/utils/include-path";import{isBot}from"../middleware/utils/is-bot";import{isSentinelUserId,SENTINEL_STICKINESS_TTL_MS}from"../middleware/utils/sentinel-user-id";import{parseCookieFromRequest,extractClientIpFromRequest,extractDeviceIdFromRequest,extractDeviceIdFromRequestOrUnknown,isSecureWebRequest,jsonResponse,emptyResponse,bodyResponse,mergeResponseHeaders}from"./web-helpers";const CHECK_USER_TIMEOUT_MS=500;export function createWebProtectionMiddleware(e,s){if(!s.userId)throw new Error("[Unshared] userId resolver is required");const{userId:r,emailAddress:t,routePrefix:n="/__unshared",corsOrigins:i,cacheTTL:o=6e4,skipPaths:a,includePathPrefix:d,sessionId:c,deviceId:u,fingerprintSdkBundle:l="",onFlagged:p,onError:m}=s,f=new VerdictCache(o),h=new RateLimitBackoff,_=new DispatchDedupe,R=Date.now().toString(36),I=generateFingerprintScript(n,R),g=`${n}/fp.js`,v=`${n}/submit-fp`,S=`${n}/verify-trigger`,y=`${n}/verify`,C=`${n}/status`,w=i?Array.isArray(i)?i:[i]:null;return async function(s,i){let o,R,A;try{const e=new URL(s.url);o=e.pathname,R=e.search}catch{return i(s)}if(o.startsWith(n+"/")){const n=function(e){if(!w)return{};const s=e.headers.get("origin")??"",r=w.includes("*");return r||w.includes(s)?{"Access-Control-Allow-Origin":r?"*":s,"Access-Control-Allow-Methods":"POST, OPTIONS","Access-Control-Allow-Headers":"Content-Type, X-Idempotency-Key, X-Session-Id, X-Device-Id","Access-Control-Allow-Credentials":"true"}:{}}(s);if("OPTIONS"===s.method)return emptyResponse(204,n);if("GET"===s.method&&o===g)return l?bodyResponse(200,l,{...n,"Content-Type":"application/javascript","Cache-Control":"public, max-age=3600"}):jsonResponse(404,{success:!1,error:{code:"NOT_FOUND",message:"Fingerprint SDK bundle not configured. Pass fingerprintSdkBundle in config."}},n);if("POST"===s.method&&(o===v||o===S||o===y)){let i;try{i=await s.json()}catch{return jsonResponse(400,{success:!1,error:{code:"BODY_PARSER_MISSING",message:"Request body is not valid JSON."}},n)}return o===v?handleSubmitFp(s,i,{client:e,verdictCache:f,rateLimitBackoff:h,dispatchDedupe:_,resolveUserId:r,resolveEmailAddress:t,resolveSessionId:c,resolveDeviceId:u,onError:m},n):o===S?handleVerifyTriggerWeb(s,i,{client:e,verdictCache:f,resolveEmailAddress:t,resolveDeviceId:u,onError:m},n):handleVerifyWeb(s,i,{client:e,verdictCache:f,resolveEmailAddress:t,resolveDeviceId:u,onError:m},n)}if("GET"===s.method&&o===C){let e;try{e=r(s)}catch{}if(!e)return jsonResponse(200,{status:"anonymous"},n);const i=resolveEmail(s,t),o=f.get(e);return o&&o.isFlagged&&!o.isVerified&&p&&i?jsonResponse(403,{error:"account_flagged",email:i},n):jsonResponse(200,{status:"ok"},n)}return jsonResponse(404,{success:!1,error:{code:"NOT_FOUND",message:"Unknown route"}},n)}if(shouldSkipPath(o,a))return i(s);if(!shouldIncludePath(o,d))return injectIntoHtmlResponse(await i(s),I);try{A=r(s)}catch{}if(isSentinelUserId(A)){const e=parseCookieFromRequest(s,"__unshared_uid"),r=parseCookieFromRequest(s,"__unshared_uid_at"),t=r?Number(r):NaN,n=Number.isFinite(t)&&Date.now()-t<=SENTINEL_STICKINESS_TTL_MS;A=e&&n?e:void 0}if(!A){const e=isSecureWebRequest(s)?"; Secure":"",r=[`__unshared_uid=; Path=/; SameSite=Lax; Max-Age=0${e}`,`__unshared_uid_at=; Path=/; SameSite=Lax; Max-Age=0${e}`,`__unshared_sid=; Path=/; SameSite=Lax; Max-Age=0${e}`,`__unshared_email=; Path=/; SameSite=Lax; Max-Age=0${e}`];return injectIntoHtmlResponse(await i(s),I,r)}const E=resolveEmail(s,t),F=[],T=isSecureWebRequest(s)?"; Secure":"";if(F.push(`__unshared_uid=${encodeURIComponent(A)}; Path=/; SameSite=Lax${T}`),F.push(`__unshared_uid_at=${Date.now()}; Path=/; SameSite=Lax${T}`),E&&F.push(`__unshared_email=${encodeURIComponent(E)}; HttpOnly; Path=/; SameSite=Lax${T}`),!E)return injectIntoHtmlResponse(await i(s),I,F);const k=extractSessionIdFromRequest(s,c),q=extractDeviceIdFromRequest(s,u),x=parseCookieFromRequest(s,"__unshared_fingerprint_id")||void 0,O=s.headers.get("user-agent")??"",j=extractClientIpFromRequest(s),D=q??x;if(isBot(O))return i(s);let L=f.get(A);if(L)f.isStale(A)&&!f.isRefreshing(A)&&(f.markRefreshing(A),fetchAndCacheVerdict(e,f,A,E,D??"unknown",x,k).finally(()=>f.clearRefreshing(A)));else try{L=await fetchAndCacheVerdict(e,f,A,E,D??"unknown",x,k)}catch{return injectIntoHtmlResponse(await i(s),I,F)}if(L.isFlagged&&!L.isVerified&&p)try{const e=await p({userId:A,emailAddress:E,verdict:L,request:s});if(e)return injectIntoHtmlResponse(e,I,F)}catch(e){m&&m(e,{operation:"checkUser",userId:A,emailAddress:E})}return L.isFlagged||"unknown"===k||!D||h.isPaused()||dispatchUserEvent(e,f,h,_,{userId:A,emailAddress:E,sessionId:k,deviceId:D,fingerprintId:x,userAgent:O,ipAddress:j,eventType:o+R},m),injectIntoHtmlResponse(await i(s),I,F)}}async function injectIntoHtmlResponse(e,s,r){const t=e.headers.get("content-type");if(!isHtmlContentType(t??void 0)){if(!r||0===r.length)return e;const s=mergeResponseHeaders(e.headers,void 0,r);return new Response(e.body,{status:e.status,statusText:e.statusText,headers:s})}const n=await e.text(),i=n.lastIndexOf("</body>"),o=-1===i?n+s:n.slice(0,i)+s+n.slice(i),a=mergeResponseHeaders(e.headers,{"Cache-Control":"no-store","Content-Length":String((new TextEncoder).encode(o).length)},r);return a.delete("ETag"),a.delete("Last-Modified"),a.delete("Content-Encoding"),new Response(o,{status:e.status,statusText:e.statusText,headers:a})}function resolveEmail(e,s){if(s)try{const r=s(e);if(r)return r}catch{}const r=parseCookieFromRequest(e,"__unshared_email");if(r)return r}function resolveEmailWithBody(e,s,r){const t=resolveEmail(e,r);if(t)return t;const n=s.email;return"string"==typeof n&&n?n:void 0}function extractSessionIdFromRequest(e,s){if(s)try{const r=s(e);if(r)return r}catch{}return parseCookieFromRequest(e,"__unshared_sid")??"unknown"}function dispatchUserEvent(e,s,r,t,n,i){t.mark(n.userId,n.eventType),e.processUserEvent({eventType:n.eventType,userId:n.userId,emailAddress:n.emailAddress,ipAddress:n.ipAddress,deviceId:n.deviceId,fingerprintId:n.fingerprintId,sessionHash:n.sessionId,userAgent:n.userAgent}).then(e=>{e.success&&e.data?.analysis&&s.update(n.userId,{isFlagged:e.data.analysis.is_user_flagged}),!e.success&&e.error?.retryAfter&&r.pause(1e3*e.error.retryAfter)}).catch(e=>{i&&i(e,{operation:"processUserEvent",userId:n.userId,emailAddress:n.emailAddress})})}async function fetchAndCacheVerdict(e,s,r,t,n,i,o){const a={};let d;n&&"unknown"!==n&&(a.deviceId=n),i&&(a.fingerprintId=i);const c=await Promise.race([e.checkUser(t,a),new Promise(e=>{d=setTimeout(()=>e(null),500)})]);if(clearTimeout(d),!c)return{isFlagged:!1,isVerified:!1,emailAddress:t,sessionId:o,cachedAt:0,ttl:0};const u=c.data?.is_user_flagged??!1;return s.set(r,{isFlagged:u,isVerified:!1,emailAddress:t,sessionId:o}),s.get(r)}async function handleSubmitFp(e,s,r,t){try{const n={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 i,o,a;try{const s=r.resolveUserId(e);s&&!isSentinelUserId(s)&&(i=s)}catch{}if(!i){const e="string"==typeof s.user_id?s.user_id:void 0;e&&!isSentinelUserId(e)&&(i=e)}if(!i){const s=parseCookieFromRequest(e,"__unshared_uid");s&&!isSentinelUserId(s)&&(i=s)}try{o=r.resolveEmailAddress?r.resolveEmailAddress(e):void 0}catch{}o=o??parseCookieFromRequest(e,"__unshared_email")??s.email??void 0;try{a=r.resolveSessionId?r.resolveSessionId(e):void 0}catch{}a=a??s.session_id??parseCookieFromRequest(e,"__unshared_sid");const d=extractClientIpFromRequest(e),c=e.headers.get("user-agent")??"";if(isBot(c))return jsonResponse(200,{success:!0},t);const u=(n.fingerprint_id&&n.fingerprint_id.length>0?n.fingerprint_id:void 0)??extractDeviceIdFromRequestOrUnknown(e,r.resolveDeviceId),l=n.fingerprint_id||void 0,p=n.full_hash||void 0,m=isSecureWebRequest(e)?"; Secure":"",f=[];if(p&&!parseCookieFromRequest(e,"__unshared_fingerprint_id")&&f.push(`__unshared_fingerprint_id=${encodeURIComponent(p)}; HttpOnly; Path=/; SameSite=Lax${m}`),l){const s=parseCookieFromRequest(e,"__unshared_fp_id");s&&s===l||f.push(`__unshared_fp_id=${encodeURIComponent(l)}; Path=/; SameSite=Lax; Max-Age=31536000${m}`)}let h;if(o&&!parseCookieFromRequest(e,"__unshared_email")&&f.push(`__unshared_email=${encodeURIComponent(o)}; HttpOnly; Path=/; SameSite=Lax${m}`),"string"==typeof s.event_type&&s.event_type)h=s.event_type;else{const s=e.headers.get("referer")??e.headers.get("referrer");let r="unknown";if(s)try{const e=new URL(s);r=(e.pathname||"/")+(e.search||"")}catch{}h=r}const _=e.headers.get("x-idempotency-key")||void 0,R=Date.now(),I=_?`${_}|${R}`:l&&i?`${l}|${i}|${h}|${R}`:void 0;i&&r.client.submitFingerprintEvent(n,{userId:i,emailAddress:o,sessionHash:a,eventType:h,ipAddress:d,userAgent:c,idempotencyKey:I}).catch(e=>{r.onError&&r.onError(e,{operation:"submitFingerprintEvent",userId:i,emailAddress:o})}),i&&o&&!r.rateLimitBackoff.isPaused()&&!r.dispatchDedupe.wasRecentlyDispatched(i,h)&&r.client.processUserEvent({eventType:h,userId:i,emailAddress:o,ipAddress:d,deviceId:u,fingerprintId:l,sessionHash:a??"unknown",userAgent:c}).then(e=>{e.success&&e.data?.analysis&&r.verdictCache.update(i,{isFlagged:e.data.analysis.is_user_flagged}),!e.success&&e.error?.retryAfter&&r.rateLimitBackoff.pause(1e3*e.error.retryAfter)}).catch(e=>{r.onError&&r.onError(e,{operation:"processUserEvent",userId:i,emailAddress:o})});const g={...t,"Content-Type":"application/json"},v=new Response(JSON.stringify({success:!0}),{status:200,headers:g});for(const e of f)v.headers.append("Set-Cookie",e);return v}catch{return jsonResponse(200,{success:!0},t)}}async function handleVerifyTriggerWeb(e,s,r,t){try{const n=resolveEmailWithBody(e,s??{},r.resolveEmailAddress);if(!n)return jsonResponse(400,{success:!1,error:{code:"VALIDATION_ERROR",message:"Email is required"}},t);const i=extractDeviceIdFromRequestOrUnknown(e,r.resolveDeviceId),o=parseCookieFromRequest(e,"__unshared_fingerprint_id")||void 0,a=await r.client.triggerEmailVerification(n,i,{fingerprintId:o});return a.success?jsonResponse(200,{success:!0,data:a.data},t):jsonResponse(200,{success:!1,error:a.error??{code:"TRIGGER_FAILED",message:"Failed to send verification email"}},t)}catch(e){return r.onError&&r.onError(e,{operation:"verifyTrigger"}),jsonResponse(200,{success:!1,error:{code:"INTERNAL_ERROR",message:"Failed to trigger verification"}},t)}}async function handleVerifyWeb(e,s,r,t){try{const n=resolveEmailWithBody(e,s??{},r.resolveEmailAddress),i=s?.code;if(!n||!i)return jsonResponse(400,{success:!1,error:{code:"VALIDATION_ERROR",message:"Email and code are required"}},t);const o=extractDeviceIdFromRequestOrUnknown(e,r.resolveDeviceId),a=parseCookieFromRequest(e,"__unshared_fingerprint_id")||void 0,d=await r.client.verify(n,o,i,{fingerprintId:a});if(d.success){const s=parseCookieFromRequest(e,"__unshared_uid");return s&&r.verdictCache.update(s,{isVerified:!0}),jsonResponse(200,{success:!0,data:{verified:!0}},t)}return jsonResponse(200,{success:!1,error:d.error??{code:"VERIFICATION_FAILED",message:"Verification failed"}},t)}catch(e){return r.onError&&r.onError(e,{operation:"verify"}),jsonResponse(200,{success:!1,error:{code:"INTERNAL_ERROR",message:"Verification failed"}},t)}}
@@ -0,0 +1,27 @@
1
+ import type { UnsharedClient } from '../client';
2
+ import type { WebSubmitOptions } from './types';
3
+ /**
4
+ * Web Standard equivalent of `createUnsharedMiddleware` from src/middleware.ts.
5
+ *
6
+ * Returns a handler `(request: Request) => Promise<Response>` suitable for
7
+ * Next.js App Router Route Handlers, Vercel Edge Functions, Cloudflare
8
+ * Workers, Deno Deploy, and any other Web Standard runtime.
9
+ *
10
+ * @example Next.js App Router
11
+ * ```typescript
12
+ * // app/unshared/submit-fingerprint-event/route.ts
13
+ * import { createWebSubmitHandler } from 'unshared-clientjs-sdk/web';
14
+ * import { UnsharedClient } from 'unshared-clientjs-sdk';
15
+ *
16
+ * const client = new UnsharedClient({ apiKey: '...', clientId: '...' });
17
+ * const handler = createWebSubmitHandler(client);
18
+ *
19
+ * export const POST = handler;
20
+ * export const OPTIONS = handler;
21
+ * ```
22
+ *
23
+ * **Error contract:** Never returns 5xx. Upstream failures return HTTP 200
24
+ * with `{ success: false, error: { code: "UPSTREAM_ERROR" } }`. Same contract
25
+ * as the Node.js middleware.
26
+ */
27
+ export declare function createWebSubmitHandler(client: UnsharedClient, options?: WebSubmitOptions): (request: Request) => Promise<Response>;