unshared-clientjs-sdk 2.0.2 → 2.1.0-rc.3

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 (44) hide show
  1. package/README.md +27 -2
  2. package/dist/client.d.ts +10 -0
  3. package/dist/client.js +1 -1
  4. package/dist/esm/client.d.mts +10 -0
  5. package/dist/esm/client.mjs +1 -1
  6. package/dist/esm/middleware/index.d.mts +13 -1
  7. package/dist/esm/middleware/index.mjs +1 -1
  8. package/dist/esm/middleware/injection/fingerprint-script.mjs +1 -1
  9. package/dist/esm/middleware/injection/gate-page.d.mts +15 -0
  10. package/dist/esm/middleware/injection/gate-page.mjs +1 -0
  11. package/dist/esm/middleware/routes/interstitial.d.mts +12 -0
  12. package/dist/esm/middleware/routes/interstitial.mjs +1 -0
  13. package/dist/esm/middleware/routes/submit-fp.d.mts +2 -0
  14. package/dist/esm/middleware/routes/submit-fp.mjs +1 -1
  15. package/dist/esm/middleware/utils/content-type.d.mts +6 -0
  16. package/dist/esm/middleware/utils/content-type.mjs +1 -1
  17. package/dist/esm/middleware/utils/flagged-response.d.mts +5 -0
  18. package/dist/esm/middleware/utils/is-bot.mjs +1 -1
  19. package/dist/esm/util.d.mts +6 -0
  20. package/dist/esm/util.mjs +1 -1
  21. package/dist/esm/web/index.d.mts +1 -0
  22. package/dist/esm/web/index.mjs +1 -1
  23. package/dist/esm/web/protection-handler.mjs +1 -1
  24. package/dist/esm/web/types.d.mts +14 -0
  25. package/dist/middleware/index.d.ts +13 -1
  26. package/dist/middleware/index.js +1 -1
  27. package/dist/middleware/injection/fingerprint-script.js +1 -1
  28. package/dist/middleware/injection/gate-page.d.ts +15 -0
  29. package/dist/middleware/injection/gate-page.js +1 -0
  30. package/dist/middleware/routes/interstitial.d.ts +12 -0
  31. package/dist/middleware/routes/interstitial.js +1 -0
  32. package/dist/middleware/routes/submit-fp.d.ts +2 -0
  33. package/dist/middleware/routes/submit-fp.js +1 -1
  34. package/dist/middleware/utils/content-type.d.ts +6 -0
  35. package/dist/middleware/utils/content-type.js +1 -1
  36. package/dist/middleware/utils/flagged-response.d.ts +5 -0
  37. package/dist/middleware/utils/is-bot.js +1 -1
  38. package/dist/util.d.ts +6 -0
  39. package/dist/util.js +1 -1
  40. package/dist/web/index.d.ts +1 -0
  41. package/dist/web/index.js +1 -1
  42. package/dist/web/protection-handler.js +1 -1
  43. package/dist/web/types.d.ts +14 -0
  44. package/package.json +3 -2
package/README.md CHANGED
@@ -151,7 +151,11 @@ app.use(unsharedBoundToUser(client, {
151
151
  | `corsOrigins` | `string \| string[]` | — | Allowed CORS origins; handles OPTIONS preflight |
152
152
  | `onError` | `(error, ctx) => void` | — | Called on background SDK errors for observability |
153
153
 
154
- See [quickstart](./docs/quickstart.md) and [flag semantics](./docs/flag-semantics.md) for full setup and testing details.
154
+ `flaggedResponse(email)` formats the 403 body the inline script expects to trigger the `unshared:flagged` browser event. `ACCOUNT_FLAGGED_ERROR` is the error code string it uses, also exported for server-side checks.
155
+
156
+ ### Interstitial (proxy mode)
157
+
158
+ `unsharedBoundToUser` mounts a `GET /__unshared/interstitial-flow` route that returns the company's published interstitial flow. It calls `client.getInterstitialFlow()` with the secret key and carries no user data. The browser SDK's `showInterstitial()` calls this route automatically when constructed with `baseUrl` (proxy mode), then runs the modal's OTP actions through the existing `/__unshared/verify-trigger` and `/__unshared/verify` routes. Identity is resolved server-side via your `emailAddress` resolver or the `__unshared_email` cookie — the browser sends only the OTP.
155
159
 
156
160
  ---
157
161
 
@@ -168,6 +172,8 @@ app.use(createUnsharedMiddleware(client, {
168
172
  }));
169
173
  ```
170
174
 
175
+ **Options:**
176
+
171
177
  | Option | Type | Default | Description |
172
178
  |--------|------|---------|-------------|
173
179
  | `userIdExtractor` | `(req) => string \| undefined` | — | Pull user ID from your auth session |
@@ -176,12 +182,24 @@ app.use(createUnsharedMiddleware(client, {
176
182
  | `ipAddressExtractor` | `(req) => string \| undefined` | — | Override IP address |
177
183
  | `defaultEventType` | `string` | `"browser_event"` | Fallback event type |
178
184
  | `routePrefix` | `string` | `"/unshared"` | Route mount prefix |
179
- | `corsOrigins` | `string \| string[]` | — | Allowed CORS origins |
185
+ | `corsOrigins` | `string \| string[]` | — | Allowed CORS origins; handles OPTIONS preflight automatically |
180
186
 
181
187
  > **Note:** This middleware uses a different default prefix (`/unshared`) and route (`submit-fingerprint-event`) than `unsharedBoundToUser` (`/__unshared`, `submit-fp`).
182
188
 
183
189
  ---
184
190
 
191
+ ## Web / Edge Handler
192
+
193
+ For serverless and edge environments (Next.js App Router, Vercel Edge Functions, Cloudflare Workers), import from the `unshared-clientjs-sdk/web` entry point:
194
+
195
+ ```typescript
196
+ import { createWebProtectionMiddleware } from 'unshared-clientjs-sdk/web';
197
+ ```
198
+
199
+ `createWebProtectionMiddleware` accepts the same `disableBotFilter` and `checkUserTimeoutMs` options as `unsharedBoundToUser`, with Web Standard `Request`/`Response` types instead of Express ones.
200
+
201
+ ---
202
+
185
203
  ## Configuration
186
204
 
187
205
  ```typescript
@@ -213,3 +231,10 @@ All methods return `ApiResult<T>`:
213
231
  ## Security
214
232
 
215
233
  All PII is encrypted with **AES-256-GCM** before leaving your server. The encryption key is derived from your API key with SHA-256. Your API key is sent as the `X-API-Key` header — never in a URL or browser context.
234
+
235
+ ---
236
+
237
+ ## See Also
238
+
239
+ - [Quickstart](./docs/quickstart.md) — step-by-step Express setup from zero
240
+ - [Flag semantics and testing](./docs/flag-semantics.md) — how flags work, E2E testing tips, timeout behavior
package/dist/client.d.ts CHANGED
@@ -203,4 +203,14 @@ export declare class UnsharedClient {
203
203
  verify(emailAddress: string, deviceId: string, code: string, opts?: {
204
204
  fingerprintId?: string;
205
205
  }): Promise<ApiResult<VerifyResult>>;
206
+ /**
207
+ * Fetch the published interstitial flow for this company (secret key, server-side).
208
+ * Returns the projected flow definition to hand to the browser for rendering.
209
+ * No PII in the query, so nothing is encrypted.
210
+ * Maps to: GET /v2/interstitial-flow
211
+ */
212
+ getInterstitialFlow(opts?: {
213
+ flowType?: string;
214
+ platform?: 'web' | 'ios' | 'android';
215
+ }): Promise<ApiResult<Record<string, unknown>>>;
206
216
  }
package/dist/client.js CHANGED
@@ -1 +1 @@
1
- "use strict";Object.defineProperty(exports,"t",{value:!0}),exports.UnsharedClient=void 0;const crypto_1=require("crypto"),util_1=require("./util"),DEFAULT_BASE_URL="https://api.unshared.ai",DEFAULT_TIMEOUT_MS=1e4,DEFAULT_MAX_RETRIES=3,MAX_DELAY_MS=3e4,BASE_DELAY_MS=1e3;function sleep(e){return new Promise(s=>setTimeout(s,e))}function retryDelay(e){const s=Math.min(1e3*Math.pow(2,e-1),3e4),t=s*(.5*Math.random()-.25);return Math.max(0,s+t)}async function parseErrorBody(e){const s=await e.text().catch(()=>"");try{const t=JSON.parse(s);return t?.error?.code?{code:t.error.code,message:t.error.message??"Unknown error",details:t.error.details}:{code:"UNKNOWN_ERROR",message:s||e.statusText}}catch{return{code:"UNKNOWN_ERROR",message:s||e.statusText}}}class UnsharedClient{constructor(e){if(!e.apiKey||""===e.apiKey.trim())throw new Error("apiKey is required");this.i=e.apiKey,this.o=e.baseUrl?e.baseUrl.replace(/\/$/,""):DEFAULT_BASE_URL,this.h=e.timeout??1e4,this.u=e.maxRetries??3,this.l=(0,crypto_1.createHash)("sha256").update(e.apiKey).digest(),this._=e.fetch}p(e){return(0,util_1.encryptData)(e,this.l)}async m(e,s){const t=this.u+1;let r={success:!1,status:0,error:{code:"NETWORK_ERROR",message:"Request failed"}};for(let i=1;i<=t;i++){i>1&&await sleep(retryDelay(i-1));const t=new AbortController,a=setTimeout(()=>t.abort(),this.h);try{const i=this._??globalThis.fetch,n=await i(e,{method:s.method,headers:{"X-API-Key":this.i,...s.headers},body:s.body,signal:t.signal});if(clearTimeout(a),n.ok){const e=await n.text().catch(()=>"{}");let s;try{s=JSON.parse(e)}catch{s={}}const t="data"in s?s.data:s;return{success:!0,status:n.status,data:t}}const o=await parseErrorBody(n);if(n.status>=400&&n.status<500){if(429===n.status){const e=n.headers.get("Retry-After");if(null!=e){const s=parseInt(e,10);isNaN(s)||(o.retryAfter=s)}}return{success:!1,status:n.status,error:o}}r={success:!1,status:n.status,error:o}}catch(e){clearTimeout(a),r={success:!1,status:0,error:{code:"NETWORK_ERROR",message:e instanceof Error?e.message:String(e)}}}}return r}async submitFingerprintEvent(e,s){const t={hash:e.full_hash,stable_hash:e.fingerprint_id,collected_at:e.timestamp,is_incognito:e.isIncognito,components:e.components,version:e.version};return null!=s?.userId&&(t.user_id=this.p(s.userId)),null!=s?.emailAddress&&(t.email_address=this.p(s.emailAddress)),null!=s?.sessionHash&&(t.session_hash=s.sessionHash),null!=s?.eventType&&(t.event_type=s.eventType),null!=s?.ipAddress&&(t.ip_address=this.p(s.ipAddress)),null!=s?.userAgent&&(t.user_agent=this.p(s.userAgent)),this.m(`${this.o}/v2/submit-fingerprint-event`,{method:"POST",headers:{"Content-Type":"application/json","X-Idempotency-Key":s?.idempotencyKey??(0,crypto_1.randomUUID)()},body:JSON.stringify(t)})}async processUserEvent(e){const s={event_type:e.eventType,user_id:this.p(e.userId),ip_address:this.p(e.ipAddress),device_id:this.p(e.deviceId),session_hash:e.sessionHash,user_agent:this.p(e.userAgent),email_address:this.p(e.emailAddress)};return null!=e.fingerprintId&&(s.fingerprint_id=this.p(e.fingerprintId)),null!=e.subscriptionStatus&&(s.subscription_status=e.subscriptionStatus),null!=e.eventDetails&&(s.event_details=e.eventDetails),this.m(`${this.o}/v2/process-user-event`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(s)})}async checkUser(e,s){const t="string"==typeof s?{deviceId:s}:s;if(!t.deviceId&&!t.fingerprintId)return{success:!0,status:200,data:{is_user_flagged:!1}};const r=new URLSearchParams;r.set("email_address",this.p(e)),t.deviceId&&r.set("device_id",this.p(t.deviceId)),t.fingerprintId&&r.set("fingerprint_id",this.p(t.fingerprintId));const i=await this.m(`${this.o}/v2/check-user?${r}`,{method:"GET"});return i.success?i:{success:!0,status:200,data:{is_user_flagged:!1}}}async triggerEmailVerification(e,s,t){const r={email_address:this.p(e),device_id:this.p(s)};t?.fingerprintId&&(r.fingerprint_id=this.p(t.fingerprintId));const i=await this.m(`${this.o}/v2/trigger-email-verification`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(r)});return!i.success&&(0===i.status||i.status>=500)?{success:!1,status:i.status,error:{code:"DELIVERY_FAILED",message:i.error?.message??"Delivery failed"}}:i}async verify(e,s,t,r){const i={email_address:this.p(e),device_id:this.p(s),code:this.p(t)};r?.fingerprintId&&(i.fingerprint_id=this.p(r.fingerprintId));const a=await this.m(`${this.o}/v2/verify`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(i)});return!a.success&&(0===a.status||a.status>=500)?{success:!1,status:a.status,error:{code:"DELIVERY_FAILED",message:a.error?.message??"Delivery failed"}}:a.success&&!1===a.data?.verified?{success:!1,status:a.status,error:{code:"VERIFICATION_FAILED",message:"Code is incorrect or expired",details:a.data.reason?{reason:a.data.reason}:void 0}}:a}}exports.UnsharedClient=UnsharedClient;
1
+ "use strict";Object.defineProperty(exports,"t",{value:!0}),exports.UnsharedClient=void 0;const crypto_1=require("crypto"),util_1=require("./util"),DEFAULT_BASE_URL="https://api.unshared.ai",DEFAULT_TIMEOUT_MS=1e4,DEFAULT_MAX_RETRIES=3,MAX_DELAY_MS=3e4,BASE_DELAY_MS=1e3;function sleep(e){return new Promise(t=>setTimeout(t,e))}function retryDelay(e){const t=Math.min(1e3*Math.pow(2,e-1),3e4),s=t*(.5*Math.random()-.25);return Math.max(0,t+s)}async function parseErrorBody(e){const t=await e.text().catch(()=>"");try{const s=JSON.parse(t);return s?.error?.code?{code:s.error.code,message:s.error.message??"Unknown error",details:s.error.details}:{code:"UNKNOWN_ERROR",message:t||e.statusText}}catch{return{code:"UNKNOWN_ERROR",message:t||e.statusText}}}class UnsharedClient{constructor(e){if(!e.apiKey||""===e.apiKey.trim())throw new Error("apiKey is required");this.i=e.apiKey,this.o=e.baseUrl?e.baseUrl.replace(/\/$/,""):DEFAULT_BASE_URL,this.h=e.timeout??1e4,this.u=e.maxRetries??3,this.l=(0,crypto_1.createHash)("sha256").update(e.apiKey).digest(),this._=e.fetch}p(e){return(0,util_1.encryptData)(e,this.l)}async m(e,t){const s=this.u+1;let r={success:!1,status:0,error:{code:"NETWORK_ERROR",message:"Request failed"}};for(let i=1;i<=s;i++){i>1&&await sleep(retryDelay(i-1));const s=new AbortController,a=setTimeout(()=>s.abort(),this.h);try{const i=this._??globalThis.fetch,n=await i(e,{method:t.method,headers:{"X-API-Key":this.i,...t.headers},body:t.body,signal:s.signal});if(clearTimeout(a),n.ok){const e=await n.text().catch(()=>"{}");let t;try{t=JSON.parse(e)}catch{t={}}const s="data"in t?t.data:t;return{success:!0,status:n.status,data:s}}const o=await parseErrorBody(n);if(n.status>=400&&n.status<500){if(429===n.status){const e=n.headers.get("Retry-After");if(null!=e){const t=parseInt(e,10);isNaN(t)||(o.retryAfter=t)}}return{success:!1,status:n.status,error:o}}r={success:!1,status:n.status,error:o}}catch(e){clearTimeout(a),r={success:!1,status:0,error:{code:"NETWORK_ERROR",message:e instanceof Error?e.message:String(e)}}}}return r}async submitFingerprintEvent(e,t){const s={hash:e.full_hash,stable_hash:e.fingerprint_id,collected_at:e.timestamp,is_incognito:e.isIncognito,components:e.components,version:e.version};return null!=t?.userId&&(s.user_id=this.p(t.userId)),null!=t?.emailAddress&&(s.email_address=this.p(t.emailAddress)),null!=t?.sessionHash&&(s.session_hash=t.sessionHash),null!=t?.eventType&&(s.event_type=t.eventType),null!=t?.ipAddress&&(s.ip_address=this.p(t.ipAddress)),null!=t?.userAgent&&(s.user_agent=this.p(t.userAgent)),this.m(`${this.o}/v2/submit-fingerprint-event`,{method:"POST",headers:{"Content-Type":"application/json","X-Idempotency-Key":t?.idempotencyKey??(0,crypto_1.randomUUID)()},body:JSON.stringify(s)})}async processUserEvent(e){const t={event_type:e.eventType,user_id:this.p(e.userId),ip_address:this.p(e.ipAddress),device_id:this.p(e.deviceId),session_hash:e.sessionHash,user_agent:this.p(e.userAgent),email_address:this.p(e.emailAddress)};return null!=e.fingerprintId&&(t.fingerprint_id=this.p(e.fingerprintId)),null!=e.subscriptionStatus&&(t.subscription_status=e.subscriptionStatus),null!=e.eventDetails&&(t.event_details=e.eventDetails),this.m(`${this.o}/v2/process-user-event`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(t)})}async checkUser(e,t){const s="string"==typeof t?{deviceId:t}:t;if(!s.deviceId&&!s.fingerprintId)return{success:!0,status:200,data:{is_user_flagged:!1}};const r=new URLSearchParams;r.set("email_address",this.p(e)),s.deviceId&&r.set("device_id",this.p(s.deviceId)),s.fingerprintId&&r.set("fingerprint_id",this.p(s.fingerprintId));const i=await this.m(`${this.o}/v2/check-user?${r}`,{method:"GET"});return i.success?i:{success:!0,status:200,data:{is_user_flagged:!1}}}async triggerEmailVerification(e,t,s){const r={email_address:this.p(e),device_id:this.p(t)};s?.fingerprintId&&(r.fingerprint_id=this.p(s.fingerprintId));const i=await this.m(`${this.o}/v2/trigger-email-verification`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(r)});return!i.success&&(0===i.status||i.status>=500)?{success:!1,status:i.status,error:{code:"DELIVERY_FAILED",message:i.error?.message??"Delivery failed"}}:i}async verify(e,t,s,r){const i={email_address:this.p(e),device_id:this.p(t),code:this.p(s)};r?.fingerprintId&&(i.fingerprint_id=this.p(r.fingerprintId));const a=await this.m(`${this.o}/v2/verify`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(i)});return!a.success&&(0===a.status||a.status>=500)?{success:!1,status:a.status,error:{code:"DELIVERY_FAILED",message:a.error?.message??"Delivery failed"}}:a.success&&!1===a.data?.verified?{success:!1,status:a.status,error:{code:"VERIFICATION_FAILED",message:"Code is incorrect or expired",details:a.data.reason?{reason:a.data.reason}:void 0}}:a}async getInterstitialFlow(e){const t=new URLSearchParams;return t.set("flow_type",e?.flowType||"email_verification"),t.set("platform",e?.platform||"web"),this.m(`${this.o}/v2/interstitial-flow?${t}`,{method:"GET"})}}exports.UnsharedClient=UnsharedClient;
@@ -203,4 +203,14 @@ export declare class UnsharedClient {
203
203
  verify(emailAddress: string, deviceId: string, code: string, opts?: {
204
204
  fingerprintId?: string;
205
205
  }): Promise<ApiResult<VerifyResult>>;
206
+ /**
207
+ * Fetch the published interstitial flow for this company (secret key, server-side).
208
+ * Returns the projected flow definition to hand to the browser for rendering.
209
+ * No PII in the query, so nothing is encrypted.
210
+ * Maps to: GET /v2/interstitial-flow
211
+ */
212
+ getInterstitialFlow(opts?: {
213
+ flowType?: string;
214
+ platform?: 'web' | 'ios' | 'android';
215
+ }): Promise<ApiResult<Record<string, unknown>>>;
206
216
  }
@@ -1 +1 @@
1
- import{createHash,randomUUID}from"crypto";import{encryptData}from"./util";const DEFAULT_BASE_URL="https://api.unshared.ai",DEFAULT_TIMEOUT_MS=1e4,DEFAULT_MAX_RETRIES=3,MAX_DELAY_MS=3e4,BASE_DELAY_MS=1e3;function sleep(e){return new Promise(s=>setTimeout(s,e))}function retryDelay(e){const s=Math.min(1e3*Math.pow(2,e-1),3e4),t=s*(.5*Math.random()-.25);return Math.max(0,s+t)}async function parseErrorBody(e){const s=await e.text().catch(()=>"");try{const t=JSON.parse(s);return t?.error?.code?{code:t.error.code,message:t.error.message??"Unknown error",details:t.error.details}:{code:"UNKNOWN_ERROR",message:s||e.statusText}}catch{return{code:"UNKNOWN_ERROR",message:s||e.statusText}}}export class UnsharedClient{constructor(e){if(!e.apiKey||""===e.apiKey.trim())throw new Error("apiKey is required");this.t=e.apiKey,this.i=e.baseUrl?e.baseUrl.replace(/\/$/,""):DEFAULT_BASE_URL,this.o=e.timeout??1e4,this.h=e.maxRetries??3,this.u=createHash("sha256").update(e.apiKey).digest(),this.l=e.fetch}_(e){return encryptData(e,this.u)}async p(e,s){const t=this.h+1;let r={success:!1,status:0,error:{code:"NETWORK_ERROR",message:"Request failed"}};for(let i=1;i<=t;i++){i>1&&await sleep(retryDelay(i-1));const t=new AbortController,a=setTimeout(()=>t.abort(),this.o);try{const i=this.l??globalThis.fetch,n=await i(e,{method:s.method,headers:{"X-API-Key":this.t,...s.headers},body:s.body,signal:t.signal});if(clearTimeout(a),n.ok){const e=await n.text().catch(()=>"{}");let s;try{s=JSON.parse(e)}catch{s={}}const t="data"in s?s.data:s;return{success:!0,status:n.status,data:t}}const o=await parseErrorBody(n);if(n.status>=400&&n.status<500){if(429===n.status){const e=n.headers.get("Retry-After");if(null!=e){const s=parseInt(e,10);isNaN(s)||(o.retryAfter=s)}}return{success:!1,status:n.status,error:o}}r={success:!1,status:n.status,error:o}}catch(e){clearTimeout(a),r={success:!1,status:0,error:{code:"NETWORK_ERROR",message:e instanceof Error?e.message:String(e)}}}}return r}async submitFingerprintEvent(e,s){const t={hash:e.full_hash,stable_hash:e.fingerprint_id,collected_at:e.timestamp,is_incognito:e.isIncognito,components:e.components,version:e.version};return null!=s?.userId&&(t.user_id=this._(s.userId)),null!=s?.emailAddress&&(t.email_address=this._(s.emailAddress)),null!=s?.sessionHash&&(t.session_hash=s.sessionHash),null!=s?.eventType&&(t.event_type=s.eventType),null!=s?.ipAddress&&(t.ip_address=this._(s.ipAddress)),null!=s?.userAgent&&(t.user_agent=this._(s.userAgent)),this.p(`${this.i}/v2/submit-fingerprint-event`,{method:"POST",headers:{"Content-Type":"application/json","X-Idempotency-Key":s?.idempotencyKey??randomUUID()},body:JSON.stringify(t)})}async processUserEvent(e){const s={event_type:e.eventType,user_id:this._(e.userId),ip_address:this._(e.ipAddress),device_id:this._(e.deviceId),session_hash:e.sessionHash,user_agent:this._(e.userAgent),email_address:this._(e.emailAddress)};return null!=e.fingerprintId&&(s.fingerprint_id=this._(e.fingerprintId)),null!=e.subscriptionStatus&&(s.subscription_status=e.subscriptionStatus),null!=e.eventDetails&&(s.event_details=e.eventDetails),this.p(`${this.i}/v2/process-user-event`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(s)})}async checkUser(e,s){const t="string"==typeof s?{deviceId:s}:s;if(!t.deviceId&&!t.fingerprintId)return{success:!0,status:200,data:{is_user_flagged:!1}};const r=new URLSearchParams;r.set("email_address",this._(e)),t.deviceId&&r.set("device_id",this._(t.deviceId)),t.fingerprintId&&r.set("fingerprint_id",this._(t.fingerprintId));const i=await this.p(`${this.i}/v2/check-user?${r}`,{method:"GET"});return i.success?i:{success:!0,status:200,data:{is_user_flagged:!1}}}async triggerEmailVerification(e,s,t){const r={email_address:this._(e),device_id:this._(s)};t?.fingerprintId&&(r.fingerprint_id=this._(t.fingerprintId));const i=await this.p(`${this.i}/v2/trigger-email-verification`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(r)});return!i.success&&(0===i.status||i.status>=500)?{success:!1,status:i.status,error:{code:"DELIVERY_FAILED",message:i.error?.message??"Delivery failed"}}:i}async verify(e,s,t,r){const i={email_address:this._(e),device_id:this._(s),code:this._(t)};r?.fingerprintId&&(i.fingerprint_id=this._(r.fingerprintId));const a=await this.p(`${this.i}/v2/verify`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(i)});return!a.success&&(0===a.status||a.status>=500)?{success:!1,status:a.status,error:{code:"DELIVERY_FAILED",message:a.error?.message??"Delivery failed"}}:a.success&&!1===a.data?.verified?{success:!1,status:a.status,error:{code:"VERIFICATION_FAILED",message:"Code is incorrect or expired",details:a.data.reason?{reason:a.data.reason}:void 0}}:a}}
1
+ import{createHash,randomUUID}from"crypto";import{encryptData}from"./util";const DEFAULT_BASE_URL="https://api.unshared.ai",DEFAULT_TIMEOUT_MS=1e4,DEFAULT_MAX_RETRIES=3,MAX_DELAY_MS=3e4,BASE_DELAY_MS=1e3;function sleep(e){return new Promise(t=>setTimeout(t,e))}function retryDelay(e){const t=Math.min(1e3*Math.pow(2,e-1),3e4),s=t*(.5*Math.random()-.25);return Math.max(0,t+s)}async function parseErrorBody(e){const t=await e.text().catch(()=>"");try{const s=JSON.parse(t);return s?.error?.code?{code:s.error.code,message:s.error.message??"Unknown error",details:s.error.details}:{code:"UNKNOWN_ERROR",message:t||e.statusText}}catch{return{code:"UNKNOWN_ERROR",message:t||e.statusText}}}export class UnsharedClient{constructor(e){if(!e.apiKey||""===e.apiKey.trim())throw new Error("apiKey is required");this.t=e.apiKey,this.i=e.baseUrl?e.baseUrl.replace(/\/$/,""):DEFAULT_BASE_URL,this.o=e.timeout??1e4,this.h=e.maxRetries??3,this.l=createHash("sha256").update(e.apiKey).digest(),this.u=e.fetch}_(e){return encryptData(e,this.l)}async m(e,t){const s=this.h+1;let r={success:!1,status:0,error:{code:"NETWORK_ERROR",message:"Request failed"}};for(let i=1;i<=s;i++){i>1&&await sleep(retryDelay(i-1));const s=new AbortController,a=setTimeout(()=>s.abort(),this.o);try{const i=this.u??globalThis.fetch,n=await i(e,{method:t.method,headers:{"X-API-Key":this.t,...t.headers},body:t.body,signal:s.signal});if(clearTimeout(a),n.ok){const e=await n.text().catch(()=>"{}");let t;try{t=JSON.parse(e)}catch{t={}}const s="data"in t?t.data:t;return{success:!0,status:n.status,data:s}}const o=await parseErrorBody(n);if(n.status>=400&&n.status<500){if(429===n.status){const e=n.headers.get("Retry-After");if(null!=e){const t=parseInt(e,10);isNaN(t)||(o.retryAfter=t)}}return{success:!1,status:n.status,error:o}}r={success:!1,status:n.status,error:o}}catch(e){clearTimeout(a),r={success:!1,status:0,error:{code:"NETWORK_ERROR",message:e instanceof Error?e.message:String(e)}}}}return r}async submitFingerprintEvent(e,t){const s={hash:e.full_hash,stable_hash:e.fingerprint_id,collected_at:e.timestamp,is_incognito:e.isIncognito,components:e.components,version:e.version};return null!=t?.userId&&(s.user_id=this._(t.userId)),null!=t?.emailAddress&&(s.email_address=this._(t.emailAddress)),null!=t?.sessionHash&&(s.session_hash=t.sessionHash),null!=t?.eventType&&(s.event_type=t.eventType),null!=t?.ipAddress&&(s.ip_address=this._(t.ipAddress)),null!=t?.userAgent&&(s.user_agent=this._(t.userAgent)),this.m(`${this.i}/v2/submit-fingerprint-event`,{method:"POST",headers:{"Content-Type":"application/json","X-Idempotency-Key":t?.idempotencyKey??randomUUID()},body:JSON.stringify(s)})}async processUserEvent(e){const t={event_type:e.eventType,user_id:this._(e.userId),ip_address:this._(e.ipAddress),device_id:this._(e.deviceId),session_hash:e.sessionHash,user_agent:this._(e.userAgent),email_address:this._(e.emailAddress)};return null!=e.fingerprintId&&(t.fingerprint_id=this._(e.fingerprintId)),null!=e.subscriptionStatus&&(t.subscription_status=e.subscriptionStatus),null!=e.eventDetails&&(t.event_details=e.eventDetails),this.m(`${this.i}/v2/process-user-event`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(t)})}async checkUser(e,t){const s="string"==typeof t?{deviceId:t}:t;if(!s.deviceId&&!s.fingerprintId)return{success:!0,status:200,data:{is_user_flagged:!1}};const r=new URLSearchParams;r.set("email_address",this._(e)),s.deviceId&&r.set("device_id",this._(s.deviceId)),s.fingerprintId&&r.set("fingerprint_id",this._(s.fingerprintId));const i=await this.m(`${this.i}/v2/check-user?${r}`,{method:"GET"});return i.success?i:{success:!0,status:200,data:{is_user_flagged:!1}}}async triggerEmailVerification(e,t,s){const r={email_address:this._(e),device_id:this._(t)};s?.fingerprintId&&(r.fingerprint_id=this._(s.fingerprintId));const i=await this.m(`${this.i}/v2/trigger-email-verification`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(r)});return!i.success&&(0===i.status||i.status>=500)?{success:!1,status:i.status,error:{code:"DELIVERY_FAILED",message:i.error?.message??"Delivery failed"}}:i}async verify(e,t,s,r){const i={email_address:this._(e),device_id:this._(t),code:this._(s)};r?.fingerprintId&&(i.fingerprint_id=this._(r.fingerprintId));const a=await this.m(`${this.i}/v2/verify`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(i)});return!a.success&&(0===a.status||a.status>=500)?{success:!1,status:a.status,error:{code:"DELIVERY_FAILED",message:a.error?.message??"Delivery failed"}}:a.success&&!1===a.data?.verified?{success:!1,status:a.status,error:{code:"VERIFICATION_FAILED",message:"Code is incorrect or expired",details:a.data.reason?{reason:a.data.reason}:void 0}}:a}async getInterstitialFlow(e){const t=new URLSearchParams;return t.set("flow_type",e?.flowType||"email_verification"),t.set("platform",e?.platform||"web"),this.m(`${this.i}/v2/interstitial-flow?${t}`,{method:"GET"})}}
@@ -2,7 +2,6 @@ import type { UnsharedRequest, UnsharedResponse, UnsharedNextFunction } from '..
2
2
  import type { UnsharedClient } from '../client';
3
3
  import { VerdictCache } from './verdict-cache';
4
4
  import type { Verdict } from './verdict-cache';
5
- export { flaggedResponse, ACCOUNT_FLAGGED_ERROR } from './utils/flagged-response';
6
5
  export interface ProtectionConfig<TReq extends UnsharedRequest = UnsharedRequest> {
7
6
  /**
8
7
  * Required. Resolves the current user's ID from the request.
@@ -51,6 +50,18 @@ export interface ProtectionConfig<TReq extends UnsharedRequest = UnsharedRequest
51
50
  res: UnsharedResponse;
52
51
  next: UnsharedNextFunction;
53
52
  }) => void;
53
+ /**
54
+ * Server-side enforcement. When `true`, a flagged + unverified user's request is
55
+ * short-circuited BEFORE your route handler runs — the protected content is never
56
+ * served. HTML navigations receive a standalone verification gate page (which renders
57
+ * the interstitial and reloads on success); all other requests receive a
58
+ * `403 { error: 'account_flagged' }`. This is the only way to prevent access that a
59
+ * user can't bypass by removing the modal from the DOM.
60
+ *
61
+ * When `true`, the SDK owns the flagged response and `onFlagged` is ignored. Requires
62
+ * `unshared-frontend-sdk` to be installed and the default `routePrefix`. @default false
63
+ */
64
+ blockFlagged?: boolean;
54
65
  /**
55
66
  * Called when a background SDK operation fails (fire-and-forget API calls,
56
67
  * verdict refreshes, etc.). Use this to pipe errors to your logging or
@@ -67,4 +78,5 @@ export interface ProtectionConfig<TReq extends UnsharedRequest = UnsharedRequest
67
78
  }
68
79
  export type { Verdict };
69
80
  export { VerdictCache };
81
+ export { flaggedResponse, ACCOUNT_FLAGGED_ERROR } from './utils/flagged-response';
70
82
  export declare function unsharedBoundToUser<TReq extends UnsharedRequest = UnsharedRequest>(client: UnsharedClient, config: ProtectionConfig<TReq>): (req: TReq, res: UnsharedResponse, next: UnsharedNextFunction) => void;
@@ -1 +1 @@
1
- import{readFileSync}from"fs";import{VerdictCache}from"./verdict-cache";import{RateLimitBackoff}from"./rate-limit-backoff";import{DispatchDedupe}from"./dispatch-dedupe";import{interceptResponse}from"./response-interceptor";import{generateFingerprintScript}from"./injection/fingerprint-script";import{handleSubmitFingerprint}from"./routes/submit-fp";import{handleVerifyTrigger,handleVerify}from"./routes/verify";import{sendJson,sendEmpty,sendBody,getRequestPath}from"./utils/http-helpers";import{isHtmlContentType}from"./utils/content-type";import{shouldSkipPath}from"./utils/skip-paths";import{shouldIncludePath}from"./utils/include-path";import{isBot}from"./utils/is-bot";import{extractClientIp}from"./utils/client-ip";import{parseCookie}from"./utils/cookies";import{extractDeviceIdOrUndefined}from"./utils/device-id";import{isSecureRequest}from"./utils/secure";export{flaggedResponse,ACCOUNT_FLAGGED_ERROR}from"./utils/flagged-response";import{isSentinelUserId,SENTINEL_STICKINESS_TTL_MS}from"./utils/sentinel-user-id";export{VerdictCache};const CHECK_USER_TIMEOUT_MS=500;export function unsharedBoundToUser(e,t){if(!t.userId)throw new Error("[Unshared] userId resolver is required");if(!t.emailAddress){let e=!1;try{require.resolve("unshared-frontend-sdk"),e=!0}catch{}e||console.warn("[Unshared] Warning: emailAddress resolver is not configured and unshared-frontend-sdk is not installed.\nNo user events will be submitted. Either install unshared-frontend-sdk (Tier 1) or\nprovide emailAddress in your middleware config (Tier 2).")}const{userId:r,emailAddress:i,routePrefix:n="/__unshared",corsOrigins:s,cacheTTL:o=6e4,skipPaths:d,includePathPrefix:a,disableBotFilter:c=!1,checkUserTimeoutMs:u=CHECK_USER_TIMEOUT_MS,sessionId:l,deviceId:p,onFlagged:f,onError:m}=t,h=new VerdictCache(o),S=new RateLimitBackoff,I=new DispatchDedupe,_=Date.now().toString(36),v=generateFingerprintScript(n,_);let C="";try{const e=require.resolve("unshared-frontend-sdk/dist/index.umd.js");C=readFileSync(e,"utf8")}catch{}const g=handleSubmitFingerprint({client:e,verdictCache:h,rateLimitBackoff:S,dispatchDedupe:I,resolveUserId:r,resolveEmailAddress:i,resolveSessionId:l,resolveDeviceId:p,onError:m}),y=handleVerifyTrigger({client:e,verdictCache:h,resolveEmailAddress:i,resolveDeviceId:p,onError:m}),k=handleVerify({client:e,verdictCache:h,resolveEmailAddress:i,resolveDeviceId:p,onError:m}),A=s?Array.isArray(s)?s:[s]:null,E=`${n}/fp.js`,T=`${n}/submit-fp`,x=`${n}/verify-trigger`,U=`${n}/verify`,P=`${n}/status`;return function(t,s,o){const _=getRequestPath(t.url),w=t.url||_;if(_.startsWith(n+"/")){if(function(e,t){if(!A)return;const r=e.headers.origin??"",i=A.includes("*");(i||A.includes(r))&&(t.setHeader("Access-Control-Allow-Origin",i?"*":r),t.setHeader("Access-Control-Allow-Methods","POST, OPTIONS"),t.setHeader("Access-Control-Allow-Headers","Content-Type, X-Idempotency-Key, X-Session-Id, X-Device-Id"),t.setHeader("Access-Control-Allow-Credentials","true"))}(t,s),"OPTIONS"===t.method)return void sendEmpty(s,204);if("GET"===t.method&&_===E)return s.setHeader("Content-Type","application/javascript"),s.setHeader("Cache-Control","public, max-age=3600"),void sendBody(s,200,C);if("POST"===t.method&&(_===T||_===x||_===U))return void 0===t.body?void sendJson(s,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."}}):_===T?void g(t,s):_===x?void y(t,s):void k(t,s);if("GET"===t.method&&_===P){let n;try{n=r(t)}catch{}if(!n)return void sendJson(s,200,{status:"anonymous"});const o=resolveEmail(t,i);return void(async()=>{let r=h.get(n);if((!r||h.isStale(n))&&o&&!S.isPaused()&&!h.isRefreshing(n)){h.markRefreshing(n);try{const i=extractDeviceIdOrUndefined(t,p),s=extractFingerprintId(t),d=extractSessionId(t,l),a=i??s??"unknown";await fetchAndCacheVerdict(e,h,n,o,a,s,d,u),r=h.get(n)}catch(e){m&&m(e,{operation:"checkUser",userId:n,emailAddress:o})}finally{h.clearRefreshing(n)}}r&&r.isFlagged&&!r.isVerified&&f&&o?sendJson(s,200,{status:"flagged",email:o}):sendJson(s,200,{status:"ok"})})()}return void sendJson(s,404,{success:!1,error:{code:"NOT_FOUND",message:"Unknown route"}})}if(shouldSkipPath(_,d))return void o();if(!shouldIncludePath(_,a))return interceptForInjection(t,s,v),void o();let F;try{F=r(t)}catch{}if(isSentinelUserId(F)){const e=parseCookie(t,"__unshared_uid"),r=parseCookie(t,"__unshared_uid_at"),i=r?Number(r):NaN,n=Number.isFinite(i)&&Date.now()-i<=SENTINEL_STICKINESS_TTL_MS;F=e&&n?e:void 0}if(!F){const e=isSecureRequest(t)?"; Secure":"";return appendSetCookie(s,`__unshared_uid=; Path=/; SameSite=Lax; Max-Age=0${e}`),appendSetCookie(s,`__unshared_uid_at=; Path=/; SameSite=Lax; Max-Age=0${e}`),appendSetCookie(s,`__unshared_sid=; Path=/; SameSite=Lax; Max-Age=0${e}`),appendSetCookie(s,`__unshared_email=; Path=/; SameSite=Lax; Max-Age=0${e}`),interceptForInjection(t,s,v),void o()}const O=resolveEmail(t,i);if(setUserIdCookie(t,s,F),O&&setEmailCookie(t,s,O),!O)return interceptForInjection(t,s,v),void o();const R=extractSessionId(t,l),D=extractDeviceIdOrUndefined(t,p),N=extractFingerprintId(t),b=t.headers["user-agent"]??"",L=extractClientIp(t),M=D??N;if(!c&&isBot(b))return void o();const V=h.get(F);function $(){"unknown"!==R&&M&&(S.isPaused()||dispatchUserEvent(e,h,S,I,{userId:F,emailAddress:O,sessionId:R,deviceId:M,fingerprintId:N,userAgent:b,ipAddress:L,eventType:w},m))}V?(h.isStale(F)&&!h.isRefreshing(F)&&(h.markRefreshing(F),fetchAndCacheVerdict(e,h,F,O,M??"unknown",N,R,u).finally(()=>h.clearRefreshing(F))),V.isFlagged||$(),applyVerdict(V,F,O,t,s,o,v,f)):fetchAndCacheVerdict(e,h,F,O,M??"unknown",N,R,u).then(e=>{e.isFlagged||$(),applyVerdict(e,F,O,t,s,o,v,f)}).catch(()=>{$(),interceptForInjection(t,s,v),o()})}}function resolveEmail(e,t){if(t)try{const r=t(e);if(r)return r}catch{}const r=parseCookie(e,"__unshared_email");if(r)return r;const i=e.body?.email;return"string"==typeof i&&i?i:void 0}function applyVerdict(e,t,r,i,n,s,o,d){if(interceptForInjection(i,n,o),e.isFlagged&&!e.isVerified&&d)try{d({userId:t,emailAddress:r,verdict:e,req:i,res:n,next:s})}catch{s()}else s()}function interceptForInjection(e,t,r){delete e.headers["if-none-match"],delete e.headers["if-modified-since"],interceptResponse(t,(e,t)=>{if(!isHtmlContentType(t))return null;const i=e.toString("utf8"),n=i.lastIndexOf("</body>");return-1===n?i+r:i.slice(0,n)+r+i.slice(n)},{preventCaching:!0})}function dispatchUserEvent(e,t,r,i,n,s){i.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&&t.update(n.userId,{isFlagged:e.data.analysis.is_user_flagged}),!e.success&&e.error?.retryAfter&&r.pause(1e3*e.error.retryAfter)}).catch(e=>{s&&s(e,{operation:"processUserEvent",userId:n.userId,emailAddress:n.emailAddress})})}async function fetchAndCacheVerdict(e,t,r,i,n,s,o,d=CHECK_USER_TIMEOUT_MS){const a={};let c;n&&"unknown"!==n&&(a.deviceId=n),s&&(a.fingerprintId=s);const u=await Promise.race([e.checkUser(i,a),new Promise(e=>{c=setTimeout(()=>e(null),d)})]);if(clearTimeout(c),!u)return{isFlagged:!1,isVerified:!1,emailAddress:i,sessionId:o,cachedAt:0,ttl:0};const l=u.data?.is_user_flagged??!1;return t.set(r,{isFlagged:l,isVerified:!1,emailAddress:i,sessionId:o}),t.get(r)}function extractSessionId(e,t){if(t)try{const r=t(e);if(r)return r}catch{}return parseCookie(e,"__unshared_sid")??"unknown"}function extractFingerprintId(e){return parseCookie(e,"__unshared_fingerprint_id")||void 0}function appendSetCookie(e,t){const r=e.getHeader("Set-Cookie");if(r){const i=Array.isArray(r)?[...r]:[String(r)];i.push(t),e.setHeader("Set-Cookie",i)}else e.setHeader("Set-Cookie",t)}function setUserIdCookie(e,t,r){const i=isSecureRequest(e)?"; Secure":"";appendSetCookie(t,`__unshared_uid=${encodeURIComponent(r)}; Path=/; SameSite=Lax${i}`),appendSetCookie(t,`__unshared_uid_at=${Date.now()}; Path=/; SameSite=Lax${i}`)}function setEmailCookie(e,t,r){const i=isSecureRequest(e)?"; Secure":"";appendSetCookie(t,`__unshared_email=${encodeURIComponent(r)}; HttpOnly; Path=/; SameSite=Lax${i}`)}
1
+ import{readFileSync}from"fs";import{VerdictCache}from"./verdict-cache";import{RateLimitBackoff}from"./rate-limit-backoff";import{DispatchDedupe}from"./dispatch-dedupe";import{interceptResponse}from"./response-interceptor";import{generateFingerprintScript}from"./injection/fingerprint-script";import{handleSubmitFingerprint}from"./routes/submit-fp";import{handleVerifyTrigger,handleVerify}from"./routes/verify";import{handleGetInterstitialFlow}from"./routes/interstitial";import{generateGatePage}from"./injection/gate-page";import{sendJson,sendEmpty,sendBody,getRequestPath}from"./utils/http-helpers";import{isHtmlContentType,isHtmlNavigation}from"./utils/content-type";import{flaggedResponse}from"./utils/flagged-response";import{shouldSkipPath}from"./utils/skip-paths";import{shouldIncludePath}from"./utils/include-path";import{isBot}from"./utils/is-bot";import{extractClientIp}from"./utils/client-ip";import{parseCookie}from"./utils/cookies";import{extractDeviceIdOrUndefined}from"./utils/device-id";import{isSecureRequest}from"./utils/secure";import{isSentinelUserId,SENTINEL_STICKINESS_TTL_MS}from"./utils/sentinel-user-id";export{VerdictCache};export{flaggedResponse,ACCOUNT_FLAGGED_ERROR}from"./utils/flagged-response";const CHECK_USER_TIMEOUT_MS=500;export function unsharedBoundToUser(e,t){if(!t.userId)throw new Error("[Unshared] userId resolver is required");if(!t.emailAddress){let e=!1;try{require.resolve("unshared-frontend-sdk"),e=!0}catch{}e||console.warn("[Unshared] Warning: emailAddress resolver is not configured and unshared-frontend-sdk is not installed.\nNo user events will be submitted. Either install unshared-frontend-sdk (Tier 1) or\nprovide emailAddress in your middleware config (Tier 2).")}const{userId:r,emailAddress:i,routePrefix:n="/__unshared",corsOrigins:o,cacheTTL:s=6e4,skipPaths:d,includePathPrefix:a,disableBotFilter:c=!1,checkUserTimeoutMs:l=CHECK_USER_TIMEOUT_MS,sessionId:u,deviceId:p,onFlagged:f,onError:h,blockFlagged:m=!1}=t,g=new VerdictCache(s),S=new RateLimitBackoff,_=new DispatchDedupe,I=Date.now().toString(36),v=generateFingerprintScript(n,I);let C="";try{const e=require.resolve("unshared-frontend-sdk/dist/index.umd.js");C=readFileSync(e,"utf8")}catch{}if(m&&!C)throw new Error("[Unshared] blockFlagged requires unshared-frontend-sdk to be installed (its UMD bundle is the gate-page renderer).");if(m&&"/__unshared"!==n)throw new Error('[Unshared] blockFlagged requires the default routePrefix ("/__unshared"); the browser SDK proxy routes are fixed to that prefix.');const k=m?generateGatePage(n):"",y=handleSubmitFingerprint({client:e,verdictCache:g,rateLimitBackoff:S,dispatchDedupe:_,resolveUserId:r,resolveEmailAddress:i,resolveSessionId:u,resolveDeviceId:p,disableBotFilter:c,onError:h}),E=handleVerifyTrigger({client:e,verdictCache:g,resolveEmailAddress:i,resolveDeviceId:p,onError:h}),T=handleVerify({client:e,verdictCache:g,resolveEmailAddress:i,resolveDeviceId:p,onError:h}),A=handleGetInterstitialFlow({client:e}),x=o?Array.isArray(o)?o:[o]:null,w=`${n}/fp.js`,U=`${n}/submit-fp`,F=`${n}/verify-trigger`,P=`${n}/verify`,b=`${n}/status`,R=`${n}/interstitial-flow`;return function(t,o,s){const I=getRequestPath(t.url),O=t.url||I;if(I.startsWith(n+"/")){if(function(e,t){if(!x)return;const r=e.headers.origin??"",i=x.includes("*");(i||x.includes(r))&&(t.setHeader("Access-Control-Allow-Origin",i?"*":r),t.setHeader("Access-Control-Allow-Methods","GET, POST, OPTIONS"),t.setHeader("Access-Control-Allow-Headers","Content-Type, X-Idempotency-Key, X-Session-Id, X-Device-Id"),t.setHeader("Access-Control-Allow-Credentials","true"))}(t,o),"OPTIONS"===t.method)return void sendEmpty(o,204);if("GET"===t.method&&I===w)return o.setHeader("Content-Type","application/javascript"),o.setHeader("Cache-Control","public, max-age=3600"),void sendBody(o,200,C);if("POST"===t.method&&(I===U||I===F||I===P))return void 0===t.body?void sendJson(o,400,{success:!1,error:{code:"BODY_PARSER_MISSING",message:"req.body is undefined. Mount a JSON body-parsing middleware (e.g., express.json()) before the Unshared middleware."}}):I===U?void y(t,o):I===F?void E(t,o):void T(t,o);if("GET"===t.method&&I===R)return void A(t,o);if("GET"===t.method&&I===b){let n;try{n=r(t)}catch{}if(!n)return void sendJson(o,200,{status:"anonymous"});const s=resolveEmail(t,i);return void(async()=>{let r=g.get(n);if((!r||g.isStale(n))&&s&&!S.isPaused()&&!g.isRefreshing(n)){g.markRefreshing(n);try{const i=extractDeviceIdOrUndefined(t,p),o=extractFingerprintId(t),d=extractSessionId(t,u),a=i??o??"unknown";await fetchAndCacheVerdict(e,g,n,s,a,o,d,l),r=g.get(n)}catch(e){h&&h(e,{operation:"checkUser",userId:n,emailAddress:s})}finally{g.clearRefreshing(n)}}r&&r.isFlagged&&!r.isVerified&&f&&s?sendJson(o,200,{status:"flagged",email:s}):sendJson(o,200,{status:"ok"})})()}return void sendJson(o,404,{success:!1,error:{code:"NOT_FOUND",message:"Unknown route"}})}if(shouldSkipPath(I,d))return void s();if(!shouldIncludePath(I,a))return interceptForInjection(t,o,v),void s();let D;try{D=r(t)}catch{}if(isSentinelUserId(D)){const e=parseCookie(t,"__unshared_uid"),r=parseCookie(t,"__unshared_uid_at"),i=r?Number(r):NaN,n=Number.isFinite(i)&&Date.now()-i<=SENTINEL_STICKINESS_TTL_MS;D=e&&n?e:void 0}if(!D){const e=isSecureRequest(t)?"; Secure":"";return appendSetCookie(o,`__unshared_uid=; Path=/; SameSite=Lax; Max-Age=0${e}`),appendSetCookie(o,`__unshared_uid_at=; Path=/; SameSite=Lax; Max-Age=0${e}`),appendSetCookie(o,`__unshared_sid=; Path=/; SameSite=Lax; Max-Age=0${e}`),appendSetCookie(o,`__unshared_email=; Path=/; SameSite=Lax; Max-Age=0${e}`),interceptForInjection(t,o,v),void s()}const N=resolveEmail(t,i);if(setUserIdCookie(t,o,D),N&&setEmailCookie(t,o,N),!N)return interceptForInjection(t,o,v),void s();const M=extractSessionId(t,u),L=extractDeviceIdOrUndefined(t,p),V=extractFingerprintId(t),$=t.headers["user-agent"]??"",q=extractClientIp(t),j=L??V;if(!c&&isBot($))return void s();const B=g.get(D);function G(){"unknown"!==M&&j&&(S.isPaused()||dispatchUserEvent(e,g,S,_,{userId:D,emailAddress:N,sessionId:M,deviceId:j,fingerprintId:V,userAgent:$,ipAddress:q,eventType:O},h))}B?(g.isStale(D)&&!g.isRefreshing(D)&&(g.markRefreshing(D),fetchAndCacheVerdict(e,g,D,N,j??"unknown",V,M,l).finally(()=>g.clearRefreshing(D))),B.isFlagged||G(),applyVerdict(B,D,N,t,o,s,v,f,m,k)):fetchAndCacheVerdict(e,g,D,N,j??"unknown",V,M,l).then(e=>{e.isFlagged||G(),applyVerdict(e,D,N,t,o,s,v,f,m,k)}).catch(()=>{G(),interceptForInjection(t,o,v),s()})}}function resolveEmail(e,t){if(t)try{const r=t(e);if(r)return r}catch{}const r=parseCookie(e,"__unshared_email");if(r)return r;const i=e.body?.email;return"string"==typeof i&&i?i:void 0}function applyVerdict(e,t,r,i,n,o,s,d,a,c){if(a&&e.isFlagged&&!e.isVerified)isHtmlNavigation(i.method,i.headers.accept)?(n.statusCode=200,n.setHeader("Content-Type","text/html; charset=utf-8"),n.setHeader("Cache-Control","no-store"),n.end(c)):sendJson(n,403,flaggedResponse(r));else if(interceptForInjection(i,n,s),e.isFlagged&&!e.isVerified&&d)try{d({userId:t,emailAddress:r,verdict:e,req:i,res:n,next:o})}catch{o()}else o()}function interceptForInjection(e,t,r){delete e.headers["if-none-match"],delete e.headers["if-modified-since"],interceptResponse(t,(e,t)=>{if(!isHtmlContentType(t))return null;const i=e.toString("utf8"),n=i.lastIndexOf("</body>");return-1===n?i+r:i.slice(0,n)+r+i.slice(n)},{preventCaching:!0})}function dispatchUserEvent(e,t,r,i,n,o){i.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&&t.update(n.userId,{isFlagged:e.data.analysis.is_user_flagged}),!e.success&&e.error?.retryAfter&&r.pause(1e3*e.error.retryAfter)}).catch(e=>{o&&o(e,{operation:"processUserEvent",userId:n.userId,emailAddress:n.emailAddress})})}async function fetchAndCacheVerdict(e,t,r,i,n,o,s,d=CHECK_USER_TIMEOUT_MS){const a={};let c;n&&"unknown"!==n&&(a.deviceId=n),o&&(a.fingerprintId=o);const l=await Promise.race([e.checkUser(i,a),new Promise(e=>{c=setTimeout(()=>e(null),d)})]);if(clearTimeout(c),!l)return{isFlagged:!1,isVerified:!1,emailAddress:i,sessionId:s,cachedAt:0,ttl:0};const u=l.data?.is_user_flagged??!1;return t.set(r,{isFlagged:u,isVerified:!1,emailAddress:i,sessionId:s}),t.get(r)}function extractSessionId(e,t){if(t)try{const r=t(e);if(r)return r}catch{}return parseCookie(e,"__unshared_sid")??"unknown"}function extractFingerprintId(e){return parseCookie(e,"__unshared_fingerprint_id")||void 0}function appendSetCookie(e,t){const r=e.getHeader("Set-Cookie");if(r){const i=Array.isArray(r)?[...r]:[String(r)];i.push(t),e.setHeader("Set-Cookie",i)}else e.setHeader("Set-Cookie",t)}function setUserIdCookie(e,t,r){const i=isSecureRequest(e)?"; Secure":"";appendSetCookie(t,`__unshared_uid=${encodeURIComponent(r)}; Path=/; SameSite=Lax${i}`),appendSetCookie(t,`__unshared_uid_at=${Date.now()}; Path=/; SameSite=Lax${i}`)}function setEmailCookie(e,t,r){const i=isSecureRequest(e)?"; Secure":"";appendSetCookie(t,`__unshared_email=${encodeURIComponent(r)}; HttpOnly; Path=/; SameSite=Lax${i}`)}
@@ -1 +1 @@
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// Page-scoped dedup state SHARED with the frontend SDK (browser.ts getSharedDedup).\n// On a Tier 1 page both this inline script and the SDK submit; each holds its own\n// in-memory lastSubmitKey, so both could pass the check below and POST. window.__unshared\n// is read+written synchronously in submitFP (no await between), so whichever fires\n// first claims the uid|route key and the other no-ops — killing the read-before-write\n// race that doubled events. KEEP IN SYNC with browser.ts: namespace, lastKey, key formula.\nvar shared=(window.__unshared=window.__unshared||{});\n\n// --- Helpers ---\nfunction gC(n){var m=document.cookie.match(new RegExp("(?:^|; )"+n+"=([^;]*)"));return m?decodeURIComponent(m[1]):null}\nfunction sC(n,v,d){var e="";if(d){var dt=new Date();dt.setTime(dt.getTime()+d*864e5);e="; expires="+dt.toUTCString()}document.cookie=n+"="+encodeURIComponent(v)+e+"; path=/; SameSite=Lax"}\nfunction uuid(){return(typeof crypto!=="undefined"&&crypto.randomUUID)?crypto.randomUUID():("xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g,function(c){var r=Math.random()*16|0;return(c==="x"?r:r&0x3|0x8).toString(16)}))}\n// Sentinel user IDs that must never be treated as real users. Mirrors\n// the Set in sentinel-user-id.ts — keep in sync. Empty string is handled\n// by the separate !uid checks below.\nvar SENTINEL_UIDS={"__pre_auth__":1,"anonymous":1,"guest":1,"undefined":1,"null":1};\nfunction isSentinelUid(v){return typeof v==="string"&&SENTINEL_UIDS.hasOwnProperty(v)}\n\n// --- Session + device IDs ---\n// Session ID is a UUID because it's supposed to be tab-scoped and random.\n// Device ID is intentionally NOT a UUID — Issue 9: random UUIDs wrote\n// meaningless device_ids to every fingerprint row. Instead we read the\n// stable fingerprint hash from localStorage if a previous submission\n// already persisted it; otherwise we leave did empty and let submitFP()\n// reconcile on the first successful collection. The Node middleware's\n// Issue 8 bootstrap-skip branch handles the empty-device_id window so we\n// never dispatch with a random or "unknown" value.\nvar sid=gC("__unshared_sid");\nif(!sid){sid=uuid();sC("__unshared_sid",sid,365)}\nvar did="";\ntry{did=localStorage.getItem("__unshared_device_id")||""}catch(e){}\nif(did){sC("__unshared_fp_id",did,365)}\n\n// --- Fingerprint cache (sessionStorage) ---\nfunction getFP(){try{var r=sessionStorage.getItem(SS_FP);return r?JSON.parse(r):null}catch(e){return null}}\nfunction setFP(fp){try{sessionStorage.setItem(SS_FP,JSON.stringify(fp))}catch(e){}}\n\n// --- Submit fingerprint to backend ---\nfunction submitFP(fp){\n var uid=gC("__unshared_uid");\n if(!uid||isSentinelUid(uid))return;\n // Issue 9: reconcile device_id to the stable fingerprint hash. This runs\n // before we send the X-Device-Id header so the very first submission\n // already carries the real value. Persist to localStorage so other tabs\n // (and future reloads) pick up the same stable ID without needing to\n // re-collect the fingerprint.\n if(fp.fingerprint_id){\n did=fp.fingerprint_id;\n try{localStorage.setItem("__unshared_device_id",did)}catch(e){}\n sC("__unshared_fp_id",did,365);\n }\n // event_type is the SPA route, not a fixed enum. Page-level event names\n // (page_load/route_change) collapsed every row into one of two buckets;\n // the URL is more useful for analytics and matches the frontend SDK.\n var route=(location.pathname||"/")+(location.search||"");\n var key=uid+"|"+route;\n // Check the shared window guard AND the in-memory key (last-key semantics, so an\n // SPA A->B->A revisit still submits the second A). The shared guard makes this\n // submitter and the frontend SDK see each other within the page.\n if(key===lastSubmitKey||shared.lastKey===key)return;\n shared.lastKey=key;\n lastSubmitKey=key;\n try{sessionStorage.setItem(SS_LAST_SUBMIT,key)}catch(e){}\n // collected_at is stamped fresh at submit time rather than carried from fp.timestamp,\n // because fp is cached per-tab in sessionStorage — reusing its original timestamp would\n // freeze collected_at at first load and drift against server created_at as the tab ages.\n // The server authoritatively overwrites this value again on ingress.\n var body={hash:fp.full_hash,stable_hash:fp.fingerprint_id,collected_at:(new Date()).toISOString(),is_incognito:fp.isIncognito,components:fp.components,version:fp.version,session_id:sid,user_id:uid,event_type:route};\n // Idempotency key derived from (stable_hash, user_id, route). NOTE: the\n // middleware appends |Date.now() before forwarding (submit-fp.ts), so the\n // backend sees a unique value per submission and dedups only PubSub\n // redeliveries — NOT two distinct POSTs. Cross-submitter / cross-reload dedup\n // is client-side (the shared window guard + sessionStorage above).\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
+ 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// Page-scoped dedup state SHARED with the frontend SDK (browser.ts getSharedDedup).\n// On a Tier 1 page both this inline script and the SDK submit; each holds its own\n// in-memory lastSubmitKey, so both could pass the check below and POST. window.__unshared\n// is read+written synchronously in submitFP (no await between), so whichever fires\n// first claims the uid|route key and the other no-ops — killing the read-before-write\n// race that doubled events. KEEP IN SYNC with browser.ts: namespace, lastKey, key formula.\nvar shared=(window.__unshared=window.__unshared||{});\n\n// --- Helpers ---\nfunction gC(n){var m=document.cookie.match(new RegExp("(?:^|; )"+n+"=([^;]*)"));return m?decodeURIComponent(m[1]):null}\nfunction sC(n,v,d){var e="";if(d){var dt=new Date();dt.setTime(dt.getTime()+d*864e5);e="; expires="+dt.toUTCString()}document.cookie=n+"="+encodeURIComponent(v)+e+"; path=/; SameSite=Lax"+(location.protocol==="https:"?"; Secure":"")}\nfunction uuid(){return(typeof crypto!=="undefined"&&crypto.randomUUID)?crypto.randomUUID():("xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g,function(c){var r=Math.random()*16|0;return(c==="x"?r:r&0x3|0x8).toString(16)}))}\n// Sentinel user IDs that must never be treated as real users. Mirrors\n// the Set in sentinel-user-id.ts — keep in sync. Empty string is handled\n// by the separate !uid checks below.\nvar SENTINEL_UIDS={"__pre_auth__":1,"anonymous":1,"guest":1,"undefined":1,"null":1};\nfunction isSentinelUid(v){return typeof v==="string"&&SENTINEL_UIDS.hasOwnProperty(v)}\n\n// --- Session + device IDs ---\n// Session ID is a UUID because it's supposed to be tab-scoped and random.\n// Device ID is intentionally NOT a UUID — Issue 9: random UUIDs wrote\n// meaningless device_ids to every fingerprint row. Instead we read the\n// stable fingerprint hash from localStorage if a previous submission\n// already persisted it; otherwise we leave did empty and let submitFP()\n// reconcile on the first successful collection. The Node middleware's\n// Issue 8 bootstrap-skip branch handles the empty-device_id window so we\n// never dispatch with a random or "unknown" value.\nvar sid=gC("__unshared_sid");\nif(!sid){sid=uuid();sC("__unshared_sid",sid,365)}\nvar did="";\ntry{did=localStorage.getItem("__unshared_device_id")||""}catch(e){}\nif(did){sC("__unshared_fp_id",did,365)}\n\n// --- Fingerprint cache (sessionStorage) ---\nfunction getFP(){try{var r=sessionStorage.getItem(SS_FP);return r?JSON.parse(r):null}catch(e){return null}}\nfunction setFP(fp){try{sessionStorage.setItem(SS_FP,JSON.stringify(fp))}catch(e){}}\n\n// --- Submit fingerprint to backend ---\nfunction submitFP(fp){\n var uid=gC("__unshared_uid");\n if(!uid||isSentinelUid(uid))return;\n // Issue 9: reconcile device_id to the stable fingerprint hash. This runs\n // before we send the X-Device-Id header so the very first submission\n // already carries the real value. Persist to localStorage so other tabs\n // (and future reloads) pick up the same stable ID without needing to\n // re-collect the fingerprint.\n if(fp.fingerprint_id){\n did=fp.fingerprint_id;\n try{localStorage.setItem("__unshared_device_id",did)}catch(e){}\n sC("__unshared_fp_id",did,365);\n }\n // event_type is the SPA route, not a fixed enum. Page-level event names\n // (page_load/route_change) collapsed every row into one of two buckets;\n // the URL is more useful for analytics and matches the frontend SDK.\n var route=(location.pathname||"/")+(location.search||"");\n var key=uid+"|"+route;\n // Check the shared window guard AND the in-memory key (last-key semantics, so an\n // SPA A->B->A revisit still submits the second A). The shared guard makes this\n // submitter and the frontend SDK see each other within the page.\n if(key===lastSubmitKey||shared.lastKey===key)return;\n shared.lastKey=key;\n lastSubmitKey=key;\n try{sessionStorage.setItem(SS_LAST_SUBMIT,key)}catch(e){}\n // collected_at is stamped fresh at submit time rather than carried from fp.timestamp,\n // because fp is cached per-tab in sessionStorage — reusing its original timestamp would\n // freeze collected_at at first load and drift against server created_at as the tab ages.\n // The server authoritatively overwrites this value again on ingress.\n var body={hash:fp.full_hash,stable_hash:fp.fingerprint_id,collected_at:(new Date()).toISOString(),is_incognito:fp.isIncognito,components:fp.components,version:fp.version,session_id:sid,user_id:uid,event_type:route};\n // Idempotency key derived from (stable_hash, user_id, route), SHA-256-hashed\n // when WebCrypto is available so the user_id never appears in the platform's\n // stored idempotency_key column (raw fallback on non-secure contexts — the\n // middleware accepts both). NOTE: the middleware appends |Date.now() before\n // forwarding (submit-fp.ts), so the backend sees a unique value per\n // submission and dedups only PubSub redeliveries — NOT two distinct POSTs.\n // Cross-submitter / cross-reload dedup is client-side (the shared window\n // guard + sessionStorage above).\n var idem=fp.fingerprint_id+"|"+uid+"|"+route;\n function sendFP(idemKey){\n var xhr=new XMLHttpRequest();\n xhr.open("POST",pfx+"/submit-fp",true);\n xhr.setRequestHeader("Content-Type","application/json");\n xhr.setRequestHeader("X-Session-Id",sid);\n if(did)xhr.setRequestHeader("X-Device-Id",did);\n xhr.setRequestHeader("X-Idempotency-Key",idemKey);\n xhr.send(JSON.stringify(body));\n }\n if(window.crypto&&crypto.subtle&&window.TextEncoder){\n crypto.subtle.digest("SHA-256",new TextEncoder().encode(idem)).then(function(d){\n var a=new Uint8Array(d),s="";\n for(var i=0;i<a.length;i++){s+=("0"+a[i].toString(16)).slice(-2)}\n sendFP(s);\n }).catch(function(){sendFP(idem)});\n }else{sendFP(idem)}\n}\n\n// --- Collect fingerprint (loads fp.js if needed) then submit ---\nvar fpReady=false;\nfunction collectAndSubmit(){\n var uid=gC("__unshared_uid");\n if(!uid||isSentinelUid(uid))return;\n var cached=getFP();\n if(cached){submitFP(cached);return}\n if(!fpReady)return;\n try{\n var c=new UnsharedBrowser.UnsharedBrowser({baseUrl:""});\n c.collect({exclude:["timing","speech"]}).then(function(fp){setFP(fp);submitFP(fp)});\n }catch(e){}\n}\n\n// --- Load fp.js (always — browser caches it for 1h) ---\n// Submit cached FP immediately if available; load fp.js for fresh collection\nvar pageLoadSubmitted=false;\nvar _boot_uid=gC("__unshared_uid");\nif(getFP()&&_boot_uid&&!isSentinelUid(_boot_uid)){submitFP(getFP());pageLoadSubmitted=true;deferredCheck()}\nvar s=document.createElement("script");\ns.src=pfx+"/fp.js${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,"\\'")}
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Standalone verification gate page returned by the middleware (block mode) for a
3
+ * flagged + unverified user's HTML navigation. It contains NO protected content — only
4
+ * the loader that renders the interstitial modal. The protected route handler never
5
+ * runs, so there is nothing in the DOM behind the modal to reveal.
6
+ *
7
+ * It loads the browser SDK UMD from `${routePrefix}/fp.js` (already served by the
8
+ * middleware), runs it in same-origin proxy mode, and shows the interstitial. On
9
+ * successful verification the flow reaches its terminal screen → `onComplete` fires →
10
+ * the page reloads; the verdict is now verified, so the middleware serves real content.
11
+ *
12
+ * NOTE: the browser SDK's proxy paths are hardcoded to the default `/__unshared` prefix,
13
+ * so block mode requires the default routePrefix (the middleware validates this).
14
+ */
15
+ export declare function generateGatePage(routePrefix: string): string;
@@ -0,0 +1 @@
1
+ export function generateGatePage(n){return`<!doctype html>\n<html lang="en">\n<head>\n<meta charset="utf-8">\n<meta name="viewport" content="width=device-width, initial-scale=1">\n<meta name="robots" content="noindex,nofollow">\n<title>Verification required</title>\n<style>html,body{margin:0;height:100%;background:#0b0b0c;font-family:system-ui,sans-serif}</style>\n</head>\n<body>\n<noscript>Verification is required to continue. Please enable JavaScript.</noscript>\n<script src="${n}/fp.js"><\/script>\n<script>\n(function(){\n function boot(){\n try{\n var ns = window.UnsharedBrowser;\n if(!ns || !ns.UnsharedBrowser){ return; }\n var sdk = new ns.UnsharedBrowser({ baseUrl: '' });\n sdk.showInterstitial({ onComplete: function(){ try{ location.reload(); }catch(e){} } });\n }catch(e){}\n }\n if(window.UnsharedBrowser){ boot(); return; }\n var tries = 0;\n var timer = setInterval(function(){\n if(window.UnsharedBrowser || tries++ > 50){ clearInterval(timer); boot(); }\n }, 100);\n})();\n<\/script>\n</body>\n</html>`}
@@ -0,0 +1,12 @@
1
+ import type { UnsharedRequest, UnsharedResponse } from '../../types';
2
+ import type { UnsharedClient } from '../../client';
3
+ export interface InterstitialDependencies {
4
+ client: UnsharedClient;
5
+ }
6
+ /**
7
+ * GET /__unshared/interstitial-flow
8
+ * Fetches the published interstitial flow via the secret-key client and returns it
9
+ * to the browser. Carries no user data — only the flow definition. Never 500s
10
+ * (mirrors the verify routes' always-200 envelope style).
11
+ */
12
+ export declare function handleGetInterstitialFlow(deps: InterstitialDependencies): (req: UnsharedRequest, res: UnsharedResponse) => Promise<void>;
@@ -0,0 +1 @@
1
+ import{sendJson}from"../utils/http-helpers";export function handleGetInterstitialFlow(e){return async(s,t)=>{try{const o=s.url&&s.url.includes("?")?s.url.slice(s.url.indexOf("?")+1):"",a=new URLSearchParams(o),r=a.get("flow_type")||void 0,n=a.get("platform")||void 0,l=await e.client.getInterstitialFlow({flowType:r,platform:n});l.success?sendJson(t,200,{success:!0,data:l.data}):sendJson(t,200,{success:!1,error:l.error??{code:"FLOW_FETCH_FAILED",message:"Failed to load interstitial flow"}})}catch{sendJson(t,200,{success:!1,error:{code:"INTERNAL_ERROR",message:"Failed to load interstitial flow"}})}}}
@@ -12,6 +12,8 @@ export interface SubmitFingerprintDependencies<TReq extends UnsharedRequest = Un
12
12
  resolveEmailAddress?: (req: TReq) => string | undefined;
13
13
  resolveSessionId?: (req: TReq) => string | undefined;
14
14
  resolveDeviceId?: (req: TReq) => string | undefined;
15
+ /** When true, bot/crawler UAs are NOT skipped — used in E2E so automated browsers' fingerprints are recorded. @default false */
16
+ disableBotFilter?: boolean;
15
17
  onError?: (error: unknown, context: {
16
18
  operation: 'processUserEvent' | 'submitFingerprintEvent' | 'checkUser' | 'verifyTrigger' | 'verify';
17
19
  userId?: string;
@@ -1 +1 @@
1
- import{isBot}from"../utils/is-bot";import{extractClientIp}from"../utils/client-ip";import{parseCookie}from"../utils/cookies";import{extractDeviceId}from"../utils/device-id";import{isSecureRequest}from"../utils/secure";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
+ 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,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(!e.disableBotFilter&&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?`${sha256Hex(`${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,5 +1,11 @@
1
1
  /** Check if a Content-Type header value indicates HTML. */
2
2
  export declare function isHtmlContentType(contentType: string | undefined): boolean;
3
+ /**
4
+ * Whether a request is a top-level HTML navigation (a page load) rather than an API/XHR
5
+ * call: a GET whose Accept header asks for text/html. Used by block mode to decide
6
+ * between serving the HTML gate page and returning a 403 JSON.
7
+ */
8
+ export declare function isHtmlNavigation(method: string | undefined, accept: string | undefined): boolean;
3
9
  /** Check if a Content-Type header value indicates JSON. */
4
10
  export declare function isJsonContentType(contentType: string | undefined): boolean;
5
11
  /** Check if a Content-Type indicates a static asset (images, fonts, etc). */
@@ -1 +1 @@
1
- export function isHtmlContentType(t){return!!t&&t.includes("text/html")}export function isJsonContentType(t){return!!t&&t.includes("application/json")}export function isStaticContentType(t){return!!t&&["image/","font/","audio/","video/","application/javascript","text/javascript","text/css","application/wasm"].some(n=>t.includes(n))}
1
+ export function isHtmlContentType(t){return!!t&&t.includes("text/html")}export function isHtmlNavigation(t,n){return"GET"===(t??"GET").toUpperCase()&&!!n&&n.includes("text/html")}export function isJsonContentType(t){return!!t&&t.includes("application/json")}export function isStaticContentType(t){return!!t&&["image/","font/","audio/","video/","application/javascript","text/javascript","text/css","application/wasm"].some(n=>t.includes(n))}
@@ -1,4 +1,9 @@
1
1
  export declare const ACCOUNT_FLAGGED_ERROR: "account_flagged";
2
+ /**
3
+ * Standardized response body for a flagged, unverified user. The injected
4
+ * inline script keys off `error === 'account_flagged'` to emit the
5
+ * "unshared:flagged" event, so always use this helper for that body.
6
+ */
2
7
  export declare function flaggedResponse(email: string): {
3
8
  readonly error: "account_flagged";
4
9
  readonly email: string;
@@ -1 +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)}
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.map(e=>e.replace(/[.*+?^${}()|[\]\\]/g,"\\$&")).join("|"),"i");export function isBot(e){return!!e&&BOT_RE.test(e)}
@@ -1 +1,7 @@
1
+ /**
2
+ * SHA-256 hex digest. Used for idempotency keys: deterministic (same input,
3
+ * same digest) yet irreversible, so user IDs and emails never appear in the
4
+ * platform's stored idempotency_key column. Matches the browser SDK's hashing.
5
+ */
6
+ export declare function sha256Hex(value: string): string;
1
7
  export declare function encryptData(data: string, key: Buffer): string;
package/dist/esm/util.mjs CHANGED
@@ -1 +1 @@
1
- import{createCipheriv,randomBytes}from"crypto";export function encryptData(e,t){const r=randomBytes(12),a=createCipheriv("aes-256-gcm",t,r);let o=a.update(e,"utf8","base64");o+=a.final("base64");const s=a.getAuthTag();return r.toString("base64")+":"+s.toString("base64")+":"+o}
1
+ import{createCipheriv,createHash,randomBytes}from"crypto";export function sha256Hex(e){return createHash("sha256").update(e).digest("hex")}export function encryptData(e,t){const r=randomBytes(12),a=createCipheriv("aes-256-gcm",t,r);let s=a.update(e,"utf8","base64");s+=a.final("base64");const c=a.getAuthTag();return r.toString("base64")+":"+c.toString("base64")+":"+s}
@@ -12,6 +12,7 @@
12
12
  */
13
13
  export { createWebSubmitHandler } from './submit-handler';
14
14
  export { createWebProtectionMiddleware } from './protection-handler';
15
+ export { flaggedResponse, ACCOUNT_FLAGGED_ERROR } from '../middleware/utils/flagged-response';
15
16
  export type { WebHandler, WebMiddleware, WebSubmitOptions, WebProtectionConfig, } from './types';
16
17
  export { VerdictCache } from '../middleware/verdict-cache';
17
18
  export type { Verdict } from '../middleware/verdict-cache';
@@ -1 +1 @@
1
- export{createWebSubmitHandler}from"./submit-handler";export{createWebProtectionMiddleware}from"./protection-handler";export{VerdictCache}from"../middleware/verdict-cache";
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 +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)}}
1
+ import{sha256Hex}from"../util";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{generateGatePage}from"../middleware/injection/gate-page";import{flaggedResponse}from"../middleware/utils/flagged-response";import{isHtmlContentType,isHtmlNavigation}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,disableBotFilter:f=!1,checkUserTimeoutMs:h=CHECK_USER_TIMEOUT_MS,blockFlagged:_=!1}=s;if(_&&!l)throw new Error("[Unshared] blockFlagged requires fingerprintSdkBundle (the browser SDK UMD served at {routePrefix}/fp.js renders the gate page).");if(_&&"/__unshared"!==n)throw new Error('[Unshared] blockFlagged requires the default routePrefix ("/__unshared"); the browser SDK proxy routes are fixed to that prefix.');const g=_?generateGatePage(n):"",R=new VerdictCache(o),I=new RateLimitBackoff,v=new DispatchDedupe,S=Date.now().toString(36),y=generateFingerprintScript(n,S),w=`${n}/fp.js`,C=`${n}/submit-fp`,E=`${n}/verify-trigger`,A=`${n}/verify`,F=`${n}/status`,T=i?Array.isArray(i)?i:[i]:null;return async function(s,i){let o,S,k;try{const e=new URL(s.url);o=e.pathname,S=e.search}catch{return i(s)}if(o.startsWith(n+"/")){const n=function(e){if(!T)return{};const s=e.headers.get("origin")??"",r=T.includes("*");return r||T.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===w)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===C||o===E||o===A)){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===C?handleSubmitFp(s,i,{client:e,verdictCache:R,rateLimitBackoff:I,dispatchDedupe:v,resolveUserId:r,resolveEmailAddress:t,resolveSessionId:c,resolveDeviceId:u,disableBotFilter:f,onError:m},n):o===E?handleVerifyTriggerWeb(s,i,{client:e,verdictCache:R,resolveEmailAddress:t,resolveDeviceId:u,onError:m},n):handleVerifyWeb(s,i,{client:e,verdictCache:R,resolveEmailAddress:t,resolveDeviceId:u,onError:m},n)}if("GET"===s.method&&o===F){let i;try{i=r(s)}catch{}if(!i)return jsonResponse(200,{status:"anonymous"},n);const o=resolveEmail(s,t);let a=R.get(i);if((!a||R.isStale(i))&&o&&!I.isPaused()&&!R.isRefreshing(i)){R.markRefreshing(i);try{const r=extractDeviceIdFromRequest(s,u),t=parseCookieFromRequest(s,"__unshared_fingerprint_id")||void 0,n=extractSessionIdFromRequest(s,c),d=r??t??"unknown";await fetchAndCacheVerdict(e,R,i,o,d,t,n,h),a=R.get(i)}catch(e){m&&m(e,{operation:"checkUser",userId:i,emailAddress:o})}finally{R.clearRefreshing(i)}}return a&&a.isFlagged&&!a.isVerified&&p&&o?jsonResponse(200,{status:"flagged",email:o},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),y);try{k=r(s)}catch{}if(isSentinelUserId(k)){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;k=e&&n?e:void 0}if(!k){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),y,r)}const x=resolveEmail(s,t),q=[],U=isSecureWebRequest(s)?"; Secure":"";if(q.push(`__unshared_uid=${encodeURIComponent(k)}; Path=/; SameSite=Lax${U}`),q.push(`__unshared_uid_at=${Date.now()}; Path=/; SameSite=Lax${U}`),x&&q.push(`__unshared_email=${encodeURIComponent(x)}; HttpOnly; Path=/; SameSite=Lax${U}`),!x)return injectIntoHtmlResponse(await i(s),y,q);const b=extractSessionIdFromRequest(s,c),D=extractDeviceIdFromRequest(s,u),O=parseCookieFromRequest(s,"__unshared_fingerprint_id")||void 0,j=s.headers.get("user-agent")??"",P=extractClientIpFromRequest(s),L=D??O;if(!f&&isBot(j))return i(s);let N=R.get(k);if(N)R.isStale(k)&&!R.isRefreshing(k)&&(R.markRefreshing(k),fetchAndCacheVerdict(e,R,k,x,L??"unknown",O,b,h).finally(()=>R.clearRefreshing(k)));else try{N=await fetchAndCacheVerdict(e,R,k,x,L??"unknown",O,b,h)}catch{return injectIntoHtmlResponse(await i(s),y,q)}if(_&&N.isFlagged&&!N.isVerified)return isHtmlNavigation(s.method,s.headers.get("accept")??void 0)?bodyResponse(200,g,{"Content-Type":"text/html; charset=utf-8","Cache-Control":"no-store"}):jsonResponse(403,flaggedResponse(x));if(N.isFlagged&&!N.isVerified&&p)try{const e=await p({userId:k,emailAddress:x,verdict:N,request:s});if(e)return injectIntoHtmlResponse(e,y,q)}catch(e){m&&m(e,{operation:"checkUser",userId:k,emailAddress:x})}return N.isFlagged||"unknown"===b||!L||I.isPaused()||dispatchUserEvent(e,R,I,v,{userId:k,emailAddress:x,sessionId:b,deviceId:L,fingerprintId:O,userAgent:j,ipAddress:P,eventType:o+S},m),injectIntoHtmlResponse(await i(s),y,q)}}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,a=CHECK_USER_TIMEOUT_MS){const d={};let c;n&&"unknown"!==n&&(d.deviceId=n),i&&(d.fingerprintId=i);const u=await Promise.race([e.checkUser(t,d),new Promise(e=>{c=setTimeout(()=>e(null),a)})]);if(clearTimeout(c),!u)return{isFlagged:!1,isVerified:!1,emailAddress:t,sessionId:o,cachedAt:0,ttl:0};const l=u.data?.is_user_flagged??!1;return s.set(r,{isFlagged:l,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(!r.disableBotFilter&&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,g=Date.now(),R=_?`${_}|${g}`:l&&i?`${sha256Hex(`${l}|${i}|${h}`)}|${g}`:void 0;i&&r.client.submitFingerprintEvent(n,{userId:i,emailAddress:o,sessionHash:a,eventType:h,ipAddress:d,userAgent:c,idempotencyKey:R}).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 I={...t,"Content-Type":"application/json"},v=new Response(JSON.stringify({success:!0}),{status:200,headers:I});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)}}
@@ -57,6 +57,10 @@ export interface WebProtectionConfig {
57
57
  skipPaths?: string[];
58
58
  /** When set, only paths matching one of these prefixes get events dispatched and checkUser called. */
59
59
  includePathPrefix?: string[];
60
+ /** Skip the bot/crawler UA filter. Set to true in test environments so automated browsers (Playwright, Puppeteer, etc.) can observe verdicts. @default false */
61
+ disableBotFilter?: boolean;
62
+ /** Hard timeout (ms) for checkUser on cache miss. Fails open on timeout. @default 500 */
63
+ checkUserTimeoutMs?: number;
60
64
  /** Resolves a custom session ID. Falls back to __unshared_sid cookie. */
61
65
  sessionId?: (req: Request) => string | undefined;
62
66
  /**
@@ -81,6 +85,16 @@ export interface WebProtectionConfig {
81
85
  * gracefully — the cached-fingerprint path still works.
82
86
  */
83
87
  fingerprintSdkBundle?: string;
88
+ /**
89
+ * Server-side enforcement. When `true`, a flagged + unverified request is
90
+ * short-circuited before `next()` runs — the protected content is never served.
91
+ * HTML navigations receive a standalone verification gate page (which renders the
92
+ * interstitial and reloads on success); everything else receives a
93
+ * `403 { error: 'account_flagged' }`. `onFlagged` is ignored in this mode.
94
+ * Requires `fingerprintSdkBundle` to be provided and the default `routePrefix`.
95
+ * @default false
96
+ */
97
+ blockFlagged?: boolean;
84
98
  /**
85
99
  * Called when a flagged, unverified user makes a request.
86
100
  *
@@ -2,7 +2,6 @@ import type { UnsharedRequest, UnsharedResponse, UnsharedNextFunction } from '..
2
2
  import type { UnsharedClient } from '../client';
3
3
  import { VerdictCache } from './verdict-cache';
4
4
  import type { Verdict } from './verdict-cache';
5
- export { flaggedResponse, ACCOUNT_FLAGGED_ERROR } from './utils/flagged-response';
6
5
  export interface ProtectionConfig<TReq extends UnsharedRequest = UnsharedRequest> {
7
6
  /**
8
7
  * Required. Resolves the current user's ID from the request.
@@ -51,6 +50,18 @@ export interface ProtectionConfig<TReq extends UnsharedRequest = UnsharedRequest
51
50
  res: UnsharedResponse;
52
51
  next: UnsharedNextFunction;
53
52
  }) => void;
53
+ /**
54
+ * Server-side enforcement. When `true`, a flagged + unverified user's request is
55
+ * short-circuited BEFORE your route handler runs — the protected content is never
56
+ * served. HTML navigations receive a standalone verification gate page (which renders
57
+ * the interstitial and reloads on success); all other requests receive a
58
+ * `403 { error: 'account_flagged' }`. This is the only way to prevent access that a
59
+ * user can't bypass by removing the modal from the DOM.
60
+ *
61
+ * When `true`, the SDK owns the flagged response and `onFlagged` is ignored. Requires
62
+ * `unshared-frontend-sdk` to be installed and the default `routePrefix`. @default false
63
+ */
64
+ blockFlagged?: boolean;
54
65
  /**
55
66
  * Called when a background SDK operation fails (fire-and-forget API calls,
56
67
  * verdict refreshes, etc.). Use this to pipe errors to your logging or
@@ -67,4 +78,5 @@ export interface ProtectionConfig<TReq extends UnsharedRequest = UnsharedRequest
67
78
  }
68
79
  export type { Verdict };
69
80
  export { VerdictCache };
81
+ export { flaggedResponse, ACCOUNT_FLAGGED_ERROR } from './utils/flagged-response';
70
82
  export declare function unsharedBoundToUser<TReq extends UnsharedRequest = UnsharedRequest>(client: UnsharedClient, config: ProtectionConfig<TReq>): (req: TReq, res: UnsharedResponse, next: UnsharedNextFunction) => void;
@@ -1 +1 @@
1
- "use strict";Object.defineProperty(exports,"t",{value:!0}),exports.VerdictCache=exports.ACCOUNT_FLAGGED_ERROR=exports.flaggedResponse=void 0,exports.unsharedBoundToUser=unsharedBoundToUser;const fs_1=require("fs"),verdict_cache_1=require("./verdict-cache");Object.defineProperty(exports,"VerdictCache",{enumerable:!0,get:function(){return verdict_cache_1.VerdictCache}});const rate_limit_backoff_1=require("./rate-limit-backoff"),dispatch_dedupe_1=require("./dispatch-dedupe"),response_interceptor_1=require("./response-interceptor"),fingerprint_script_1=require("./injection/fingerprint-script"),submit_fp_1=require("./routes/submit-fp"),verify_1=require("./routes/verify"),http_helpers_1=require("./utils/http-helpers"),content_type_1=require("./utils/content-type"),skip_paths_1=require("./utils/skip-paths"),include_path_1=require("./utils/include-path"),is_bot_1=require("./utils/is-bot"),client_ip_1=require("./utils/client-ip"),cookies_1=require("./utils/cookies"),device_id_1=require("./utils/device-id"),secure_1=require("./utils/secure");var flagged_response_1=require("./utils/flagged-response");Object.defineProperty(exports,"flaggedResponse",{enumerable:!0,get:function(){return flagged_response_1.flaggedResponse}}),Object.defineProperty(exports,"ACCOUNT_FLAGGED_ERROR",{enumerable:!0,get:function(){return flagged_response_1.ACCOUNT_FLAGGED_ERROR}});const sentinel_user_id_1=require("./utils/sentinel-user-id"),CHECK_USER_TIMEOUT_MS=500;function unsharedBoundToUser(e,r){if(!r.userId)throw new Error("[Unshared] userId resolver is required");if(!r.emailAddress){let e=!1;try{require.resolve("unshared-frontend-sdk"),e=!0}catch{}e||console.warn("[Unshared] Warning: emailAddress resolver is not configured and unshared-frontend-sdk is not installed.\nNo user events will be submitted. Either install unshared-frontend-sdk (Tier 1) or\nprovide emailAddress in your middleware config (Tier 2).")}const{userId:t,emailAddress:i,routePrefix:s="/__unshared",corsOrigins:n,cacheTTL:o=6e4,skipPaths:d,includePathPrefix:c,disableBotFilter:a=!1,checkUserTimeoutMs:u=CHECK_USER_TIMEOUT_MS,sessionId:_,deviceId:l,onFlagged:p,onError:h}=r,f=new verdict_cache_1.VerdictCache(o),v=new rate_limit_backoff_1.RateLimitBackoff,m=new dispatch_dedupe_1.DispatchDedupe,g=Date.now().toString(36),S=(0,fingerprint_script_1.generateFingerprintScript)(s,g);let I="";try{const e=require.resolve("unshared-frontend-sdk/dist/index.umd.js");I=(0,fs_1.readFileSync)(e,"utf8")}catch{}const k=(0,submit_fp_1.handleSubmitFingerprint)({client:e,verdictCache:f,rateLimitBackoff:v,dispatchDedupe:m,resolveUserId:t,resolveEmailAddress:i,resolveSessionId:_,resolveDeviceId:l,onError:h}),C=(0,verify_1.handleVerifyTrigger)({client:e,verdictCache:f,resolveEmailAddress:i,resolveDeviceId:l,onError:h}),A=(0,verify_1.handleVerify)({client:e,verdictCache:f,resolveEmailAddress:i,resolveDeviceId:l,onError:h}),y=n?Array.isArray(n)?n:[n]:null,x=`${s}/fp.js`,E=`${s}/submit-fp`,b=`${s}/verify-trigger`,T=`${s}/verify`,q=`${s}/status`;return function(r,n,o){const g=(0,http_helpers_1.getRequestPath)(r.url),U=r.url||g;if(g.startsWith(s+"/")){if(function(e,r){if(!y)return;const t=e.headers.origin??"",i=y.includes("*");(i||y.includes(t))&&(r.setHeader("Access-Control-Allow-Origin",i?"*":t),r.setHeader("Access-Control-Allow-Methods","POST, OPTIONS"),r.setHeader("Access-Control-Allow-Headers","Content-Type, X-Idempotency-Key, X-Session-Id, X-Device-Id"),r.setHeader("Access-Control-Allow-Credentials","true"))}(r,n),"OPTIONS"===r.method)return void(0,http_helpers_1.sendEmpty)(n,204);if("GET"===r.method&&g===x)return n.setHeader("Content-Type","application/javascript"),n.setHeader("Cache-Control","public, max-age=3600"),void(0,http_helpers_1.sendBody)(n,200,I);if("POST"===r.method&&(g===E||g===b||g===T))return void 0===r.body?void(0,http_helpers_1.sendJson)(n,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."}}):g===E?void k(r,n):g===b?void C(r,n):void A(r,n);if("GET"===r.method&&g===q){let s;try{s=t(r)}catch{}if(!s)return void(0,http_helpers_1.sendJson)(n,200,{status:"anonymous"});const o=resolveEmail(r,i);return void(async()=>{let t=f.get(s);if((!t||f.isStale(s))&&o&&!v.isPaused()&&!f.isRefreshing(s)){f.markRefreshing(s);try{const i=(0,device_id_1.extractDeviceIdOrUndefined)(r,l),n=extractFingerprintId(r),d=extractSessionId(r,_),c=i??n??"unknown";await fetchAndCacheVerdict(e,f,s,o,c,n,d,u),t=f.get(s)}catch(e){h&&h(e,{operation:"checkUser",userId:s,emailAddress:o})}finally{f.clearRefreshing(s)}}t&&t.isFlagged&&!t.isVerified&&p&&o?(0,http_helpers_1.sendJson)(n,200,{status:"flagged",email:o}):(0,http_helpers_1.sendJson)(n,200,{status:"ok"})})()}return void(0,http_helpers_1.sendJson)(n,404,{success:!1,error:{code:"NOT_FOUND",message:"Unknown route"}})}if((0,skip_paths_1.shouldSkipPath)(g,d))return void o();if(!(0,include_path_1.shouldIncludePath)(g,c))return interceptForInjection(r,n,S),void o();let w;try{w=t(r)}catch{}if((0,sentinel_user_id_1.isSentinelUserId)(w)){const e=(0,cookies_1.parseCookie)(r,"__unshared_uid"),t=(0,cookies_1.parseCookie)(r,"__unshared_uid_at"),i=t?Number(t):NaN,s=Number.isFinite(i)&&Date.now()-i<=sentinel_user_id_1.SENTINEL_STICKINESS_TTL_MS;w=e&&s?e:void 0}if(!w){const e=(0,secure_1.isSecureRequest)(r)?"; Secure":"";return appendSetCookie(n,`__unshared_uid=; Path=/; SameSite=Lax; Max-Age=0${e}`),appendSetCookie(n,`__unshared_uid_at=; Path=/; SameSite=Lax; Max-Age=0${e}`),appendSetCookie(n,`__unshared_sid=; Path=/; SameSite=Lax; Max-Age=0${e}`),appendSetCookie(n,`__unshared_email=; Path=/; SameSite=Lax; Max-Age=0${e}`),interceptForInjection(r,n,S),void o()}const O=resolveEmail(r,i);if(setUserIdCookie(r,n,w),O&&setEmailCookie(r,n,O),!O)return interceptForInjection(r,n,S),void o();const P=extractSessionId(r,_),F=(0,device_id_1.extractDeviceIdOrUndefined)(r,l),j=extractFingerprintId(r),M=r.headers["user-agent"]??"",$=(0,client_ip_1.extractClientIp)(r),N=F??j;if(!a&&(0,is_bot_1.isBot)(M))return void o();const D=f.get(w);function R(){"unknown"!==P&&N&&(v.isPaused()||dispatchUserEvent(e,f,v,m,{userId:w,emailAddress:O,sessionId:P,deviceId:N,fingerprintId:j,userAgent:M,ipAddress:$,eventType:U},h))}D?(f.isStale(w)&&!f.isRefreshing(w)&&(f.markRefreshing(w),fetchAndCacheVerdict(e,f,w,O,N??"unknown",j,P,u).finally(()=>f.clearRefreshing(w))),D.isFlagged||R(),applyVerdict(D,w,O,r,n,o,S,p)):fetchAndCacheVerdict(e,f,w,O,N??"unknown",j,P,u).then(e=>{e.isFlagged||R(),applyVerdict(e,w,O,r,n,o,S,p)}).catch(()=>{R(),interceptForInjection(r,n,S),o()})}}function resolveEmail(e,r){if(r)try{const t=r(e);if(t)return t}catch{}const t=(0,cookies_1.parseCookie)(e,"__unshared_email");if(t)return t;const i=e.body?.email;return"string"==typeof i&&i?i:void 0}function applyVerdict(e,r,t,i,s,n,o,d){if(interceptForInjection(i,s,o),e.isFlagged&&!e.isVerified&&d)try{d({userId:r,emailAddress:t,verdict:e,req:i,res:s,next:n})}catch{n()}else n()}function interceptForInjection(e,r,t){delete e.headers["if-none-match"],delete e.headers["if-modified-since"],(0,response_interceptor_1.interceptResponse)(r,(e,r)=>{if(!(0,content_type_1.isHtmlContentType)(r))return null;const i=e.toString("utf8"),s=i.lastIndexOf("</body>");return-1===s?i+t:i.slice(0,s)+t+i.slice(s)},{preventCaching:!0})}function dispatchUserEvent(e,r,t,i,s,n){i.mark(s.userId,s.eventType),e.processUserEvent({eventType:s.eventType,userId:s.userId,emailAddress:s.emailAddress,ipAddress:s.ipAddress,deviceId:s.deviceId,fingerprintId:s.fingerprintId,sessionHash:s.sessionId,userAgent:s.userAgent}).then(e=>{e.success&&e.data?.analysis&&r.update(s.userId,{isFlagged:e.data.analysis.is_user_flagged}),!e.success&&e.error?.retryAfter&&t.pause(1e3*e.error.retryAfter)}).catch(e=>{n&&n(e,{operation:"processUserEvent",userId:s.userId,emailAddress:s.emailAddress})})}async function fetchAndCacheVerdict(e,r,t,i,s,n,o,d=CHECK_USER_TIMEOUT_MS){const c={};let a;s&&"unknown"!==s&&(c.deviceId=s),n&&(c.fingerprintId=n);const u=await Promise.race([e.checkUser(i,c),new Promise(e=>{a=setTimeout(()=>e(null),d)})]);if(clearTimeout(a),!u)return{isFlagged:!1,isVerified:!1,emailAddress:i,sessionId:o,cachedAt:0,ttl:0};const _=u.data?.is_user_flagged??!1;return r.set(t,{isFlagged:_,isVerified:!1,emailAddress:i,sessionId:o}),r.get(t)}function extractSessionId(e,r){if(r)try{const t=r(e);if(t)return t}catch{}return(0,cookies_1.parseCookie)(e,"__unshared_sid")??"unknown"}function extractFingerprintId(e){return(0,cookies_1.parseCookie)(e,"__unshared_fingerprint_id")||void 0}function appendSetCookie(e,r){const t=e.getHeader("Set-Cookie");if(t){const i=Array.isArray(t)?[...t]:[String(t)];i.push(r),e.setHeader("Set-Cookie",i)}else e.setHeader("Set-Cookie",r)}function setUserIdCookie(e,r,t){const i=(0,secure_1.isSecureRequest)(e)?"; Secure":"";appendSetCookie(r,`__unshared_uid=${encodeURIComponent(t)}; Path=/; SameSite=Lax${i}`),appendSetCookie(r,`__unshared_uid_at=${Date.now()}; Path=/; SameSite=Lax${i}`)}function setEmailCookie(e,r,t){const i=(0,secure_1.isSecureRequest)(e)?"; Secure":"";appendSetCookie(r,`__unshared_email=${encodeURIComponent(t)}; HttpOnly; Path=/; SameSite=Lax${i}`)}
1
+ "use strict";Object.defineProperty(exports,"t",{value:!0}),exports.ACCOUNT_FLAGGED_ERROR=exports.flaggedResponse=exports.VerdictCache=void 0,exports.unsharedBoundToUser=unsharedBoundToUser;const fs_1=require("fs"),verdict_cache_1=require("./verdict-cache");Object.defineProperty(exports,"VerdictCache",{enumerable:!0,get:function(){return verdict_cache_1.VerdictCache}});const rate_limit_backoff_1=require("./rate-limit-backoff"),dispatch_dedupe_1=require("./dispatch-dedupe"),response_interceptor_1=require("./response-interceptor"),fingerprint_script_1=require("./injection/fingerprint-script"),submit_fp_1=require("./routes/submit-fp"),verify_1=require("./routes/verify"),interstitial_1=require("./routes/interstitial"),gate_page_1=require("./injection/gate-page"),http_helpers_1=require("./utils/http-helpers"),content_type_1=require("./utils/content-type"),flagged_response_1=require("./utils/flagged-response"),skip_paths_1=require("./utils/skip-paths"),include_path_1=require("./utils/include-path"),is_bot_1=require("./utils/is-bot"),client_ip_1=require("./utils/client-ip"),cookies_1=require("./utils/cookies"),device_id_1=require("./utils/device-id"),secure_1=require("./utils/secure"),sentinel_user_id_1=require("./utils/sentinel-user-id");var flagged_response_2=require("./utils/flagged-response");Object.defineProperty(exports,"flaggedResponse",{enumerable:!0,get:function(){return flagged_response_2.flaggedResponse}}),Object.defineProperty(exports,"ACCOUNT_FLAGGED_ERROR",{enumerable:!0,get:function(){return flagged_response_2.ACCOUNT_FLAGGED_ERROR}});const CHECK_USER_TIMEOUT_MS=500;function unsharedBoundToUser(e,t){if(!t.userId)throw new Error("[Unshared] userId resolver is required");if(!t.emailAddress){let e=!1;try{require.resolve("unshared-frontend-sdk"),e=!0}catch{}e||console.warn("[Unshared] Warning: emailAddress resolver is not configured and unshared-frontend-sdk is not installed.\nNo user events will be submitted. Either install unshared-frontend-sdk (Tier 1) or\nprovide emailAddress in your middleware config (Tier 2).")}const{userId:r,emailAddress:i,routePrefix:s="/__unshared",corsOrigins:n,cacheTTL:o=6e4,skipPaths:d,includePathPrefix:a,disableBotFilter:c=!1,checkUserTimeoutMs:u=CHECK_USER_TIMEOUT_MS,sessionId:_,deviceId:l,onFlagged:p,onError:h,blockFlagged:f=!1}=t,g=new verdict_cache_1.VerdictCache(o),v=new rate_limit_backoff_1.RateLimitBackoff,m=new dispatch_dedupe_1.DispatchDedupe,S=Date.now().toString(36),k=(0,fingerprint_script_1.generateFingerprintScript)(s,S);let I="";try{const e=require.resolve("unshared-frontend-sdk/dist/index.umd.js");I=(0,fs_1.readFileSync)(e,"utf8")}catch{}if(f&&!I)throw new Error("[Unshared] blockFlagged requires unshared-frontend-sdk to be installed (its UMD bundle is the gate-page renderer).");if(f&&"/__unshared"!==s)throw new Error('[Unshared] blockFlagged requires the default routePrefix ("/__unshared"); the browser SDK proxy routes are fixed to that prefix.');const C=f?(0,gate_page_1.generateGatePage)(s):"",y=(0,submit_fp_1.handleSubmitFingerprint)({client:e,verdictCache:g,rateLimitBackoff:v,dispatchDedupe:m,resolveUserId:r,resolveEmailAddress:i,resolveSessionId:_,resolveDeviceId:l,disableBotFilter:c,onError:h}),A=(0,verify_1.handleVerifyTrigger)({client:e,verdictCache:g,resolveEmailAddress:i,resolveDeviceId:l,onError:h}),x=(0,verify_1.handleVerify)({client:e,verdictCache:g,resolveEmailAddress:i,resolveDeviceId:l,onError:h}),E=(0,interstitial_1.handleGetInterstitialFlow)({client:e}),b=n?Array.isArray(n)?n:[n]:null,T=`${s}/fp.js`,q=`${s}/submit-fp`,w=`${s}/verify-trigger`,U=`${s}/verify`,O=`${s}/status`,F=`${s}/interstitial-flow`;return function(t,n,o){const S=(0,http_helpers_1.getRequestPath)(t.url),P=t.url||S;if(S.startsWith(s+"/")){if(function(e,t){if(!b)return;const r=e.headers.origin??"",i=b.includes("*");(i||b.includes(r))&&(t.setHeader("Access-Control-Allow-Origin",i?"*":r),t.setHeader("Access-Control-Allow-Methods","GET, POST, OPTIONS"),t.setHeader("Access-Control-Allow-Headers","Content-Type, X-Idempotency-Key, X-Session-Id, X-Device-Id"),t.setHeader("Access-Control-Allow-Credentials","true"))}(t,n),"OPTIONS"===t.method)return void(0,http_helpers_1.sendEmpty)(n,204);if("GET"===t.method&&S===T)return n.setHeader("Content-Type","application/javascript"),n.setHeader("Cache-Control","public, max-age=3600"),void(0,http_helpers_1.sendBody)(n,200,I);if("POST"===t.method&&(S===q||S===w||S===U))return void 0===t.body?void(0,http_helpers_1.sendJson)(n,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."}}):S===q?void y(t,n):S===w?void A(t,n):void x(t,n);if("GET"===t.method&&S===F)return void E(t,n);if("GET"===t.method&&S===O){let s;try{s=r(t)}catch{}if(!s)return void(0,http_helpers_1.sendJson)(n,200,{status:"anonymous"});const o=resolveEmail(t,i);return void(async()=>{let r=g.get(s);if((!r||g.isStale(s))&&o&&!v.isPaused()&&!g.isRefreshing(s)){g.markRefreshing(s);try{const i=(0,device_id_1.extractDeviceIdOrUndefined)(t,l),n=extractFingerprintId(t),d=extractSessionId(t,_),a=i??n??"unknown";await fetchAndCacheVerdict(e,g,s,o,a,n,d,u),r=g.get(s)}catch(e){h&&h(e,{operation:"checkUser",userId:s,emailAddress:o})}finally{g.clearRefreshing(s)}}r&&r.isFlagged&&!r.isVerified&&p&&o?(0,http_helpers_1.sendJson)(n,200,{status:"flagged",email:o}):(0,http_helpers_1.sendJson)(n,200,{status:"ok"})})()}return void(0,http_helpers_1.sendJson)(n,404,{success:!1,error:{code:"NOT_FOUND",message:"Unknown route"}})}if((0,skip_paths_1.shouldSkipPath)(S,d))return void o();if(!(0,include_path_1.shouldIncludePath)(S,a))return interceptForInjection(t,n,k),void o();let j;try{j=r(t)}catch{}if((0,sentinel_user_id_1.isSentinelUserId)(j)){const e=(0,cookies_1.parseCookie)(t,"__unshared_uid"),r=(0,cookies_1.parseCookie)(t,"__unshared_uid_at"),i=r?Number(r):NaN,s=Number.isFinite(i)&&Date.now()-i<=sentinel_user_id_1.SENTINEL_STICKINESS_TTL_MS;j=e&&s?e:void 0}if(!j){const e=(0,secure_1.isSecureRequest)(t)?"; Secure":"";return appendSetCookie(n,`__unshared_uid=; Path=/; SameSite=Lax; Max-Age=0${e}`),appendSetCookie(n,`__unshared_uid_at=; Path=/; SameSite=Lax; Max-Age=0${e}`),appendSetCookie(n,`__unshared_sid=; Path=/; SameSite=Lax; Max-Age=0${e}`),appendSetCookie(n,`__unshared_email=; Path=/; SameSite=Lax; Max-Age=0${e}`),interceptForInjection(t,n,k),void o()}const M=resolveEmail(t,i);if(setUserIdCookie(t,n,j),M&&setEmailCookie(t,n,M),!M)return interceptForInjection(t,n,k),void o();const $=extractSessionId(t,_),D=(0,device_id_1.extractDeviceIdOrUndefined)(t,l),N=extractFingerprintId(t),R=t.headers["user-agent"]??"",L=(0,client_ip_1.extractClientIp)(t),V=D??N;if(!c&&(0,is_bot_1.isBot)(R))return void o();const G=g.get(j);function B(){"unknown"!==$&&V&&(v.isPaused()||dispatchUserEvent(e,g,v,m,{userId:j,emailAddress:M,sessionId:$,deviceId:V,fingerprintId:N,userAgent:R,ipAddress:L,eventType:P},h))}G?(g.isStale(j)&&!g.isRefreshing(j)&&(g.markRefreshing(j),fetchAndCacheVerdict(e,g,j,M,V??"unknown",N,$,u).finally(()=>g.clearRefreshing(j))),G.isFlagged||B(),applyVerdict(G,j,M,t,n,o,k,p,f,C)):fetchAndCacheVerdict(e,g,j,M,V??"unknown",N,$,u).then(e=>{e.isFlagged||B(),applyVerdict(e,j,M,t,n,o,k,p,f,C)}).catch(()=>{B(),interceptForInjection(t,n,k),o()})}}function resolveEmail(e,t){if(t)try{const r=t(e);if(r)return r}catch{}const r=(0,cookies_1.parseCookie)(e,"__unshared_email");if(r)return r;const i=e.body?.email;return"string"==typeof i&&i?i:void 0}function applyVerdict(e,t,r,i,s,n,o,d,a,c){if(a&&e.isFlagged&&!e.isVerified)(0,content_type_1.isHtmlNavigation)(i.method,i.headers.accept)?(s.statusCode=200,s.setHeader("Content-Type","text/html; charset=utf-8"),s.setHeader("Cache-Control","no-store"),s.end(c)):(0,http_helpers_1.sendJson)(s,403,(0,flagged_response_1.flaggedResponse)(r));else if(interceptForInjection(i,s,o),e.isFlagged&&!e.isVerified&&d)try{d({userId:t,emailAddress:r,verdict:e,req:i,res:s,next:n})}catch{n()}else n()}function interceptForInjection(e,t,r){delete e.headers["if-none-match"],delete e.headers["if-modified-since"],(0,response_interceptor_1.interceptResponse)(t,(e,t)=>{if(!(0,content_type_1.isHtmlContentType)(t))return null;const i=e.toString("utf8"),s=i.lastIndexOf("</body>");return-1===s?i+r:i.slice(0,s)+r+i.slice(s)},{preventCaching:!0})}function dispatchUserEvent(e,t,r,i,s,n){i.mark(s.userId,s.eventType),e.processUserEvent({eventType:s.eventType,userId:s.userId,emailAddress:s.emailAddress,ipAddress:s.ipAddress,deviceId:s.deviceId,fingerprintId:s.fingerprintId,sessionHash:s.sessionId,userAgent:s.userAgent}).then(e=>{e.success&&e.data?.analysis&&t.update(s.userId,{isFlagged:e.data.analysis.is_user_flagged}),!e.success&&e.error?.retryAfter&&r.pause(1e3*e.error.retryAfter)}).catch(e=>{n&&n(e,{operation:"processUserEvent",userId:s.userId,emailAddress:s.emailAddress})})}async function fetchAndCacheVerdict(e,t,r,i,s,n,o,d=CHECK_USER_TIMEOUT_MS){const a={};let c;s&&"unknown"!==s&&(a.deviceId=s),n&&(a.fingerprintId=n);const u=await Promise.race([e.checkUser(i,a),new Promise(e=>{c=setTimeout(()=>e(null),d)})]);if(clearTimeout(c),!u)return{isFlagged:!1,isVerified:!1,emailAddress:i,sessionId:o,cachedAt:0,ttl:0};const _=u.data?.is_user_flagged??!1;return t.set(r,{isFlagged:_,isVerified:!1,emailAddress:i,sessionId:o}),t.get(r)}function extractSessionId(e,t){if(t)try{const r=t(e);if(r)return r}catch{}return(0,cookies_1.parseCookie)(e,"__unshared_sid")??"unknown"}function extractFingerprintId(e){return(0,cookies_1.parseCookie)(e,"__unshared_fingerprint_id")||void 0}function appendSetCookie(e,t){const r=e.getHeader("Set-Cookie");if(r){const i=Array.isArray(r)?[...r]:[String(r)];i.push(t),e.setHeader("Set-Cookie",i)}else e.setHeader("Set-Cookie",t)}function setUserIdCookie(e,t,r){const i=(0,secure_1.isSecureRequest)(e)?"; Secure":"";appendSetCookie(t,`__unshared_uid=${encodeURIComponent(r)}; Path=/; SameSite=Lax${i}`),appendSetCookie(t,`__unshared_uid_at=${Date.now()}; Path=/; SameSite=Lax${i}`)}function setEmailCookie(e,t,r){const i=(0,secure_1.isSecureRequest)(e)?"; Secure":"";appendSetCookie(t,`__unshared_email=${encodeURIComponent(r)}; HttpOnly; Path=/; SameSite=Lax${i}`)}
@@ -1 +1 @@
1
- "use strict";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// Page-scoped dedup state SHARED with the frontend SDK (browser.ts getSharedDedup).\n// On a Tier 1 page both this inline script and the SDK submit; each holds its own\n// in-memory lastSubmitKey, so both could pass the check below and POST. window.__unshared\n// is read+written synchronously in submitFP (no await between), so whichever fires\n// first claims the uid|route key and the other no-ops — killing the read-before-write\n// race that doubled events. KEEP IN SYNC with browser.ts: namespace, lastKey, key formula.\nvar shared=(window.__unshared=window.__unshared||{});\n\n// --- Helpers ---\nfunction gC(n){var m=document.cookie.match(new RegExp("(?:^|; )"+n+"=([^;]*)"));return m?decodeURIComponent(m[1]):null}\nfunction sC(n,v,d){var e="";if(d){var dt=new Date();dt.setTime(dt.getTime()+d*864e5);e="; expires="+dt.toUTCString()}document.cookie=n+"="+encodeURIComponent(v)+e+"; path=/; SameSite=Lax"}\nfunction uuid(){return(typeof crypto!=="undefined"&&crypto.randomUUID)?crypto.randomUUID():("xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g,function(c){var r=Math.random()*16|0;return(c==="x"?r:r&0x3|0x8).toString(16)}))}\n// Sentinel user IDs that must never be treated as real users. Mirrors\n// the Set in sentinel-user-id.ts — keep in sync. Empty string is handled\n// by the separate !uid checks below.\nvar SENTINEL_UIDS={"__pre_auth__":1,"anonymous":1,"guest":1,"undefined":1,"null":1};\nfunction isSentinelUid(v){return typeof v==="string"&&SENTINEL_UIDS.hasOwnProperty(v)}\n\n// --- Session + device IDs ---\n// Session ID is a UUID because it's supposed to be tab-scoped and random.\n// Device ID is intentionally NOT a UUID — Issue 9: random UUIDs wrote\n// meaningless device_ids to every fingerprint row. Instead we read the\n// stable fingerprint hash from localStorage if a previous submission\n// already persisted it; otherwise we leave did empty and let submitFP()\n// reconcile on the first successful collection. The Node middleware's\n// Issue 8 bootstrap-skip branch handles the empty-device_id window so we\n// never dispatch with a random or "unknown" value.\nvar sid=gC("__unshared_sid");\nif(!sid){sid=uuid();sC("__unshared_sid",sid,365)}\nvar did="";\ntry{did=localStorage.getItem("__unshared_device_id")||""}catch(e){}\nif(did){sC("__unshared_fp_id",did,365)}\n\n// --- Fingerprint cache (sessionStorage) ---\nfunction getFP(){try{var r=sessionStorage.getItem(SS_FP);return r?JSON.parse(r):null}catch(e){return null}}\nfunction setFP(fp){try{sessionStorage.setItem(SS_FP,JSON.stringify(fp))}catch(e){}}\n\n// --- Submit fingerprint to backend ---\nfunction submitFP(fp){\n var uid=gC("__unshared_uid");\n if(!uid||isSentinelUid(uid))return;\n // Issue 9: reconcile device_id to the stable fingerprint hash. This runs\n // before we send the X-Device-Id header so the very first submission\n // already carries the real value. Persist to localStorage so other tabs\n // (and future reloads) pick up the same stable ID without needing to\n // re-collect the fingerprint.\n if(fp.fingerprint_id){\n did=fp.fingerprint_id;\n try{localStorage.setItem("__unshared_device_id",did)}catch(e){}\n sC("__unshared_fp_id",did,365);\n }\n // event_type is the SPA route, not a fixed enum. Page-level event names\n // (page_load/route_change) collapsed every row into one of two buckets;\n // the URL is more useful for analytics and matches the frontend SDK.\n var route=(location.pathname||"/")+(location.search||"");\n var key=uid+"|"+route;\n // Check the shared window guard AND the in-memory key (last-key semantics, so an\n // SPA A->B->A revisit still submits the second A). The shared guard makes this\n // submitter and the frontend SDK see each other within the page.\n if(key===lastSubmitKey||shared.lastKey===key)return;\n shared.lastKey=key;\n lastSubmitKey=key;\n try{sessionStorage.setItem(SS_LAST_SUBMIT,key)}catch(e){}\n // collected_at is stamped fresh at submit time rather than carried from fp.timestamp,\n // because fp is cached per-tab in sessionStorage — reusing its original timestamp would\n // freeze collected_at at first load and drift against server created_at as the tab ages.\n // The server authoritatively overwrites this value again on ingress.\n var body={hash:fp.full_hash,stable_hash:fp.fingerprint_id,collected_at:(new Date()).toISOString(),is_incognito:fp.isIncognito,components:fp.components,version:fp.version,session_id:sid,user_id:uid,event_type:route};\n // Idempotency key derived from (stable_hash, user_id, route). NOTE: the\n // middleware appends |Date.now() before forwarding (submit-fp.ts), so the\n // backend sees a unique value per submission and dedups only PubSub\n // redeliveries — NOT two distinct POSTs. Cross-submitter / cross-reload dedup\n // is client-side (the shared window guard + sessionStorage above).\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,"\\'")}Object.defineProperty(exports,"t",{value:!0}),exports.generateFingerprintScript=generateFingerprintScript;
1
+ "use strict";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// Page-scoped dedup state SHARED with the frontend SDK (browser.ts getSharedDedup).\n// On a Tier 1 page both this inline script and the SDK submit; each holds its own\n// in-memory lastSubmitKey, so both could pass the check below and POST. window.__unshared\n// is read+written synchronously in submitFP (no await between), so whichever fires\n// first claims the uid|route key and the other no-ops — killing the read-before-write\n// race that doubled events. KEEP IN SYNC with browser.ts: namespace, lastKey, key formula.\nvar shared=(window.__unshared=window.__unshared||{});\n\n// --- Helpers ---\nfunction gC(n){var m=document.cookie.match(new RegExp("(?:^|; )"+n+"=([^;]*)"));return m?decodeURIComponent(m[1]):null}\nfunction sC(n,v,d){var e="";if(d){var dt=new Date();dt.setTime(dt.getTime()+d*864e5);e="; expires="+dt.toUTCString()}document.cookie=n+"="+encodeURIComponent(v)+e+"; path=/; SameSite=Lax"+(location.protocol==="https:"?"; Secure":"")}\nfunction uuid(){return(typeof crypto!=="undefined"&&crypto.randomUUID)?crypto.randomUUID():("xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g,function(c){var r=Math.random()*16|0;return(c==="x"?r:r&0x3|0x8).toString(16)}))}\n// Sentinel user IDs that must never be treated as real users. Mirrors\n// the Set in sentinel-user-id.ts — keep in sync. Empty string is handled\n// by the separate !uid checks below.\nvar SENTINEL_UIDS={"__pre_auth__":1,"anonymous":1,"guest":1,"undefined":1,"null":1};\nfunction isSentinelUid(v){return typeof v==="string"&&SENTINEL_UIDS.hasOwnProperty(v)}\n\n// --- Session + device IDs ---\n// Session ID is a UUID because it's supposed to be tab-scoped and random.\n// Device ID is intentionally NOT a UUID — Issue 9: random UUIDs wrote\n// meaningless device_ids to every fingerprint row. Instead we read the\n// stable fingerprint hash from localStorage if a previous submission\n// already persisted it; otherwise we leave did empty and let submitFP()\n// reconcile on the first successful collection. The Node middleware's\n// Issue 8 bootstrap-skip branch handles the empty-device_id window so we\n// never dispatch with a random or "unknown" value.\nvar sid=gC("__unshared_sid");\nif(!sid){sid=uuid();sC("__unshared_sid",sid,365)}\nvar did="";\ntry{did=localStorage.getItem("__unshared_device_id")||""}catch(e){}\nif(did){sC("__unshared_fp_id",did,365)}\n\n// --- Fingerprint cache (sessionStorage) ---\nfunction getFP(){try{var r=sessionStorage.getItem(SS_FP);return r?JSON.parse(r):null}catch(e){return null}}\nfunction setFP(fp){try{sessionStorage.setItem(SS_FP,JSON.stringify(fp))}catch(e){}}\n\n// --- Submit fingerprint to backend ---\nfunction submitFP(fp){\n var uid=gC("__unshared_uid");\n if(!uid||isSentinelUid(uid))return;\n // Issue 9: reconcile device_id to the stable fingerprint hash. This runs\n // before we send the X-Device-Id header so the very first submission\n // already carries the real value. Persist to localStorage so other tabs\n // (and future reloads) pick up the same stable ID without needing to\n // re-collect the fingerprint.\n if(fp.fingerprint_id){\n did=fp.fingerprint_id;\n try{localStorage.setItem("__unshared_device_id",did)}catch(e){}\n sC("__unshared_fp_id",did,365);\n }\n // event_type is the SPA route, not a fixed enum. Page-level event names\n // (page_load/route_change) collapsed every row into one of two buckets;\n // the URL is more useful for analytics and matches the frontend SDK.\n var route=(location.pathname||"/")+(location.search||"");\n var key=uid+"|"+route;\n // Check the shared window guard AND the in-memory key (last-key semantics, so an\n // SPA A->B->A revisit still submits the second A). The shared guard makes this\n // submitter and the frontend SDK see each other within the page.\n if(key===lastSubmitKey||shared.lastKey===key)return;\n shared.lastKey=key;\n lastSubmitKey=key;\n try{sessionStorage.setItem(SS_LAST_SUBMIT,key)}catch(e){}\n // collected_at is stamped fresh at submit time rather than carried from fp.timestamp,\n // because fp is cached per-tab in sessionStorage — reusing its original timestamp would\n // freeze collected_at at first load and drift against server created_at as the tab ages.\n // The server authoritatively overwrites this value again on ingress.\n var body={hash:fp.full_hash,stable_hash:fp.fingerprint_id,collected_at:(new Date()).toISOString(),is_incognito:fp.isIncognito,components:fp.components,version:fp.version,session_id:sid,user_id:uid,event_type:route};\n // Idempotency key derived from (stable_hash, user_id, route), SHA-256-hashed\n // when WebCrypto is available so the user_id never appears in the platform's\n // stored idempotency_key column (raw fallback on non-secure contexts — the\n // middleware accepts both). NOTE: the middleware appends |Date.now() before\n // forwarding (submit-fp.ts), so the backend sees a unique value per\n // submission and dedups only PubSub redeliveries — NOT two distinct POSTs.\n // Cross-submitter / cross-reload dedup is client-side (the shared window\n // guard + sessionStorage above).\n var idem=fp.fingerprint_id+"|"+uid+"|"+route;\n function sendFP(idemKey){\n var xhr=new XMLHttpRequest();\n xhr.open("POST",pfx+"/submit-fp",true);\n xhr.setRequestHeader("Content-Type","application/json");\n xhr.setRequestHeader("X-Session-Id",sid);\n if(did)xhr.setRequestHeader("X-Device-Id",did);\n xhr.setRequestHeader("X-Idempotency-Key",idemKey);\n xhr.send(JSON.stringify(body));\n }\n if(window.crypto&&crypto.subtle&&window.TextEncoder){\n crypto.subtle.digest("SHA-256",new TextEncoder().encode(idem)).then(function(d){\n var a=new Uint8Array(d),s="";\n for(var i=0;i<a.length;i++){s+=("0"+a[i].toString(16)).slice(-2)}\n sendFP(s);\n }).catch(function(){sendFP(idem)});\n }else{sendFP(idem)}\n}\n\n// --- Collect fingerprint (loads fp.js if needed) then submit ---\nvar fpReady=false;\nfunction collectAndSubmit(){\n var uid=gC("__unshared_uid");\n if(!uid||isSentinelUid(uid))return;\n var cached=getFP();\n if(cached){submitFP(cached);return}\n if(!fpReady)return;\n try{\n var c=new UnsharedBrowser.UnsharedBrowser({baseUrl:""});\n c.collect({exclude:["timing","speech"]}).then(function(fp){setFP(fp);submitFP(fp)});\n }catch(e){}\n}\n\n// --- Load fp.js (always — browser caches it for 1h) ---\n// Submit cached FP immediately if available; load fp.js for fresh collection\nvar pageLoadSubmitted=false;\nvar _boot_uid=gC("__unshared_uid");\nif(getFP()&&_boot_uid&&!isSentinelUid(_boot_uid)){submitFP(getFP());pageLoadSubmitted=true;deferredCheck()}\nvar s=document.createElement("script");\ns.src=pfx+"/fp.js${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,"\\'")}Object.defineProperty(exports,"t",{value:!0}),exports.generateFingerprintScript=generateFingerprintScript;
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Standalone verification gate page returned by the middleware (block mode) for a
3
+ * flagged + unverified user's HTML navigation. It contains NO protected content — only
4
+ * the loader that renders the interstitial modal. The protected route handler never
5
+ * runs, so there is nothing in the DOM behind the modal to reveal.
6
+ *
7
+ * It loads the browser SDK UMD from `${routePrefix}/fp.js` (already served by the
8
+ * middleware), runs it in same-origin proxy mode, and shows the interstitial. On
9
+ * successful verification the flow reaches its terminal screen → `onComplete` fires →
10
+ * the page reloads; the verdict is now verified, so the middleware serves real content.
11
+ *
12
+ * NOTE: the browser SDK's proxy paths are hardcoded to the default `/__unshared` prefix,
13
+ * so block mode requires the default routePrefix (the middleware validates this).
14
+ */
15
+ export declare function generateGatePage(routePrefix: string): string;
@@ -0,0 +1 @@
1
+ "use strict";function generateGatePage(n){return`<!doctype html>\n<html lang="en">\n<head>\n<meta charset="utf-8">\n<meta name="viewport" content="width=device-width, initial-scale=1">\n<meta name="robots" content="noindex,nofollow">\n<title>Verification required</title>\n<style>html,body{margin:0;height:100%;background:#0b0b0c;font-family:system-ui,sans-serif}</style>\n</head>\n<body>\n<noscript>Verification is required to continue. Please enable JavaScript.</noscript>\n<script src="${n}/fp.js"><\/script>\n<script>\n(function(){\n function boot(){\n try{\n var ns = window.UnsharedBrowser;\n if(!ns || !ns.UnsharedBrowser){ return; }\n var sdk = new ns.UnsharedBrowser({ baseUrl: '' });\n sdk.showInterstitial({ onComplete: function(){ try{ location.reload(); }catch(e){} } });\n }catch(e){}\n }\n if(window.UnsharedBrowser){ boot(); return; }\n var tries = 0;\n var timer = setInterval(function(){\n if(window.UnsharedBrowser || tries++ > 50){ clearInterval(timer); boot(); }\n }, 100);\n})();\n<\/script>\n</body>\n</html>`}Object.defineProperty(exports,"t",{value:!0}),exports.generateGatePage=generateGatePage;
@@ -0,0 +1,12 @@
1
+ import type { UnsharedRequest, UnsharedResponse } from '../../types';
2
+ import type { UnsharedClient } from '../../client';
3
+ export interface InterstitialDependencies {
4
+ client: UnsharedClient;
5
+ }
6
+ /**
7
+ * GET /__unshared/interstitial-flow
8
+ * Fetches the published interstitial flow via the secret-key client and returns it
9
+ * to the browser. Carries no user data — only the flow definition. Never 500s
10
+ * (mirrors the verify routes' always-200 envelope style).
11
+ */
12
+ export declare function handleGetInterstitialFlow(deps: InterstitialDependencies): (req: UnsharedRequest, res: UnsharedResponse) => Promise<void>;
@@ -0,0 +1 @@
1
+ "use strict";Object.defineProperty(exports,"t",{value:!0}),exports.handleGetInterstitialFlow=handleGetInterstitialFlow;const http_helpers_1=require("../utils/http-helpers");function handleGetInterstitialFlow(e){return async(t,s)=>{try{const r=t.url&&t.url.includes("?")?t.url.slice(t.url.indexOf("?")+1):"",l=new URLSearchParams(r),a=l.get("flow_type")||void 0,o=l.get("platform")||void 0,i=await e.client.getInterstitialFlow({flowType:a,platform:o});i.success?(0,http_helpers_1.sendJson)(s,200,{success:!0,data:i.data}):(0,http_helpers_1.sendJson)(s,200,{success:!1,error:i.error??{code:"FLOW_FETCH_FAILED",message:"Failed to load interstitial flow"}})}catch{(0,http_helpers_1.sendJson)(s,200,{success:!1,error:{code:"INTERNAL_ERROR",message:"Failed to load interstitial flow"}})}}}
@@ -12,6 +12,8 @@ export interface SubmitFingerprintDependencies<TReq extends UnsharedRequest = Un
12
12
  resolveEmailAddress?: (req: TReq) => string | undefined;
13
13
  resolveSessionId?: (req: TReq) => string | undefined;
14
14
  resolveDeviceId?: (req: TReq) => string | undefined;
15
+ /** When true, bot/crawler UAs are NOT skipped — used in E2E so automated browsers' fingerprints are recorded. @default false */
16
+ disableBotFilter?: boolean;
15
17
  onError?: (error: unknown, context: {
16
18
  operation: 'processUserEvent' | 'submitFingerprintEvent' | 'checkUser' | 'verifyTrigger' | 'verify';
17
19
  userId?: string;
@@ -1 +1 @@
1
- "use strict";Object.defineProperty(exports,"i",{value:!0}),exports.handleSubmitFingerprint=handleSubmitFingerprint;const is_bot_1=require("../utils/is-bot"),client_ip_1=require("../utils/client-ip"),cookies_1=require("../utils/cookies"),device_id_1=require("../utils/device-id"),secure_1=require("../utils/secure"),sentinel_user_id_1=require("../utils/sentinel-user-id"),http_helpers_1=require("../utils/http-helpers");function handleSubmitFingerprint(e){return async(i,s)=>{try{const t=i.body??{},n={full_hash:t.hash??"",fingerprint_id:t.stable_hash??"",timestamp:t.collected_at??(new Date).toISOString(),isIncognito:t.is_incognito??!1,components:t.components??{},version:t.version??"inline-1.0.0"};let r,o,_;try{const s=e.resolveUserId?e.resolveUserId(i):void 0;s&&!(0,sentinel_user_id_1.isSentinelUserId)(s)&&(r=s)}catch{}if(!r){const e="string"==typeof t.user_id?t.user_id:void 0;e&&!(0,sentinel_user_id_1.isSentinelUserId)(e)&&(r=e)}if(!r){const e=(0,cookies_1.parseCookie)(i,"__unshared_uid");e&&!(0,sentinel_user_id_1.isSentinelUserId)(e)&&(r=e)}try{o=e.resolveEmailAddress?e.resolveEmailAddress(i):void 0}catch{}o=o??(0,cookies_1.parseCookie)(i,"__unshared_email")??t.email??void 0;try{_=e.resolveSessionId?e.resolveSessionId(i):void 0}catch{}_=_??t.session_id??(0,cookies_1.parseCookie)(i,"__unshared_sid");const d=(0,client_ip_1.extractClientIp)(i),c=i.headers["user-agent"]??"";if((0,is_bot_1.isBot)(c))return void(0,http_helpers_1.sendJson)(s,200,{success:!0});const u=(n.fingerprint_id&&n.fingerprint_id.length>0?n.fingerprint_id:void 0)??(0,device_id_1.extractDeviceId)(i,e.resolveDeviceId),a=n.fingerprint_id||void 0,p=n.full_hash||void 0,l=(0,secure_1.isSecureRequest)(i)?"; Secure":"",h=[];if(p&&!(0,cookies_1.parseCookie)(i,"__unshared_fingerprint_id")&&h.push(`__unshared_fingerprint_id=${encodeURIComponent(p)}; HttpOnly; Path=/; SameSite=Lax${l}`),a){const e=(0,cookies_1.parseCookie)(i,"__unshared_fp_id");e&&e===a||h.push(`__unshared_fp_id=${encodeURIComponent(a)}; Path=/; SameSite=Lax; Max-Age=31536000${l}`)}if(o&&!(0,cookies_1.parseCookie)(i,"__unshared_email")&&h.push(`__unshared_email=${encodeURIComponent(o)}; HttpOnly; Path=/; SameSite=Lax${l}`),h.length>0){const e=s.getHeader("Set-Cookie");if(e){const i=Array.isArray(e)?[...e]:[String(e)];i.push(...h),s.setHeader("Set-Cookie",i)}else s.setHeader("Set-Cookie",h)}let f;if("string"==typeof t.event_type&&t.event_type)f=t.event_type;else{const e=i.headers.referer??i.headers.referrer;let s="unknown";if("string"==typeof e&&e.length>0)try{const i=new URL(e);s=(i.pathname||"/")+(i.search||"")}catch{}f=s}const v=i.headers["x-idempotency-key"],m="string"==typeof v&&v.length>0?v:void 0,g=Date.now(),y=m?`${m}|${g}`:a&&r?`${a}|${r}|${f}|${g}`:void 0;if(r&&e.client.submitFingerprintEvent(n,{userId:r,emailAddress:o,sessionHash:_,eventType:f,ipAddress:d,userAgent:c,idempotencyKey:y}).catch(i=>{e.onError&&e.onError(i,{operation:"submitFingerprintEvent",userId:r,emailAddress:o})}),r&&o&&!e.rateLimitBackoff.isPaused()&&!e.dispatchDedupe.wasRecentlyDispatched(r,f))try{const i=await e.client.processUserEvent({eventType:f,userId:r,emailAddress:o,ipAddress:d,deviceId:u,fingerprintId:a,sessionHash:_??"unknown",userAgent:c});i.success&&i.data?.analysis&&e.verdictCache.update(r,{isFlagged:i.data.analysis.is_user_flagged}),!i.success&&i.error?.retryAfter&&e.rateLimitBackoff.pause(1e3*i.error.retryAfter)}catch(i){e.onError&&e.onError(i,{operation:"processUserEvent",userId:r,emailAddress:o})}(0,http_helpers_1.sendJson)(s,200,{success:!0})}catch{(0,http_helpers_1.sendJson)(s,200,{success:!0})}}}
1
+ "use strict";Object.defineProperty(exports,"i",{value:!0}),exports.handleSubmitFingerprint=handleSubmitFingerprint;const util_1=require("../../util"),is_bot_1=require("../utils/is-bot"),client_ip_1=require("../utils/client-ip"),cookies_1=require("../utils/cookies"),device_id_1=require("../utils/device-id"),secure_1=require("../utils/secure"),sentinel_user_id_1=require("../utils/sentinel-user-id"),http_helpers_1=require("../utils/http-helpers");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 r,o,_;try{const t=e.resolveUserId?e.resolveUserId(i):void 0;t&&!(0,sentinel_user_id_1.isSentinelUserId)(t)&&(r=t)}catch{}if(!r){const e="string"==typeof s.user_id?s.user_id:void 0;e&&!(0,sentinel_user_id_1.isSentinelUserId)(e)&&(r=e)}if(!r){const e=(0,cookies_1.parseCookie)(i,"__unshared_uid");e&&!(0,sentinel_user_id_1.isSentinelUserId)(e)&&(r=e)}try{o=e.resolveEmailAddress?e.resolveEmailAddress(i):void 0}catch{}o=o??(0,cookies_1.parseCookie)(i,"__unshared_email")??s.email??void 0;try{_=e.resolveSessionId?e.resolveSessionId(i):void 0}catch{}_=_??s.session_id??(0,cookies_1.parseCookie)(i,"__unshared_sid");const d=(0,client_ip_1.extractClientIp)(i),c=i.headers["user-agent"]??"";if(!e.disableBotFilter&&(0,is_bot_1.isBot)(c))return void(0,http_helpers_1.sendJson)(t,200,{success:!0});const u=(n.fingerprint_id&&n.fingerprint_id.length>0?n.fingerprint_id:void 0)??(0,device_id_1.extractDeviceId)(i,e.resolveDeviceId),a=n.fingerprint_id||void 0,l=n.full_hash||void 0,p=(0,secure_1.isSecureRequest)(i)?"; Secure":"",h=[];if(l&&!(0,cookies_1.parseCookie)(i,"__unshared_fingerprint_id")&&h.push(`__unshared_fingerprint_id=${encodeURIComponent(l)}; HttpOnly; Path=/; SameSite=Lax${p}`),a){const e=(0,cookies_1.parseCookie)(i,"__unshared_fp_id");e&&e===a||h.push(`__unshared_fp_id=${encodeURIComponent(a)}; Path=/; SameSite=Lax; Max-Age=31536000${p}`)}if(o&&!(0,cookies_1.parseCookie)(i,"__unshared_email")&&h.push(`__unshared_email=${encodeURIComponent(o)}; HttpOnly; Path=/; SameSite=Lax${p}`),h.length>0){const e=t.getHeader("Set-Cookie");if(e){const i=Array.isArray(e)?[...e]:[String(e)];i.push(...h),t.setHeader("Set-Cookie",i)}else t.setHeader("Set-Cookie",h)}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 v=i.headers["x-idempotency-key"],m="string"==typeof v&&v.length>0?v:void 0,g=Date.now(),y=m?`${m}|${g}`:a&&r?`${(0,util_1.sha256Hex)(`${a}|${r}|${f}`)}|${g}`:void 0;if(r&&e.client.submitFingerprintEvent(n,{userId:r,emailAddress:o,sessionHash:_,eventType:f,ipAddress:d,userAgent:c,idempotencyKey:y}).catch(i=>{e.onError&&e.onError(i,{operation:"submitFingerprintEvent",userId:r,emailAddress:o})}),r&&o&&!e.rateLimitBackoff.isPaused()&&!e.dispatchDedupe.wasRecentlyDispatched(r,f))try{const i=await e.client.processUserEvent({eventType:f,userId:r,emailAddress:o,ipAddress:d,deviceId:u,fingerprintId:a,sessionHash:_??"unknown",userAgent:c});i.success&&i.data?.analysis&&e.verdictCache.update(r,{isFlagged:i.data.analysis.is_user_flagged}),!i.success&&i.error?.retryAfter&&e.rateLimitBackoff.pause(1e3*i.error.retryAfter)}catch(i){e.onError&&e.onError(i,{operation:"processUserEvent",userId:r,emailAddress:o})}(0,http_helpers_1.sendJson)(t,200,{success:!0})}catch{(0,http_helpers_1.sendJson)(t,200,{success:!0})}}}
@@ -1,5 +1,11 @@
1
1
  /** Check if a Content-Type header value indicates HTML. */
2
2
  export declare function isHtmlContentType(contentType: string | undefined): boolean;
3
+ /**
4
+ * Whether a request is a top-level HTML navigation (a page load) rather than an API/XHR
5
+ * call: a GET whose Accept header asks for text/html. Used by block mode to decide
6
+ * between serving the HTML gate page and returning a 403 JSON.
7
+ */
8
+ export declare function isHtmlNavigation(method: string | undefined, accept: string | undefined): boolean;
3
9
  /** Check if a Content-Type header value indicates JSON. */
4
10
  export declare function isJsonContentType(contentType: string | undefined): boolean;
5
11
  /** Check if a Content-Type indicates a static asset (images, fonts, etc). */
@@ -1 +1 @@
1
- "use strict";function isHtmlContentType(t){return!!t&&t.includes("text/html")}function isJsonContentType(t){return!!t&&t.includes("application/json")}function isStaticContentType(t){return!!t&&["image/","font/","audio/","video/","application/javascript","text/javascript","text/css","application/wasm"].some(e=>t.includes(e))}Object.defineProperty(exports,"t",{value:!0}),exports.isHtmlContentType=isHtmlContentType,exports.isJsonContentType=isJsonContentType,exports.isStaticContentType=isStaticContentType;
1
+ "use strict";function isHtmlContentType(t){return!!t&&t.includes("text/html")}function isHtmlNavigation(t,n){return"GET"===(t??"GET").toUpperCase()&&!!n&&n.includes("text/html")}function isJsonContentType(t){return!!t&&t.includes("application/json")}function isStaticContentType(t){return!!t&&["image/","font/","audio/","video/","application/javascript","text/javascript","text/css","application/wasm"].some(n=>t.includes(n))}Object.defineProperty(exports,"t",{value:!0}),exports.isHtmlContentType=isHtmlContentType,exports.isHtmlNavigation=isHtmlNavigation,exports.isJsonContentType=isJsonContentType,exports.isStaticContentType=isStaticContentType;
@@ -1,4 +1,9 @@
1
1
  export declare const ACCOUNT_FLAGGED_ERROR: "account_flagged";
2
+ /**
3
+ * Standardized response body for a flagged, unverified user. The injected
4
+ * inline script keys off `error === 'account_flagged'` to emit the
5
+ * "unshared:flagged" event, so always use this helper for that body.
6
+ */
2
7
  export declare function flaggedResponse(email: string): {
3
8
  readonly error: "account_flagged";
4
9
  readonly email: string;
@@ -1 +1 @@
1
- "use strict";Object.defineProperty(exports,"t",{value:!0}),exports.isBot=isBot;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");function isBot(e){return!!e&&BOT_RE.test(e)}
1
+ "use strict";Object.defineProperty(exports,"t",{value:!0}),exports.isBot=isBot;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.map(e=>e.replace(/[.*+?^${}()|[\]\\]/g,"\\$&")).join("|"),"i");function isBot(e){return!!e&&BOT_RE.test(e)}
package/dist/util.d.ts CHANGED
@@ -1 +1,7 @@
1
+ /**
2
+ * SHA-256 hex digest. Used for idempotency keys: deterministic (same input,
3
+ * same digest) yet irreversible, so user IDs and emails never appear in the
4
+ * platform's stored idempotency_key column. Matches the browser SDK's hashing.
5
+ */
6
+ export declare function sha256Hex(value: string): string;
1
7
  export declare function encryptData(data: string, key: Buffer): string;
package/dist/util.js CHANGED
@@ -1 +1 @@
1
- "use strict";Object.defineProperty(exports,"t",{value:!0}),exports.encryptData=encryptData;const crypto_1=require("crypto");function encryptData(t,e){const c=(0,crypto_1.randomBytes)(12),r=(0,crypto_1.createCipheriv)("aes-256-gcm",e,c);let s=r.update(t,"utf8","base64");s+=r.final("base64");const o=r.getAuthTag();return c.toString("base64")+":"+o.toString("base64")+":"+s}
1
+ "use strict";Object.defineProperty(exports,"t",{value:!0}),exports.sha256Hex=sha256Hex,exports.encryptData=encryptData;const crypto_1=require("crypto");function sha256Hex(t){return(0,crypto_1.createHash)("sha256").update(t).digest("hex")}function encryptData(t,e){const r=(0,crypto_1.randomBytes)(12),s=(0,crypto_1.createCipheriv)("aes-256-gcm",e,r);let c=s.update(t,"utf8","base64");c+=s.final("base64");const o=s.getAuthTag();return r.toString("base64")+":"+o.toString("base64")+":"+c}
@@ -12,6 +12,7 @@
12
12
  */
13
13
  export { createWebSubmitHandler } from './submit-handler';
14
14
  export { createWebProtectionMiddleware } from './protection-handler';
15
+ export { flaggedResponse, ACCOUNT_FLAGGED_ERROR } from '../middleware/utils/flagged-response';
15
16
  export type { WebHandler, WebMiddleware, WebSubmitOptions, WebProtectionConfig, } from './types';
16
17
  export { VerdictCache } from '../middleware/verdict-cache';
17
18
  export type { Verdict } from '../middleware/verdict-cache';
package/dist/web/index.js CHANGED
@@ -1 +1 @@
1
- "use strict";Object.defineProperty(exports,"t",{value:!0}),exports.VerdictCache=exports.createWebProtectionMiddleware=exports.createWebSubmitHandler=void 0;var submit_handler_1=require("./submit-handler");Object.defineProperty(exports,"createWebSubmitHandler",{enumerable:!0,get:function(){return submit_handler_1.createWebSubmitHandler}});var protection_handler_1=require("./protection-handler");Object.defineProperty(exports,"createWebProtectionMiddleware",{enumerable:!0,get:function(){return protection_handler_1.createWebProtectionMiddleware}});var verdict_cache_1=require("../middleware/verdict-cache");Object.defineProperty(exports,"VerdictCache",{enumerable:!0,get:function(){return verdict_cache_1.VerdictCache}});
1
+ "use strict";Object.defineProperty(exports,"t",{value:!0}),exports.VerdictCache=exports.ACCOUNT_FLAGGED_ERROR=exports.flaggedResponse=exports.createWebProtectionMiddleware=exports.createWebSubmitHandler=void 0;var submit_handler_1=require("./submit-handler");Object.defineProperty(exports,"createWebSubmitHandler",{enumerable:!0,get:function(){return submit_handler_1.createWebSubmitHandler}});var protection_handler_1=require("./protection-handler");Object.defineProperty(exports,"createWebProtectionMiddleware",{enumerable:!0,get:function(){return protection_handler_1.createWebProtectionMiddleware}});var flagged_response_1=require("../middleware/utils/flagged-response");Object.defineProperty(exports,"flaggedResponse",{enumerable:!0,get:function(){return flagged_response_1.flaggedResponse}}),Object.defineProperty(exports,"ACCOUNT_FLAGGED_ERROR",{enumerable:!0,get:function(){return flagged_response_1.ACCOUNT_FLAGGED_ERROR}});var verdict_cache_1=require("../middleware/verdict-cache");Object.defineProperty(exports,"VerdictCache",{enumerable:!0,get:function(){return verdict_cache_1.VerdictCache}});
@@ -1 +1 @@
1
- "use strict";Object.defineProperty(exports,"t",{value:!0}),exports.createWebProtectionMiddleware=createWebProtectionMiddleware;const verdict_cache_1=require("../middleware/verdict-cache"),rate_limit_backoff_1=require("../middleware/rate-limit-backoff"),dispatch_dedupe_1=require("../middleware/dispatch-dedupe"),fingerprint_script_1=require("../middleware/injection/fingerprint-script"),content_type_1=require("../middleware/utils/content-type"),skip_paths_1=require("../middleware/utils/skip-paths"),include_path_1=require("../middleware/utils/include-path"),is_bot_1=require("../middleware/utils/is-bot"),sentinel_user_id_1=require("../middleware/utils/sentinel-user-id"),web_helpers_1=require("./web-helpers"),CHECK_USER_TIMEOUT_MS=500;function createWebProtectionMiddleware(e,r){if(!r.userId)throw new Error("[Unshared] userId resolver is required");const{userId:s,emailAddress:t,routePrefix:i="/__unshared",corsOrigins:n,cacheTTL:a=6e4,skipPaths:o,includePathPrefix:d,sessionId:c,deviceId:_,fingerprintSdkBundle:l="",onFlagged:u,onError:p}=r,h=new verdict_cache_1.VerdictCache(a),f=new rate_limit_backoff_1.RateLimitBackoff,m=new dispatch_dedupe_1.DispatchDedupe,w=Date.now().toString(36),g=(0,fingerprint_script_1.generateFingerprintScript)(i,w),b=`${i}/fp.js`,v=`${i}/submit-fp`,I=`${i}/verify-trigger`,y=`${i}/verify`,A=`${i}/status`,S=n?Array.isArray(n)?n:[n]:null;return async function(r,n){let a,w,E;try{const e=new URL(r.url);a=e.pathname,w=e.search}catch{return n(r)}if(a.startsWith(i+"/")){const i=function(e){if(!S)return{};const r=e.headers.get("origin")??"",s=S.includes("*");return s||S.includes(r)?{"Access-Control-Allow-Origin":s?"*":r,"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"}:{}}(r);if("OPTIONS"===r.method)return(0,web_helpers_1.emptyResponse)(204,i);if("GET"===r.method&&a===b)return l?(0,web_helpers_1.bodyResponse)(200,l,{...i,"Content-Type":"application/javascript","Cache-Control":"public, max-age=3600"}):(0,web_helpers_1.jsonResponse)(404,{success:!1,error:{code:"NOT_FOUND",message:"Fingerprint SDK bundle not configured. Pass fingerprintSdkBundle in config."}},i);if("POST"===r.method&&(a===v||a===I||a===y)){let n;try{n=await r.json()}catch{return(0,web_helpers_1.jsonResponse)(400,{success:!1,error:{code:"BODY_PARSER_MISSING",message:"Request body is not valid JSON."}},i)}return a===v?handleSubmitFp(r,n,{client:e,verdictCache:h,rateLimitBackoff:f,dispatchDedupe:m,resolveUserId:s,resolveEmailAddress:t,resolveSessionId:c,resolveDeviceId:_,onError:p},i):a===I?handleVerifyTriggerWeb(r,n,{client:e,verdictCache:h,resolveEmailAddress:t,resolveDeviceId:_,onError:p},i):handleVerifyWeb(r,n,{client:e,verdictCache:h,resolveEmailAddress:t,resolveDeviceId:_,onError:p},i)}if("GET"===r.method&&a===A){let e;try{e=s(r)}catch{}if(!e)return(0,web_helpers_1.jsonResponse)(200,{status:"anonymous"},i);const n=resolveEmail(r,t),a=h.get(e);return a&&a.isFlagged&&!a.isVerified&&u&&n?(0,web_helpers_1.jsonResponse)(403,{error:"account_flagged",email:n},i):(0,web_helpers_1.jsonResponse)(200,{status:"ok"},i)}return(0,web_helpers_1.jsonResponse)(404,{success:!1,error:{code:"NOT_FOUND",message:"Unknown route"}},i)}if((0,skip_paths_1.shouldSkipPath)(a,o))return n(r);if(!(0,include_path_1.shouldIncludePath)(a,d))return injectIntoHtmlResponse(await n(r),g);try{E=s(r)}catch{}if((0,sentinel_user_id_1.isSentinelUserId)(E)){const e=(0,web_helpers_1.parseCookieFromRequest)(r,"__unshared_uid"),s=(0,web_helpers_1.parseCookieFromRequest)(r,"__unshared_uid_at"),t=s?Number(s):NaN,i=Number.isFinite(t)&&Date.now()-t<=sentinel_user_id_1.SENTINEL_STICKINESS_TTL_MS;E=e&&i?e:void 0}if(!E){const e=(0,web_helpers_1.isSecureWebRequest)(r)?"; Secure":"",s=[`__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 n(r),g,s)}const R=resolveEmail(r,t),T=[],C=(0,web_helpers_1.isSecureWebRequest)(r)?"; Secure":"";if(T.push(`__unshared_uid=${encodeURIComponent(E)}; Path=/; SameSite=Lax${C}`),T.push(`__unshared_uid_at=${Date.now()}; Path=/; SameSite=Lax${C}`),R&&T.push(`__unshared_email=${encodeURIComponent(R)}; HttpOnly; Path=/; SameSite=Lax${C}`),!R)return injectIntoHtmlResponse(await n(r),g,T);const O=extractSessionIdFromRequest(r,c),$=(0,web_helpers_1.extractDeviceIdFromRequest)(r,_),x=(0,web_helpers_1.parseCookieFromRequest)(r,"__unshared_fingerprint_id")||void 0,P=r.headers.get("user-agent")??"",k=(0,web_helpers_1.extractClientIpFromRequest)(r),L=$??x;if((0,is_bot_1.isBot)(P))return n(r);let N=h.get(E);if(N)h.isStale(E)&&!h.isRefreshing(E)&&(h.markRefreshing(E),fetchAndCacheVerdict(e,h,E,R,L??"unknown",x,O).finally(()=>h.clearRefreshing(E)));else try{N=await fetchAndCacheVerdict(e,h,E,R,L??"unknown",x,O)}catch{return injectIntoHtmlResponse(await n(r),g,T)}if(N.isFlagged&&!N.isVerified&&u)try{const e=await u({userId:E,emailAddress:R,verdict:N,request:r});if(e)return injectIntoHtmlResponse(e,g,T)}catch(e){p&&p(e,{operation:"checkUser",userId:E,emailAddress:R})}return N.isFlagged||"unknown"===O||!L||f.isPaused()||dispatchUserEvent(e,h,f,m,{userId:E,emailAddress:R,sessionId:O,deviceId:L,fingerprintId:x,userAgent:P,ipAddress:k,eventType:a+w},p),injectIntoHtmlResponse(await n(r),g,T)}}async function injectIntoHtmlResponse(e,r,s){const t=e.headers.get("content-type");if(!(0,content_type_1.isHtmlContentType)(t??void 0)){if(!s||0===s.length)return e;const r=(0,web_helpers_1.mergeResponseHeaders)(e.headers,void 0,s);return new Response(e.body,{status:e.status,statusText:e.statusText,headers:r})}const i=await e.text(),n=i.lastIndexOf("</body>"),a=-1===n?i+r:i.slice(0,n)+r+i.slice(n),o=(0,web_helpers_1.mergeResponseHeaders)(e.headers,{"Cache-Control":"no-store","Content-Length":String((new TextEncoder).encode(a).length)},s);return o.delete("ETag"),o.delete("Last-Modified"),o.delete("Content-Encoding"),new Response(a,{status:e.status,statusText:e.statusText,headers:o})}function resolveEmail(e,r){if(r)try{const s=r(e);if(s)return s}catch{}const s=(0,web_helpers_1.parseCookieFromRequest)(e,"__unshared_email");if(s)return s}function resolveEmailWithBody(e,r,s){const t=resolveEmail(e,s);if(t)return t;const i=r.email;return"string"==typeof i&&i?i:void 0}function extractSessionIdFromRequest(e,r){if(r)try{const s=r(e);if(s)return s}catch{}return(0,web_helpers_1.parseCookieFromRequest)(e,"__unshared_sid")??"unknown"}function dispatchUserEvent(e,r,s,t,i,n){t.mark(i.userId,i.eventType),e.processUserEvent({eventType:i.eventType,userId:i.userId,emailAddress:i.emailAddress,ipAddress:i.ipAddress,deviceId:i.deviceId,fingerprintId:i.fingerprintId,sessionHash:i.sessionId,userAgent:i.userAgent}).then(e=>{e.success&&e.data?.analysis&&r.update(i.userId,{isFlagged:e.data.analysis.is_user_flagged}),!e.success&&e.error?.retryAfter&&s.pause(1e3*e.error.retryAfter)}).catch(e=>{n&&n(e,{operation:"processUserEvent",userId:i.userId,emailAddress:i.emailAddress})})}async function fetchAndCacheVerdict(e,r,s,t,i,n,a){const o={};let d;i&&"unknown"!==i&&(o.deviceId=i),n&&(o.fingerprintId=n);const c=await Promise.race([e.checkUser(t,o),new Promise(e=>{d=setTimeout(()=>e(null),500)})]);if(clearTimeout(d),!c)return{isFlagged:!1,isVerified:!1,emailAddress:t,sessionId:a,cachedAt:0,ttl:0};const _=c.data?.is_user_flagged??!1;return r.set(s,{isFlagged:_,isVerified:!1,emailAddress:t,sessionId:a}),r.get(s)}async function handleSubmitFp(e,r,s,t){try{const i={full_hash:r.hash??"",fingerprint_id:r.stable_hash??"",timestamp:r.collected_at??(new Date).toISOString(),isIncognito:r.is_incognito??!1,components:r.components??{},version:r.version??"inline-1.0.0"};let n,a,o;try{const r=s.resolveUserId(e);r&&!(0,sentinel_user_id_1.isSentinelUserId)(r)&&(n=r)}catch{}if(!n){const e="string"==typeof r.user_id?r.user_id:void 0;e&&!(0,sentinel_user_id_1.isSentinelUserId)(e)&&(n=e)}if(!n){const r=(0,web_helpers_1.parseCookieFromRequest)(e,"__unshared_uid");r&&!(0,sentinel_user_id_1.isSentinelUserId)(r)&&(n=r)}try{a=s.resolveEmailAddress?s.resolveEmailAddress(e):void 0}catch{}a=a??(0,web_helpers_1.parseCookieFromRequest)(e,"__unshared_email")??r.email??void 0;try{o=s.resolveSessionId?s.resolveSessionId(e):void 0}catch{}o=o??r.session_id??(0,web_helpers_1.parseCookieFromRequest)(e,"__unshared_sid");const d=(0,web_helpers_1.extractClientIpFromRequest)(e),c=e.headers.get("user-agent")??"";if((0,is_bot_1.isBot)(c))return(0,web_helpers_1.jsonResponse)(200,{success:!0},t);const _=(i.fingerprint_id&&i.fingerprint_id.length>0?i.fingerprint_id:void 0)??(0,web_helpers_1.extractDeviceIdFromRequestOrUnknown)(e,s.resolveDeviceId),l=i.fingerprint_id||void 0,u=i.full_hash||void 0,p=(0,web_helpers_1.isSecureWebRequest)(e)?"; Secure":"",h=[];if(u&&!(0,web_helpers_1.parseCookieFromRequest)(e,"__unshared_fingerprint_id")&&h.push(`__unshared_fingerprint_id=${encodeURIComponent(u)}; HttpOnly; Path=/; SameSite=Lax${p}`),l){const r=(0,web_helpers_1.parseCookieFromRequest)(e,"__unshared_fp_id");r&&r===l||h.push(`__unshared_fp_id=${encodeURIComponent(l)}; Path=/; SameSite=Lax; Max-Age=31536000${p}`)}let f;if(a&&!(0,web_helpers_1.parseCookieFromRequest)(e,"__unshared_email")&&h.push(`__unshared_email=${encodeURIComponent(a)}; HttpOnly; Path=/; SameSite=Lax${p}`),"string"==typeof r.event_type&&r.event_type)f=r.event_type;else{const r=e.headers.get("referer")??e.headers.get("referrer");let s="unknown";if(r)try{const e=new URL(r);s=(e.pathname||"/")+(e.search||"")}catch{}f=s}const m=e.headers.get("x-idempotency-key")||void 0,w=Date.now(),g=m?`${m}|${w}`:l&&n?`${l}|${n}|${f}|${w}`:void 0;n&&s.client.submitFingerprintEvent(i,{userId:n,emailAddress:a,sessionHash:o,eventType:f,ipAddress:d,userAgent:c,idempotencyKey:g}).catch(e=>{s.onError&&s.onError(e,{operation:"submitFingerprintEvent",userId:n,emailAddress:a})}),n&&a&&!s.rateLimitBackoff.isPaused()&&!s.dispatchDedupe.wasRecentlyDispatched(n,f)&&s.client.processUserEvent({eventType:f,userId:n,emailAddress:a,ipAddress:d,deviceId:_,fingerprintId:l,sessionHash:o??"unknown",userAgent:c}).then(e=>{e.success&&e.data?.analysis&&s.verdictCache.update(n,{isFlagged:e.data.analysis.is_user_flagged}),!e.success&&e.error?.retryAfter&&s.rateLimitBackoff.pause(1e3*e.error.retryAfter)}).catch(e=>{s.onError&&s.onError(e,{operation:"processUserEvent",userId:n,emailAddress:a})});const b={...t,"Content-Type":"application/json"},v=new Response(JSON.stringify({success:!0}),{status:200,headers:b});for(const e of h)v.headers.append("Set-Cookie",e);return v}catch{return(0,web_helpers_1.jsonResponse)(200,{success:!0},t)}}async function handleVerifyTriggerWeb(e,r,s,t){try{const i=resolveEmailWithBody(e,r??{},s.resolveEmailAddress);if(!i)return(0,web_helpers_1.jsonResponse)(400,{success:!1,error:{code:"VALIDATION_ERROR",message:"Email is required"}},t);const n=(0,web_helpers_1.extractDeviceIdFromRequestOrUnknown)(e,s.resolveDeviceId),a=(0,web_helpers_1.parseCookieFromRequest)(e,"__unshared_fingerprint_id")||void 0,o=await s.client.triggerEmailVerification(i,n,{fingerprintId:a});return o.success?(0,web_helpers_1.jsonResponse)(200,{success:!0,data:o.data},t):(0,web_helpers_1.jsonResponse)(200,{success:!1,error:o.error??{code:"TRIGGER_FAILED",message:"Failed to send verification email"}},t)}catch(e){return s.onError&&s.onError(e,{operation:"verifyTrigger"}),(0,web_helpers_1.jsonResponse)(200,{success:!1,error:{code:"INTERNAL_ERROR",message:"Failed to trigger verification"}},t)}}async function handleVerifyWeb(e,r,s,t){try{const i=resolveEmailWithBody(e,r??{},s.resolveEmailAddress),n=r?.code;if(!i||!n)return(0,web_helpers_1.jsonResponse)(400,{success:!1,error:{code:"VALIDATION_ERROR",message:"Email and code are required"}},t);const a=(0,web_helpers_1.extractDeviceIdFromRequestOrUnknown)(e,s.resolveDeviceId),o=(0,web_helpers_1.parseCookieFromRequest)(e,"__unshared_fingerprint_id")||void 0,d=await s.client.verify(i,a,n,{fingerprintId:o});if(d.success){const r=(0,web_helpers_1.parseCookieFromRequest)(e,"__unshared_uid");return r&&s.verdictCache.update(r,{isVerified:!0}),(0,web_helpers_1.jsonResponse)(200,{success:!0,data:{verified:!0}},t)}return(0,web_helpers_1.jsonResponse)(200,{success:!1,error:d.error??{code:"VERIFICATION_FAILED",message:"Verification failed"}},t)}catch(e){return s.onError&&s.onError(e,{operation:"verify"}),(0,web_helpers_1.jsonResponse)(200,{success:!1,error:{code:"INTERNAL_ERROR",message:"Verification failed"}},t)}}
1
+ "use strict";Object.defineProperty(exports,"t",{value:!0}),exports.createWebProtectionMiddleware=createWebProtectionMiddleware;const util_1=require("../util"),verdict_cache_1=require("../middleware/verdict-cache"),rate_limit_backoff_1=require("../middleware/rate-limit-backoff"),dispatch_dedupe_1=require("../middleware/dispatch-dedupe"),fingerprint_script_1=require("../middleware/injection/fingerprint-script"),gate_page_1=require("../middleware/injection/gate-page"),flagged_response_1=require("../middleware/utils/flagged-response"),content_type_1=require("../middleware/utils/content-type"),skip_paths_1=require("../middleware/utils/skip-paths"),include_path_1=require("../middleware/utils/include-path"),is_bot_1=require("../middleware/utils/is-bot"),sentinel_user_id_1=require("../middleware/utils/sentinel-user-id"),web_helpers_1=require("./web-helpers"),CHECK_USER_TIMEOUT_MS=500;function createWebProtectionMiddleware(e,r){if(!r.userId)throw new Error("[Unshared] userId resolver is required");const{userId:t,emailAddress:s,routePrefix:i="/__unshared",corsOrigins:n,cacheTTL:a=6e4,skipPaths:o,includePathPrefix:d,sessionId:_,deviceId:c,fingerprintSdkBundle:l="",onFlagged:u,onError:h,disableBotFilter:p=!1,checkUserTimeoutMs:f=CHECK_USER_TIMEOUT_MS,blockFlagged:w=!1}=r;if(w&&!l)throw new Error("[Unshared] blockFlagged requires fingerprintSdkBundle (the browser SDK UMD served at {routePrefix}/fp.js renders the gate page).");if(w&&"/__unshared"!==i)throw new Error('[Unshared] blockFlagged requires the default routePrefix ("/__unshared"); the browser SDK proxy routes are fixed to that prefix.');const m=w?(0,gate_page_1.generateGatePage)(i):"",g=new verdict_cache_1.VerdictCache(a),b=new rate_limit_backoff_1.RateLimitBackoff,v=new dispatch_dedupe_1.DispatchDedupe,I=Date.now().toString(36),y=(0,fingerprint_script_1.generateFingerprintScript)(i,I),A=`${i}/fp.js`,S=`${i}/submit-fp`,E=`${i}/verify-trigger`,R=`${i}/verify`,T=`${i}/status`,C=n?Array.isArray(n)?n:[n]:null;return async function(r,n){let a,I,x;try{const e=new URL(r.url);a=e.pathname,I=e.search}catch{return n(r)}if(a.startsWith(i+"/")){const i=function(e){if(!C)return{};const r=e.headers.get("origin")??"",t=C.includes("*");return t||C.includes(r)?{"Access-Control-Allow-Origin":t?"*":r,"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"}:{}}(r);if("OPTIONS"===r.method)return(0,web_helpers_1.emptyResponse)(204,i);if("GET"===r.method&&a===A)return l?(0,web_helpers_1.bodyResponse)(200,l,{...i,"Content-Type":"application/javascript","Cache-Control":"public, max-age=3600"}):(0,web_helpers_1.jsonResponse)(404,{success:!1,error:{code:"NOT_FOUND",message:"Fingerprint SDK bundle not configured. Pass fingerprintSdkBundle in config."}},i);if("POST"===r.method&&(a===S||a===E||a===R)){let n;try{n=await r.json()}catch{return(0,web_helpers_1.jsonResponse)(400,{success:!1,error:{code:"BODY_PARSER_MISSING",message:"Request body is not valid JSON."}},i)}return a===S?handleSubmitFp(r,n,{client:e,verdictCache:g,rateLimitBackoff:b,dispatchDedupe:v,resolveUserId:t,resolveEmailAddress:s,resolveSessionId:_,resolveDeviceId:c,disableBotFilter:p,onError:h},i):a===E?handleVerifyTriggerWeb(r,n,{client:e,verdictCache:g,resolveEmailAddress:s,resolveDeviceId:c,onError:h},i):handleVerifyWeb(r,n,{client:e,verdictCache:g,resolveEmailAddress:s,resolveDeviceId:c,onError:h},i)}if("GET"===r.method&&a===T){let n;try{n=t(r)}catch{}if(!n)return(0,web_helpers_1.jsonResponse)(200,{status:"anonymous"},i);const a=resolveEmail(r,s);let o=g.get(n);if((!o||g.isStale(n))&&a&&!b.isPaused()&&!g.isRefreshing(n)){g.markRefreshing(n);try{const t=(0,web_helpers_1.extractDeviceIdFromRequest)(r,c),s=(0,web_helpers_1.parseCookieFromRequest)(r,"__unshared_fingerprint_id")||void 0,i=extractSessionIdFromRequest(r,_),d=t??s??"unknown";await fetchAndCacheVerdict(e,g,n,a,d,s,i,f),o=g.get(n)}catch(e){h&&h(e,{operation:"checkUser",userId:n,emailAddress:a})}finally{g.clearRefreshing(n)}}return o&&o.isFlagged&&!o.isVerified&&u&&a?(0,web_helpers_1.jsonResponse)(200,{status:"flagged",email:a},i):(0,web_helpers_1.jsonResponse)(200,{status:"ok"},i)}return(0,web_helpers_1.jsonResponse)(404,{success:!1,error:{code:"NOT_FOUND",message:"Unknown route"}},i)}if((0,skip_paths_1.shouldSkipPath)(a,o))return n(r);if(!(0,include_path_1.shouldIncludePath)(a,d))return injectIntoHtmlResponse(await n(r),y);try{x=t(r)}catch{}if((0,sentinel_user_id_1.isSentinelUserId)(x)){const e=(0,web_helpers_1.parseCookieFromRequest)(r,"__unshared_uid"),t=(0,web_helpers_1.parseCookieFromRequest)(r,"__unshared_uid_at"),s=t?Number(t):NaN,i=Number.isFinite(s)&&Date.now()-s<=sentinel_user_id_1.SENTINEL_STICKINESS_TTL_MS;x=e&&i?e:void 0}if(!x){const e=(0,web_helpers_1.isSecureWebRequest)(r)?"; Secure":"",t=[`__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 n(r),y,t)}const k=resolveEmail(r,s),O=[],U=(0,web_helpers_1.isSecureWebRequest)(r)?"; Secure":"";if(O.push(`__unshared_uid=${encodeURIComponent(x)}; Path=/; SameSite=Lax${U}`),O.push(`__unshared_uid_at=${Date.now()}; Path=/; SameSite=Lax${U}`),k&&O.push(`__unshared_email=${encodeURIComponent(k)}; HttpOnly; Path=/; SameSite=Lax${U}`),!k)return injectIntoHtmlResponse(await n(r),y,O);const $=extractSessionIdFromRequest(r,_),P=(0,web_helpers_1.extractDeviceIdFromRequest)(r,c),F=(0,web_helpers_1.parseCookieFromRequest)(r,"__unshared_fingerprint_id")||void 0,q=r.headers.get("user-agent")??"",L=(0,web_helpers_1.extractClientIpFromRequest)(r),D=P??F;if(!p&&(0,is_bot_1.isBot)(q))return n(r);let N=g.get(x);if(N)g.isStale(x)&&!g.isRefreshing(x)&&(g.markRefreshing(x),fetchAndCacheVerdict(e,g,x,k,D??"unknown",F,$,f).finally(()=>g.clearRefreshing(x)));else try{N=await fetchAndCacheVerdict(e,g,x,k,D??"unknown",F,$,f)}catch{return injectIntoHtmlResponse(await n(r),y,O)}if(w&&N.isFlagged&&!N.isVerified)return(0,content_type_1.isHtmlNavigation)(r.method,r.headers.get("accept")??void 0)?(0,web_helpers_1.bodyResponse)(200,m,{"Content-Type":"text/html; charset=utf-8","Cache-Control":"no-store"}):(0,web_helpers_1.jsonResponse)(403,(0,flagged_response_1.flaggedResponse)(k));if(N.isFlagged&&!N.isVerified&&u)try{const e=await u({userId:x,emailAddress:k,verdict:N,request:r});if(e)return injectIntoHtmlResponse(e,y,O)}catch(e){h&&h(e,{operation:"checkUser",userId:x,emailAddress:k})}return N.isFlagged||"unknown"===$||!D||b.isPaused()||dispatchUserEvent(e,g,b,v,{userId:x,emailAddress:k,sessionId:$,deviceId:D,fingerprintId:F,userAgent:q,ipAddress:L,eventType:a+I},h),injectIntoHtmlResponse(await n(r),y,O)}}async function injectIntoHtmlResponse(e,r,t){const s=e.headers.get("content-type");if(!(0,content_type_1.isHtmlContentType)(s??void 0)){if(!t||0===t.length)return e;const r=(0,web_helpers_1.mergeResponseHeaders)(e.headers,void 0,t);return new Response(e.body,{status:e.status,statusText:e.statusText,headers:r})}const i=await e.text(),n=i.lastIndexOf("</body>"),a=-1===n?i+r:i.slice(0,n)+r+i.slice(n),o=(0,web_helpers_1.mergeResponseHeaders)(e.headers,{"Cache-Control":"no-store","Content-Length":String((new TextEncoder).encode(a).length)},t);return o.delete("ETag"),o.delete("Last-Modified"),o.delete("Content-Encoding"),new Response(a,{status:e.status,statusText:e.statusText,headers:o})}function resolveEmail(e,r){if(r)try{const t=r(e);if(t)return t}catch{}const t=(0,web_helpers_1.parseCookieFromRequest)(e,"__unshared_email");if(t)return t}function resolveEmailWithBody(e,r,t){const s=resolveEmail(e,t);if(s)return s;const i=r.email;return"string"==typeof i&&i?i:void 0}function extractSessionIdFromRequest(e,r){if(r)try{const t=r(e);if(t)return t}catch{}return(0,web_helpers_1.parseCookieFromRequest)(e,"__unshared_sid")??"unknown"}function dispatchUserEvent(e,r,t,s,i,n){s.mark(i.userId,i.eventType),e.processUserEvent({eventType:i.eventType,userId:i.userId,emailAddress:i.emailAddress,ipAddress:i.ipAddress,deviceId:i.deviceId,fingerprintId:i.fingerprintId,sessionHash:i.sessionId,userAgent:i.userAgent}).then(e=>{e.success&&e.data?.analysis&&r.update(i.userId,{isFlagged:e.data.analysis.is_user_flagged}),!e.success&&e.error?.retryAfter&&t.pause(1e3*e.error.retryAfter)}).catch(e=>{n&&n(e,{operation:"processUserEvent",userId:i.userId,emailAddress:i.emailAddress})})}async function fetchAndCacheVerdict(e,r,t,s,i,n,a,o=CHECK_USER_TIMEOUT_MS){const d={};let _;i&&"unknown"!==i&&(d.deviceId=i),n&&(d.fingerprintId=n);const c=await Promise.race([e.checkUser(s,d),new Promise(e=>{_=setTimeout(()=>e(null),o)})]);if(clearTimeout(_),!c)return{isFlagged:!1,isVerified:!1,emailAddress:s,sessionId:a,cachedAt:0,ttl:0};const l=c.data?.is_user_flagged??!1;return r.set(t,{isFlagged:l,isVerified:!1,emailAddress:s,sessionId:a}),r.get(t)}async function handleSubmitFp(e,r,t,s){try{const i={full_hash:r.hash??"",fingerprint_id:r.stable_hash??"",timestamp:r.collected_at??(new Date).toISOString(),isIncognito:r.is_incognito??!1,components:r.components??{},version:r.version??"inline-1.0.0"};let n,a,o;try{const r=t.resolveUserId(e);r&&!(0,sentinel_user_id_1.isSentinelUserId)(r)&&(n=r)}catch{}if(!n){const e="string"==typeof r.user_id?r.user_id:void 0;e&&!(0,sentinel_user_id_1.isSentinelUserId)(e)&&(n=e)}if(!n){const r=(0,web_helpers_1.parseCookieFromRequest)(e,"__unshared_uid");r&&!(0,sentinel_user_id_1.isSentinelUserId)(r)&&(n=r)}try{a=t.resolveEmailAddress?t.resolveEmailAddress(e):void 0}catch{}a=a??(0,web_helpers_1.parseCookieFromRequest)(e,"__unshared_email")??r.email??void 0;try{o=t.resolveSessionId?t.resolveSessionId(e):void 0}catch{}o=o??r.session_id??(0,web_helpers_1.parseCookieFromRequest)(e,"__unshared_sid");const d=(0,web_helpers_1.extractClientIpFromRequest)(e),_=e.headers.get("user-agent")??"";if(!t.disableBotFilter&&(0,is_bot_1.isBot)(_))return(0,web_helpers_1.jsonResponse)(200,{success:!0},s);const c=(i.fingerprint_id&&i.fingerprint_id.length>0?i.fingerprint_id:void 0)??(0,web_helpers_1.extractDeviceIdFromRequestOrUnknown)(e,t.resolveDeviceId),l=i.fingerprint_id||void 0,u=i.full_hash||void 0,h=(0,web_helpers_1.isSecureWebRequest)(e)?"; Secure":"",p=[];if(u&&!(0,web_helpers_1.parseCookieFromRequest)(e,"__unshared_fingerprint_id")&&p.push(`__unshared_fingerprint_id=${encodeURIComponent(u)}; HttpOnly; Path=/; SameSite=Lax${h}`),l){const r=(0,web_helpers_1.parseCookieFromRequest)(e,"__unshared_fp_id");r&&r===l||p.push(`__unshared_fp_id=${encodeURIComponent(l)}; Path=/; SameSite=Lax; Max-Age=31536000${h}`)}let f;if(a&&!(0,web_helpers_1.parseCookieFromRequest)(e,"__unshared_email")&&p.push(`__unshared_email=${encodeURIComponent(a)}; HttpOnly; Path=/; SameSite=Lax${h}`),"string"==typeof r.event_type&&r.event_type)f=r.event_type;else{const r=e.headers.get("referer")??e.headers.get("referrer");let t="unknown";if(r)try{const e=new URL(r);t=(e.pathname||"/")+(e.search||"")}catch{}f=t}const w=e.headers.get("x-idempotency-key")||void 0,m=Date.now(),g=w?`${w}|${m}`:l&&n?`${(0,util_1.sha256Hex)(`${l}|${n}|${f}`)}|${m}`:void 0;n&&t.client.submitFingerprintEvent(i,{userId:n,emailAddress:a,sessionHash:o,eventType:f,ipAddress:d,userAgent:_,idempotencyKey:g}).catch(e=>{t.onError&&t.onError(e,{operation:"submitFingerprintEvent",userId:n,emailAddress:a})}),n&&a&&!t.rateLimitBackoff.isPaused()&&!t.dispatchDedupe.wasRecentlyDispatched(n,f)&&t.client.processUserEvent({eventType:f,userId:n,emailAddress:a,ipAddress:d,deviceId:c,fingerprintId:l,sessionHash:o??"unknown",userAgent:_}).then(e=>{e.success&&e.data?.analysis&&t.verdictCache.update(n,{isFlagged:e.data.analysis.is_user_flagged}),!e.success&&e.error?.retryAfter&&t.rateLimitBackoff.pause(1e3*e.error.retryAfter)}).catch(e=>{t.onError&&t.onError(e,{operation:"processUserEvent",userId:n,emailAddress:a})});const b={...s,"Content-Type":"application/json"},v=new Response(JSON.stringify({success:!0}),{status:200,headers:b});for(const e of p)v.headers.append("Set-Cookie",e);return v}catch{return(0,web_helpers_1.jsonResponse)(200,{success:!0},s)}}async function handleVerifyTriggerWeb(e,r,t,s){try{const i=resolveEmailWithBody(e,r??{},t.resolveEmailAddress);if(!i)return(0,web_helpers_1.jsonResponse)(400,{success:!1,error:{code:"VALIDATION_ERROR",message:"Email is required"}},s);const n=(0,web_helpers_1.extractDeviceIdFromRequestOrUnknown)(e,t.resolveDeviceId),a=(0,web_helpers_1.parseCookieFromRequest)(e,"__unshared_fingerprint_id")||void 0,o=await t.client.triggerEmailVerification(i,n,{fingerprintId:a});return o.success?(0,web_helpers_1.jsonResponse)(200,{success:!0,data:o.data},s):(0,web_helpers_1.jsonResponse)(200,{success:!1,error:o.error??{code:"TRIGGER_FAILED",message:"Failed to send verification email"}},s)}catch(e){return t.onError&&t.onError(e,{operation:"verifyTrigger"}),(0,web_helpers_1.jsonResponse)(200,{success:!1,error:{code:"INTERNAL_ERROR",message:"Failed to trigger verification"}},s)}}async function handleVerifyWeb(e,r,t,s){try{const i=resolveEmailWithBody(e,r??{},t.resolveEmailAddress),n=r?.code;if(!i||!n)return(0,web_helpers_1.jsonResponse)(400,{success:!1,error:{code:"VALIDATION_ERROR",message:"Email and code are required"}},s);const a=(0,web_helpers_1.extractDeviceIdFromRequestOrUnknown)(e,t.resolveDeviceId),o=(0,web_helpers_1.parseCookieFromRequest)(e,"__unshared_fingerprint_id")||void 0,d=await t.client.verify(i,a,n,{fingerprintId:o});if(d.success){const r=(0,web_helpers_1.parseCookieFromRequest)(e,"__unshared_uid");return r&&t.verdictCache.update(r,{isVerified:!0}),(0,web_helpers_1.jsonResponse)(200,{success:!0,data:{verified:!0}},s)}return(0,web_helpers_1.jsonResponse)(200,{success:!1,error:d.error??{code:"VERIFICATION_FAILED",message:"Verification failed"}},s)}catch(e){return t.onError&&t.onError(e,{operation:"verify"}),(0,web_helpers_1.jsonResponse)(200,{success:!1,error:{code:"INTERNAL_ERROR",message:"Verification failed"}},s)}}
@@ -57,6 +57,10 @@ export interface WebProtectionConfig {
57
57
  skipPaths?: string[];
58
58
  /** When set, only paths matching one of these prefixes get events dispatched and checkUser called. */
59
59
  includePathPrefix?: string[];
60
+ /** Skip the bot/crawler UA filter. Set to true in test environments so automated browsers (Playwright, Puppeteer, etc.) can observe verdicts. @default false */
61
+ disableBotFilter?: boolean;
62
+ /** Hard timeout (ms) for checkUser on cache miss. Fails open on timeout. @default 500 */
63
+ checkUserTimeoutMs?: number;
60
64
  /** Resolves a custom session ID. Falls back to __unshared_sid cookie. */
61
65
  sessionId?: (req: Request) => string | undefined;
62
66
  /**
@@ -81,6 +85,16 @@ export interface WebProtectionConfig {
81
85
  * gracefully — the cached-fingerprint path still works.
82
86
  */
83
87
  fingerprintSdkBundle?: string;
88
+ /**
89
+ * Server-side enforcement. When `true`, a flagged + unverified request is
90
+ * short-circuited before `next()` runs — the protected content is never served.
91
+ * HTML navigations receive a standalone verification gate page (which renders the
92
+ * interstitial and reloads on success); everything else receives a
93
+ * `403 { error: 'account_flagged' }`. `onFlagged` is ignored in this mode.
94
+ * Requires `fingerprintSdkBundle` to be provided and the default `routePrefix`.
95
+ * @default false
96
+ */
97
+ blockFlagged?: boolean;
84
98
  /**
85
99
  * Called when a flagged, unverified user makes a request.
86
100
  *
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "unshared-clientjs-sdk",
3
- "version": "2.0.2",
3
+ "version": "2.1.0-rc.3",
4
4
  "description": "Server-side Node.js SDK for the Unshared Labs V2 API",
5
5
  "main": "dist/index.js",
6
6
  "module": "dist/esm/index.mjs",
@@ -52,9 +52,10 @@
52
52
  "author": "",
53
53
  "license": "MIT",
54
54
  "dependencies": {
55
- "unshared-frontend-sdk": "2.0.2"
55
+ "unshared-frontend-sdk": "2.1.0-rc.3"
56
56
  },
57
57
  "devDependencies": {
58
+ "@unshared-labs/shared-types": "file:../../../shared/types",
58
59
  "@types/express": "^4.17.21",
59
60
  "@types/node": "^24.10.1",
60
61
  "express": "^4.18.2",