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

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 (100) hide show
  1. package/README.md +6 -6
  2. package/dist/client.d.ts +43 -35
  3. package/dist/client.js +1 -1
  4. package/dist/esm/client.d.mts +43 -35
  5. package/dist/esm/client.mjs +1 -1
  6. package/dist/esm/index.d.mts +4 -2
  7. package/dist/esm/index.mjs +1 -1
  8. package/dist/esm/middleware/dispatch-dedupe.d.mts +11 -0
  9. package/dist/esm/middleware/dispatch-dedupe.mjs +1 -0
  10. package/dist/esm/middleware/index.d.mts +27 -12
  11. package/dist/esm/middleware/index.mjs +1 -1
  12. package/dist/esm/middleware/injection/fingerprint-script.d.mts +11 -5
  13. package/dist/esm/middleware/injection/fingerprint-script.mjs +1 -1
  14. package/dist/esm/middleware/response-interceptor.d.mts +10 -8
  15. package/dist/esm/middleware/response-interceptor.mjs +1 -1
  16. package/dist/esm/middleware/routes/submit-fp.d.mts +16 -9
  17. package/dist/esm/middleware/routes/submit-fp.mjs +1 -1
  18. package/dist/esm/middleware/routes/verify.d.mts +13 -8
  19. package/dist/esm/middleware/routes/verify.mjs +1 -1
  20. package/dist/esm/middleware/utils/client-ip.d.mts +6 -0
  21. package/dist/esm/middleware/utils/client-ip.mjs +1 -0
  22. package/dist/esm/middleware/utils/cookies.d.mts +6 -0
  23. package/dist/esm/middleware/utils/cookies.mjs +1 -0
  24. package/dist/esm/middleware/utils/device-id.d.mts +19 -0
  25. package/dist/esm/middleware/utils/device-id.mjs +1 -0
  26. package/dist/esm/middleware/utils/http-helpers.d.mts +21 -0
  27. package/dist/esm/middleware/utils/http-helpers.mjs +1 -0
  28. package/dist/esm/middleware/utils/include-path.d.mts +6 -0
  29. package/dist/esm/middleware/utils/include-path.mjs +1 -0
  30. package/dist/esm/middleware/utils/is-bot.d.mts +5 -0
  31. package/dist/esm/middleware/utils/is-bot.mjs +1 -0
  32. package/dist/esm/middleware/utils/secure.d.mts +3 -0
  33. package/dist/esm/middleware/utils/secure.mjs +1 -0
  34. package/dist/esm/middleware/utils/sentinel-user-id.d.mts +10 -0
  35. package/dist/esm/middleware/utils/sentinel-user-id.mjs +1 -0
  36. package/dist/esm/middleware/utils/skip-paths.mjs +1 -1
  37. package/dist/esm/middleware/verdict-cache.d.mts +12 -1
  38. package/dist/esm/middleware/verdict-cache.mjs +1 -1
  39. package/dist/esm/middleware.d.mts +12 -9
  40. package/dist/esm/middleware.mjs +1 -1
  41. package/dist/esm/types.d.mts +44 -0
  42. package/dist/esm/types.mjs +1 -0
  43. package/dist/esm/web/index.d.mts +17 -0
  44. package/dist/esm/web/index.mjs +1 -0
  45. package/dist/esm/web/protection-handler.d.mts +28 -0
  46. package/dist/esm/web/protection-handler.mjs +1 -0
  47. package/dist/esm/web/submit-handler.d.mts +27 -0
  48. package/dist/esm/web/submit-handler.mjs +1 -0
  49. package/dist/esm/web/types.d.mts +110 -0
  50. package/dist/esm/web/types.mjs +1 -0
  51. package/dist/esm/web/web-helpers.d.mts +55 -0
  52. package/dist/esm/web/web-helpers.mjs +1 -0
  53. package/dist/index.d.ts +4 -2
  54. package/dist/index.js +1 -1
  55. package/dist/middleware/dispatch-dedupe.d.ts +11 -0
  56. package/dist/middleware/dispatch-dedupe.js +1 -0
  57. package/dist/middleware/index.d.ts +27 -12
  58. package/dist/middleware/index.js +1 -1
  59. package/dist/middleware/injection/fingerprint-script.d.ts +11 -5
  60. package/dist/middleware/injection/fingerprint-script.js +1 -1
  61. package/dist/middleware/response-interceptor.d.ts +10 -8
  62. package/dist/middleware/response-interceptor.js +1 -1
  63. package/dist/middleware/routes/submit-fp.d.ts +16 -9
  64. package/dist/middleware/routes/submit-fp.js +1 -1
  65. package/dist/middleware/routes/verify.d.ts +13 -8
  66. package/dist/middleware/routes/verify.js +1 -1
  67. package/dist/middleware/utils/client-ip.d.ts +6 -0
  68. package/dist/middleware/utils/client-ip.js +1 -0
  69. package/dist/middleware/utils/cookies.d.ts +6 -0
  70. package/dist/middleware/utils/cookies.js +1 -0
  71. package/dist/middleware/utils/device-id.d.ts +19 -0
  72. package/dist/middleware/utils/device-id.js +1 -0
  73. package/dist/middleware/utils/http-helpers.d.ts +21 -0
  74. package/dist/middleware/utils/http-helpers.js +1 -0
  75. package/dist/middleware/utils/include-path.d.ts +6 -0
  76. package/dist/middleware/utils/include-path.js +1 -0
  77. package/dist/middleware/utils/is-bot.d.ts +5 -0
  78. package/dist/middleware/utils/is-bot.js +1 -0
  79. package/dist/middleware/utils/secure.d.ts +3 -0
  80. package/dist/middleware/utils/secure.js +1 -0
  81. package/dist/middleware/utils/sentinel-user-id.d.ts +10 -0
  82. package/dist/middleware/utils/sentinel-user-id.js +1 -0
  83. package/dist/middleware/utils/skip-paths.js +1 -1
  84. package/dist/middleware/verdict-cache.d.ts +12 -1
  85. package/dist/middleware/verdict-cache.js +1 -1
  86. package/dist/middleware.d.ts +12 -9
  87. package/dist/middleware.js +1 -1
  88. package/dist/types.d.ts +44 -0
  89. package/dist/types.js +1 -0
  90. package/dist/web/index.d.ts +17 -0
  91. package/dist/web/index.js +1 -0
  92. package/dist/web/protection-handler.d.ts +28 -0
  93. package/dist/web/protection-handler.js +1 -0
  94. package/dist/web/submit-handler.d.ts +27 -0
  95. package/dist/web/submit-handler.js +1 -0
  96. package/dist/web/types.d.ts +110 -0
  97. package/dist/web/types.js +1 -0
  98. package/dist/web/web-helpers.d.ts +55 -0
  99. package/dist/web/web-helpers.js +1 -0
  100. package/package.json +7 -10
@@ -0,0 +1,110 @@
1
+ /**
2
+ * Web Standard Request/Response handler types for serverless/edge environments.
3
+ *
4
+ * These types use the global `Request` and `Response` from the Fetch API,
5
+ * available in: Next.js App Router Route Handlers, Vercel Edge Functions,
6
+ * Cloudflare Workers, Deno Deploy, Bun, and Node.js 18+.
7
+ *
8
+ * The DOM lib reference above scopes the Fetch API globals to this file only,
9
+ * avoiding `window`/`document` pollution in the Node.js middleware code.
10
+ */
11
+ import type { Verdict } from '../middleware/verdict-cache';
12
+ /** A downstream handler that returns a Response. */
13
+ export type WebHandler = (request: Request) => Response | Promise<Response>;
14
+ /** Middleware that wraps a downstream handler, optionally intercepting the response. */
15
+ export type WebMiddleware = (request: Request, next: WebHandler) => Response | Promise<Response>;
16
+ export interface WebSubmitOptions {
17
+ /** Override userId extractor. Falls back to body.user_id. */
18
+ userIdExtractor?: (req: Request) => string | undefined;
19
+ /** Override eventType extractor. Falls back to body.event_type. */
20
+ eventTypeExtractor?: (req: Request) => string | undefined;
21
+ /** Override sessionId extractor. Falls back to X-Session-Id header, then body.session_id. */
22
+ sessionIdExtractor?: (req: Request) => string | undefined;
23
+ /** Override IP address extractor. Falls back to CF-Connecting-IP / X-Real-IP / X-Forwarded-For headers. */
24
+ ipAddressExtractor?: (req: Request) => string | undefined;
25
+ /** Default event type when none is extractable. @default "browser_event" */
26
+ defaultEventType?: string;
27
+ /**
28
+ * Route prefix the handler responds under.
29
+ * @default "/unshared"
30
+ */
31
+ routePrefix?: string;
32
+ /**
33
+ * Allowed CORS origins for the fingerprint route.
34
+ * Use `"*"` to allow all origins, or pass a specific origin / array of origins.
35
+ * The handler handles OPTIONS preflight automatically when this is set.
36
+ */
37
+ corsOrigins?: string | string[];
38
+ }
39
+ export interface WebProtectionConfig {
40
+ /**
41
+ * Required. Resolves the current user's ID from the request.
42
+ * Return undefined for anonymous/logged-out visitors.
43
+ */
44
+ userId: (req: Request) => string | undefined;
45
+ /**
46
+ * Resolves the current user's email address from the request.
47
+ * Falls back to HttpOnly cookie → request body when not configured.
48
+ */
49
+ emailAddress?: (req: Request) => string | undefined;
50
+ /** Route prefix for internal routes. @default "/__unshared" */
51
+ routePrefix?: string;
52
+ /** Allowed CORS origins for /__unshared/* routes. */
53
+ corsOrigins?: string | string[];
54
+ /** Verdict cache TTL in ms. @default 60000 */
55
+ cacheTTL?: number;
56
+ /** Paths to skip entirely (static assets, health checks). */
57
+ skipPaths?: string[];
58
+ /** When set, only paths matching one of these prefixes get events dispatched and checkUser called. */
59
+ includePathPrefix?: string[];
60
+ /** Resolves a custom session ID. Falls back to __unshared_sid cookie. */
61
+ sessionId?: (req: Request) => string | undefined;
62
+ /**
63
+ * Resolves a device ID from the request.
64
+ * Falls back to X-Device-Id header → __unshared_fp_id cookie.
65
+ */
66
+ deviceId?: (req: Request) => string | undefined;
67
+ /**
68
+ * Inline JavaScript bundle for the frontend SDK, served at /__unshared/fp.js.
69
+ *
70
+ * In Node.js environments the middleware reads this from node_modules via
71
+ * `require.resolve('unshared-frontend-sdk/dist/index.umd.js')`. Edge runtimes
72
+ * do not have filesystem access, so the bundle must be passed as a string
73
+ * (typically imported via a bundler):
74
+ *
75
+ * ```typescript
76
+ * import fingerprintSdkBundle from 'unshared-frontend-sdk/dist/index.umd.js?raw';
77
+ * createWebProtectionMiddleware(client, { ..., fingerprintSdkBundle });
78
+ * ```
79
+ *
80
+ * If omitted, the fp.js route returns 404. The inline script degrades
81
+ * gracefully — the cached-fingerprint path still works.
82
+ */
83
+ fingerprintSdkBundle?: string;
84
+ /**
85
+ * Called when a flagged, unverified user makes a request.
86
+ *
87
+ * Return a `Response` to block/redirect, or `null` to pass through (injection
88
+ * still happens). Exceptions are caught — the request passes through on error.
89
+ *
90
+ * This differs from the Node.js middleware: in Web Standard environments
91
+ * Response objects are immutable, so the callback returns a new Response
92
+ * rather than mutating an existing one.
93
+ */
94
+ onFlagged?: (context: {
95
+ userId: string;
96
+ emailAddress: string;
97
+ verdict: Verdict;
98
+ request: Request;
99
+ }) => Response | Promise<Response> | null;
100
+ /**
101
+ * Called when a background SDK operation fails (fire-and-forget API calls,
102
+ * verdict refreshes, etc.). Use this to pipe errors to your logging or
103
+ * monitoring system for observability.
104
+ */
105
+ onError?: (error: unknown, context: {
106
+ operation: 'processUserEvent' | 'submitFingerprintEvent' | 'checkUser' | 'verifyTrigger' | 'verify';
107
+ userId?: string;
108
+ emailAddress?: string;
109
+ }) => void;
110
+ }
@@ -0,0 +1 @@
1
+ export{};
@@ -0,0 +1,55 @@
1
+ /**
2
+ * Thin adapter functions that bridge Web Standard `Request`/`Response` to the
3
+ * data shapes the rest of the SDK expects. These mirror the Node.js
4
+ * `src/middleware/utils/*` functions but read from Web Standard Headers
5
+ * instead of Node.js `req.headers` objects.
6
+ */
7
+ /**
8
+ * Reads a single cookie value from the raw Cookie header.
9
+ * Mirrors `parseCookie(req, name)` in src/middleware/utils/cookies.ts.
10
+ */
11
+ export declare function parseCookieFromRequest(request: Request, name: string): string | undefined;
12
+ /**
13
+ * Extract the real client IP from proxy headers. Web Standard has no
14
+ * equivalent of `req.ip` or `req.socket.remoteAddress`, so we rely entirely
15
+ * on standard proxy headers (which all edge platforms populate).
16
+ *
17
+ * Priority: CF-Connecting-IP → X-Real-IP → X-Forwarded-For (first entry).
18
+ */
19
+ export declare function extractClientIpFromRequest(request: Request): string;
20
+ /**
21
+ * Resolves device ID from: custom resolver → X-Device-Id header → __unshared_fp_id cookie.
22
+ * Mirrors `extractDeviceIdOrUndefined` in src/middleware/utils/device-id.ts.
23
+ */
24
+ export declare function extractDeviceIdFromRequest(request: Request, resolveDeviceId?: (req: Request) => string | undefined): string | undefined;
25
+ /** Same as extractDeviceIdFromRequest but returns "unknown" when nothing is available. */
26
+ export declare function extractDeviceIdFromRequestOrUnknown(request: Request, resolveDeviceId?: (req: Request) => string | undefined): string;
27
+ /**
28
+ * Returns true if the request arrived over HTTPS.
29
+ *
30
+ * Web Standard has no `socket.encrypted`, so this checks:
31
+ * 1. The `x-forwarded-proto` header (set by reverse proxies / load balancers)
32
+ * 2. The URL protocol (for direct HTTPS requests)
33
+ */
34
+ export declare function isSecureWebRequest(request: Request): boolean;
35
+ /**
36
+ * Build a JSON response. Mirrors `sendJson(res, status, data)` in
37
+ * src/middleware/utils/http-helpers.ts, but returns a new Response instead
38
+ * of mutating one.
39
+ */
40
+ export declare function jsonResponse(statusCode: number, data: unknown, extraHeaders?: HeadersInit): Response;
41
+ /** Build a status-only response with no body. */
42
+ export declare function emptyResponse(statusCode: number, extraHeaders?: HeadersInit): Response;
43
+ /** Build a raw-body response (for the fp.js bundle). */
44
+ export declare function bodyResponse(statusCode: number, body: string, extraHeaders?: HeadersInit): Response;
45
+ /**
46
+ * Append a Set-Cookie header to a Headers object. Web Standard `Headers`
47
+ * supports multiple Set-Cookie values via `.append()`.
48
+ */
49
+ export declare function appendCookieToHeaders(headers: Headers, cookieValue: string): void;
50
+ /**
51
+ * Merge a downstream Response's headers with additional Set-Cookie entries
52
+ * and overrides. Preserves multiple Set-Cookie values from the source
53
+ * response via `getSetCookie()` (Node 20+, all modern edge runtimes).
54
+ */
55
+ export declare function mergeResponseHeaders(source: Headers, overrides?: Record<string, string>, extraCookies?: string[]): Headers;
@@ -0,0 +1 @@
1
+ export function parseCookieFromRequest(e,t){const o=e.headers.get("cookie");if(!o)return;const n=o.match(new RegExp(`(?:^|; )${t}=([^;]*)`));return n?decodeURIComponent(n[1]):void 0}export function extractClientIpFromRequest(e){const t=e.headers.get("cf-connecting-ip");if(t)return t;const o=e.headers.get("x-real-ip");if(o)return o;const n=e.headers.get("x-forwarded-for");if(n){const e=n.split(",")[0]?.trim();if(e)return e}return""}export function extractDeviceIdFromRequest(e,t){if(t)try{const o=t(e);if(o)return o}catch{}const o=e.headers.get("x-device-id");if(o)return o;return parseCookieFromRequest(e,"__unshared_fp_id")||void 0}export function extractDeviceIdFromRequestOrUnknown(e,t){return extractDeviceIdFromRequest(e,t)??"unknown"}export function isSecureWebRequest(e){if("https"===e.headers.get("x-forwarded-proto"))return!0;try{return"https:"===new URL(e.url).protocol}catch{return!1}}export function jsonResponse(e,t,o){const n=new Headers(o);return n.set("Content-Type","application/json"),new Response(JSON.stringify(t),{status:e,headers:n})}export function emptyResponse(e,t){return new Response(null,{status:e,headers:new Headers(t)})}export function bodyResponse(e,t,o){return new Response(t,{status:e,headers:new Headers(o)})}export function appendCookieToHeaders(e,t){e.append("Set-Cookie",t)}export function mergeResponseHeaders(e,t,o){const n=new Headers;e.forEach((e,t)=>{"set-cookie"!==t.toLowerCase()&&n.set(t,e)});const r="function"==typeof e.getSetCookie?e.getSetCookie():[];for(const e of r)n.append("Set-Cookie",e);if(o)for(const e of o)n.append("Set-Cookie",e);if(t)for(const[e,o]of Object.entries(t))n.set(e,o);return n}
package/dist/index.d.ts CHANGED
@@ -1,6 +1,8 @@
1
- export { UnsharedLabsClient } from './client';
1
+ export { UnsharedClient } from './client';
2
2
  export { createUnsharedMiddleware, assertTrustProxy } from './middleware';
3
3
  export type { MiddlewareOptions } from './middleware';
4
4
  export { unsharedBoundToUser, VerdictCache, } from './middleware/index';
5
5
  export type { ProtectionConfig, Verdict } from './middleware/index';
6
- export type { UnsharedLabsClientConfig, ApiResult, UnsharedLabsError, SubmitFingerprintOptions, SubmitFingerprintResult, ProcessUserEventParams, ProcessUserEventResult, CheckUserResult, TriggerEmailVerificationResult, VerifyResult, VerificationFlowStep, VerificationFlowConfigResult, } from './client';
6
+ export type { UnsharedClientConfig, ApiResult, UnsharedError, SubmitFingerprintOptions, SubmitFingerprintResult, ProcessUserEventParams, ProcessUserEventResult, CheckUserResult, TriggerEmailVerificationResult, VerifyResult, } from './client';
7
+ export type { UnsharedRequest, UnsharedResponse, UnsharedNextFunction } from './types';
8
+ export { sendJson } from './middleware/utils/http-helpers';
package/dist/index.js CHANGED
@@ -1 +1 @@
1
- "use strict";Object.defineProperty(exports,"t",{value:!0}),exports.VerdictCache=exports.unsharedBoundToUser=exports.assertTrustProxy=exports.createUnsharedMiddleware=exports.UnsharedLabsClient=void 0;var client_1=require("./client");Object.defineProperty(exports,"UnsharedLabsClient",{enumerable:!0,get:function(){return client_1.UnsharedLabsClient}});var middleware_1=require("./middleware");Object.defineProperty(exports,"createUnsharedMiddleware",{enumerable:!0,get:function(){return middleware_1.createUnsharedMiddleware}}),Object.defineProperty(exports,"assertTrustProxy",{enumerable:!0,get:function(){return middleware_1.assertTrustProxy}});var index_1=require("./middleware/index");Object.defineProperty(exports,"unsharedBoundToUser",{enumerable:!0,get:function(){return index_1.unsharedBoundToUser}}),Object.defineProperty(exports,"VerdictCache",{enumerable:!0,get:function(){return index_1.VerdictCache}});
1
+ "use strict";Object.defineProperty(exports,"t",{value:!0}),exports.sendJson=exports.VerdictCache=exports.unsharedBoundToUser=exports.assertTrustProxy=exports.createUnsharedMiddleware=exports.UnsharedClient=void 0;var client_1=require("./client");Object.defineProperty(exports,"UnsharedClient",{enumerable:!0,get:function(){return client_1.UnsharedClient}});var middleware_1=require("./middleware");Object.defineProperty(exports,"createUnsharedMiddleware",{enumerable:!0,get:function(){return middleware_1.createUnsharedMiddleware}}),Object.defineProperty(exports,"assertTrustProxy",{enumerable:!0,get:function(){return middleware_1.assertTrustProxy}});var index_1=require("./middleware/index");Object.defineProperty(exports,"unsharedBoundToUser",{enumerable:!0,get:function(){return index_1.unsharedBoundToUser}}),Object.defineProperty(exports,"VerdictCache",{enumerable:!0,get:function(){return index_1.VerdictCache}});var http_helpers_1=require("./middleware/utils/http-helpers");Object.defineProperty(exports,"sendJson",{enumerable:!0,get:function(){return http_helpers_1.sendJson}});
@@ -0,0 +1,11 @@
1
+ export declare class DispatchDedupe {
2
+ private readonly _entries;
3
+ private readonly _ttlMs;
4
+ constructor(ttlMs?: number);
5
+ private _key;
6
+ mark(userId: string, eventType: string): void;
7
+ wasRecentlyDispatched(userId: string, eventType: string): boolean;
8
+ clear(): void;
9
+ get size(): number;
10
+ private _sweep;
11
+ }
@@ -0,0 +1 @@
1
+ "use strict";Object.defineProperty(exports,"t",{value:!0}),exports.DispatchDedupe=void 0;const DEFAULT_TTL_MS=1e4,SWEEP_THRESHOLD=1e3;class DispatchDedupe{constructor(t=1e4){this.i=new Map,this.h=t}o(t,s){return`${t}|${s}`}mark(t,s){this.i.set(this.o(t,s),Date.now()),this.i.size>1e3&&this.p()}wasRecentlyDispatched(t,s){const e=this.o(t,s),i=this.i.get(e);return!(void 0===i||Date.now()-i>this.h&&(this.i.delete(e),1))}clear(){this.i.clear()}get size(){return this.i.size}p(){const t=Date.now()-this.h;for(const[s,e]of this.i)e<t&&this.i.delete(s)}}exports.DispatchDedupe=DispatchDedupe;
@@ -1,19 +1,19 @@
1
- import type { Request, Response, NextFunction } from 'express';
2
- import type { UnsharedLabsClient } from '../client';
1
+ import type { UnsharedRequest, UnsharedResponse, UnsharedNextFunction } from '../types';
2
+ import type { UnsharedClient } from '../client';
3
3
  import { VerdictCache } from './verdict-cache';
4
4
  import type { Verdict } from './verdict-cache';
5
- export interface ProtectionConfig {
5
+ export interface ProtectionConfig<TReq extends UnsharedRequest = UnsharedRequest> {
6
6
  /**
7
7
  * Required. Resolves the current user's ID from the request.
8
8
  * Return undefined for anonymous/logged-out visitors.
9
9
  */
10
- userId: (req: Request) => string | undefined;
10
+ userId: (req: TReq) => string | undefined;
11
11
  /**
12
12
  * Resolves the current user's email address from the request.
13
13
  * Required in Tier 2 (backend-only). Recommended in Tier 1.
14
14
  * Falls back to HttpOnly cookie → req.body.email when not configured.
15
15
  */
16
- emailAddress?: (req: Request) => string | undefined;
16
+ emailAddress?: (req: TReq) => string | undefined;
17
17
  /** Route prefix for internal routes. @default "/__unshared" */
18
18
  routePrefix?: string;
19
19
  /** Allowed CORS origins for /__unshared/* routes. */
@@ -22,13 +22,15 @@ export interface ProtectionConfig {
22
22
  cacheTTL?: number;
23
23
  /** Paths to skip entirely (static assets, health checks). */
24
24
  skipPaths?: string[];
25
+ /** When set, only paths matching one of these prefixes get events dispatched and checkUser called. */
26
+ includePathPrefix?: string[];
25
27
  /** Resolves a custom session ID. Falls back to __unshared_sid cookie. */
26
- sessionId?: (req: Request) => string | undefined;
28
+ sessionId?: (req: TReq) => string | undefined;
27
29
  /**
28
30
  * Resolves a device ID from the request.
29
- * Falls back to __unshared_fp_id cookie → X-Device-Id header.
31
+ * Falls back to X-Device-Id header → __unshared_fp_id cookie.
30
32
  */
31
- deviceId?: (req: Request) => string | undefined;
33
+ deviceId?: (req: TReq) => string | undefined;
32
34
  /**
33
35
  * Called when a flagged, unverified user makes a request.
34
36
  * You own the response — block, redirect, or call next() to let it through.
@@ -40,11 +42,24 @@ export interface ProtectionConfig {
40
42
  userId: string;
41
43
  emailAddress: string;
42
44
  verdict: Verdict;
43
- req: Request;
44
- res: Response;
45
- next: NextFunction;
45
+ req: TReq;
46
+ res: UnsharedResponse;
47
+ next: UnsharedNextFunction;
48
+ }) => void;
49
+ /**
50
+ * Called when a background SDK operation fails (fire-and-forget API calls,
51
+ * verdict refreshes, etc.). Use this to pipe errors to your logging or
52
+ * monitoring system for observability.
53
+ *
54
+ * Without this callback, background errors are silently swallowed (fail-open).
55
+ * The middleware never blocks requests due to these errors regardless.
56
+ */
57
+ onError?: (error: unknown, context: {
58
+ operation: 'processUserEvent' | 'submitFingerprintEvent' | 'checkUser' | 'verifyTrigger' | 'verify';
59
+ userId?: string;
60
+ emailAddress?: string;
46
61
  }) => void;
47
62
  }
48
63
  export type { Verdict };
49
64
  export { VerdictCache };
50
- export declare function unsharedBoundToUser(client: UnsharedLabsClient, config: ProtectionConfig): (req: Request, res: Response, next: NextFunction) => void;
65
+ 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=void 0,exports.unsharedBoundToUser=unsharedBoundToUser;const fs_1=require("fs"),verdict_cache_1=require("./verdict-cache");Object.defineProperty(exports,"VerdictCache",{enumerable:!0,get:function(){return verdict_cache_1.VerdictCache}});const rate_limit_backoff_1=require("./rate-limit-backoff"),response_interceptor_1=require("./response-interceptor"),fingerprint_script_1=require("./injection/fingerprint-script"),submit_fp_1=require("./routes/submit-fp"),verify_1=require("./routes/verify"),content_type_1=require("./utils/content-type"),skip_paths_1=require("./utils/skip-paths"),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:n,routePrefix:i="/__unshared",corsOrigins:s,cacheTTL:o=6e4,skipPaths:c,sessionId:d,deviceId:a,onFlagged:u}=t,l=new verdict_cache_1.VerdictCache(o),f=new rate_limit_backoff_1.RateLimitBackoff,p=Date.now().toString(36),_=(0,fingerprint_script_1.generateFingerprintScript)(i,p);let h="";try{const e=require.resolve("unshared-frontend-sdk/dist/index.umd.js");h=(0,fs_1.readFileSync)(e,"utf8")}catch{}const v=(0,submit_fp_1.handleSubmitFingerprint)({client:e,verdictCache:l,rateLimitBackoff:f,resolveUserId:r,resolveEmailAddress:n,resolveSessionId:d,resolveDeviceId:a}),m=(0,verify_1.handleVerifyTrigger)({client:e,verdictCache:l,resolveEmailAddress:n,resolveDeviceId:a}),C=(0,verify_1.handleVerify)({client:e,verdictCache:l,resolveEmailAddress:n,resolveDeviceId:a}),I=s?Array.isArray(s)?s:[s]:null,g=`${i}/fp.js`,k=`${i}/submit-fp`,y=`${i}/verify-trigger`,A=`${i}/verify`;return function(t,s,o){const p=t.path;if(p.startsWith(i+"/"))return function(e,t){if(!I)return;const r=e.headers.origin??"",n=I.includes("*");(n||I.includes(r))&&(t.setHeader("Access-Control-Allow-Origin",n?"*":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?void s.status(204).end():"GET"===t.method&&p===g?(s.setHeader("Content-Type","application/javascript"),s.setHeader("Cache-Control","public, max-age=3600"),void s.status(200).end(h)):"POST"===t.method&&p===k?void v(t,s):"POST"===t.method&&p===y?void m(t,s):"POST"===t.method&&p===A?void C(t,s):void s.status(404).json({success:!1,error:{code:"NOT_FOUND",message:"Unknown route"}});if((0,skip_paths_1.shouldSkipPath)(p,c))return void o();let S;try{S=r(t)}catch{}if(!S)return clearEmailCookieIfPresent(t,s),interceptForInjection(t,s,_),void o();const T=resolveEmail(t,n);if(setUserIdCookie(s,S),T&&setEmailCookie(s,T),!T)return interceptForInjection(t,s,_),void o();const x=extractSessionId(t,d),E=extractDeviceId(t,a),w=extractFingerprintId(t),O=t.headers["user-agent"]??"",U=t.ip??"";f.isPaused()||dispatchUserEvent(e,l,f,{userId:S,emailAddress:T,sessionId:x,deviceId:E,fingerprintId:w,userAgent:O,ipAddress:U,eventType:`${t.method} ${t.path}`});const P=l.get(S);P?(l.isStale(S)&&!l.isRefreshing(S)&&(l.markRefreshing(S),fetchAndCacheVerdict(e,l,S,T,E,w,x).finally(()=>l.clearRefreshing(S))),applyVerdict(P,S,T,t,s,o,_,u)):fetchAndCacheVerdict(e,l,S,T,E,w,x).then(e=>{applyVerdict(e,S,T,t,s,o,_,u)}).catch(()=>{interceptForInjection(t,s,_),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 n=e.body?.email;return"string"==typeof n&&n?n:void 0}function applyVerdict(e,t,r,n,i,s,o,c){if(interceptForInjection(n,i,o),e.isFlagged&&!e.isVerified&&c)try{c({userId:t,emailAddress:r,verdict:e,req:n,res:i,next:s})}catch{s()}else s()}function preventHtmlCaching(e,t){delete e.headers["if-none-match"],delete e.headers["if-modified-since"];const r=t.writeHead.bind(t);t.writeHead=function(e,...n){const i=t.getHeader("content-type");return i&&String(i).includes("text/html")&&(t.setHeader("Cache-Control","no-store"),t.removeHeader("ETag"),t.removeHeader("Last-Modified")),r(e,...n)}}function interceptForInjection(e,t,r){preventHtmlCaching(e,t),(0,response_interceptor_1.interceptResponse)(t,(e,t)=>{if(!(0,content_type_1.isHtmlContentType)(t))return null;const n=e.toString("utf8"),i=n.lastIndexOf("</body>");return-1===i?n+r:n.slice(0,i)+r+n.slice(i)})}function dispatchUserEvent(e,t,r,n){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(()=>{})}async function fetchAndCacheVerdict(e,t,r,n,i,s,o){const c={};i&&"unknown"!==i&&(c.deviceId=i),s&&(c.fingerprintId=s);const d=await Promise.race([e.checkUser(n,c),new Promise(e=>setTimeout(()=>e(null),500))]);if(!d)return{isFlagged:!1,isVerified:!1,emailAddress:n,sessionId:o,cachedAt:0,ttl:0};const a=d.data?.is_user_flagged??!1;return t.set(r,{isFlagged:a,isVerified:!1,emailAddress:n,sessionId:o}),t.get(r)}function parseCookie(e,t){const r=e.headers.cookie;if(!r)return;const n=r.match(new RegExp(`(?:^|; )${t}=([^;]*)`));return n?decodeURIComponent(n[1]):void 0}function extractSessionId(e,t){if(t)try{const r=t(e);if(r)return r}catch{}return parseCookie(e,"__unshared_sid")??"unknown"}function extractDeviceId(e,t){if(t)try{const r=t(e);if(r)return r}catch{}const r=parseCookie(e,"__unshared_fp_id");if(r)return r;const n=e.headers["x-device-id"];return"string"==typeof n&&n?n:"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 n=Array.isArray(r)?[...r]:[String(r)];n.push(t),e.setHeader("Set-Cookie",n)}else e.setHeader("Set-Cookie",t)}function setUserIdCookie(e,t){appendSetCookie(e,`__unshared_uid=${encodeURIComponent(t)}; Path=/; SameSite=Lax`)}function setEmailCookie(e,t){appendSetCookie(e,`__unshared_email=${encodeURIComponent(t)}; HttpOnly; Path=/; SameSite=Lax`)}function clearEmailCookieIfPresent(e,t){parseCookie(e,"__unshared_email")&&appendSetCookie(t,"__unshared_email=; HttpOnly; Path=/; SameSite=Lax; Max-Age=0")}
1
+ "use strict";Object.defineProperty(exports,"t",{value:!0}),exports.VerdictCache=void 0,exports.unsharedBoundToUser=unsharedBoundToUser;const fs_1=require("fs"),verdict_cache_1=require("./verdict-cache");Object.defineProperty(exports,"VerdictCache",{enumerable:!0,get:function(){return verdict_cache_1.VerdictCache}});const rate_limit_backoff_1=require("./rate-limit-backoff"),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"),sentinel_user_id_1=require("./utils/sentinel-user-id"),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:n="/__unshared",corsOrigins:s,cacheTTL:o=6e4,skipPaths:d,includePathPrefix:c,sessionId:a,deviceId:u,onFlagged:_,onError:l}=t,p=new verdict_cache_1.VerdictCache(o),h=new rate_limit_backoff_1.RateLimitBackoff,f=new dispatch_dedupe_1.DispatchDedupe,v=Date.now().toString(36),m=(0,fingerprint_script_1.generateFingerprintScript)(n,v);let I="";try{const e=require.resolve("unshared-frontend-sdk/dist/index.umd.js");I=(0,fs_1.readFileSync)(e,"utf8")}catch{}const S=(0,submit_fp_1.handleSubmitFingerprint)({client:e,verdictCache:p,rateLimitBackoff:h,dispatchDedupe:f,resolveUserId:r,resolveEmailAddress:i,resolveSessionId:a,resolveDeviceId:u,onError:l}),k=(0,verify_1.handleVerifyTrigger)({client:e,verdictCache:p,resolveEmailAddress:i,resolveDeviceId:u,onError:l}),g=(0,verify_1.handleVerify)({client:e,verdictCache:p,resolveEmailAddress:i,resolveDeviceId:u,onError:l}),y=s?Array.isArray(s)?s:[s]:null,A=`${n}/fp.js`,C=`${n}/submit-fp`,x=`${n}/verify-trigger`,q=`${n}/verify`,w=`${n}/status`;return function(t,s,o){const v=(0,http_helpers_1.getRequestPath)(t.url),E=t.url||v;if(v.startsWith(n+"/")){if(function(e,t){if(!y)return;const r=e.headers.origin??"",i=y.includes("*");(i||y.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(0,http_helpers_1.sendEmpty)(s,204);if("GET"===t.method&&v===A)return s.setHeader("Content-Type","application/javascript"),s.setHeader("Cache-Control","public, max-age=3600"),void(0,http_helpers_1.sendBody)(s,200,I);if("POST"===t.method&&(v===C||v===x||v===q))return void 0===t.body?void(0,http_helpers_1.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."}}):v===C?void S(t,s):v===x?void k(t,s):void g(t,s);if("GET"===t.method&&v===w){let n;try{n=r(t)}catch{}if(!n)return void(0,http_helpers_1.sendJson)(s,200,{status:"anonymous"});const o=resolveEmail(t,i);return void(async()=>{let r=p.get(n);if((!r||p.isStale(n))&&o&&!h.isPaused()&&!p.isRefreshing(n)){p.markRefreshing(n);try{const i=(0,device_id_1.extractDeviceIdOrUndefined)(t,u),s=extractFingerprintId(t),d=extractSessionId(t,a),c=i??s??"unknown";await fetchAndCacheVerdict(e,p,n,o,c,s,d),r=p.get(n)}catch(e){l&&l(e,{operation:"checkUser",userId:n,emailAddress:o})}finally{p.clearRefreshing(n)}}r&&r.isFlagged&&!r.isVerified&&_&&o?(0,http_helpers_1.sendJson)(s,200,{status:"flagged",email:o}):(0,http_helpers_1.sendJson)(s,200,{status:"ok"})})()}return void(0,http_helpers_1.sendJson)(s,404,{success:!1,error:{code:"NOT_FOUND",message:"Unknown route"}})}if((0,skip_paths_1.shouldSkipPath)(v,d))return void o();if(!(0,include_path_1.shouldIncludePath)(v,c))return interceptForInjection(t,s,m),void o();let b;try{b=r(t)}catch{}if((0,sentinel_user_id_1.isSentinelUserId)(b)){const e=(0,cookies_1.parseCookie)(t,"__unshared_uid"),r=(0,cookies_1.parseCookie)(t,"__unshared_uid_at"),i=r?Number(r):NaN,n=Number.isFinite(i)&&Date.now()-i<=sentinel_user_id_1.SENTINEL_STICKINESS_TTL_MS;b=e&&n?e:void 0}if(!b){const e=(0,secure_1.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,m),void o()}const T=resolveEmail(t,i);if(setUserIdCookie(t,s,b),T&&setEmailCookie(t,s,T),!T)return interceptForInjection(t,s,m),void o();const P=extractSessionId(t,a),U=(0,device_id_1.extractDeviceIdOrUndefined)(t,u),O=extractFingerprintId(t),$=t.headers["user-agent"]??"",F=(0,client_ip_1.extractClientIp)(t),j=U??O;if((0,is_bot_1.isBot)($))return void o();const N=p.get(b);function D(){"unknown"!==P&&j&&(h.isPaused()||dispatchUserEvent(e,p,h,f,{userId:b,emailAddress:T,sessionId:P,deviceId:j,fingerprintId:O,userAgent:$,ipAddress:F,eventType:E},l))}N?(p.isStale(b)&&!p.isRefreshing(b)&&(p.markRefreshing(b),fetchAndCacheVerdict(e,p,b,T,j??"unknown",O,P).finally(()=>p.clearRefreshing(b))),N.isFlagged||D(),applyVerdict(N,b,T,t,s,o,m,_)):fetchAndCacheVerdict(e,p,b,T,j??"unknown",O,P).then(e=>{e.isFlagged||D(),applyVerdict(e,b,T,t,s,o,m,_)}).catch(()=>{D(),interceptForInjection(t,s,m),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,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"],(0,response_interceptor_1.interceptResponse)(t,(e,t)=>{if(!(0,content_type_1.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){const d={};let c;n&&"unknown"!==n&&(d.deviceId=n),s&&(d.fingerprintId=s);const a=await Promise.race([e.checkUser(i,d),new Promise(e=>{c=setTimeout(()=>e(null),500)})]);if(clearTimeout(c),!a)return{isFlagged:!1,isVerified:!1,emailAddress:i,sessionId:o,cachedAt:0,ttl:0};const u=a.data?.is_user_flagged??!1;return t.set(r,{isFlagged:u,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,10 +1,16 @@
1
1
  /**
2
- * Generates a small inline loader script that:
3
- * 1. Loads the real fingerprint SDK from /__unshared/fp.js
4
- * 2. Collects a full fingerprint (31+ signals, MurmurHash3 Merkle tree)
5
- * 3. POSTs the result to /__unshared/submit-fp
2
+ * Generates an inline loader script that:
3
+ * 1. Loads the fingerprint SDK from /__unshared/fp.js
4
+ * 2. Collects a fingerprint and POSTs to /__unshared/submit-fp
5
+ * 3. Caches fingerprint in sessionStorage for reuse on SPA navigations
6
+ * 4. Patches History API to detect SPA route changes → re-submits fingerprint
7
+ * 5. Patches fetch/XHR to detect 403 account_flagged → dispatches "unshared:flagged" event
6
8
  *
7
9
  * The actual SDK UMD bundle is served by the middleware at /__unshared/fp.js.
8
- * This keeps the injected HTML small (~500 bytes) while using the full library.
10
+ *
11
+ * Event contract:
12
+ * window.addEventListener("unshared:flagged", (e) => {
13
+ * e.detail.email — the flagged user's email (from 403 response body)
14
+ * });
9
15
  */
10
16
  export declare function generateFingerprintScript(routePrefix: string, version?: string): string;
@@ -1 +1 @@
1
- "use strict";function generateFingerprintScript(e,n){const t=n?`?v=${escapeJavaScript(n)}`:"";return`<script>\n(function(){\ntry{\nvar pfx="${escapeJavaScript(e)}";\n\n// Session cookie helpers\nfunction getCookie(n){var m=document.cookie.match(new RegExp("(?:^|; )"+n+"=([^;]*)"));return m?decodeURIComponent(m[1]):null}\nfunction setCookie(n,v,d){var e="";if(d){var dt=new Date();dt.setTime(dt.getTime()+d*864e5);e="; expires="+dt.toUTCString()}document.cookie=n+"="+encodeURIComponent(v)+e+"; path=/; SameSite=Lax"}\n\n// UUID helper\nfunction uuid(){return(typeof crypto!=="undefined"&&crypto.randomUUID)?crypto.randomUUID():("xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g,function(c){var r=Math.random()*16|0;return(c==="x"?r:r&0x3|0x8).toString(16)}))}\n\n// Ensure session ID cookie exists\nvar sid=getCookie("__unshared_sid");\nif(!sid){sid=uuid();setCookie("__unshared_sid",sid,365)}\n\n// Persistent device ID (survives across sessions via localStorage)\nvar did="";\ntry{did=localStorage.getItem("__unshared_device_id")||"";if(!did){did=uuid();localStorage.setItem("__unshared_device_id",did)}}catch(e){did=did||uuid()}\n\nvar uid=getCookie("__unshared_uid")||"";\n\n// Collect on every page load if a userId is present\nif(uid){\n var s=document.createElement("script");\n s.src=pfx+"/fp.js${t}";\n s.onload=function(){\n try{\n var client=new UnsharedLabsBrowser.UnsharedLabsBrowser({baseUrl:""});\n client.collect({exclude:["timing","navigatorConnection"]}).then(function(fp){\n var body={\n hash:fp.full_hash,\n stable_hash:fp.fingerprint_id,\n collected_at:fp.timestamp,\n is_incognito:fp.isIncognito,\n components:fp.components,\n version:fp.version,\n session_id:sid,\n user_id:uid\n };\n var xhr=new XMLHttpRequest();\n xhr.open("POST",pfx+"/submit-fp",true);\n xhr.setRequestHeader("Content-Type","application/json");\n xhr.setRequestHeader("X-Session-Id",sid);\n xhr.setRequestHeader("X-Device-Id",did);\n xhr.send(JSON.stringify(body));\n });\n }catch(e){}\n };\n document.head.appendChild(s);\n}\n}catch(e){}\n})();\n<\/script>`}function escapeJavaScript(e){return e.replace(/\\/g,"\\\\").replace(/"/g,'\\"').replace(/'/g,"\\'")}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// --- Helpers ---\nfunction gC(n){var m=document.cookie.match(new RegExp("(?:^|; )"+n+"=([^;]*)"));return m?decodeURIComponent(m[1]):null}\nfunction sC(n,v,d){var e="";if(d){var dt=new Date();dt.setTime(dt.getTime()+d*864e5);e="; expires="+dt.toUTCString()}document.cookie=n+"="+encodeURIComponent(v)+e+"; path=/; SameSite=Lax"}\nfunction uuid(){return(typeof crypto!=="undefined"&&crypto.randomUUID)?crypto.randomUUID():("xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g,function(c){var r=Math.random()*16|0;return(c==="x"?r:r&0x3|0x8).toString(16)}))}\n// Sentinel user IDs that must never be treated as real users. Mirrors\n// the Set in sentinel-user-id.ts — keep in sync. Empty string is handled\n// by the separate !uid checks below.\nvar SENTINEL_UIDS={"__pre_auth__":1,"anonymous":1,"guest":1,"undefined":1,"null":1};\nfunction isSentinelUid(v){return typeof v==="string"&&SENTINEL_UIDS.hasOwnProperty(v)}\n\n// --- Session + device IDs ---\n// Session ID is a UUID because it's supposed to be tab-scoped and random.\n// Device ID is intentionally NOT a UUID — Issue 9: random UUIDs wrote\n// meaningless device_ids to every fingerprint row. Instead we read the\n// stable fingerprint hash from localStorage if a previous submission\n// already persisted it; otherwise we leave did empty and let submitFP()\n// reconcile on the first successful collection. The Node middleware's\n// Issue 8 bootstrap-skip branch handles the empty-device_id window so we\n// never dispatch with a random or "unknown" value.\nvar sid=gC("__unshared_sid");\nif(!sid){sid=uuid();sC("__unshared_sid",sid,365)}\nvar did="";\ntry{did=localStorage.getItem("__unshared_device_id")||""}catch(e){}\nif(did){sC("__unshared_fp_id",did,365)}\n\n// --- Fingerprint cache (sessionStorage) ---\nfunction getFP(){try{var r=sessionStorage.getItem(SS_FP);return r?JSON.parse(r):null}catch(e){return null}}\nfunction setFP(fp){try{sessionStorage.setItem(SS_FP,JSON.stringify(fp))}catch(e){}}\n\n// --- Submit fingerprint to backend ---\nfunction submitFP(fp){\n var uid=gC("__unshared_uid");\n if(!uid||isSentinelUid(uid))return;\n // Issue 9: reconcile device_id to the stable fingerprint hash. This runs\n // before we send the X-Device-Id header so the very first submission\n // already carries the real value. Persist to localStorage so other tabs\n // (and future reloads) pick up the same stable ID without needing to\n // re-collect the fingerprint.\n if(fp.fingerprint_id){\n did=fp.fingerprint_id;\n try{localStorage.setItem("__unshared_device_id",did)}catch(e){}\n sC("__unshared_fp_id",did,365);\n }\n // event_type is the SPA route, not a fixed enum. Page-level event names\n // (page_load/route_change) collapsed every row into one of two buckets;\n // the URL is more useful for analytics and matches the frontend SDK.\n var route=(location.pathname||"/")+(location.search||"");\n var key=uid+"|"+route;\n if(key===lastSubmitKey)return;\n lastSubmitKey=key;\n try{sessionStorage.setItem(SS_LAST_SUBMIT,key)}catch(e){}\n // collected_at is stamped fresh at submit time rather than carried from fp.timestamp,\n // because fp is cached per-tab in sessionStorage — reusing its original timestamp would\n // freeze collected_at at first load and drift against server created_at as the tab ages.\n // The server authoritatively overwrites this value again on ingress.\n var body={hash:fp.full_hash,stable_hash:fp.fingerprint_id,collected_at:(new Date()).toISOString(),is_incognito:fp.isIncognito,components:fp.components,version:fp.version,session_id:sid,user_id:uid,event_type:route};\n // Deterministic idempotency key: (stable_hash, user_id, event_type) fully\n // identifies one logical submission. A stable key lets the backend's\n // ON CONFLICT (idempotency_key) DO NOTHING actually catch duplicates across\n // reloads, tabs, and concurrent SDK instances — a fresh UUID could not.\n var idem=fp.fingerprint_id+"|"+uid+"|"+route;\n var xhr=new XMLHttpRequest();\n xhr.open("POST",pfx+"/submit-fp",true);\n xhr.setRequestHeader("Content-Type","application/json");\n xhr.setRequestHeader("X-Session-Id",sid);\n if(did)xhr.setRequestHeader("X-Device-Id",did);\n xhr.setRequestHeader("X-Idempotency-Key",idem);\n xhr.send(JSON.stringify(body));\n}\n\n// --- Collect fingerprint (loads fp.js if needed) then submit ---\nvar fpReady=false;\nfunction collectAndSubmit(){\n var uid=gC("__unshared_uid");\n if(!uid||isSentinelUid(uid))return;\n var cached=getFP();\n if(cached){submitFP(cached);return}\n if(!fpReady)return;\n try{\n var c=new UnsharedBrowser.UnsharedBrowser({baseUrl:""});\n c.collect({exclude:["timing","speech"]}).then(function(fp){setFP(fp);submitFP(fp)});\n }catch(e){}\n}\n\n// --- Load fp.js (always — browser caches it for 1h) ---\n// Submit cached FP immediately if available; load fp.js for fresh collection\nvar pageLoadSubmitted=false;\nvar _boot_uid=gC("__unshared_uid");\nif(getFP()&&_boot_uid&&!isSentinelUid(_boot_uid)){submitFP(getFP());pageLoadSubmitted=true;deferredCheck()}\nvar s=document.createElement("script");\ns.src=pfx+"/fp.js${n}";\ns.onload=function(){fpReady=true;if(!pageLoadSubmitted){collectAndSubmit();deferredCheck()}};\ndocument.head.appendChild(s);\n\n// --- Deferred verdict check ---\n// After fingerprint submission, the backend processes the event async.\n// If the user was just flagged, the initial page load may have beaten\n// the verdict update. Re-check after a delay so newly flagged sessions\n// get caught without waiting for user interaction.\n// The endpoint always returns 200 so browsers don't log a scary red\n// network error — we inspect the body and dispatch the flagged event\n// ourselves when status==="flagged".\nfunction deferredCheck(){\n var uid=gC("__unshared_uid");\n if(!uid||isSentinelUid(uid))return;\n setTimeout(function(){\n try{fetch(pfx+"/status",{method:"GET",credentials:"same-origin"}).then(function(r){return r.json()}).then(function(b){if(b&&b.status==="flagged")emitFlagged(b)}).catch(function(){})}catch(e){}\n },500);\n}\n\n// --- SPA route change tracking (History API + popstate) ---\nvar oPush=history.pushState,oReplace=history.replaceState;\nhistory.pushState=function(){oPush.apply(this,arguments);try{collectAndSubmit()}catch(e){}};\nhistory.replaceState=function(){oReplace.apply(this,arguments);try{collectAndSubmit()}catch(e){}};\nwindow.addEventListener("popstate",function(){try{collectAndSubmit()}catch(e){}});\n\n// --- 403 interception: dispatch "unshared:flagged" event ---\nfunction emitFlagged(body){\n try{window.dispatchEvent(new CustomEvent("unshared:flagged",{detail:{email:body.email||""}}))}catch(e){}\n}\n\n// Patch fetch\nvar oFetch=window.fetch;\nif(oFetch){window.fetch=function(){return oFetch.apply(this,arguments).then(function(r){if(r.status===403){try{var cl=r.clone();cl.json().then(function(b){if(b&&b.error==="account_flagged")emitFlagged(b)}).catch(function(){})}catch(e){}}return r})}}\n\n// Patch XMLHttpRequest\nvar oXSend=XMLHttpRequest.prototype.send;\nXMLHttpRequest.prototype.send=function(){var x=this;x.addEventListener("load",function(){if(x.status===403){try{var b=JSON.parse(x.responseText);if(b&&b.error==="account_flagged")emitFlagged(b)}catch(e){}}});return oXSend.apply(this,arguments)};\n\n}catch(e){}\n})();\n<\/script>`}function escapeJavaScript(e){return e.replace(/\\/g,"\\\\").replace(/"/g,'\\"').replace(/'/g,"\\'")}Object.defineProperty(exports,"t",{value:!0}),exports.generateFingerprintScript=generateFingerprintScript;
@@ -1,13 +1,15 @@
1
- import type { Response } from 'express';
1
+ import type { UnsharedResponse } from '../types';
2
2
  /**
3
3
  * Intercepts the response body by wrapping res.write() and res.end().
4
4
  *
5
- * Collects all chunks written to the response. When res.end() is called,
6
- * invokes the `transform` callback with the complete body buffer and the
7
- * Content-Type header. The transform can return modified content or null
8
- * to pass through unchanged.
5
+ * Only buffers HTML responses (text/html). Non-HTML responses (JSON, images,
6
+ * CSS, JS, etc.) pass through to the original write/end without buffering,
7
+ * avoiding unnecessary memory usage on large payloads.
9
8
  *
10
- * Does NOT monkey-patch res.send uses the lower-level write/end API
11
- * as required by the spec.
9
+ * When res.end() is called on an HTML response, invokes the `transform`
10
+ * callback with the complete body buffer and the Content-Type header.
11
+ * The transform can return modified content or null to pass through unchanged.
12
12
  */
13
- export declare function interceptResponse(res: Response, transform: (body: Buffer, contentType: string | undefined) => Buffer | string | null): void;
13
+ export declare function interceptResponse(res: UnsharedResponse, transform: (body: Buffer, contentType: string | undefined) => Buffer | string | null, options?: {
14
+ preventCaching?: boolean;
15
+ }): void;
@@ -1 +1 @@
1
- "use strict";function interceptResponse(t,n){const e=[],f=t.write.bind(t),u=t.end.bind(t);let o=!1;t.write=function(t,n,f){if(null!=t){const f=Buffer.isBuffer(t)?t:Buffer.from(t,"string"==typeof n?n:"utf8");e.push(f)}return"function"==typeof n&&n(null),"function"==typeof f&&f(null),!0},t.end=function(r,c,s){if(o)return t;if(o=!0,null!=r){const t=Buffer.isBuffer(r)?r:Buffer.from(r,"string"==typeof c?c:"utf8");e.push(t)}const l=Buffer.concat(e),i=t.getHeader("content-type");let p;try{p=n(l,i)}catch{p=null}if(null!=p){const n=Buffer.isBuffer(p)?p:Buffer.from(p,"utf8");t.setHeader("Content-Length",n.length),t.removeHeader("Content-Encoding"),f(n)}else l.length>0&&f(l);const y="function"==typeof c?c:s;return y?u(y):u(),t}}Object.defineProperty(exports,"t",{value:!0}),exports.interceptResponse=interceptResponse;
1
+ "use strict";function interceptResponse(t,n,e){const f=e?.preventCaching??!1,o=t.write.bind(t),u=t.end.bind(t),r=t.writeHead.bind(t),c=[];let i=!1,l=null;function s(){if(null!==l)return;const n=t.getHeader("content-type");null!=n&&(l=String(n).includes("text/html"),l||function(){t.write=o,t.end=u;for(const t of c)o(t);c.length=0}())}t.writeHead=function(n,...e){return s(),f&&l&&(t.setHeader("Cache-Control","no-store"),t.removeHeader("ETag"),t.removeHeader("Last-Modified")),r(n,...e)},t.write=function(t,n,e){if(s(),!1===l)return o(t,n,e);if(null!=t){const e=Buffer.isBuffer(t)?t:Buffer.from(t,"string"==typeof n?n:"utf8");c.push(e)}return"function"==typeof n&&n(null),"function"==typeof e&&e(null),!0},t.end=function(e,f,r){if(i)return t;if(i=!0,s(),!1===l)return u(e,f,r);if(null!=e){const t=Buffer.isBuffer(e)?e:Buffer.from(e,"string"==typeof f?f:"utf8");c.push(t)}const p=Buffer.concat(c),y=t.getHeader("content-type");let B;try{B=n(p,y)}catch{B=null}if(null!=B){const n=Buffer.isBuffer(B)?B:Buffer.from(B,"utf8");t.setHeader("Content-Length",n.length),t.removeHeader("Content-Encoding"),o(n)}else p.length>0&&o(p);const g="function"==typeof f?f:r;return g?u(g):u(),t}}Object.defineProperty(exports,"t",{value:!0}),exports.interceptResponse=interceptResponse;
@@ -1,15 +1,22 @@
1
- import type { Request, Response } from 'express';
2
- import type { UnsharedLabsClient } from '../../client';
1
+ import type { UnsharedRequest, UnsharedResponse } from '../../types';
2
+ import type { UnsharedClient } from '../../client';
3
3
  import type { VerdictCache } from '../verdict-cache';
4
4
  import type { RateLimitBackoff } from '../rate-limit-backoff';
5
- export interface SubmitFingerprintDependencies {
6
- client: UnsharedLabsClient;
5
+ import type { DispatchDedupe } from '../dispatch-dedupe';
6
+ export interface SubmitFingerprintDependencies<TReq extends UnsharedRequest = UnsharedRequest> {
7
+ client: UnsharedClient;
7
8
  verdictCache: VerdictCache;
8
9
  rateLimitBackoff: RateLimitBackoff;
9
- resolveUserId?: (req: Request) => string | undefined;
10
- resolveEmailAddress?: (req: Request) => string | undefined;
11
- resolveSessionId?: (req: Request) => string | undefined;
12
- resolveDeviceId?: (req: Request) => string | undefined;
10
+ dispatchDedupe: DispatchDedupe;
11
+ resolveUserId?: (req: TReq) => string | undefined;
12
+ resolveEmailAddress?: (req: TReq) => string | undefined;
13
+ resolveSessionId?: (req: TReq) => string | undefined;
14
+ resolveDeviceId?: (req: TReq) => string | undefined;
15
+ onError?: (error: unknown, context: {
16
+ operation: 'processUserEvent' | 'submitFingerprintEvent' | 'checkUser' | 'verifyTrigger' | 'verify';
17
+ userId?: string;
18
+ emailAddress?: string;
19
+ }) => void;
13
20
  }
14
21
  /**
15
22
  * Handles POST /__unshared/submit-fp
@@ -21,4 +28,4 @@ export interface SubmitFingerprintDependencies {
21
28
  *
22
29
  * Always returns 200 (fire-and-forget from browser's perspective).
23
30
  */
24
- export declare function handleSubmitFingerprint(dependencies: SubmitFingerprintDependencies): (req: Request, res: Response) => Promise<void>;
31
+ export declare function handleSubmitFingerprint<TReq extends UnsharedRequest = UnsharedRequest>(dependencies: SubmitFingerprintDependencies<TReq>): (req: TReq, res: UnsharedResponse) => Promise<void>;
@@ -1 +1 @@
1
- "use strict";function handleSubmitFingerprint(e){return async(n,t)=>{try{const i=n.body??{},o={full_hash:i.hash??"",fingerprint_id:i.stable_hash??"",timestamp:i.collected_at??(new Date).toISOString(),isIncognito:i.is_incognito??!1,components:i.components??{},version:i.version??"inline-1.0.0"};let s,r,a;try{s=e.resolveUserId?e.resolveUserId(n):void 0}catch{}s=s??i.user_id??void 0;try{r=e.resolveEmailAddress?e.resolveEmailAddress(n):void 0}catch{}r=r??parseCookie(n,"__unshared_email")??i.email??void 0;try{a=e.resolveSessionId?e.resolveSessionId(n):void 0}catch{}a=a??i.session_id??parseCookie(n,"__unshared_sid");const c=n.ip??"",d=n.headers["user-agent"]??"",u=extractDeviceId(n,e.resolveDeviceId),_=o.fingerprint_id||void 0,p=[];if(_&&!parseCookie(n,"__unshared_fingerprint_id")&&p.push(`__unshared_fingerprint_id=${encodeURIComponent(_)}; HttpOnly; Path=/; SameSite=Lax`),r&&!parseCookie(n,"__unshared_email")&&p.push(`__unshared_email=${encodeURIComponent(r)}; HttpOnly; Path=/; SameSite=Lax`),p.length>0){const e=t.getHeader("Set-Cookie");if(e){const n=Array.isArray(e)?[...e]:[String(e)];n.push(...p),t.setHeader("Set-Cookie",n)}else t.setHeader("Set-Cookie",p)}s&&e.client.submitFingerprintEvent(o,{userId:s,sessionHash:a,eventType:"auto_collect",ipAddress:c}).catch(()=>{}),s&&r&&!e.rateLimitBackoff.isPaused()&&e.client.processUserEvent({eventType:"auto_collect",userId:s,emailAddress:r,ipAddress:c,deviceId:u,fingerprintId:_,sessionHash:a??"unknown",userAgent:d}).then(n=>{n.success&&n.data?.analysis&&e.verdictCache.update(s,{isFlagged:n.data.analysis.is_user_flagged}),!n.success&&n.error?.retryAfter&&e.rateLimitBackoff.pause(1e3*n.error.retryAfter)}).catch(()=>{}),t.status(200).json({success:!0})}catch{t.status(200).json({success:!0})}}}function extractDeviceId(e,n){if(n)try{const t=n(e);if(t)return t}catch{}const t=parseCookie(e,"__unshared_fp_id");if(t)return t;const i=e.headers["x-device-id"];return"string"==typeof i&&i?i:"unknown"}function parseCookie(e,n){const t=e.headers.cookie;if(!t)return;const i=t.match(new RegExp(`(?:^|; )${n}=([^;]*)`));return i?decodeURIComponent(i[1]):void 0}Object.defineProperty(exports,"t",{value:!0}),exports.handleSubmitFingerprint=handleSubmitFingerprint;
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,11 +1,16 @@
1
- import type { Request, Response } from 'express';
2
- import type { UnsharedLabsClient } from '../../client';
1
+ import type { UnsharedRequest, UnsharedResponse } from '../../types';
2
+ import type { UnsharedClient } from '../../client';
3
3
  import type { VerdictCache } from '../verdict-cache';
4
- export interface VerificationDependencies {
5
- client: UnsharedLabsClient;
4
+ export interface VerificationDependencies<TReq extends UnsharedRequest = UnsharedRequest> {
5
+ client: UnsharedClient;
6
6
  verdictCache: VerdictCache;
7
- resolveEmailAddress?: (req: Request) => string | undefined;
8
- resolveDeviceId?: (req: Request) => string | undefined;
7
+ resolveEmailAddress?: (req: TReq) => string | undefined;
8
+ resolveDeviceId?: (req: TReq) => string | undefined;
9
+ onError?: (error: unknown, context: {
10
+ operation: 'processUserEvent' | 'submitFingerprintEvent' | 'checkUser' | 'verifyTrigger' | 'verify';
11
+ userId?: string;
12
+ emailAddress?: string;
13
+ }) => void;
9
14
  }
10
15
  /**
11
16
  * POST /__unshared/verify-trigger
@@ -15,7 +20,7 @@ export interface VerificationDependencies {
15
20
  *
16
21
  * The deviceId is resolved via extractDeviceId (same as the middleware).
17
22
  */
18
- export declare function handleVerifyTrigger(dependencies: VerificationDependencies): (req: Request, res: Response) => Promise<void>;
23
+ export declare function handleVerifyTrigger<TReq extends UnsharedRequest = UnsharedRequest>(dependencies: VerificationDependencies<TReq>): (req: TReq, res: UnsharedResponse) => Promise<void>;
19
24
  /**
20
25
  * POST /__unshared/verify
21
26
  * Validates OTP code. Called by the blocker overlay UI.
@@ -25,4 +30,4 @@ export declare function handleVerifyTrigger(dependencies: VerificationDependenci
25
30
  * On successful verification, updates the verdict cache to mark
26
31
  * the user as verified so subsequent requests pass through.
27
32
  */
28
- export declare function handleVerify(dependencies: VerificationDependencies): (req: Request, res: Response) => Promise<void>;
33
+ export declare function handleVerify<TReq extends UnsharedRequest = UnsharedRequest>(dependencies: VerificationDependencies<TReq>): (req: TReq, res: UnsharedResponse) => Promise<void>;
@@ -1 +1 @@
1
- "use strict";function handleVerifyTrigger(e){return async(r,i)=>{try{const s=resolveEmail(r,r.body??{},e.resolveEmailAddress);if(!s)return void i.status(400).json({success:!1,error:{code:"VALIDATION_ERROR",message:"Email is required"}});const t=extractDeviceId(r,e.resolveDeviceId),n=parseCookie(r,"__unshared_fingerprint_id")||void 0,o=await e.client.triggerEmailVerification(s,t,{fingerprintId:n});o.success?i.status(200).json({success:!0,data:o.data}):i.status(200).json({success:!1,error:o.error??{code:"TRIGGER_FAILED",message:"Failed to send verification email"}})}catch{i.status(200).json({success:!1,error:{code:"INTERNAL_ERROR",message:"Failed to trigger verification"}})}}}function handleVerify(e){return async(r,i)=>{try{const s=r.body??{},t=resolveEmail(r,s,e.resolveEmailAddress),n=s.code;if(!t||!n)return void i.status(400).json({success:!1,error:{code:"VALIDATION_ERROR",message:"Email and code are required"}});const o=extractDeviceId(r,e.resolveDeviceId),c=parseCookie(r,"__unshared_fingerprint_id")||void 0,a=await e.client.verify(t,o,n,{fingerprintId:c});if(a.success){const s=parseCookie(r,"__unshared_uid");s&&e.verdictCache.update(s,{isVerified:!0}),i.status(200).json({success:!0,data:{verified:!0}})}else i.status(200).json({success:!1,error:a.error??{code:"VERIFICATION_FAILED",message:"Verification failed"}})}catch{i.status(200).json({success:!1,error:{code:"INTERNAL_ERROR",message:"Verification failed"}})}}}function resolveEmail(e,r,i){if(i)try{const r=i(e);if(r)return r}catch{}const s=parseCookie(e,"__unshared_email");if(s)return s;const t=r.email;return"string"==typeof t&&t?t:void 0}function extractDeviceId(e,r){if(r)try{const i=r(e);if(i)return i}catch{}const i=parseCookie(e,"__unshared_fp_id");if(i)return i;const s=e.headers["x-device-id"];return"string"==typeof s&&s?s:"unknown"}function parseCookie(e,r){const i=e.headers.cookie;if(!i)return;const s=i.match(new RegExp(`(?:^|; )${r}=([^;]*)`));return s?decodeURIComponent(s[1]):void 0}Object.defineProperty(exports,"i",{value:!0}),exports.handleVerifyTrigger=handleVerifyTrigger,exports.handleVerify=handleVerify;
1
+ "use strict";Object.defineProperty(exports,"i",{value:!0}),exports.handleVerifyTrigger=handleVerifyTrigger,exports.handleVerify=handleVerify;const cookies_1=require("../utils/cookies"),device_id_1=require("../utils/device-id"),http_helpers_1=require("../utils/http-helpers");function handleVerifyTrigger(e){return async(r,i)=>{try{const s=resolveEmail(r,r.body??{},e.resolveEmailAddress);if(!s)return void(0,http_helpers_1.sendJson)(i,400,{success:!1,error:{code:"VALIDATION_ERROR",message:"Email is required"}});const t=(0,device_id_1.extractDeviceId)(r,e.resolveDeviceId),o=(0,cookies_1.parseCookie)(r,"__unshared_fingerprint_id")||void 0,c=await e.client.triggerEmailVerification(s,t,{fingerprintId:o});c.success?(0,http_helpers_1.sendJson)(i,200,{success:!0,data:c.data}):(0,http_helpers_1.sendJson)(i,200,{success:!1,error:c.error??{code:"TRIGGER_FAILED",message:"Failed to send verification email"}})}catch(r){e.onError&&e.onError(r,{operation:"verifyTrigger"}),(0,http_helpers_1.sendJson)(i,200,{success:!1,error:{code:"INTERNAL_ERROR",message:"Failed to trigger verification"}})}}}function handleVerify(e){return async(r,i)=>{try{const s=r.body??{},t=resolveEmail(r,s,e.resolveEmailAddress),o=s.code;if(!t||!o)return void(0,http_helpers_1.sendJson)(i,400,{success:!1,error:{code:"VALIDATION_ERROR",message:"Email and code are required"}});const c=(0,device_id_1.extractDeviceId)(r,e.resolveDeviceId),_=(0,cookies_1.parseCookie)(r,"__unshared_fingerprint_id")||void 0,n=await e.client.verify(t,c,o,{fingerprintId:_});if(n.success){const s=(0,cookies_1.parseCookie)(r,"__unshared_uid");s&&e.verdictCache.update(s,{isVerified:!0}),(0,http_helpers_1.sendJson)(i,200,{success:!0,data:{verified:!0}})}else(0,http_helpers_1.sendJson)(i,200,{success:!1,error:n.error??{code:"VERIFICATION_FAILED",message:"Verification failed"}})}catch(r){e.onError&&e.onError(r,{operation:"verify"}),(0,http_helpers_1.sendJson)(i,200,{success:!1,error:{code:"INTERNAL_ERROR",message:"Verification failed"}})}}}function resolveEmail(e,r,i){if(i)try{const r=i(e);if(r)return r}catch{}const s=(0,cookies_1.parseCookie)(e,"__unshared_email");if(s)return s;const t=r.email;return"string"==typeof t&&t?t:void 0}
@@ -0,0 +1,6 @@
1
+ import type { UnsharedRequest } from '../../types';
2
+ /**
3
+ * Extract the real client IP from proxy headers, falling back to req.ip or socket address.
4
+ * Checked in order: CF-Connecting-IP (Cloudflare) → X-Real-IP (nginx/ALB) → req.ip → socket.
5
+ */
6
+ export declare function extractClientIp(req: UnsharedRequest): string;
@@ -0,0 +1 @@
1
+ "use strict";function extractClientIp(t){const e=t.headers["cf-connecting-ip"];if("string"==typeof e&&e)return e;const r=t.headers["x-real-ip"];return"string"==typeof r&&r?r:t.ip??t.socket?.remoteAddress??""}Object.defineProperty(exports,"t",{value:!0}),exports.extractClientIp=extractClientIp;
@@ -0,0 +1,6 @@
1
+ import type { UnsharedRequest } from '../../types';
2
+ /**
3
+ * Reads a single cookie value from the raw Cookie header.
4
+ * Works without cookie-parser middleware.
5
+ */
6
+ export declare function parseCookie(req: UnsharedRequest, name: string): string | undefined;
@@ -0,0 +1 @@
1
+ "use strict";function parseCookie(e,o){const t=e.headers.cookie;if(!t)return;const n=t.match(new RegExp(`(?:^|; )${o}=([^;]*)`));return n?decodeURIComponent(n[1]):void 0}Object.defineProperty(exports,"o",{value:!0}),exports.parseCookie=parseCookie;
@@ -0,0 +1,19 @@
1
+ import type { UnsharedRequest } from '../../types';
2
+ /**
3
+ * Resolves device ID from: custom resolver → X-Device-Id header → __unshared_fp_id cookie.
4
+ *
5
+ * Returns the literal string `"unknown"` when no source provides a value.
6
+ * Callers that can tolerate a tri-state should use `extractDeviceIdOrUndefined`
7
+ * instead — the "unknown" sentinel polluted 19% of FP rows in production
8
+ * because it was being dispatched as-if-real during the first request of a
9
+ * session, before the inline script had a chance to set `__unshared_fp_id`.
10
+ * Kept for API compatibility.
11
+ */
12
+ export declare function extractDeviceId<TReq extends UnsharedRequest = UnsharedRequest>(req: TReq, resolveDeviceId?: (req: TReq) => string | undefined): string;
13
+ /**
14
+ * Resolves device ID from: custom resolver → X-Device-Id header → __unshared_fp_id cookie.
15
+ * Returns `undefined` when nothing is available. Use this at any call site
16
+ * that can take a "skip dispatch" branch during the bootstrap window, so we
17
+ * stop writing `device_id="unknown"` rows to the analytics table on first request.
18
+ */
19
+ export declare function extractDeviceIdOrUndefined<TReq extends UnsharedRequest = UnsharedRequest>(req: TReq, resolveDeviceId?: (req: TReq) => string | undefined): string | undefined;
@@ -0,0 +1 @@
1
+ "use strict";Object.defineProperty(exports,"t",{value:!0}),exports.extractDeviceId=extractDeviceId,exports.extractDeviceIdOrUndefined=extractDeviceIdOrUndefined;const cookies_1=require("./cookies");function extractDeviceId(e,t){return extractDeviceIdOrUndefined(e,t)??"unknown"}function extractDeviceIdOrUndefined(e,t){if(t)try{const r=t(e);if(r)return r}catch{}const r=e.headers["x-device-id"];if("string"==typeof r&&r)return r;return(0,cookies_1.parseCookie)(e,"__unshared_fp_id")||void 0}