unshared-clientjs-sdk 2.2.1 → 3.0.0-rc.14
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +176 -151
- package/dist/client.d.ts +58 -24
- package/dist/client.js +1 -1
- package/dist/esm/client.d.mts +58 -24
- package/dist/esm/client.mjs +1 -1
- package/dist/esm/index.d.mts +8 -8
- package/dist/esm/index.mjs +1 -1
- package/dist/esm/middleware/dispatch-dedupe.d.mts +1 -0
- package/dist/esm/middleware/dispatch-dedupe.mjs +1 -1
- package/dist/esm/middleware/dispatch-processing.d.mts +25 -0
- package/dist/esm/middleware/dispatch-processing.mjs +1 -0
- package/dist/esm/middleware/index.d.mts +19 -5
- package/dist/esm/middleware/index.mjs +1 -1
- package/dist/esm/middleware/injection/fingerprint-script.d.mts +7 -0
- package/dist/esm/middleware/injection/fingerprint-script.mjs +1 -1
- package/dist/esm/middleware/injection/fp-bundle-source.d.mts +5 -4
- package/dist/esm/middleware/injection/fp-bundle-source.mjs +1 -1
- package/dist/esm/middleware/rate-limit-backoff.d.mts +11 -8
- package/dist/esm/middleware/rate-limit-backoff.mjs +1 -1
- package/dist/esm/middleware/response-interceptor.d.mts +2 -1
- package/dist/esm/middleware/response-interceptor.mjs +1 -1
- package/dist/esm/middleware/routes/interstitial.d.mts +2 -2
- package/dist/esm/middleware/routes/interstitial.mjs +1 -1
- package/dist/esm/middleware/routes/submit-fp.d.mts +5 -5
- package/dist/esm/middleware/routes/submit-fp.mjs +1 -1
- package/dist/esm/middleware/routes/verify.d.mts +3 -3
- package/dist/esm/middleware/routes/verify.mjs +1 -1
- package/dist/esm/middleware/utils/client-ip.d.mts +1 -1
- package/dist/esm/middleware/utils/cookies.d.mts +1 -1
- package/dist/esm/middleware/utils/device-id.d.mts +1 -1
- package/dist/esm/middleware/utils/device-id.mjs +1 -1
- package/dist/esm/middleware/utils/flagged-response.mjs +1 -1
- package/dist/esm/middleware/utils/http-helpers.d.mts +1 -1
- package/dist/esm/middleware/utils/permanent-device-id.d.mts +7 -0
- package/dist/esm/middleware/utils/permanent-device-id.mjs +1 -0
- package/dist/esm/middleware/utils/read-json-body.d.mts +3 -0
- package/dist/esm/middleware/utils/read-json-body.mjs +1 -0
- package/dist/esm/middleware/utils/secure.d.mts +1 -1
- package/dist/esm/middleware/utils/skip-paths.d.mts +5 -1
- package/dist/esm/middleware/utils/skip-paths.mjs +1 -1
- package/dist/esm/middleware/verdict-cache.d.mts +8 -1
- package/dist/esm/middleware/verdict-cache.mjs +1 -1
- package/dist/esm/middleware.d.mts +5 -6
- package/dist/esm/middleware.mjs +1 -1
- package/dist/esm/shared-types.d.mts +148 -0
- package/dist/esm/web/index.d.mts +6 -6
- package/dist/esm/web/index.mjs +1 -1
- package/dist/esm/web/protection-handler.d.mts +31 -3
- package/dist/esm/web/protection-handler.mjs +1 -1
- package/dist/esm/web/submit-handler.d.mts +2 -2
- package/dist/esm/web/submit-handler.mjs +1 -1
- package/dist/esm/web/types.d.mts +12 -2
- package/dist/esm/web/web-helpers.d.mts +11 -0
- package/dist/esm/web/web-helpers.mjs +1 -1
- package/dist/esm/webcrypto.d.mts +8 -0
- package/dist/esm/webcrypto.mjs +1 -0
- package/dist/middleware/dispatch-dedupe.d.ts +1 -0
- package/dist/middleware/dispatch-dedupe.js +1 -1
- package/dist/middleware/dispatch-processing.d.ts +25 -0
- package/dist/middleware/dispatch-processing.js +1 -0
- package/dist/middleware/index.d.ts +14 -0
- package/dist/middleware/index.js +1 -1
- package/dist/middleware/injection/fingerprint-script.d.ts +7 -0
- package/dist/middleware/injection/fingerprint-script.js +1 -1
- package/dist/middleware/injection/fp-bundle-source.d.ts +5 -4
- package/dist/middleware/injection/fp-bundle-source.js +1 -1
- package/dist/middleware/rate-limit-backoff.d.ts +11 -8
- package/dist/middleware/rate-limit-backoff.js +1 -1
- package/dist/middleware/response-interceptor.d.ts +1 -0
- package/dist/middleware/response-interceptor.js +1 -1
- package/dist/middleware/routes/submit-fp.js +1 -1
- package/dist/middleware/routes/verify.js +1 -1
- package/dist/middleware/utils/permanent-device-id.d.ts +7 -0
- package/dist/middleware/utils/permanent-device-id.js +1 -0
- package/dist/middleware/utils/read-json-body.d.ts +3 -0
- package/dist/middleware/utils/read-json-body.js +1 -0
- package/dist/middleware/utils/skip-paths.d.ts +5 -1
- package/dist/middleware/utils/skip-paths.js +1 -1
- package/dist/middleware/verdict-cache.d.ts +8 -1
- package/dist/middleware/verdict-cache.js +1 -1
- package/dist/middleware.d.ts +3 -4
- package/dist/middleware.js +1 -1
- package/dist/shared-types.d.ts +148 -0
- package/dist/web/protection-handler.d.ts +29 -1
- package/dist/web/protection-handler.js +1 -1
- package/dist/web/submit-handler.js +1 -1
- package/dist/web/types.d.ts +11 -1
- package/dist/web/web-helpers.d.ts +11 -0
- package/dist/web/web-helpers.js +1 -1
- package/dist/webcrypto.d.ts +8 -0
- package/dist/webcrypto.js +1 -0
- package/package.json +5 -5
|
@@ -1,14 +1,17 @@
|
|
|
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
1
|
export declare class RateLimitBackoff {
|
|
9
2
|
private _resumeAtTimestamp;
|
|
10
|
-
|
|
3
|
+
private _failureResumeAtTimestamp;
|
|
4
|
+
private _consecutiveFailures;
|
|
5
|
+
/** Pause processUserEvent calls (server-directed 429), clamped to MAX_BACKOFF_MS. */
|
|
11
6
|
pause(durationMs: number): void;
|
|
7
|
+
/** Record a network/5xx/timeout failure; engage failure backoff at the threshold. */
|
|
8
|
+
noteFailure(): void;
|
|
9
|
+
/** Record a success; resets the failure streak and lifts ONLY the failure-streak
|
|
10
|
+
* pause. It deliberately does NOT touch a server-directed 429 pause (pause()),
|
|
11
|
+
* so a success on one request can't wipe another request's legitimate 429 backoff. */
|
|
12
|
+
noteSuccess(): void;
|
|
12
13
|
/** Returns true if processUserEvent calls should be skipped. */
|
|
13
14
|
isPaused(): boolean;
|
|
15
|
+
/** Parse a Retry-After header value (delta-seconds OR HTTP-date) into ms, or null. */
|
|
16
|
+
static parseRetryAfter(headerValue: string | null, now?: number): number | null;
|
|
14
17
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
export class RateLimitBackoff{constructor(){this.t=0}pause(t){const s=Date.now()+
|
|
1
|
+
const MAX_BACKOFF_MS=3e5,FAILURE_THRESHOLD=3,FAILURE_BACKOFF_MS=1e4;export class RateLimitBackoff{constructor(){this.t=0,this.i=0,this.h=0}pause(t){const s=Math.min(Math.max(0,t),3e5),e=Date.now()+s;e>this.t&&(this.t=e)}noteFailure(){if(this.h++,this.h>=3){const t=Date.now()+1e4;t>this.i&&(this.i=t)}}noteSuccess(){this.h=0,this.i=0}isPaused(){const t=Date.now();return t<this.t||t<this.i}static parseRetryAfter(t,s=Date.now()){if(null==t)return null;const e=parseInt(t,10);if(!isNaN(e)&&String(e)===t.trim())return 1e3*e;const i=Date.parse(t);return isNaN(i)?null:Math.max(0,i-s)}}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { UnsharedResponse } from '../types';
|
|
1
|
+
import type { UnsharedResponse } from '../types.mjs';
|
|
2
2
|
/**
|
|
3
3
|
* Intercepts the response body by wrapping res.write() and res.end().
|
|
4
4
|
*
|
|
@@ -19,4 +19,5 @@ import type { UnsharedResponse } from '../types';
|
|
|
19
19
|
*/
|
|
20
20
|
export declare function interceptResponse(res: UnsharedResponse, transform: (body: Buffer, contentType: string | undefined) => Buffer | string | null, options?: {
|
|
21
21
|
preventCaching?: boolean;
|
|
22
|
+
streamingIdleMs?: number;
|
|
22
23
|
}): void;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
const MAX_INJECT_BYTES=2e6;function findWriteHeadHeaders(e){return e.find(e=>e&&"object"==typeof e)}function readHeaderFromWriteHeadArgs(e,t){const n=findWriteHeadHeaders(e);if(!n)return;const r=t.toLowerCase();if(Array.isArray(n)){if(n.length&&Array.isArray(n[0])){for(const e of n)if(String(e[0]).toLowerCase()===r)return e[1]}else for(let e=0;e+1<n.length;e+=2)if(String(n[e]).toLowerCase()===r)return n[e+1]}else for(const e of Object.keys(n))if(e.toLowerCase()===r)return n[e]}function deleteHeaderFromWriteHeadArgs(e,t){const n=findWriteHeadHeaders(e);if(!n)return;const r=t.toLowerCase();if(Array.isArray(n))if(n.length&&Array.isArray(n[0]))for(let e=n.length-1;e>=0;e--)String(n[e][0]).toLowerCase()===r&&n.splice(e,1);else for(let e=n.length-2;e>=0;e-=2)String(n[e]).toLowerCase()===r&&n.splice(e,2);else for(const e of Object.keys(n))e.toLowerCase()===r&&delete n[e]}export function interceptResponse(e,t,n){const r=n?.preventCaching??!1,o=e.write.bind(e),
|
|
1
|
+
const MAX_INJECT_BYTES=2e6;function findWriteHeadHeaders(e){return e.find(e=>e&&"object"==typeof e)}function readHeaderFromWriteHeadArgs(e,t){const n=findWriteHeadHeaders(e);if(!n)return;const r=t.toLowerCase();if(Array.isArray(n)){if(n.length&&Array.isArray(n[0])){for(const e of n)if(String(e[0]).toLowerCase()===r)return e[1]}else for(let e=0;e+1<n.length;e+=2)if(String(n[e]).toLowerCase()===r)return n[e+1]}else for(const e of Object.keys(n))if(e.toLowerCase()===r)return n[e]}function deleteHeaderFromWriteHeadArgs(e,t){const n=findWriteHeadHeaders(e);if(!n)return;const r=t.toLowerCase();if(Array.isArray(n))if(n.length&&Array.isArray(n[0]))for(let e=n.length-1;e>=0;e--)String(n[e][0]).toLowerCase()===r&&n.splice(e,1);else for(let e=n.length-2;e>=0;e-=2)String(n[e]).toLowerCase()===r&&n.splice(e,2);else for(const e of Object.keys(n))e.toLowerCase()===r&&delete n[e]}export function interceptResponse(e,t,n){const r=n?.preventCaching??!1,o=n?.streamingIdleMs??50,f=e.write.bind(e),i=e.end.bind(e),u=e.writeHead.bind(e),c=[];let l=0,d=!1,s=null,a=null;function g(){s&&(clearTimeout(s),s=null),e.write=f,e.end=i;for(const e of c)f(e);c.length=0}function H(e,t){null===a&&null!=e&&(a=String(e).includes("text/html"),a&&t&&(a=!1),!1===a&&g())}function y(){H(e.getHeader("content-type"),e.getHeader("content-encoding"))}e.writeHead=function(t,...n){return H(readHeaderFromWriteHeadArgs(n,"content-type")??e.getHeader("content-type"),readHeaderFromWriteHeadArgs(n,"content-encoding")??e.getHeader("content-encoding")),a&&(r&&(e.setHeader("Cache-Control","no-store"),e.removeHeader("ETag"),e.removeHeader("Last-Modified"),deleteHeaderFromWriteHeadArgs(n,"cache-control"),deleteHeaderFromWriteHeadArgs(n,"etag"),deleteHeaderFromWriteHeadArgs(n,"last-modified")),e.removeHeader("Content-Length"),deleteHeaderFromWriteHeadArgs(n,"content-length")),u(t,...n)},e.write=function(e,t,n){if(y(),!1===a)return f(e,t,n);if(null!=e){const r=Buffer.isBuffer(e)?e:Buffer.from(e,"string"==typeof t?t:"utf8");if(l+=r.length,c.push(r),l>2e6)return g(),"function"==typeof t&&t(null),"function"==typeof n&&n(null),!0;s||d||(s=setTimeout(()=>{d||g()},o),"function"==typeof s.unref&&s.unref())}return"function"==typeof t&&t(null),"function"==typeof n&&n(null),!0},e.end=function(n,r,o){if(d)return e;if(d=!0,s&&(clearTimeout(s),s=null),y(),!1===a)return i(n,r,o);if(null!=n){const e=Buffer.isBuffer(n)?n:Buffer.from(n,"string"==typeof r?r:"utf8");l+=e.length,c.push(e)}if(l>2e6){g();const e="function"==typeof r?r:o;return e?i(e):i()}const u=Buffer.concat(c),H=e.getHeader("content-type");let p;try{p=t(u,H)}catch{p=null}if(null!=p){const t=Buffer.isBuffer(p)?p:Buffer.from(p,"utf8");e.headersSent||(e.setHeader("Content-Length",t.length),e.removeHeader("Content-Encoding")),f(t)}else u.length>0&&f(u);const m="function"==typeof r?r:o;return m?i(m):i(),e}}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import type { UnsharedRequest, UnsharedResponse } from '../../types';
|
|
2
|
-
import type { UnsharedClient } from '../../client';
|
|
1
|
+
import type { UnsharedRequest, UnsharedResponse } from '../../types.mjs';
|
|
2
|
+
import type { UnsharedClient } from '../../client.mjs';
|
|
3
3
|
export interface InterstitialDependencies {
|
|
4
4
|
client: UnsharedClient;
|
|
5
5
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
import{sendJson}from"../utils/http-helpers";function isPublishedFlow(s){return!!s&&"object"==typeof s&&Object.keys(s).length>0}const FLOW_NOT_PUBLISHED={code:"FLOW_NOT_PUBLISHED",message:"No interstitial flow is published for this company."};export function handleGetInterstitialFlow(s){return async(e,o)=>{try{const t=e.url&&e.url.includes("?")?e.url.slice(e.url.indexOf("?")+1):"",r=new URLSearchParams(t),n=r.get("flow_type")||void 0,i=r.get("platform")||void 0,a=await s.client.getInterstitialFlow({flowType:n,platform:i});a.success?isPublishedFlow(a.data)?sendJson(o,200,{success:!0,data:a.data}):sendJson(o,200,{success:!1,error:{...FLOW_NOT_PUBLISHED}}):404===a.status?sendJson(o,200,{success:!1,error:{...FLOW_NOT_PUBLISHED}}):sendJson(o,200,{success:!1,error:a.error??{code:"FLOW_FETCH_FAILED",message:"Failed to load interstitial flow"}})}catch{sendJson(o,200,{success:!1,error:{code:"INTERNAL_ERROR",message:"Failed to load interstitial flow"}})}}}
|
|
1
|
+
import{sendJson}from"../utils/http-helpers.mjs";function isPublishedFlow(s){return!!s&&"object"==typeof s&&Object.keys(s).length>0}const FLOW_NOT_PUBLISHED={code:"FLOW_NOT_PUBLISHED",message:"No interstitial flow is published for this company."};export function handleGetInterstitialFlow(s){return async(e,o)=>{try{const t=e.url&&e.url.includes("?")?e.url.slice(e.url.indexOf("?")+1):"",r=new URLSearchParams(t),n=r.get("flow_type")||void 0,i=r.get("platform")||void 0,a=await s.client.getInterstitialFlow({flowType:n,platform:i});a.success?isPublishedFlow(a.data)?sendJson(o,200,{success:!0,data:a.data}):sendJson(o,200,{success:!1,error:{...FLOW_NOT_PUBLISHED}}):404===a.status?sendJson(o,200,{success:!1,error:{...FLOW_NOT_PUBLISHED}}):sendJson(o,200,{success:!1,error:a.error??{code:"FLOW_FETCH_FAILED",message:"Failed to load interstitial flow"}})}catch{sendJson(o,200,{success:!1,error:{code:"INTERNAL_ERROR",message:"Failed to load interstitial flow"}})}}}
|
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
import type { UnsharedRequest, UnsharedResponse } from '../../types';
|
|
2
|
-
import type { UnsharedClient } from '../../client';
|
|
3
|
-
import type { VerdictCache } from '../verdict-cache';
|
|
4
|
-
import type { RateLimitBackoff } from '../rate-limit-backoff';
|
|
5
|
-
import type { DispatchDedupe } from '../dispatch-dedupe';
|
|
1
|
+
import type { UnsharedRequest, UnsharedResponse } from '../../types.mjs';
|
|
2
|
+
import type { UnsharedClient } from '../../client.mjs';
|
|
3
|
+
import type { VerdictCache } from '../verdict-cache.mjs';
|
|
4
|
+
import type { RateLimitBackoff } from '../rate-limit-backoff.mjs';
|
|
5
|
+
import type { DispatchDedupe } from '../dispatch-dedupe.mjs';
|
|
6
6
|
export interface SubmitFingerprintDependencies<TReq extends UnsharedRequest = UnsharedRequest> {
|
|
7
7
|
client: UnsharedClient;
|
|
8
8
|
verdictCache: VerdictCache;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
import{sha256Hex}from"../../util";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
|
|
1
|
+
import{sha256Hex}from"../../util.mjs";import{isBot}from"../utils/is-bot.mjs";import{extractClientIp}from"../utils/client-ip.mjs";import{parseCookie}from"../utils/cookies.mjs";import{extractDeviceId}from"../utils/device-id.mjs";import{isSecureRequest}from"../utils/secure.mjs";import{isSentinelUserId}from"../utils/sentinel-user-id.mjs";import{sendJson}from"../utils/http-helpers.mjs";import{resolveOrCreatePermanentDeviceId}from"../utils/permanent-device-id.mjs";import{runCheckUser,SUBMIT_FP_TIMEOUT_MS}from"../dispatch-processing.mjs";function asRecord(e){return null===e||"object"!=typeof e||Array.isArray(e)?{}:e}export function handleSubmitFingerprint(e){return async(t,i)=>{try{const s=asRecord(t.body),r=asRecord(s.fingerprint??s),o=asRecord(s.context),n=asRecord(o.identity),a=asRecord(o.identifiers),d=asRecord(o.event),c={...r,full_hash:r.hash??r.full_hash??"",fingerprint_id:r.stableHash??r.stable_hash??r.fingerprint_id??"",timestamp:r.timestamp??r.collected_at??(new Date).toISOString(),isIncognito:r.isIncognito??r.is_incognito??!1,components:r.components??{},version:r.version??"unknown",..."remote"===r.source||"bundled"===r.source?{source:r.source}:{}};let p,m,u;try{const i=e.resolveUserId?e.resolveUserId(t):void 0;i&&!isSentinelUserId(i)&&(p=i)}catch{}if(!p){const e="string"==typeof n.user_id?n.user_id:"string"==typeof s.user_id?s.user_id:void 0;e&&!isSentinelUserId(e)&&(p=e)}if(!p){const e=parseCookie(t,"__unshared_uid");e&&!isSentinelUserId(e)&&(p=e)}try{m=e.resolveEmailAddress?e.resolveEmailAddress(t):void 0}catch{}m=m??parseCookie(t,"__unshared_email")??n.email_address??s.email??void 0;try{u=e.resolveSessionId?e.resolveSessionId(t):void 0}catch{}u=u??a.session_hash??s.session_id??parseCookie(t,"__unshared_sid");const f=extractClientIp(t),_=t.headers["user-agent"]??"";if(!e.disableBotFilter&&isBot(_))return void sendJson(i,200,{success:!0,data:{skipped:!0}});const l=(c.fingerprint_id&&c.fingerprint_id.length>0?c.fingerprint_id:void 0)??extractDeviceId(t,e.resolveDeviceId),h=c.fingerprint_id||void 0,y=c.full_hash||void 0,g=resolveOrCreatePermanentDeviceId(t,a.unshared_device_id??s.permanent_device_id),v=isSecureRequest(t)?"; Secure":"",S=[];S.push(`__unshared_device_id=${encodeURIComponent(g.id)}; Path=/; SameSite=Lax; Max-Age=34560000${v}`);const I=parseCookie(t,"__unshared_fingerprint_id")||y;if(I&&S.push(`__unshared_fingerprint_id=${encodeURIComponent(I)}; HttpOnly; Path=/; SameSite=Lax; Max-Age=31536000${v}`),h&&S.push(`__unshared_fp_id=${encodeURIComponent(h)}; Path=/; SameSite=Lax; Max-Age=31536000${v}`),m&&S.push(`__unshared_email=${encodeURIComponent(m)}; HttpOnly; Path=/; SameSite=Lax; Max-Age=31536000${v}`),S.length>0){const e=i.getHeader("Set-Cookie");if(e){const t=Array.isArray(e)?[...e]:[String(e)];t.push(...S),i.setHeader("Set-Cookie",t)}else i.setHeader("Set-Cookie",S)}let j;if("string"==typeof d.type&&d.type)j=d.type;else if("string"==typeof s.event_type&&s.event_type)j=s.event_type;else{const e=t.headers.referer??t.headers.referrer;let i="unknown";if("string"==typeof e&&e.length>0)try{const t=new URL(e);i=(t.pathname||"/")+(t.search||"")}catch{}j=i}const C=t.headers["x-idempotency-key"],x="string"==typeof C&&C.length>0?C:void 0,R=Date.now(),k=x?`${x}|${R}`:h&&p?`${sha256Hex(`${h}|${p}|${j}`)}|${R}`:void 0,b={...a,unshared_device_id:g.id,...l?{device_id:l}:{},...h?{stable_hash:h}:{},...y?{full_hash:y}:{},...u?{session_hash:u}:{}},U="string"==typeof asRecord(o.sdk).name?asRecord(o.sdk).name:void 0,$="string"==typeof U&&U.startsWith("unshared-")&&Object.prototype.hasOwnProperty.call(s,"fingerprint")&&s.context&&"object"==typeof s.context&&!Array.isArray(s.context)&&o.identity&&"object"==typeof o.identity&&!Array.isArray(o.identity)&&o.identifiers&&"object"==typeof o.identifiers&&!Array.isArray(o.identifiers)&&o.event&&"object"==typeof o.event&&!Array.isArray(o.event),A=!Object.prototype.hasOwnProperty.call(s,"context")&&"string"==typeof s.hash&&"string"==typeof s.stable_hash&&"string"==typeof s.collected_at&&s.components&&"object"==typeof s.components&&!Array.isArray(s.components),M=$||A?{...s,fingerprint:r,context:{...o,sdk:asRecord(o.sdk),identity:{...n,...p?{user_id:p}:{},...m?{email_address:m}:{}},identifiers:b,event:{...d,type:j,ip_address:f,user_agent:_}}}:s,O=await e.client.submitFingerprint(M,{idempotencyKey:k});O.success||e.onError?.(O.error,{operation:"submitFingerprintEvent",userId:p,emailAddress:m}),p&&m&&!e.rateLimitBackoff.isPaused()&&!e.dispatchDedupe.wasRecentlyDispatched(p,j)&&await runCheckUser({client:e.client,cache:e.verdictCache,backoff:e.rateLimitBackoff,onError:e.onError},{user_id:p,email_address:m,identifiers:b},p,m,SUBMIT_FP_TIMEOUT_MS),O.success?sendJson(i,200,{success:!0,data:O.data}):sendJson(i,200,{success:!1,error:O.error})}catch(t){e.onError&&e.onError(t,{operation:"submitFingerprintEvent"}),sendJson(i,200,{success:!0,data:{accepted:!1}})}}}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import type { UnsharedRequest, UnsharedResponse } from '../../types';
|
|
2
|
-
import type { UnsharedClient } from '../../client';
|
|
3
|
-
import type { VerdictCache } from '../verdict-cache';
|
|
1
|
+
import type { UnsharedRequest, UnsharedResponse } from '../../types.mjs';
|
|
2
|
+
import type { UnsharedClient } from '../../client.mjs';
|
|
3
|
+
import type { VerdictCache } from '../verdict-cache.mjs';
|
|
4
4
|
export interface VerificationDependencies<TReq extends UnsharedRequest = UnsharedRequest> {
|
|
5
5
|
client: UnsharedClient;
|
|
6
6
|
verdictCache: VerdictCache;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
import{parseCookie}from"../utils/cookies";import{extractDeviceId}from"../utils/device-id";import{sendJson}from"../utils/http-helpers";export function handleVerifyTrigger(e){return async(
|
|
1
|
+
import{parseCookie}from"../utils/cookies.mjs";import{extractDeviceId}from"../utils/device-id.mjs";import{sendJson}from"../utils/http-helpers.mjs";import{extractPermanentDeviceId}from"../utils/permanent-device-id.mjs";import{isSecureRequest}from"../utils/secure.mjs";const VERIFICATION_COOKIE="__unshared_verification_id";function appendCookie(e,i){const s=e.getHeader("Set-Cookie"),r=s?Array.isArray(s)?[...s]:[String(s)]:[];r.push(i),e.setHeader("Set-Cookie",r)}export function handleVerifyTrigger(e){return async(i,s)=>{try{const r=resolveEmail(i,i.body??{},e.resolveEmailAddress);if(!r)return void sendJson(s,400,{success:!1,error:{code:"VALIDATION_ERROR",message:"Email is required"}});const o=extractDeviceId(i,e.resolveDeviceId),t=parseCookie(i,"__unshared_fp_id")||void 0,n=parseCookie(i,"__unshared_fingerprint_id")||void 0,a=extractPermanentDeviceId(i),c=await e.client.triggerEmailVerification(r,o,{fingerprintId:t,fullHash:n,permanentDeviceId:a});if(c.success){if(c.data?.verification_id){const e=isSecureRequest(i)?"; Secure":"";appendCookie(s,`${VERIFICATION_COOKIE}=${encodeURIComponent(c.data.verification_id)}; HttpOnly; Path=/; SameSite=Lax; Max-Age=600${e}`)}sendJson(s,200,{success:!0,data:c.data})}else sendJson(s,200,{success:!1,error:c.error??{code:"TRIGGER_FAILED",message:"Failed to send verification email"}})}catch(i){e.onError&&e.onError(i,{operation:"verifyTrigger"}),sendJson(s,200,{success:!1,error:{code:"INTERNAL_ERROR",message:"Failed to trigger verification"}})}}}export function handleVerify(e){return async(i,s)=>{try{const r=(i.body??{}).code,o=parseCookie(i,VERIFICATION_COOKIE);if(!o||!r)return void sendJson(s,400,{success:!1,error:{code:"VALIDATION_ERROR",message:"Verification ID and code are required"}});const t=await e.client.verifyEmail({verification_id:o,code:r});if(t.success&&t.data?.verified){let r,o=!1;try{r=e.resolveUserId?.(i)}catch{}const n=resolveEmail(i,{},e.resolveEmailAddress),a=extractDeviceId(i,e.resolveDeviceId),c=parseCookie(i,"__unshared_fp_id")||void 0,d=parseCookie(i,"__unshared_fingerprint_id")||void 0,u=extractPermanentDeviceId(i);if(r&&n){const i={...a?{device_id:a}:{},...c?{stable_hash:c}:{},...d?{full_hash:d}:{},...u?{unshared_device_id:u}:{}},s=await e.client.checkUser({user_id:r,email_address:n,identifiers:i},{maxRetries:0});s.success&&s.data&&!s.failedOpen&&!s.data.is_user_flagged&&(e.verdictCache.update(r,{isVerified:!0}),o=!0)}const f=isSecureRequest(i)?"; Secure":"";appendCookie(s,`${VERIFICATION_COOKIE}=; HttpOnly; Path=/; SameSite=Lax; Max-Age=0${f}`),o&&s.setHeader("X-Unshared-Enforcement-Reconciled","clean"),sendJson(s,200,{success:!0,data:t.data})}else t.success?sendJson(s,200,{success:!0,data:t.data}):sendJson(s,200,{success:!1,error:t.error??{code:"VERIFICATION_FAILED",message:"Verification failed"}})}catch(i){e.onError&&e.onError(i,{operation:"verify"}),sendJson(s,200,{success:!1,error:{code:"INTERNAL_ERROR",message:"Verification failed"}})}}}function resolveEmail(e,i,s){if(s)try{const i=s(e);if(i)return i}catch{}const r=parseCookie(e,"__unshared_email");if(r)return r;const o=i.email;return"string"==typeof o&&o?o:void 0}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { UnsharedRequest } from '../../types';
|
|
1
|
+
import type { UnsharedRequest } from '../../types.mjs';
|
|
2
2
|
/**
|
|
3
3
|
* Extract the real client IP from proxy headers, falling back to req.ip or socket address.
|
|
4
4
|
* Checked in order: CF-Connecting-IP (Cloudflare) → X-Real-IP (nginx/ALB) → req.ip → socket.
|
|
@@ -1 +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}
|
|
1
|
+
import{parseCookie}from"./cookies.mjs";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}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
import{maskEmail}from"./mask-email";export const ACCOUNT_FLAGGED_ERROR="account_flagged";export function flaggedResponse(a){return{error:"account_flagged",email:maskEmail(a)}}
|
|
1
|
+
import{maskEmail}from"./mask-email.mjs";export const ACCOUNT_FLAGGED_ERROR="account_flagged";export function flaggedResponse(a){return{error:"account_flagged",email:maskEmail(a)}}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { UnsharedRequest } from '../../types.mjs';
|
|
2
|
+
export declare function isPermanentDeviceId(value: unknown): value is string;
|
|
3
|
+
export declare function extractPermanentDeviceId(req: UnsharedRequest): string | undefined;
|
|
4
|
+
export declare function resolveOrCreatePermanentDeviceId(req: UnsharedRequest, candidate?: unknown): {
|
|
5
|
+
id: string;
|
|
6
|
+
shouldSetCookie: boolean;
|
|
7
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{randomUUID}from"crypto";import{parseCookie}from"./cookies.mjs";const PERMANENT_DEVICE_ID_PATTERN=/^upid_[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;export function isPermanentDeviceId(e){return"string"==typeof e&&PERMANENT_DEVICE_ID_PATTERN.test(e)}export function extractPermanentDeviceId(e){const i=parseCookie(e,"__unshared_device_id");if(isPermanentDeviceId(i))return i;const r=e.headers["x-permanent-device-id"];return isPermanentDeviceId(r)?r:void 0}export function resolveOrCreatePermanentDeviceId(e,i){const r=parseCookie(e,"__unshared_device_id");if(isPermanentDeviceId(r))return{id:r,shouldSetCookie:!1};const n=e.headers["x-permanent-device-id"];return{id:isPermanentDeviceId(n)?n:isPermanentDeviceId(i)?i:`upid_${randomUUID()}`,shouldSetCookie:!0}}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{sendJson}from"./http-helpers.mjs";const MAX_REQUEST_BYTES=1<<20;export async function readJsonBody(e,t){const o=e,r=(e,r,s)=>{if(!o.destroyed&&!o.readableEnded&&o.resume&&o.once&&o.removeListener&&o.destroy){const e=setTimeout(()=>o.destroy(),3e4);e.unref();const t=()=>{clearTimeout(e),o.removeListener("end",t),o.removeListener("close",t),o.removeListener("error",t)};o.once("end",t),o.once("close",t),o.once("error",t),o.resume()}return sendJson(t,e,{success:!1,error:{code:r,message:s}}),!1},s=()=>r(413,"REQUEST_TOO_LARGE","Request body exceeds 1 MiB");if(void 0!==e.body)try{return!(Buffer.byteLength(JSON.stringify(e.body),"utf8")>1048576)||s()}catch{return r(400,"INVALID_JSON","Request body is not valid JSON.")}return o.on&&o.removeListener&&o.pause?o.destroyed||o.readableEnded?r(400,"INVALID_JSON","Request body is unavailable or incomplete."):Number(e.headers["content-length"])>1048576?s():e.headers["content-encoding"]&&"identity"!==e.headers["content-encoding"]?r(415,"UNSUPPORTED_ENCODING","Request body must use identity encoding."):new Promise(t=>{const s=[];let d=0,n=!1;const u=(e,d,u)=>{n||(n=!0,clearTimeout(N),o.removeListener("data",a),o.removeListener("end",c),o.removeListener("aborted",i),o.removeListener("error",i),o.removeListener("close",i),s.length=0,t(!e||r(e,d,u)))},i=()=>u(400,"INVALID_JSON","Request body is unavailable or incomplete."),a=e=>{const t=Buffer.isBuffer(e)?e:Buffer.from(e,"utf8");d+=t.length,d>1048576?u(413,"REQUEST_TOO_LARGE","Request body exceeds 1 MiB"):s.push(t)},c=()=>{try{e.body=JSON.parse(Buffer.concat(s,d).toString("utf8"))}catch{return void u(400,"INVALID_JSON","Request body is not valid JSON.")}u()},N=setTimeout(()=>u(408,"REQUEST_TIMEOUT","Timed out reading request body."),3e4);N.unref(),o.on("data",a),o.on("end",c),o.on("aborted",i),o.on("error",i),o.on("close",i)}):r(400,"BODY_PARSER_MISSING","Provide a readable Node request stream or a parsed JSON body.")}
|
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Returns true if the request path looks like a static asset and should be
|
|
3
3
|
* skipped by the middleware (no fingerprint injection, no verdict checks).
|
|
4
|
+
*
|
|
5
|
+
* Content-bearing extensions (pdf/txt/xml/media) are only skipped when no
|
|
6
|
+
* enforcement mode is active — otherwise a flagged user could pull protected
|
|
7
|
+
* downloads/media straight through a `blockFlagged`/`flaggedMode` gate.
|
|
4
8
|
*/
|
|
5
|
-
export declare function shouldSkipPath(path: string, customSkipPaths?: string[]): boolean;
|
|
9
|
+
export declare function shouldSkipPath(path: string, customSkipPaths?: string[], enforcementActive?: boolean): boolean;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
const
|
|
1
|
+
const STATIC_ASSET_EXTENSIONS=new Set([".js",".mjs",".cjs",".css",".map",".png",".jpg",".jpeg",".gif",".svg",".ico",".webp",".avif",".woff",".woff2",".ttf",".otf",".eot",".wasm"]),CONTENT_EXTENSIONS=new Set([".mp3",".mp4",".webm",".ogg",".xml",".txt",".pdf"]),STATIC_PATH_PREFIXES=["/static/","/assets/","/public/","/_next/","/__vite/","/favicon"];export function shouldSkipPath(t,f,S=!1){if(f)for(const S of f)if(t.startsWith(S))return!0;const T=t.lastIndexOf(".");if(-1!==T){const f=t.slice(T).toLowerCase().split("?")[0];if(STATIC_ASSET_EXTENSIONS.has(f))return!0;if(!S&&CONTENT_EXTENSIONS.has(f))return!0}for(const f of STATIC_PATH_PREFIXES)if(t.startsWith(f))return!0;return!1}
|
|
@@ -13,6 +13,7 @@ export declare class VerdictCache {
|
|
|
13
13
|
private readonly _maxSize;
|
|
14
14
|
private _sweepTimer;
|
|
15
15
|
constructor(defaultTTL?: number, maxSize?: number);
|
|
16
|
+
private _ensureSweepTimer;
|
|
16
17
|
get(userId: string): Verdict | undefined;
|
|
17
18
|
set(userId: string, verdict: Omit<Verdict, 'cachedAt' | 'ttl'>, ttl?: number): void;
|
|
18
19
|
/**
|
|
@@ -42,6 +43,12 @@ export declare class VerdictCache {
|
|
|
42
43
|
destroy(): void;
|
|
43
44
|
/** Remove all entries that are past their TTL + a 2x grace period. */
|
|
44
45
|
private _sweep;
|
|
45
|
-
/**
|
|
46
|
+
/**
|
|
47
|
+
* Evict the oldest entry by last WRITE (set/update) — O(1). Map preserves
|
|
48
|
+
* insertion order, and both set() and update() re-append an existing key on
|
|
49
|
+
* write, so the first key in iteration order is the least-recently-written.
|
|
50
|
+
* Reads (get/isStale/isRefreshing) don't refresh recency — this is LRU-by-write,
|
|
51
|
+
* not LRU-by-read.
|
|
52
|
+
*/
|
|
46
53
|
private _evictOldest;
|
|
47
54
|
}
|
|
@@ -1 +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
|
|
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}u(){if(!this.h)try{this.h=setInterval(()=>this._(),3e5),this.h&&"function"==typeof this.h.unref&&this.h.unref()}catch{this.h=null}}get(t){return this.t.get(t)}set(t,s,i){this.u(),!this.t.has(t)&&this.t.size>=this.o?this.A():this.t.has(t)&&this.t.delete(t),this.t.set(t,{...s,cachedAt:Date.now(),ttl:i??this.l})}update(t,s){this.u();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.delete(t),this.t.set(t,i)):(this.t.size>=this.o&&this.A(),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()}_(){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))}A(){const t=this.t.keys().next().value;void 0!==t&&(this.t.delete(t),this.i.delete(t))}}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import type { UnsharedRequest, UnsharedResponse, UnsharedNextFunction } from './types';
|
|
2
|
-
import type { UnsharedClient } from './client';
|
|
1
|
+
import type { UnsharedRequest, UnsharedResponse, UnsharedNextFunction } from './types.mjs';
|
|
2
|
+
import type { UnsharedClient } from './client.mjs';
|
|
3
3
|
export interface MiddlewareOptions<TReq extends UnsharedRequest = UnsharedRequest> {
|
|
4
4
|
/** Override userId extractor. Falls back to req.body.user_id. */
|
|
5
5
|
userIdExtractor?: (req: TReq) => string | undefined;
|
|
@@ -38,7 +38,6 @@ export interface MiddlewareOptions<TReq extends UnsharedRequest = UnsharedReques
|
|
|
38
38
|
* @example
|
|
39
39
|
* ```typescript
|
|
40
40
|
* assertTrustProxy(app); // throws at startup if not set
|
|
41
|
-
* app.use(express.json());
|
|
42
41
|
* app.use(createUnsharedMiddleware(client, options));
|
|
43
42
|
* ```
|
|
44
43
|
*/
|
|
@@ -51,8 +50,8 @@ export declare function assertTrustProxy(app: any): void;
|
|
|
51
50
|
* Passes all other requests to next().
|
|
52
51
|
*
|
|
53
52
|
* **Prerequisites:**
|
|
54
|
-
* -
|
|
55
|
-
*
|
|
53
|
+
* - Existing default JSON parser order is supported by the browser's 100 KiB
|
|
54
|
+
* proxy limit. Unparsed SDK routes are also read automatically up to 1 MiB.
|
|
56
55
|
* - For cross-origin frontends, pass `corsOrigins` instead of configuring CORS
|
|
57
56
|
* separately — the middleware handles OPTIONS preflight automatically.
|
|
58
57
|
* - `user_id` is automatically scrubbed from `req.body` after it is read, so
|
|
@@ -67,10 +66,10 @@ export declare function assertTrustProxy(app: any): void;
|
|
|
67
66
|
* ```typescript
|
|
68
67
|
* import { createUnsharedMiddleware } from "unshared-clientjs-sdk";
|
|
69
68
|
*
|
|
70
|
-
* app.use(express.json()); // must come first
|
|
71
69
|
* app.use(createUnsharedMiddleware(client, {
|
|
72
70
|
* userIdExtractor: (req) => req.user?.id,
|
|
73
71
|
* }));
|
|
72
|
+
* app.use(express.json()); // application routes keep their own default limit
|
|
74
73
|
* ```
|
|
75
74
|
*/
|
|
76
75
|
export declare function createUnsharedMiddleware<TReq extends UnsharedRequest = UnsharedRequest>(client: UnsharedClient, options?: MiddlewareOptions<TReq>): (req: TReq, res: UnsharedResponse, next: UnsharedNextFunction) => Promise<void>;
|
package/dist/esm/middleware.mjs
CHANGED
|
@@ -1 +1 @@
|
|
|
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,
|
|
1
|
+
import{sendJson,sendEmpty,getRequestPath}from"./middleware/utils/http-helpers.mjs";import{readJsonBody}from"./middleware/utils/read-json-body.mjs";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,t){const{userIdExtractor:r,eventTypeExtractor:o,sessionIdExtractor:s,ipAddressExtractor:n,defaultEventType:i="browser_event",routePrefix:c="/unshared",corsOrigins:d}=t??{},a=`${c}/submit-fingerprint-event`,y=d?Array.isArray(d)?d:[d]:null;return async(t,c,d)=>{const p=getRequestPath(t.url);if(y&&p===a){const e=t.headers.origin??"",r=y.includes("*");if((r||y.includes(e))&&(c.setHeader("Access-Control-Allow-Origin",r?"*":e),c.setHeader("Access-Control-Allow-Methods","POST, OPTIONS"),c.setHeader("Access-Control-Allow-Headers","Content-Type, X-Idempotency-Key, X-Session-Id, X-Permanent-Device-Id")),"OPTIONS"===t.method)return void sendEmpty(c,204)}if("POST"===t.method&&p===a)try{if(!await readJsonBody(t,c))return;const d=t.body??{};if(!d||"object"!=typeof d||Array.isArray(d))return void sendJson(c,400,{success:!1,error:{code:"VALIDATION_ERROR",message:"Request body must be a JSON object"}});const a=d.context&&"object"==typeof d.context?d.context:{},y=a.identity&&"object"==typeof a.identity?a.identity:{},p=a.identifiers&&"object"==typeof a.identifiers?a.identifiers:{},u=a.event&&"object"==typeof a.event?a.event:{};let f,l,h,b;try{f=(r?r(t):void 0)??y.user_id}catch{f=y.user_id}try{l=(o?o(t):void 0)??u.type??i}catch{l=u.type??i}try{h=(s?s(t):void 0)??t.headers["x-session-id"]?.toString()??p.session_hash}catch{h=t.headers["x-session-id"]?.toString()??p.session_hash}const m=t.ip??t.socket?.remoteAddress??"";try{b=(n?n(t):void 0)??m}catch{b=m}const x=t.headers["x-permanent-device-id"]?.toString()??p.unshared_device_id,A=a.sdk&&"object"==typeof a.sdk?a.sdk.name:void 0,g="string"==typeof A&&A.startsWith("unshared-")&&d.fingerprint&&"object"==typeof d.fingerprint&&d.context&&"object"==typeof d.context&&!Array.isArray(d.context)&&d.context.identity&&"object"==typeof d.context.identity&&!Array.isArray(d.context.identity)&&d.context.identifiers&&"object"==typeof d.context.identifiers&&!Array.isArray(d.context.identifiers)&&d.context.event&&"object"==typeof d.context.event&&!Array.isArray(d.context.event),j=!Object.prototype.hasOwnProperty.call(d,"context")&&"string"==typeof d.hash&&"string"==typeof d.stable_hash&&"string"==typeof d.collected_at&&d.components&&"object"==typeof d.components&&!Array.isArray(d.components),v=g||j?{...d,context:{...a,identity:{...y,...f?{user_id:f}:{}},identifiers:{...p,...h?{session_hash:h}:{},...x?{unshared_device_id:x}:{}},event:{...u,type:l,...b?{ip_address:b}:{},user_agent:t.headers["user-agent"]?.toString()??""}}}:d,E=await e.submitFingerprint(v,{idempotencyKey:t.headers["x-idempotency-key"]?.toString()});if(!E.success)return void sendJson(c,200,{success:!1,error:E.error??{code:"UPSTREAM_ERROR",message:"Upstream request failed"}});sendJson(c,202,{success:!0,data:E.data})}catch(e){sendJson(c,200,{success:!1,error:{code:"MIDDLEWARE_ERROR",message:e instanceof Error?e.message:"Middleware error"}})}else d()}}
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Canonical wire-format for fingerprint data transmitted from the browser SDK to the backend.
|
|
3
|
+
*
|
|
4
|
+
* Single source of truth shared by:
|
|
5
|
+
* - @unshared-labs/frontend-fingerprint (serializer: _buildBody in browser.ts)
|
|
6
|
+
* - @unshared-labs/sdk (submitFingerprintEvent in client.ts)
|
|
7
|
+
*
|
|
8
|
+
* SCHEMA AUTHORITY: The browser SDK serializer is the writer. The Node.js SDK is the
|
|
9
|
+
* reader. Any field added here must be produced by the browser SDK and consumed by the
|
|
10
|
+
* server SDK. Do not add fields that only one side uses.
|
|
11
|
+
*/
|
|
12
|
+
export type JSONPrimitive = string | number | boolean | null;
|
|
13
|
+
export type JSONValue = JSONPrimitive | JSONObject | JSONValue[];
|
|
14
|
+
export interface JSONObject {
|
|
15
|
+
[key: string]: JSONValue;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface BrowserStorageEntry extends JSONObject {
|
|
19
|
+
name: string;
|
|
20
|
+
value: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface BrowserStorageSnapshot extends JSONObject {
|
|
24
|
+
cookies: BrowserStorageEntry[];
|
|
25
|
+
local_storage: BrowserStorageEntry[];
|
|
26
|
+
session_storage: BrowserStorageEntry[];
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface EncryptedEventEnvelope {
|
|
30
|
+
encrypted_data: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface FingerprintEventContext extends JSONObject {
|
|
34
|
+
sdk: JSONObject;
|
|
35
|
+
identity: JSONObject;
|
|
36
|
+
identifiers: JSONObject;
|
|
37
|
+
event: JSONObject;
|
|
38
|
+
browser_storage?: BrowserStorageSnapshot;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export type V3Identifiers = Record<string, string>;
|
|
42
|
+
|
|
43
|
+
export interface V3Success<T> {
|
|
44
|
+
success: true;
|
|
45
|
+
data: T;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface V3Error {
|
|
49
|
+
success: false;
|
|
50
|
+
error: {
|
|
51
|
+
code: string;
|
|
52
|
+
message: string;
|
|
53
|
+
retryAfter?: number;
|
|
54
|
+
nextAllowedAt?: string;
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export type V3Result<T> = V3Success<T> | V3Error;
|
|
59
|
+
|
|
60
|
+
export interface IngestResult {
|
|
61
|
+
event_id: string;
|
|
62
|
+
collected_at: string;
|
|
63
|
+
endpoint_version: 'v3';
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export interface TriggerEmailRequest {
|
|
67
|
+
email_address: string;
|
|
68
|
+
identifiers: V3Identifiers;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export interface TriggerEmailResult {
|
|
72
|
+
verification_id: string;
|
|
73
|
+
next_allowed_at: string;
|
|
74
|
+
retry_after_seconds: number;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export interface VerifyEmailRequest {
|
|
78
|
+
verification_id: string;
|
|
79
|
+
code: string;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export type VerifyEmailResult =
|
|
83
|
+
| { verified: true; verification_scope: 'email' }
|
|
84
|
+
| { verified: false; verification_scope: 'email'; reason: 'invalid_code' };
|
|
85
|
+
|
|
86
|
+
export interface CheckUserRequest {
|
|
87
|
+
user_id?: string;
|
|
88
|
+
email_address: string;
|
|
89
|
+
identifiers: V3Identifiers;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export type CheckDecisionCode =
|
|
93
|
+
| 'FLAGGED_RISK_MATCH'
|
|
94
|
+
| 'NO_ENFORCING_RISK'
|
|
95
|
+
| 'NO_IDENTITY_MATCH'
|
|
96
|
+
| 'IDENTITY_UNCERTAIN'
|
|
97
|
+
| 'CHECK_UNAVAILABLE';
|
|
98
|
+
|
|
99
|
+
export interface CheckUserResult {
|
|
100
|
+
is_user_flagged: boolean;
|
|
101
|
+
decision_id: string;
|
|
102
|
+
decision_code: CheckDecisionCode;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export interface FingerprintEventPayload extends JSONObject {
|
|
106
|
+
fingerprint: JSONObject;
|
|
107
|
+
context: FingerprintEventContext;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export interface FingerprintWireFormat {
|
|
111
|
+
[key: string]: unknown;
|
|
112
|
+
/**
|
|
113
|
+
* Primary device identifier — stable hash excluding volatile signals (audio, timing).
|
|
114
|
+
* Consistent across normal and incognito sessions on the same device.
|
|
115
|
+
* 32-character hex string.
|
|
116
|
+
*/
|
|
117
|
+
fingerprint_id: string;
|
|
118
|
+
/**
|
|
119
|
+
* Full hash including all active signals (volatile signals included).
|
|
120
|
+
* Maximum entropy; may vary slightly between sessions on the same device.
|
|
121
|
+
* 32-character hex string.
|
|
122
|
+
*/
|
|
123
|
+
full_hash: string;
|
|
124
|
+
/**
|
|
125
|
+
* Raw browser signal data keyed by signal name (e.g. `webgl`, `hardware`, `audio`).
|
|
126
|
+
*
|
|
127
|
+
* This blob is forwarded as-is in the `components` JSON field and stored verbatim
|
|
128
|
+
* in a `components JSONB` column on `fingerprint_events`. No server-side decomposition
|
|
129
|
+
* is performed; the full component tree is preserved for querying.
|
|
130
|
+
*/
|
|
131
|
+
components: Record<string, unknown>;
|
|
132
|
+
/** ISO timestamp when the fingerprint was collected. */
|
|
133
|
+
timestamp: string;
|
|
134
|
+
/** Whether private/incognito browsing was detected at collection time. */
|
|
135
|
+
isIncognito: boolean;
|
|
136
|
+
/** Fingerprint library semver, e.g. "1.0.0". */
|
|
137
|
+
version: string;
|
|
138
|
+
/**
|
|
139
|
+
* Where the fingerprint agent that produced this result was loaded from:
|
|
140
|
+
* - `'remote'` — the runtime-loaded UMD (S3 directly in browser direct mode,
|
|
141
|
+
* or the Node SDK's same-origin proxy of S3).
|
|
142
|
+
* - `'bundled'` — the copy compiled into the SDK, used as the fallback when
|
|
143
|
+
* the remote load failed/timed out.
|
|
144
|
+
* Optional: older SDK artifacts and the mobile SDKs omit it, so the reader
|
|
145
|
+
* must tolerate its absence. Observability for S3 fast-path vs fallback rates.
|
|
146
|
+
*/
|
|
147
|
+
source?: 'remote' | 'bundled';
|
|
148
|
+
}
|
package/dist/esm/web/index.d.mts
CHANGED
|
@@ -10,9 +10,9 @@
|
|
|
10
10
|
* is compatible with all frameworks that expose `http.IncomingMessage`/
|
|
11
11
|
* `http.ServerResponse` objects.
|
|
12
12
|
*/
|
|
13
|
-
export { createWebSubmitHandler } from './submit-handler';
|
|
14
|
-
export { createWebProtectionMiddleware } from './protection-handler';
|
|
15
|
-
export { flaggedResponse, ACCOUNT_FLAGGED_ERROR } from '../middleware/utils/flagged-response';
|
|
16
|
-
export type { WebHandler, WebMiddleware, WebSubmitOptions, WebProtectionConfig, } from './types';
|
|
17
|
-
export { VerdictCache } from '../middleware/verdict-cache';
|
|
18
|
-
export type { Verdict } from '../middleware/verdict-cache';
|
|
13
|
+
export { createWebSubmitHandler } from './submit-handler.mjs';
|
|
14
|
+
export { createWebProtectionMiddleware } from './protection-handler.mjs';
|
|
15
|
+
export { flaggedResponse, ACCOUNT_FLAGGED_ERROR } from '../middleware/utils/flagged-response.mjs';
|
|
16
|
+
export type { WebHandler, WebMiddleware, WebSubmitOptions, WebProtectionConfig, } from './types.mjs';
|
|
17
|
+
export { VerdictCache } from '../middleware/verdict-cache.mjs';
|
|
18
|
+
export type { Verdict } from '../middleware/verdict-cache.mjs';
|
package/dist/esm/web/index.mjs
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export{createWebSubmitHandler}from"./submit-handler";export{createWebProtectionMiddleware}from"./protection-handler";export{flaggedResponse,ACCOUNT_FLAGGED_ERROR}from"../middleware/utils/flagged-response";export{VerdictCache}from"../middleware/verdict-cache";
|
|
1
|
+
export{createWebSubmitHandler}from"./submit-handler.mjs";export{createWebProtectionMiddleware}from"./protection-handler.mjs";export{flaggedResponse,ACCOUNT_FLAGGED_ERROR}from"../middleware/utils/flagged-response.mjs";export{VerdictCache}from"../middleware/verdict-cache.mjs";
|
|
@@ -1,5 +1,7 @@
|
|
|
1
|
-
import type { UnsharedClient } from '../client';
|
|
2
|
-
import
|
|
1
|
+
import type { UnsharedClient } from '../client.mjs';
|
|
2
|
+
import { VerdictCache } from '../middleware/verdict-cache.mjs';
|
|
3
|
+
import type { Verdict } from '../middleware/verdict-cache.mjs';
|
|
4
|
+
import type { WebMiddleware, WebProtectionConfig } from './types.mjs';
|
|
3
5
|
/**
|
|
4
6
|
* Web Standard equivalent of `unsharedBoundToUser` from src/middleware/index.ts.
|
|
5
7
|
*
|
|
@@ -11,7 +13,15 @@ import type { WebMiddleware, WebProtectionConfig } from './types';
|
|
|
11
13
|
* handler calls `next(request)` to get the downstream Response, transforms
|
|
12
14
|
* it (injects the fingerprint script into HTML), and returns a new Response.
|
|
13
15
|
*
|
|
14
|
-
*
|
|
16
|
+
* NOTE: In Next.js middleware, `next(request)` is typically `() => NextResponse.next()`,
|
|
17
|
+
* which is a passthrough with a null body and no Content-Type — there is no HTML to
|
|
18
|
+
* inject a script into. This middleware still resolves the verdict and enforces it
|
|
19
|
+
* (gate/overlay/403), but fingerprint COLLECTION must be done by the browser/React SDK
|
|
20
|
+
* mounted in your app, not by injection here. See the Next.js section of the
|
|
21
|
+
* integration guide.
|
|
22
|
+
*
|
|
23
|
+
* @example Next.js App Router (in middleware.ts) — verdict + enforcement only;
|
|
24
|
+
* mount the browser/React SDK client-side to collect fingerprints.
|
|
15
25
|
* ```typescript
|
|
16
26
|
* import { createWebProtectionMiddleware } from 'unshared-clientjs-sdk/web';
|
|
17
27
|
*
|
|
@@ -26,3 +36,21 @@ import type { WebMiddleware, WebProtectionConfig } from './types';
|
|
|
26
36
|
* ```
|
|
27
37
|
*/
|
|
28
38
|
export declare function createWebProtectionMiddleware(client: UnsharedClient, config: WebProtectionConfig): WebMiddleware;
|
|
39
|
+
/** Mutable holder for the one-time passthrough-injection warning (see below). */
|
|
40
|
+
interface InjectWarnState {
|
|
41
|
+
emitted: boolean;
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Read the response body, inject the fingerprint script into HTML responses,
|
|
45
|
+
* merge Set-Cookie headers, and return a new Response. For non-HTML responses,
|
|
46
|
+
* only the cookies and cache-control override are applied.
|
|
47
|
+
*
|
|
48
|
+
* `warnState` scopes the one-time "passthrough, nothing injected" warning to a
|
|
49
|
+
* single middleware instance; when omitted, the module-level default is used.
|
|
50
|
+
*/
|
|
51
|
+
declare function injectIntoHtmlResponse(response: Response, fingerprintScript: string, extraCookies?: string[], warnState?: InjectWarnState): Promise<Response>;
|
|
52
|
+
declare function fetchAndCacheVerdict(client: UnsharedClient, cache: VerdictCache, userId: string, emailAddress: string, deviceId: string, fingerprintId: string | undefined, permanentDeviceId: string | undefined, sessionId: string, timeoutMs?: number, reportFailOpen?: (reason: 'timeout' | 'http_error' | 'no_device_id', status?: number) => void): Promise<Verdict>;
|
|
53
|
+
export declare const __test_fetchAndCacheVerdict: typeof fetchAndCacheVerdict;
|
|
54
|
+
export declare const __test_injectIntoHtmlResponse: typeof injectIntoHtmlResponse;
|
|
55
|
+
export declare function __test_resetInjectWarning(): void;
|
|
56
|
+
export {};
|