unshared-clientjs-sdk 2.0.0-rc.21 → 2.0.0-rc.23
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/client.d.ts +29 -0
- package/dist/client.js +1 -1
- package/dist/esm/client.d.mts +29 -0
- package/dist/esm/client.mjs +1 -1
- package/dist/esm/index.d.mts +2 -0
- package/dist/esm/index.mjs +1 -1
- package/dist/esm/middleware/dispatch-dedupe.d.mts +11 -0
- package/dist/esm/middleware/dispatch-dedupe.mjs +1 -0
- package/dist/esm/middleware/index.d.mts +26 -11
- package/dist/esm/middleware/index.mjs +1 -1
- package/dist/esm/middleware/injection/fingerprint-script.mjs +1 -1
- package/dist/esm/middleware/response-interceptor.d.mts +2 -2
- package/dist/esm/middleware/routes/submit-fp.d.mts +14 -7
- package/dist/esm/middleware/routes/submit-fp.mjs +1 -1
- package/dist/esm/middleware/routes/verify.d.mts +11 -6
- package/dist/esm/middleware/routes/verify.mjs +1 -1
- package/dist/esm/middleware/utils/client-ip.d.mts +4 -4
- package/dist/esm/middleware/utils/client-ip.mjs +1 -1
- package/dist/esm/middleware/utils/cookies.d.mts +2 -2
- package/dist/esm/middleware/utils/device-id.d.mts +17 -3
- package/dist/esm/middleware/utils/device-id.mjs +1 -1
- package/dist/esm/middleware/utils/http-helpers.d.mts +21 -0
- package/dist/esm/middleware/utils/http-helpers.mjs +1 -0
- package/dist/esm/middleware/utils/include-path.d.mts +6 -0
- package/dist/esm/middleware/utils/include-path.mjs +1 -0
- package/dist/esm/middleware/utils/is-bot.mjs +1 -1
- package/dist/esm/middleware/utils/secure.d.mts +2 -2
- package/dist/esm/middleware/utils/secure.mjs +1 -1
- package/dist/esm/middleware/utils/sentinel-user-id.d.mts +10 -0
- package/dist/esm/middleware/utils/sentinel-user-id.mjs +1 -0
- package/dist/esm/middleware.d.mts +11 -8
- package/dist/esm/middleware.mjs +1 -1
- package/dist/esm/types.d.mts +44 -0
- package/dist/esm/types.mjs +1 -0
- package/dist/esm/web/index.d.mts +17 -0
- package/dist/esm/web/index.mjs +1 -0
- package/dist/esm/web/protection-handler.d.mts +28 -0
- package/dist/esm/web/protection-handler.mjs +1 -0
- package/dist/esm/web/submit-handler.d.mts +27 -0
- package/dist/esm/web/submit-handler.mjs +1 -0
- package/dist/esm/web/types.d.mts +110 -0
- package/dist/esm/web/types.mjs +1 -0
- package/dist/esm/web/web-helpers.d.mts +55 -0
- package/dist/esm/web/web-helpers.mjs +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +1 -1
- package/dist/middleware/dispatch-dedupe.d.ts +11 -0
- package/dist/middleware/dispatch-dedupe.js +1 -0
- package/dist/middleware/index.d.ts +26 -11
- package/dist/middleware/index.js +1 -1
- package/dist/middleware/injection/fingerprint-script.js +1 -1
- package/dist/middleware/response-interceptor.d.ts +2 -2
- package/dist/middleware/routes/submit-fp.d.ts +14 -7
- package/dist/middleware/routes/submit-fp.js +1 -1
- package/dist/middleware/routes/verify.d.ts +11 -6
- package/dist/middleware/routes/verify.js +1 -1
- package/dist/middleware/utils/client-ip.d.ts +4 -4
- package/dist/middleware/utils/client-ip.js +1 -1
- package/dist/middleware/utils/cookies.d.ts +2 -2
- package/dist/middleware/utils/device-id.d.ts +17 -3
- package/dist/middleware/utils/device-id.js +1 -1
- package/dist/middleware/utils/http-helpers.d.ts +21 -0
- package/dist/middleware/utils/http-helpers.js +1 -0
- package/dist/middleware/utils/include-path.d.ts +6 -0
- package/dist/middleware/utils/include-path.js +1 -0
- package/dist/middleware/utils/is-bot.js +1 -1
- package/dist/middleware/utils/secure.d.ts +2 -2
- package/dist/middleware/utils/secure.js +1 -1
- package/dist/middleware/utils/sentinel-user-id.d.ts +10 -0
- package/dist/middleware/utils/sentinel-user-id.js +1 -0
- package/dist/middleware.d.ts +11 -8
- package/dist/middleware.js +1 -1
- package/dist/types.d.ts +44 -0
- package/dist/types.js +1 -0
- package/dist/web/index.d.ts +17 -0
- package/dist/web/index.js +1 -0
- package/dist/web/protection-handler.d.ts +28 -0
- package/dist/web/protection-handler.js +1 -0
- package/dist/web/submit-handler.d.ts +27 -0
- package/dist/web/submit-handler.js +1 -0
- package/dist/web/types.d.ts +110 -0
- package/dist/web/types.js +1 -0
- package/dist/web/web-helpers.d.ts +55 -0
- package/dist/web/web-helpers.js +1 -0
- package/package.json +7 -10
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Stickiness window: if the resolver returns a sentinel but the
|
|
3
|
+
* `__unshared_uid` cookie was set within this many milliseconds, we prefer
|
|
4
|
+
* the cookie value (assuming it's a hydration race on the same tab).
|
|
5
|
+
* Outside the window, the cookie is considered stale and we fall through
|
|
6
|
+
* to the "no userId" branch — without clearing the cookie, because clearing
|
|
7
|
+
* on every hydration blip is exactly the bug we're fixing.
|
|
8
|
+
*/
|
|
9
|
+
export declare const SENTINEL_STICKINESS_TTL_MS = 30000;
|
|
10
|
+
export declare function isSentinelUserId(value: string | undefined | null): boolean;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
const SENTINEL_USER_IDS=new Set(["__pre_auth__","anonymous","guest","undefined","null"]);export const SENTINEL_STICKINESS_TTL_MS=3e4;export function isSentinelUserId(e){return"string"==typeof e&&SENTINEL_USER_IDS.has(e)}
|
|
@@ -1,14 +1,14 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { UnsharedRequest, UnsharedResponse, UnsharedNextFunction } from './types';
|
|
2
2
|
import type { UnsharedLabsClient } from './client';
|
|
3
|
-
export interface MiddlewareOptions {
|
|
3
|
+
export interface MiddlewareOptions<TReq extends UnsharedRequest = UnsharedRequest> {
|
|
4
4
|
/** Override userId extractor. Falls back to req.body.user_id. */
|
|
5
|
-
userIdExtractor?: (req:
|
|
5
|
+
userIdExtractor?: (req: TReq) => string | undefined;
|
|
6
6
|
/** Override eventType extractor. Falls back to req.body.event_type. */
|
|
7
|
-
eventTypeExtractor?: (req:
|
|
7
|
+
eventTypeExtractor?: (req: TReq) => string | undefined;
|
|
8
8
|
/** Override sessionId extractor. Falls back to X-Session-Id header, then req.body.session_id. */
|
|
9
|
-
sessionIdExtractor?: (req:
|
|
9
|
+
sessionIdExtractor?: (req: TReq) => string | undefined;
|
|
10
10
|
/** Override IP address extractor. Falls back to req.ip. */
|
|
11
|
-
ipAddressExtractor?: (req:
|
|
11
|
+
ipAddressExtractor?: (req: TReq) => string | undefined;
|
|
12
12
|
/** Default event type when none is extractable. @default "browser_event" */
|
|
13
13
|
defaultEventType?: string;
|
|
14
14
|
/**
|
|
@@ -29,6 +29,9 @@ export interface MiddlewareOptions {
|
|
|
29
29
|
* Asserts that Express `trust proxy` is configured on the app.
|
|
30
30
|
* Call this once during application startup, before mounting any middleware.
|
|
31
31
|
*
|
|
32
|
+
* **Express-specific utility.** Other frameworks handle proxy trust differently;
|
|
33
|
+
* consult your framework's documentation for equivalent configuration.
|
|
34
|
+
*
|
|
32
35
|
* Throws synchronously if the setting is missing, killing the process before
|
|
33
36
|
* any requests are served.
|
|
34
37
|
*
|
|
@@ -39,7 +42,7 @@ export interface MiddlewareOptions {
|
|
|
39
42
|
* app.use(createUnsharedMiddleware(client, options));
|
|
40
43
|
* ```
|
|
41
44
|
*/
|
|
42
|
-
export declare function assertTrustProxy(app:
|
|
45
|
+
export declare function assertTrustProxy(app: any): void;
|
|
43
46
|
/**
|
|
44
47
|
* Creates an Express middleware that proxies browser fingerprint events to
|
|
45
48
|
* Unshared Labs. Mount this to handle the browser fingerprint route contract (§4 of spec).
|
|
@@ -70,4 +73,4 @@ export declare function assertTrustProxy(app: Application): void;
|
|
|
70
73
|
* }));
|
|
71
74
|
* ```
|
|
72
75
|
*/
|
|
73
|
-
export declare function createUnsharedMiddleware(client: UnsharedLabsClient, options?: MiddlewareOptions): (req:
|
|
76
|
+
export declare function createUnsharedMiddleware<TReq extends UnsharedRequest = UnsharedRequest>(client: UnsharedLabsClient, options?: MiddlewareOptions<TReq>): (req: TReq, res: UnsharedResponse, next: UnsharedNextFunction) => Promise<void>;
|
package/dist/esm/middleware.mjs
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export function assertTrustProxy(e){if(!e
|
|
1
|
+
import{sendJson,sendEmpty,getRequestPath}from"./middleware/utils/http-helpers";export function assertTrustProxy(e){if(!e?.get?.("trust proxy"))throw new Error('[unshared-labs] Express "trust proxy" is not set.\n\n Fix: add this line before mounting any middleware:\n app.set("trust proxy", 1);\n\n Why: without it, req.ip returns the proxy/load-balancer IP instead of\n the real client IP, which degrades account-sharing detection accuracy.\n Set to 1 if behind a single reverse proxy (Nginx, ALB, CloudFront),\n or the number of trusted proxies in your chain.')}export function createUnsharedMiddleware(e,s){const{userIdExtractor:r,eventTypeExtractor:t,sessionIdExtractor:n,ipAddressExtractor:o,defaultEventType:i="browser_event",routePrefix:d="/unshared",corsOrigins:c}=s??{},a=`${d}/submit-fingerprint-event`,u=c?Array.isArray(c)?c:[c]:null;return async(s,d,c)=>{const l=getRequestPath(s.url);if(u&&l===a){const e=s.headers.origin??"",r=u.includes("*");if((r||u.includes(e))&&(d.setHeader("Access-Control-Allow-Origin",r?"*":e),d.setHeader("Access-Control-Allow-Methods","POST, OPTIONS"),d.setHeader("Access-Control-Allow-Headers","Content-Type, X-Idempotency-Key, X-Session-Id")),"OPTIONS"===s.method)return void sendEmpty(d,204)}if("POST"===s.method&&l===a)try{if(void 0===s.body)return void sendJson(d,400,{success:!1,error:{code:"BODY_PARSER_MISSING",message:"req.body is undefined. Mount a JSON body-parsing middleware (e.g., express.json()) before the Unshared middleware."}});const c=s.body??{};if(!c.hash||!c.stable_hash||!c.collected_at)return void sendJson(d,400,{success:!1,error:{code:"VALIDATION_ERROR",message:"Missing required fingerprint fields: hash, stable_hash, collected_at"}});if(!s.headers["x-session-id"])return void sendJson(d,400,{success:!1,error:{code:"VALIDATION_ERROR",message:"Missing required header: X-Session-Id"}});const a={full_hash:c.hash,fingerprint_id:c.stable_hash,timestamp:c.collected_at,isIncognito:c.is_incognito??!1,components:c.components??{},version:c.version??"unknown"};let u,l,h,p;try{u=(r?r(s):void 0)??c.user_id}catch{u=c.user_id}if(s.body&&"object"==typeof s.body&&"user_id"in s.body&&delete s.body.user_id,!u)return void sendJson(d,400,{success:!1,error:{code:"VALIDATION_ERROR",message:"Missing required field: user_id"}});try{l=(t?t(s):void 0)??c.event_type??i}catch{l=c.event_type??i}try{h=(n?n(s):void 0)??s.headers["x-session-id"]?.toString()??c.session_id}catch{h=s.headers["x-session-id"]?.toString()??c.session_id}const f=s.ip??s.socket?.remoteAddress??"";try{p=(o?o(s):void 0)??f}catch{p=f}const y=await e.submitFingerprintEvent(a,{userId:u,sessionHash:h,eventType:l,ipAddress:p});if(!y.success)return void sendJson(d,200,{success:!1,error:{code:"UPSTREAM_ERROR",message:y.error?.message??"Upstream request failed"}});sendJson(d,202,{success:!0,data:y.data})}catch(e){sendJson(d,200,{success:!1,error:{code:"MIDDLEWARE_ERROR",message:e instanceof Error?e.message:"Middleware error"}})}else c()}}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Framework-agnostic HTTP types for the Unshared Labs middleware.
|
|
3
|
+
*
|
|
4
|
+
* These interfaces are structurally compatible with Express, Fastify (via raw),
|
|
5
|
+
* Koa (via ctx.req/ctx.res), and raw Node.js http.IncomingMessage/ServerResponse.
|
|
6
|
+
*/
|
|
7
|
+
import type { IncomingHttpHeaders } from 'http';
|
|
8
|
+
/**
|
|
9
|
+
* Minimal request interface for framework-agnostic middleware.
|
|
10
|
+
*
|
|
11
|
+
* Express, Fastify's `request.raw`, Koa's `ctx.req`, and Node.js
|
|
12
|
+
* `http.IncomingMessage` all satisfy this interface via structural typing.
|
|
13
|
+
*/
|
|
14
|
+
export interface UnsharedRequest {
|
|
15
|
+
method?: string;
|
|
16
|
+
url?: string;
|
|
17
|
+
headers: IncomingHttpHeaders;
|
|
18
|
+
body?: any;
|
|
19
|
+
/** Client IP address, if the framework provides it (e.g. Express `req.ip`). */
|
|
20
|
+
ip?: string;
|
|
21
|
+
socket?: {
|
|
22
|
+
remoteAddress?: string;
|
|
23
|
+
encrypted?: boolean;
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Minimal response interface for framework-agnostic middleware.
|
|
28
|
+
*
|
|
29
|
+
* Express `Response`, Fastify's `reply.raw`, Koa's `ctx.res`, and Node.js
|
|
30
|
+
* `http.ServerResponse` all satisfy this interface via structural typing.
|
|
31
|
+
*/
|
|
32
|
+
export interface UnsharedResponse {
|
|
33
|
+
statusCode: number;
|
|
34
|
+
setHeader(name: string, value: string | number | string[]): any;
|
|
35
|
+
getHeader(name: string): string | number | string[] | undefined;
|
|
36
|
+
removeHeader(name: string): void;
|
|
37
|
+
write(chunk: any, encodingOrCallback?: any, callback?: any): boolean;
|
|
38
|
+
end(chunk?: any, encodingOrCallback?: any, callback?: any): any;
|
|
39
|
+
writeHead(statusCode: number, ...args: any[]): any;
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Middleware next function. Compatible with Express, Connect, and Koa.
|
|
43
|
+
*/
|
|
44
|
+
export type UnsharedNextFunction = (err?: any) => void;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export{};
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Web Standard Request/Response handlers for serverless/edge environments.
|
|
3
|
+
*
|
|
4
|
+
* Use this entry point in Next.js App Router, Vercel Edge Functions,
|
|
5
|
+
* Cloudflare Workers, Deno Deploy, Bun, or any other runtime that uses
|
|
6
|
+
* the Web Standard Fetch API (`Request`/`Response` globals).
|
|
7
|
+
*
|
|
8
|
+
* For Node.js HTTP frameworks (Express, Fastify, Koa, Next.js Pages Router),
|
|
9
|
+
* use the default `unshared-clientjs-sdk` entry point instead — its middleware
|
|
10
|
+
* is compatible with all frameworks that expose `http.IncomingMessage`/
|
|
11
|
+
* `http.ServerResponse` objects.
|
|
12
|
+
*/
|
|
13
|
+
export { createWebSubmitHandler } from './submit-handler';
|
|
14
|
+
export { createWebProtectionMiddleware } from './protection-handler';
|
|
15
|
+
export type { WebHandler, WebMiddleware, WebSubmitOptions, WebProtectionConfig, } from './types';
|
|
16
|
+
export { VerdictCache } from '../middleware/verdict-cache';
|
|
17
|
+
export type { Verdict } from '../middleware/verdict-cache';
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export{createWebSubmitHandler}from"./submit-handler";export{createWebProtectionMiddleware}from"./protection-handler";export{VerdictCache}from"../middleware/verdict-cache";
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { UnsharedLabsClient } from '../client';
|
|
2
|
+
import type { WebMiddleware, WebProtectionConfig } from './types';
|
|
3
|
+
/**
|
|
4
|
+
* Web Standard equivalent of `unsharedBoundToUser` from src/middleware/index.ts.
|
|
5
|
+
*
|
|
6
|
+
* Returns a middleware `(request, next) => Promise<Response>` suitable for
|
|
7
|
+
* Next.js App Router, Vercel Edge Functions, Cloudflare Workers, Deno Deploy,
|
|
8
|
+
* and other Web Standard runtimes.
|
|
9
|
+
*
|
|
10
|
+
* Unlike the Node.js middleware which mutates the response object, this
|
|
11
|
+
* handler calls `next(request)` to get the downstream Response, transforms
|
|
12
|
+
* it (injects the fingerprint script into HTML), and returns a new Response.
|
|
13
|
+
*
|
|
14
|
+
* @example Next.js App Router (in middleware.ts)
|
|
15
|
+
* ```typescript
|
|
16
|
+
* import { createWebProtectionMiddleware } from 'unshared-clientjs-sdk/web';
|
|
17
|
+
*
|
|
18
|
+
* const protect = createWebProtectionMiddleware(client, {
|
|
19
|
+
* userId: (req) => parseJwt(req.headers.get('authorization'))?.sub,
|
|
20
|
+
* emailAddress: (req) => parseJwt(req.headers.get('authorization'))?.email,
|
|
21
|
+
* });
|
|
22
|
+
*
|
|
23
|
+
* export async function middleware(request: Request) {
|
|
24
|
+
* return protect(request, async () => NextResponse.next());
|
|
25
|
+
* }
|
|
26
|
+
* ```
|
|
27
|
+
*/
|
|
28
|
+
export declare function createWebProtectionMiddleware(client: UnsharedLabsClient, config: WebProtectionConfig): WebMiddleware;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{VerdictCache}from"../middleware/verdict-cache";import{RateLimitBackoff}from"../middleware/rate-limit-backoff";import{DispatchDedupe}from"../middleware/dispatch-dedupe";import{generateFingerprintScript}from"../middleware/injection/fingerprint-script";import{isHtmlContentType}from"../middleware/utils/content-type";import{shouldSkipPath}from"../middleware/utils/skip-paths";import{shouldIncludePath}from"../middleware/utils/include-path";import{isBot}from"../middleware/utils/is-bot";import{isSentinelUserId,SENTINEL_STICKINESS_TTL_MS}from"../middleware/utils/sentinel-user-id";import{parseCookieFromRequest,extractClientIpFromRequest,extractDeviceIdFromRequest,extractDeviceIdFromRequestOrUnknown,isSecureWebRequest,jsonResponse,emptyResponse,bodyResponse,mergeResponseHeaders}from"./web-helpers";const CHECK_USER_TIMEOUT_MS=500;export function createWebProtectionMiddleware(e,s){if(!s.userId)throw new Error("[Unshared] userId resolver is required");const{userId:t,emailAddress:r,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")??"",t=w.includes("*");return t||w.includes(s)?{"Access-Control-Allow-Origin":t?"*":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:t,resolveEmailAddress:r,resolveSessionId:c,resolveDeviceId:u,onError:m},n):o===S?handleVerifyTriggerWeb(s,i,{client:e,verdictCache:f,resolveEmailAddress:r,resolveDeviceId:u,onError:m},n):handleVerifyWeb(s,i,{client:e,verdictCache:f,resolveEmailAddress:r,resolveDeviceId:u,onError:m},n)}if("GET"===s.method&&o===C){let e;try{e=t(s)}catch{}if(!e)return jsonResponse(200,{status:"anonymous"},n);const i=resolveEmail(s,r),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=t(s)}catch{}if(isSentinelUserId(A)){const e=parseCookieFromRequest(s,"__unshared_uid"),t=parseCookieFromRequest(s,"__unshared_uid_at"),r=t?Number(t):NaN,n=Number.isFinite(r)&&Date.now()-r<=SENTINEL_STICKINESS_TTL_MS;A=e&&n?e:void 0}if(!A){const e=isSecureWebRequest(s)?"; 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 i(s),I,t)}const E=resolveEmail(s,r),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,j=s.headers.get("user-agent")??"",O=extractClientIpFromRequest(s);if(isBot(j))return i(s);if("unknown"===k)return injectIntoHtmlResponse(await i(s),I,F);const L=q??x;if(!L)return injectIntoHtmlResponse(await i(s),I,F);h.isPaused()||dispatchUserEvent(e,f,h,_,{userId:A,emailAddress:E,sessionId:k,deviceId:L,fingerprintId:x,userAgent:j,ipAddress:O,eventType:o+R},m);let U=f.get(A);if(U)f.isStale(A)&&!f.isRefreshing(A)&&(f.markRefreshing(A),fetchAndCacheVerdict(e,f,A,E,L,x,k).finally(()=>f.clearRefreshing(A)));else try{U=await fetchAndCacheVerdict(e,f,A,E,L,x,k)}catch{return injectIntoHtmlResponse(await i(s),I,F)}if(U.isFlagged&&!U.isVerified&&p)try{const e=await p({userId:A,emailAddress:E,verdict:U,request:s});if(e)return injectIntoHtmlResponse(e,I,F)}catch(e){m&&m(e,{operation:"checkUser",userId:A,emailAddress:E})}return injectIntoHtmlResponse(await i(s),I,F)}}async function injectIntoHtmlResponse(e,s,t){const r=e.headers.get("content-type");if(!isHtmlContentType(r??void 0)){if(!t||0===t.length)return e;const s=mergeResponseHeaders(e.headers,void 0,t);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)},t);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 t=s(e);if(t)return t}catch{}const t=parseCookieFromRequest(e,"__unshared_email");if(t)return t}function resolveEmailWithBody(e,s,t){const r=resolveEmail(e,t);if(r)return r;const n=s.email;return"string"==typeof n&&n?n:void 0}function extractSessionIdFromRequest(e,s){if(s)try{const t=s(e);if(t)return t}catch{}return parseCookieFromRequest(e,"__unshared_sid")??"unknown"}function dispatchUserEvent(e,s,t,r,n,i){r.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&&t.pause(1e3*e.error.retryAfter)}).catch(e=>{i&&i(e,{operation:"processUserEvent",userId:n.userId,emailAddress:n.emailAddress})})}async function fetchAndCacheVerdict(e,s,t,r,n,i,o){const a={};let d;n&&"unknown"!==n&&(a.deviceId=n),i&&(a.fingerprintId=i);const c=await Promise.race([e.checkUser(r,a),new Promise(e=>{d=setTimeout(()=>e(null),500)})]);if(clearTimeout(d),!c)return{isFlagged:!1,isVerified:!1,emailAddress:r,sessionId:o,cachedAt:0,ttl:0};const u=c.data?.is_user_flagged??!1;return s.set(t,{isFlagged:u,isVerified:!1,emailAddress:r,sessionId:o}),s.get(t)}async function handleSubmitFp(e,s,t,r){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=t.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=t.resolveEmailAddress?t.resolveEmailAddress(e):void 0}catch{}o=o??parseCookieFromRequest(e,"__unshared_email")??s.email??void 0;try{a=t.resolveSessionId?t.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},r);const u=(n.fingerprint_id&&n.fingerprint_id.length>0?n.fingerprint_id:void 0)??extractDeviceIdFromRequestOrUnknown(e,t.resolveDeviceId),l=n.fingerprint_id||void 0,p=isSecureWebRequest(e)?"; Secure":"",m=[];if(l&&!parseCookieFromRequest(e,"__unshared_fingerprint_id")&&m.push(`__unshared_fingerprint_id=${encodeURIComponent(l)}; HttpOnly; Path=/; SameSite=Lax${p}`),l){const s=parseCookieFromRequest(e,"__unshared_fp_id");s&&s===l||m.push(`__unshared_fp_id=${encodeURIComponent(l)}; Path=/; SameSite=Lax; Max-Age=31536000${p}`)}let f;if(o&&!parseCookieFromRequest(e,"__unshared_email")&&m.push(`__unshared_email=${encodeURIComponent(o)}; HttpOnly; Path=/; SameSite=Lax${p}`),"string"==typeof s.event_type&&s.event_type)f=s.event_type;else{const s=e.headers.get("referer")??e.headers.get("referrer");let t="unknown";if(s)try{const e=new URL(s);t=(e.pathname||"/")+(e.search||"")}catch{}f=t}const h=(e.headers.get("x-idempotency-key")||void 0)??(l&&i?`${l}|${i}|${f}`:void 0);i&&t.client.submitFingerprintEvent(n,{userId:i,emailAddress:o,sessionHash:a,eventType:f,ipAddress:d,userAgent:c,idempotencyKey:h}).catch(e=>{t.onError&&t.onError(e,{operation:"submitFingerprintEvent",userId:i,emailAddress:o})}),i&&o&&!t.rateLimitBackoff.isPaused()&&!t.dispatchDedupe.wasRecentlyDispatched(i,f)&&(t.dispatchDedupe.mark(i,f),t.client.processUserEvent({eventType:f,userId:i,emailAddress:o,ipAddress:d,deviceId:u,fingerprintId:l,sessionHash:a??"unknown",userAgent:c}).then(e=>{e.success&&e.data?.analysis&&t.verdictCache.update(i,{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:i,emailAddress:o})}));const _={...r,"Content-Type":"application/json"},R=new Response(JSON.stringify({success:!0}),{status:200,headers:_});for(const e of m)R.headers.append("Set-Cookie",e);return R}catch{return jsonResponse(200,{success:!0},r)}}async function handleVerifyTriggerWeb(e,s,t,r){try{const n=resolveEmailWithBody(e,s??{},t.resolveEmailAddress);if(!n)return jsonResponse(400,{success:!1,error:{code:"VALIDATION_ERROR",message:"Email is required"}},r);const i=extractDeviceIdFromRequestOrUnknown(e,t.resolveDeviceId),o=parseCookieFromRequest(e,"__unshared_fingerprint_id")||void 0,a=await t.client.triggerEmailVerification(n,i,{fingerprintId:o});return a.success?jsonResponse(200,{success:!0,data:a.data},r):jsonResponse(200,{success:!1,error:a.error??{code:"TRIGGER_FAILED",message:"Failed to send verification email"}},r)}catch(e){return t.onError&&t.onError(e,{operation:"verifyTrigger"}),jsonResponse(200,{success:!1,error:{code:"INTERNAL_ERROR",message:"Failed to trigger verification"}},r)}}async function handleVerifyWeb(e,s,t,r){try{const n=resolveEmailWithBody(e,s??{},t.resolveEmailAddress),i=s?.code;if(!n||!i)return jsonResponse(400,{success:!1,error:{code:"VALIDATION_ERROR",message:"Email and code are required"}},r);const o=extractDeviceIdFromRequestOrUnknown(e,t.resolveDeviceId),a=parseCookieFromRequest(e,"__unshared_fingerprint_id")||void 0,d=await t.client.verify(n,o,i,{fingerprintId:a});if(d.success){const s=parseCookieFromRequest(e,"__unshared_uid");return s&&t.verdictCache.update(s,{isVerified:!0}),jsonResponse(200,{success:!0,data:{verified:!0}},r)}return jsonResponse(200,{success:!1,error:d.error??{code:"VERIFICATION_FAILED",message:"Verification failed"}},r)}catch(e){return t.onError&&t.onError(e,{operation:"verify"}),jsonResponse(200,{success:!1,error:{code:"INTERNAL_ERROR",message:"Verification failed"}},r)}}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { UnsharedLabsClient } from '../client';
|
|
2
|
+
import type { WebSubmitOptions } from './types';
|
|
3
|
+
/**
|
|
4
|
+
* Web Standard equivalent of `createUnsharedMiddleware` from src/middleware.ts.
|
|
5
|
+
*
|
|
6
|
+
* Returns a handler `(request: Request) => Promise<Response>` suitable for
|
|
7
|
+
* Next.js App Router Route Handlers, Vercel Edge Functions, Cloudflare
|
|
8
|
+
* Workers, Deno Deploy, and any other Web Standard runtime.
|
|
9
|
+
*
|
|
10
|
+
* @example Next.js App Router
|
|
11
|
+
* ```typescript
|
|
12
|
+
* // app/unshared/submit-fingerprint-event/route.ts
|
|
13
|
+
* import { createWebSubmitHandler } from 'unshared-clientjs-sdk/web';
|
|
14
|
+
* import { UnsharedLabsClient } from 'unshared-clientjs-sdk';
|
|
15
|
+
*
|
|
16
|
+
* const client = new UnsharedLabsClient({ apiKey: '...', clientId: '...' });
|
|
17
|
+
* const handler = createWebSubmitHandler(client);
|
|
18
|
+
*
|
|
19
|
+
* export const POST = handler;
|
|
20
|
+
* export const OPTIONS = handler;
|
|
21
|
+
* ```
|
|
22
|
+
*
|
|
23
|
+
* **Error contract:** Never returns 5xx. Upstream failures return HTTP 200
|
|
24
|
+
* with `{ success: false, error: { code: "UPSTREAM_ERROR" } }`. Same contract
|
|
25
|
+
* as the Node.js middleware.
|
|
26
|
+
*/
|
|
27
|
+
export declare function createWebSubmitHandler(client: UnsharedLabsClient, options?: WebSubmitOptions): (request: Request) => Promise<Response>;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{jsonResponse,emptyResponse,extractClientIpFromRequest}from"./web-helpers";export function createWebSubmitHandler(e,s){const{userIdExtractor:r,eventTypeExtractor:n,sessionIdExtractor:o,ipAddressExtractor:t,defaultEventType:c="browser_event",routePrefix:i="/unshared",corsOrigins:a}=s??{},d=`${i}/submit-fingerprint-event`,u=a?Array.isArray(a)?a:[a]:null;return async s=>{let i;try{i=new URL(s.url).pathname}catch{return jsonResponse(400,{success:!1,error:{code:"INVALID_URL",message:"Unable to parse request URL"}})}const a=u&&i===d?function(e){if(!u)return{};const s=e.headers.get("origin")??"",r=u.includes("*");return r||u.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"}:{}}(s):{};if("OPTIONS"===s.method&&i===d)return emptyResponse(204,a);if("POST"!==s.method||i!==d)return jsonResponse(404,{success:!1,error:{code:"NOT_FOUND",message:"Unknown route"}},a);try{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."}},a)}if(!(i&&i.hash&&i.stable_hash&&i.collected_at))return jsonResponse(400,{success:!1,error:{code:"VALIDATION_ERROR",message:"Missing required fingerprint fields: hash, stable_hash, collected_at"}},a);if(!s.headers.get("x-session-id"))return jsonResponse(400,{success:!1,error:{code:"VALIDATION_ERROR",message:"Missing required header: X-Session-Id"}},a);const d={full_hash:i.hash,fingerprint_id:i.stable_hash,timestamp:i.collected_at,isIncognito:i.is_incognito??!1,components:i.components??{},version:i.version??"unknown"};let u,R,p,l;try{u=(r?r(s):void 0)??i.user_id}catch{u=i.user_id}if(!u)return jsonResponse(400,{success:!1,error:{code:"VALIDATION_ERROR",message:"Missing required field: user_id"}},a);try{R=(n?n(s):void 0)??i.event_type??c}catch{R=i.event_type??c}try{p=(o?o(s):void 0)??s.headers.get("x-session-id")??i.session_id}catch{p=s.headers.get("x-session-id")??i.session_id}const I=extractClientIpFromRequest(s);try{l=(t?t(s):void 0)??I}catch{l=I}const m=await e.submitFingerprintEvent(d,{userId:u,sessionHash:p,eventType:R,ipAddress:l});return m.success?jsonResponse(202,{success:!0,data:m.data},a):jsonResponse(200,{success:!1,error:{code:"UPSTREAM_ERROR",message:m.error?.message??"Upstream request failed"}},a)}catch(e){return jsonResponse(200,{success:!1,error:{code:"MIDDLEWARE_ERROR",message:e instanceof Error?e.message:"Handler error"}},a)}}}
|
|
@@ -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
|
@@ -4,3 +4,5 @@ export type { MiddlewareOptions } from './middleware';
|
|
|
4
4
|
export { unsharedBoundToUser, VerdictCache, } from './middleware/index';
|
|
5
5
|
export type { ProtectionConfig, Verdict } from './middleware/index';
|
|
6
6
|
export type { UnsharedLabsClientConfig, ApiResult, UnsharedLabsError, SubmitFingerprintOptions, SubmitFingerprintResult, ProcessUserEventParams, ProcessUserEventResult, CheckUserResult, TriggerEmailVerificationResult, VerifyResult, VerificationFlowStep, VerificationFlowConfigResult, } 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.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}});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 {
|
|
1
|
+
import type { UnsharedRequest, UnsharedResponse, UnsharedNextFunction } from '../types';
|
|
2
2
|
import type { UnsharedLabsClient } 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:
|
|
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:
|
|
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:
|
|
28
|
+
sessionId?: (req: TReq) => string | undefined;
|
|
27
29
|
/**
|
|
28
30
|
* Resolves a device ID from the request.
|
|
29
|
-
* Falls back to
|
|
31
|
+
* Falls back to X-Device-Id header → __unshared_fp_id cookie.
|
|
30
32
|
*/
|
|
31
|
-
deviceId?: (req:
|
|
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:
|
|
44
|
-
res:
|
|
45
|
-
next:
|
|
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:
|
|
65
|
+
export declare function unsharedBoundToUser<TReq extends UnsharedRequest = UnsharedRequest>(client: UnsharedLabsClient, config: ProtectionConfig<TReq>): (req: TReq, res: UnsharedResponse, next: UnsharedNextFunction) => void;
|
package/dist/middleware/index.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
"use strict";Object.defineProperty(exports,"
|
|
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}),g=(0,verify_1.handleVerifyTrigger)({client:e,verdictCache:p,resolveEmailAddress:i,resolveDeviceId:u,onError:l}),k=(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`,E=`${n}/status`;return function(t,s,o){const v=(0,http_helpers_1.getRequestPath)(t.url),b=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 g(t,s):void k(t,s);if("GET"===t.method&&v===E){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 w;try{w=r(t)}catch{}if((0,sentinel_user_id_1.isSentinelUserId)(w)){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;w=e&&n?e:void 0}if(!w){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,w),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),F=extractFingerprintId(t),O=t.headers["user-agent"]??"",j=(0,client_ip_1.extractClientIp)(t);if((0,is_bot_1.isBot)(O))return void o();if("unknown"===P)return interceptForInjection(t,s,m),void o();const $=U??F;if(!$)return interceptForInjection(t,s,m),void o();h.isPaused()||dispatchUserEvent(e,p,h,f,{userId:w,emailAddress:T,sessionId:P,deviceId:$,fingerprintId:F,userAgent:O,ipAddress:j,eventType:b},l);const N=p.get(w);N?(p.isStale(w)&&!p.isRefreshing(w)&&(p.markRefreshing(w),fetchAndCacheVerdict(e,p,w,T,$,F,P).finally(()=>p.clearRefreshing(w))),applyVerdict(N,w,T,t,s,o,m,_)):fetchAndCacheVerdict(e,p,w,T,$,F,P).then(e=>{applyVerdict(e,w,T,t,s,o,m,_)}).catch(()=>{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}`)}
|