unshared-clientjs-sdk 2.0.0 → 2.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +49 -11
- package/dist/client.d.ts +7 -5
- package/dist/esm/client.d.mts +7 -5
- package/dist/esm/index.d.mts +1 -1
- package/dist/esm/index.mjs +1 -1
- package/dist/esm/middleware/index.d.mts +5 -0
- package/dist/esm/middleware/index.mjs +1 -1
- package/dist/esm/middleware/injection/fingerprint-script.mjs +1 -1
- package/dist/esm/middleware/utils/flagged-response.d.mts +5 -0
- package/dist/esm/middleware/utils/flagged-response.mjs +1 -0
- package/dist/esm/middleware.d.mts +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/middleware/index.d.ts +5 -0
- package/dist/middleware/index.js +1 -1
- package/dist/middleware/injection/fingerprint-script.js +1 -1
- package/dist/middleware/utils/flagged-response.d.ts +5 -0
- package/dist/middleware/utils/flagged-response.js +1 -0
- package/dist/middleware.d.ts +1 -1
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -112,35 +112,73 @@ await client.submitFingerprintEvent(fingerprint, {
|
|
|
112
112
|
|
|
113
113
|
---
|
|
114
114
|
|
|
115
|
-
##
|
|
115
|
+
## Protection Middleware (Recommended)
|
|
116
116
|
|
|
117
|
-
|
|
117
|
+
`unsharedBoundToUser` is the full-featured middleware: auto-injects the fingerprint script, enforces verdicts, handles email verification flows, and dispatches events.
|
|
118
118
|
|
|
119
119
|
```typescript
|
|
120
|
-
import {
|
|
120
|
+
import { UnsharedClient, unsharedBoundToUser, flaggedResponse } from 'unshared-clientjs-sdk';
|
|
121
121
|
|
|
122
|
-
|
|
122
|
+
app.set('trust proxy', 1);
|
|
123
123
|
app.use(express.json());
|
|
124
124
|
|
|
125
|
-
|
|
126
|
-
|
|
125
|
+
const client = new UnsharedClient({ apiKey: process.env.UNSHARED_API_KEY! });
|
|
126
|
+
|
|
127
|
+
app.use(unsharedBoundToUser(client, {
|
|
128
|
+
userId: (req) => req.cookies?.userId,
|
|
129
|
+
emailAddress: (req) => req.cookies?.email,
|
|
130
|
+
includePathPrefix: ['/api/'],
|
|
131
|
+
onFlagged: ({ emailAddress, res }) => {
|
|
132
|
+
res.status(403).json(flaggedResponse(emailAddress));
|
|
133
|
+
},
|
|
127
134
|
}));
|
|
128
135
|
```
|
|
129
136
|
|
|
130
|
-
**
|
|
131
|
-
- Mount `express.json()` before the middleware
|
|
132
|
-
- `user_id` is automatically removed from `req.body` after being read — downstream logging won't capture it
|
|
137
|
+
**Smoke test:** `curl http://localhost:3000/__unshared/status` — returns `{ "status": "anonymous" | "ok" | "flagged" }`.
|
|
133
138
|
|
|
134
|
-
**
|
|
139
|
+
**Key options:**
|
|
140
|
+
|
|
141
|
+
| Option | Type | Default | Description |
|
|
142
|
+
|--------|------|---------|-------------|
|
|
143
|
+
| `userId` | `(req) => string \| undefined` | — | **Required.** Resolve the current user's ID |
|
|
144
|
+
| `emailAddress` | `(req) => string \| undefined` | — | Resolve the current user's email |
|
|
145
|
+
| `routePrefix` | `string` | `"/__unshared"` | Route mount prefix |
|
|
146
|
+
| `includePathPrefix` | `string[]` | — | Only these path prefixes trigger verdicts and events |
|
|
147
|
+
| `onFlagged` | `(ctx) => void` | — | Called when a flagged user makes a request |
|
|
148
|
+
| `disableBotFilter` | `boolean` | `false` | Skip bot UA filter (enable for E2E testing) |
|
|
149
|
+
| `checkUserTimeoutMs` | `number` | `500` | Timeout (ms) for checkUser API calls; fails open on timeout |
|
|
150
|
+
| `skipPaths` | `string[]` | — | Paths to skip entirely (static assets, health checks) |
|
|
151
|
+
| `corsOrigins` | `string \| string[]` | — | Allowed CORS origins; handles OPTIONS preflight |
|
|
152
|
+
| `onError` | `(error, ctx) => void` | — | Called on background SDK errors for observability |
|
|
153
|
+
|
|
154
|
+
See [quickstart](./docs/quickstart.md) and [flag semantics](./docs/flag-semantics.md) for full setup and testing details.
|
|
155
|
+
|
|
156
|
+
---
|
|
157
|
+
|
|
158
|
+
## Simple Fingerprint Middleware
|
|
159
|
+
|
|
160
|
+
`createUnsharedMiddleware` is a lightweight alternative that only proxies fingerprint events — no verdicts, no script injection, no verification flows. Use `unsharedBoundToUser` unless you have a specific reason not to.
|
|
161
|
+
|
|
162
|
+
```typescript
|
|
163
|
+
import { createUnsharedMiddleware } from 'unshared-clientjs-sdk';
|
|
164
|
+
|
|
165
|
+
app.use(express.json());
|
|
166
|
+
app.use(createUnsharedMiddleware(client, {
|
|
167
|
+
userIdExtractor: (req) => req.user?.id,
|
|
168
|
+
}));
|
|
169
|
+
```
|
|
135
170
|
|
|
136
171
|
| Option | Type | Default | Description |
|
|
137
172
|
|--------|------|---------|-------------|
|
|
138
173
|
| `userIdExtractor` | `(req) => string \| undefined` | — | Pull user ID from your auth session |
|
|
139
174
|
| `eventTypeExtractor` | `(req) => string \| undefined` | — | Override event type |
|
|
140
175
|
| `sessionIdExtractor` | `(req) => string \| undefined` | — | Override session ID |
|
|
176
|
+
| `ipAddressExtractor` | `(req) => string \| undefined` | — | Override IP address |
|
|
141
177
|
| `defaultEventType` | `string` | `"browser_event"` | Fallback event type |
|
|
142
178
|
| `routePrefix` | `string` | `"/unshared"` | Route mount prefix |
|
|
143
|
-
| `corsOrigins` | `string \| string[]` | — | Allowed CORS origins
|
|
179
|
+
| `corsOrigins` | `string \| string[]` | — | Allowed CORS origins |
|
|
180
|
+
|
|
181
|
+
> **Note:** This middleware uses a different default prefix (`/unshared`) and route (`submit-fingerprint-event`) than `unsharedBoundToUser` (`/__unshared`, `submit-fp`).
|
|
144
182
|
|
|
145
183
|
---
|
|
146
184
|
|
package/dist/client.d.ts
CHANGED
|
@@ -69,11 +69,13 @@ export interface SubmitFingerprintOptions {
|
|
|
69
69
|
/** SDK encrypts before sending. */
|
|
70
70
|
userAgent?: string;
|
|
71
71
|
/**
|
|
72
|
-
* Client-supplied idempotency key
|
|
73
|
-
*
|
|
74
|
-
*
|
|
75
|
-
*
|
|
76
|
-
*
|
|
72
|
+
* Client-supplied idempotency key, forwarded verbatim as X-Idempotency-Key.
|
|
73
|
+
* NOTE: the middleware that calls this (submit-fp.ts / protection-handler.ts)
|
|
74
|
+
* appends `|${Date.now()}`, so in practice each submission carries a unique
|
|
75
|
+
* key and the backend dedups only PubSub redeliveries of the SAME forwarded
|
|
76
|
+
* message — it does NOT collapse distinct submissions across reloads/tabs.
|
|
77
|
+
* That dedup is handled client-side (frontend SDK + inline script). When
|
|
78
|
+
* omitted, a fresh UUID is generated (dedup only within this call's retries).
|
|
77
79
|
*/
|
|
78
80
|
idempotencyKey?: string;
|
|
79
81
|
}
|
package/dist/esm/client.d.mts
CHANGED
|
@@ -69,11 +69,13 @@ export interface SubmitFingerprintOptions {
|
|
|
69
69
|
/** SDK encrypts before sending. */
|
|
70
70
|
userAgent?: string;
|
|
71
71
|
/**
|
|
72
|
-
* Client-supplied idempotency key
|
|
73
|
-
*
|
|
74
|
-
*
|
|
75
|
-
*
|
|
76
|
-
*
|
|
72
|
+
* Client-supplied idempotency key, forwarded verbatim as X-Idempotency-Key.
|
|
73
|
+
* NOTE: the middleware that calls this (submit-fp.ts / protection-handler.ts)
|
|
74
|
+
* appends `|${Date.now()}`, so in practice each submission carries a unique
|
|
75
|
+
* key and the backend dedups only PubSub redeliveries of the SAME forwarded
|
|
76
|
+
* message — it does NOT collapse distinct submissions across reloads/tabs.
|
|
77
|
+
* That dedup is handled client-side (frontend SDK + inline script). When
|
|
78
|
+
* omitted, a fresh UUID is generated (dedup only within this call's retries).
|
|
77
79
|
*/
|
|
78
80
|
idempotencyKey?: string;
|
|
79
81
|
}
|
package/dist/esm/index.d.mts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
export { UnsharedClient } from './client';
|
|
2
2
|
export { createUnsharedMiddleware, assertTrustProxy } from './middleware';
|
|
3
3
|
export type { MiddlewareOptions } from './middleware';
|
|
4
|
-
export { unsharedBoundToUser, VerdictCache, } from './middleware/index';
|
|
4
|
+
export { unsharedBoundToUser, VerdictCache, flaggedResponse, ACCOUNT_FLAGGED_ERROR, } from './middleware/index';
|
|
5
5
|
export type { ProtectionConfig, Verdict } from './middleware/index';
|
|
6
6
|
export type { UnsharedClientConfig, ApiResult, UnsharedError, SubmitFingerprintOptions, SubmitFingerprintResult, ProcessUserEventParams, ProcessUserEventResult, CheckUserResult, TriggerEmailVerificationResult, VerifyResult, } from './client';
|
|
7
7
|
export type { UnsharedRequest, UnsharedResponse, UnsharedNextFunction } from './types';
|
package/dist/esm/index.mjs
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export{UnsharedClient}from"./client";export{createUnsharedMiddleware,assertTrustProxy}from"./middleware";export{unsharedBoundToUser,VerdictCache}from"./middleware/index";export{sendJson}from"./middleware/utils/http-helpers";
|
|
1
|
+
export{UnsharedClient}from"./client";export{createUnsharedMiddleware,assertTrustProxy}from"./middleware";export{unsharedBoundToUser,VerdictCache,flaggedResponse,ACCOUNT_FLAGGED_ERROR}from"./middleware/index";export{sendJson}from"./middleware/utils/http-helpers";
|
|
@@ -2,6 +2,7 @@ import type { UnsharedRequest, UnsharedResponse, UnsharedNextFunction } from '..
|
|
|
2
2
|
import type { UnsharedClient } from '../client';
|
|
3
3
|
import { VerdictCache } from './verdict-cache';
|
|
4
4
|
import type { Verdict } from './verdict-cache';
|
|
5
|
+
export { flaggedResponse, ACCOUNT_FLAGGED_ERROR } from './utils/flagged-response';
|
|
5
6
|
export interface ProtectionConfig<TReq extends UnsharedRequest = UnsharedRequest> {
|
|
6
7
|
/**
|
|
7
8
|
* Required. Resolves the current user's ID from the request.
|
|
@@ -24,6 +25,10 @@ export interface ProtectionConfig<TReq extends UnsharedRequest = UnsharedRequest
|
|
|
24
25
|
skipPaths?: string[];
|
|
25
26
|
/** When set, only paths matching one of these prefixes get events dispatched and checkUser called. */
|
|
26
27
|
includePathPrefix?: string[];
|
|
28
|
+
/** Skip the bot/crawler UA filter. Set to true in test environments so automated browsers (Playwright, Puppeteer, etc.) can observe verdicts. @default false */
|
|
29
|
+
disableBotFilter?: boolean;
|
|
30
|
+
/** Hard timeout (ms) for checkUser on cache miss. Fails open on timeout. @default 500 */
|
|
31
|
+
checkUserTimeoutMs?: number;
|
|
27
32
|
/** Resolves a custom session ID. Falls back to __unshared_sid cookie. */
|
|
28
33
|
sessionId?: (req: TReq) => string | undefined;
|
|
29
34
|
/**
|
|
@@ -1 +1 @@
|
|
|
1
|
-
import{readFileSync}from"fs";import{VerdictCache}from"./verdict-cache";import{RateLimitBackoff}from"./rate-limit-backoff";import{DispatchDedupe}from"./dispatch-dedupe";import{interceptResponse}from"./response-interceptor";import{generateFingerprintScript}from"./injection/fingerprint-script";import{handleSubmitFingerprint}from"./routes/submit-fp";import{handleVerifyTrigger,handleVerify}from"./routes/verify";import{sendJson,sendEmpty,sendBody,getRequestPath}from"./utils/http-helpers";import{isHtmlContentType}from"./utils/content-type";import{shouldSkipPath}from"./utils/skip-paths";import{shouldIncludePath}from"./utils/include-path";import{isBot}from"./utils/is-bot";import{extractClientIp}from"./utils/client-ip";import{parseCookie}from"./utils/cookies";import{extractDeviceIdOrUndefined}from"./utils/device-id";import{isSecureRequest}from"./utils/secure";import{isSentinelUserId,SENTINEL_STICKINESS_TTL_MS}from"./utils/sentinel-user-id";export{VerdictCache};const CHECK_USER_TIMEOUT_MS=500;export function unsharedBoundToUser(e,t){if(!t.userId)throw new Error("[Unshared] userId resolver is required");if(!t.emailAddress){let e=!1;try{require.resolve("unshared-frontend-sdk"),e=!0}catch{}e||console.warn("[Unshared] Warning: emailAddress resolver is not configured and unshared-frontend-sdk is not installed.\nNo user events will be submitted. Either install unshared-frontend-sdk (Tier 1) or\nprovide emailAddress in your middleware config (Tier 2).")}const{userId:r,emailAddress:i,routePrefix:n="/__unshared",corsOrigins:s,cacheTTL:o=6e4,skipPaths:d,includePathPrefix:a,
|
|
1
|
+
import{readFileSync}from"fs";import{VerdictCache}from"./verdict-cache";import{RateLimitBackoff}from"./rate-limit-backoff";import{DispatchDedupe}from"./dispatch-dedupe";import{interceptResponse}from"./response-interceptor";import{generateFingerprintScript}from"./injection/fingerprint-script";import{handleSubmitFingerprint}from"./routes/submit-fp";import{handleVerifyTrigger,handleVerify}from"./routes/verify";import{sendJson,sendEmpty,sendBody,getRequestPath}from"./utils/http-helpers";import{isHtmlContentType}from"./utils/content-type";import{shouldSkipPath}from"./utils/skip-paths";import{shouldIncludePath}from"./utils/include-path";import{isBot}from"./utils/is-bot";import{extractClientIp}from"./utils/client-ip";import{parseCookie}from"./utils/cookies";import{extractDeviceIdOrUndefined}from"./utils/device-id";import{isSecureRequest}from"./utils/secure";export{flaggedResponse,ACCOUNT_FLAGGED_ERROR}from"./utils/flagged-response";import{isSentinelUserId,SENTINEL_STICKINESS_TTL_MS}from"./utils/sentinel-user-id";export{VerdictCache};const CHECK_USER_TIMEOUT_MS=500;export function unsharedBoundToUser(e,t){if(!t.userId)throw new Error("[Unshared] userId resolver is required");if(!t.emailAddress){let e=!1;try{require.resolve("unshared-frontend-sdk"),e=!0}catch{}e||console.warn("[Unshared] Warning: emailAddress resolver is not configured and unshared-frontend-sdk is not installed.\nNo user events will be submitted. Either install unshared-frontend-sdk (Tier 1) or\nprovide emailAddress in your middleware config (Tier 2).")}const{userId:r,emailAddress:i,routePrefix:n="/__unshared",corsOrigins:s,cacheTTL:o=6e4,skipPaths:d,includePathPrefix:a,disableBotFilter:c=!1,checkUserTimeoutMs:u=CHECK_USER_TIMEOUT_MS,sessionId:l,deviceId:p,onFlagged:f,onError:m}=t,h=new VerdictCache(o),S=new RateLimitBackoff,I=new DispatchDedupe,_=Date.now().toString(36),v=generateFingerprintScript(n,_);let C="";try{const e=require.resolve("unshared-frontend-sdk/dist/index.umd.js");C=readFileSync(e,"utf8")}catch{}const g=handleSubmitFingerprint({client:e,verdictCache:h,rateLimitBackoff:S,dispatchDedupe:I,resolveUserId:r,resolveEmailAddress:i,resolveSessionId:l,resolveDeviceId:p,onError:m}),y=handleVerifyTrigger({client:e,verdictCache:h,resolveEmailAddress:i,resolveDeviceId:p,onError:m}),k=handleVerify({client:e,verdictCache:h,resolveEmailAddress:i,resolveDeviceId:p,onError:m}),A=s?Array.isArray(s)?s:[s]:null,E=`${n}/fp.js`,T=`${n}/submit-fp`,x=`${n}/verify-trigger`,U=`${n}/verify`,P=`${n}/status`;return function(t,s,o){const _=getRequestPath(t.url),w=t.url||_;if(_.startsWith(n+"/")){if(function(e,t){if(!A)return;const r=e.headers.origin??"",i=A.includes("*");(i||A.includes(r))&&(t.setHeader("Access-Control-Allow-Origin",i?"*":r),t.setHeader("Access-Control-Allow-Methods","POST, OPTIONS"),t.setHeader("Access-Control-Allow-Headers","Content-Type, X-Idempotency-Key, X-Session-Id, X-Device-Id"),t.setHeader("Access-Control-Allow-Credentials","true"))}(t,s),"OPTIONS"===t.method)return void sendEmpty(s,204);if("GET"===t.method&&_===E)return s.setHeader("Content-Type","application/javascript"),s.setHeader("Cache-Control","public, max-age=3600"),void sendBody(s,200,C);if("POST"===t.method&&(_===T||_===x||_===U))return void 0===t.body?void sendJson(s,400,{success:!1,error:{code:"BODY_PARSER_MISSING",message:"req.body is undefined. Mount a JSON body-parsing middleware (e.g., express.json()) before the Unshared middleware."}}):_===T?void g(t,s):_===x?void y(t,s):void k(t,s);if("GET"===t.method&&_===P){let n;try{n=r(t)}catch{}if(!n)return void sendJson(s,200,{status:"anonymous"});const o=resolveEmail(t,i);return void(async()=>{let r=h.get(n);if((!r||h.isStale(n))&&o&&!S.isPaused()&&!h.isRefreshing(n)){h.markRefreshing(n);try{const i=extractDeviceIdOrUndefined(t,p),s=extractFingerprintId(t),d=extractSessionId(t,l),a=i??s??"unknown";await fetchAndCacheVerdict(e,h,n,o,a,s,d,u),r=h.get(n)}catch(e){m&&m(e,{operation:"checkUser",userId:n,emailAddress:o})}finally{h.clearRefreshing(n)}}r&&r.isFlagged&&!r.isVerified&&f&&o?sendJson(s,200,{status:"flagged",email:o}):sendJson(s,200,{status:"ok"})})()}return void sendJson(s,404,{success:!1,error:{code:"NOT_FOUND",message:"Unknown route"}})}if(shouldSkipPath(_,d))return void o();if(!shouldIncludePath(_,a))return interceptForInjection(t,s,v),void o();let F;try{F=r(t)}catch{}if(isSentinelUserId(F)){const e=parseCookie(t,"__unshared_uid"),r=parseCookie(t,"__unshared_uid_at"),i=r?Number(r):NaN,n=Number.isFinite(i)&&Date.now()-i<=SENTINEL_STICKINESS_TTL_MS;F=e&&n?e:void 0}if(!F){const e=isSecureRequest(t)?"; Secure":"";return appendSetCookie(s,`__unshared_uid=; Path=/; SameSite=Lax; Max-Age=0${e}`),appendSetCookie(s,`__unshared_uid_at=; Path=/; SameSite=Lax; Max-Age=0${e}`),appendSetCookie(s,`__unshared_sid=; Path=/; SameSite=Lax; Max-Age=0${e}`),appendSetCookie(s,`__unshared_email=; Path=/; SameSite=Lax; Max-Age=0${e}`),interceptForInjection(t,s,v),void o()}const O=resolveEmail(t,i);if(setUserIdCookie(t,s,F),O&&setEmailCookie(t,s,O),!O)return interceptForInjection(t,s,v),void o();const R=extractSessionId(t,l),D=extractDeviceIdOrUndefined(t,p),N=extractFingerprintId(t),b=t.headers["user-agent"]??"",L=extractClientIp(t),M=D??N;if(!c&&isBot(b))return void o();const V=h.get(F);function $(){"unknown"!==R&&M&&(S.isPaused()||dispatchUserEvent(e,h,S,I,{userId:F,emailAddress:O,sessionId:R,deviceId:M,fingerprintId:N,userAgent:b,ipAddress:L,eventType:w},m))}V?(h.isStale(F)&&!h.isRefreshing(F)&&(h.markRefreshing(F),fetchAndCacheVerdict(e,h,F,O,M??"unknown",N,R,u).finally(()=>h.clearRefreshing(F))),V.isFlagged||$(),applyVerdict(V,F,O,t,s,o,v,f)):fetchAndCacheVerdict(e,h,F,O,M??"unknown",N,R,u).then(e=>{e.isFlagged||$(),applyVerdict(e,F,O,t,s,o,v,f)}).catch(()=>{$(),interceptForInjection(t,s,v),o()})}}function resolveEmail(e,t){if(t)try{const r=t(e);if(r)return r}catch{}const r=parseCookie(e,"__unshared_email");if(r)return r;const i=e.body?.email;return"string"==typeof i&&i?i:void 0}function applyVerdict(e,t,r,i,n,s,o,d){if(interceptForInjection(i,n,o),e.isFlagged&&!e.isVerified&&d)try{d({userId:t,emailAddress:r,verdict:e,req:i,res:n,next:s})}catch{s()}else s()}function interceptForInjection(e,t,r){delete e.headers["if-none-match"],delete e.headers["if-modified-since"],interceptResponse(t,(e,t)=>{if(!isHtmlContentType(t))return null;const i=e.toString("utf8"),n=i.lastIndexOf("</body>");return-1===n?i+r:i.slice(0,n)+r+i.slice(n)},{preventCaching:!0})}function dispatchUserEvent(e,t,r,i,n,s){i.mark(n.userId,n.eventType),e.processUserEvent({eventType:n.eventType,userId:n.userId,emailAddress:n.emailAddress,ipAddress:n.ipAddress,deviceId:n.deviceId,fingerprintId:n.fingerprintId,sessionHash:n.sessionId,userAgent:n.userAgent}).then(e=>{e.success&&e.data?.analysis&&t.update(n.userId,{isFlagged:e.data.analysis.is_user_flagged}),!e.success&&e.error?.retryAfter&&r.pause(1e3*e.error.retryAfter)}).catch(e=>{s&&s(e,{operation:"processUserEvent",userId:n.userId,emailAddress:n.emailAddress})})}async function fetchAndCacheVerdict(e,t,r,i,n,s,o,d=CHECK_USER_TIMEOUT_MS){const a={};let c;n&&"unknown"!==n&&(a.deviceId=n),s&&(a.fingerprintId=s);const u=await Promise.race([e.checkUser(i,a),new Promise(e=>{c=setTimeout(()=>e(null),d)})]);if(clearTimeout(c),!u)return{isFlagged:!1,isVerified:!1,emailAddress:i,sessionId:o,cachedAt:0,ttl:0};const l=u.data?.is_user_flagged??!1;return t.set(r,{isFlagged:l,isVerified:!1,emailAddress:i,sessionId:o}),t.get(r)}function extractSessionId(e,t){if(t)try{const r=t(e);if(r)return r}catch{}return parseCookie(e,"__unshared_sid")??"unknown"}function extractFingerprintId(e){return parseCookie(e,"__unshared_fingerprint_id")||void 0}function appendSetCookie(e,t){const r=e.getHeader("Set-Cookie");if(r){const i=Array.isArray(r)?[...r]:[String(r)];i.push(t),e.setHeader("Set-Cookie",i)}else e.setHeader("Set-Cookie",t)}function setUserIdCookie(e,t,r){const i=isSecureRequest(e)?"; Secure":"";appendSetCookie(t,`__unshared_uid=${encodeURIComponent(r)}; Path=/; SameSite=Lax${i}`),appendSetCookie(t,`__unshared_uid_at=${Date.now()}; Path=/; SameSite=Lax${i}`)}function setEmailCookie(e,t,r){const i=isSecureRequest(e)?"; Secure":"";appendSetCookie(t,`__unshared_email=${encodeURIComponent(r)}; HttpOnly; Path=/; SameSite=Lax${i}`)}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
export function generateFingerprintScript(e,t){const n=t?`?v=${escapeJavaScript(t)}`:"";return`<script>\n(function(){\ntry{\n// --- Bot drop (defense-in-depth) ---\n// Must be the first statement: we do not want to write cookies, localStorage,\n// session IDs, or any network requests for known-bot traffic. Mirrors the\n// regex in unshared-fingerprint-lib/src/detect/bot.ts and Node middleware\n// utils/is-bot.ts. Keep all three in sync.\nvar BOT_RE=/googlebot|bingbot|slurp|baiduspider|duckduckbot|yandex|sogou|exabot|ia_archiver|curl|wget|python-requests|python-urllib|axios|node-fetch|go-http-client|java\\/|libwww-perl|okhttp|apache-httpclient|http_request|httpie|headlesschrome|phantomjs|puppeteer|playwright|cypress|selenium|webdriver|electron|jsdom|vercel-screenshot|screenshot|prerender|lighthouse|chrome-lighthouse|pagespeed|gtmetrix|pingdom|nessus|nikto|sqlmap|burp|zap|qualys|openvas|nmap|masscan|facebookexternalhit|twitterbot|linkedinbot|whatsapp|telegrambot|slackbot|discordbot|bot|crawl|spider|scrape|fetch|scan/i;\nif(typeof navigator!=="undefined"&&navigator.userAgent&&BOT_RE.test(navigator.userAgent))return;\n\nvar pfx="${escapeJavaScript(e)}";\nvar SS_FP="__unshared_fp";\nvar SS_LAST_SUBMIT="__unshared_last_submit";\n\n// Dedup state: skip submit if (user_id + URL) matches last submission.\n// Modern SPAs (Next.js App Router, React Router, etc.) call replaceState\n// 3-5 times during hydration with the same URL — without this guard,\n// each call generates a redundant FP row with identical stable_hash.\n// Persisted to sessionStorage so hard reloads and framework double-boots\n// inside the same tab still dedupe (the in-memory value resets on reload).\nvar lastSubmitKey="";\ntry{lastSubmitKey=sessionStorage.getItem(SS_LAST_SUBMIT)||""}catch(e){}\n\n// --- Helpers ---\nfunction gC(n){var m=document.cookie.match(new RegExp("(?:^|; )"+n+"=([^;]*)"));return m?decodeURIComponent(m[1]):null}\nfunction sC(n,v,d){var e="";if(d){var dt=new Date();dt.setTime(dt.getTime()+d*864e5);e="; expires="+dt.toUTCString()}document.cookie=n+"="+encodeURIComponent(v)+e+"; path=/; SameSite=Lax"}\nfunction uuid(){return(typeof crypto!=="undefined"&&crypto.randomUUID)?crypto.randomUUID():("xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g,function(c){var r=Math.random()*16|0;return(c==="x"?r:r&0x3|0x8).toString(16)}))}\n// Sentinel user IDs that must never be treated as real users. Mirrors\n// the Set in sentinel-user-id.ts — keep in sync. Empty string is handled\n// by the separate !uid checks below.\nvar SENTINEL_UIDS={"__pre_auth__":1,"anonymous":1,"guest":1,"undefined":1,"null":1};\nfunction isSentinelUid(v){return typeof v==="string"&&SENTINEL_UIDS.hasOwnProperty(v)}\n\n// --- Session + device IDs ---\n// Session ID is a UUID because it's supposed to be tab-scoped and random.\n// Device ID is intentionally NOT a UUID — Issue 9: random UUIDs wrote\n// meaningless device_ids to every fingerprint row. Instead we read the\n// stable fingerprint hash from localStorage if a previous submission\n// already persisted it; otherwise we leave did empty and let submitFP()\n// reconcile on the first successful collection. The Node middleware's\n// Issue 8 bootstrap-skip branch handles the empty-device_id window so we\n// never dispatch with a random or "unknown" value.\nvar sid=gC("__unshared_sid");\nif(!sid){sid=uuid();sC("__unshared_sid",sid,365)}\nvar did="";\ntry{did=localStorage.getItem("__unshared_device_id")||""}catch(e){}\nif(did){sC("__unshared_fp_id",did,365)}\n\n// --- Fingerprint cache (sessionStorage) ---\nfunction getFP(){try{var r=sessionStorage.getItem(SS_FP);return r?JSON.parse(r):null}catch(e){return null}}\nfunction setFP(fp){try{sessionStorage.setItem(SS_FP,JSON.stringify(fp))}catch(e){}}\n\n// --- Submit fingerprint to backend ---\nfunction submitFP(fp){\n var uid=gC("__unshared_uid");\n if(!uid||isSentinelUid(uid))return;\n // Issue 9: reconcile device_id to the stable fingerprint hash. This runs\n // before we send the X-Device-Id header so the very first submission\n // already carries the real value. Persist to localStorage so other tabs\n // (and future reloads) pick up the same stable ID without needing to\n // re-collect the fingerprint.\n if(fp.fingerprint_id){\n did=fp.fingerprint_id;\n try{localStorage.setItem("__unshared_device_id",did)}catch(e){}\n sC("__unshared_fp_id",did,365);\n }\n // event_type is the SPA route, not a fixed enum. Page-level event names\n // (page_load/route_change) collapsed every row into one of two buckets;\n // the URL is more useful for analytics and matches the frontend SDK.\n var route=(location.pathname||"/")+(location.search||"");\n var key=uid+"|"+route;\n if(key===lastSubmitKey)return;\n lastSubmitKey=key;\n try{sessionStorage.setItem(SS_LAST_SUBMIT,key)}catch(e){}\n // collected_at is stamped fresh at submit time rather than carried from fp.timestamp,\n // because fp is cached per-tab in sessionStorage — reusing its original timestamp would\n // freeze collected_at at first load and drift against server created_at as the tab ages.\n // The server authoritatively overwrites this value again on ingress.\n var body={hash:fp.full_hash,stable_hash:fp.fingerprint_id,collected_at:(new Date()).toISOString(),is_incognito:fp.isIncognito,components:fp.components,version:fp.version,session_id:sid,user_id:uid,event_type:route};\n // Deterministic idempotency key: (stable_hash, user_id, event_type) fully\n // identifies one logical submission. A stable key lets the backend's\n // ON CONFLICT (idempotency_key) DO NOTHING actually catch duplicates across\n // reloads, tabs, and concurrent SDK instances — a fresh UUID could not.\n var idem=fp.fingerprint_id+"|"+uid+"|"+route;\n var xhr=new XMLHttpRequest();\n xhr.open("POST",pfx+"/submit-fp",true);\n xhr.setRequestHeader("Content-Type","application/json");\n xhr.setRequestHeader("X-Session-Id",sid);\n if(did)xhr.setRequestHeader("X-Device-Id",did);\n xhr.setRequestHeader("X-Idempotency-Key",idem);\n xhr.send(JSON.stringify(body));\n}\n\n// --- Collect fingerprint (loads fp.js if needed) then submit ---\nvar fpReady=false;\nfunction collectAndSubmit(){\n var uid=gC("__unshared_uid");\n if(!uid||isSentinelUid(uid))return;\n var cached=getFP();\n if(cached){submitFP(cached);return}\n if(!fpReady)return;\n try{\n var c=new UnsharedBrowser.UnsharedBrowser({baseUrl:""});\n c.collect({exclude:["timing","speech"]}).then(function(fp){setFP(fp);submitFP(fp)});\n }catch(e){}\n}\n\n// --- Load fp.js (always — browser caches it for 1h) ---\n// Submit cached FP immediately if available; load fp.js for fresh collection\nvar pageLoadSubmitted=false;\nvar _boot_uid=gC("__unshared_uid");\nif(getFP()&&_boot_uid&&!isSentinelUid(_boot_uid)){submitFP(getFP());pageLoadSubmitted=true;deferredCheck()}\nvar s=document.createElement("script");\ns.src=pfx+"/fp.js${n}";\ns.onload=function(){fpReady=true;if(!pageLoadSubmitted){collectAndSubmit();deferredCheck()}};\ndocument.head.appendChild(s);\n\n// --- Deferred verdict check ---\n// After fingerprint submission, the backend processes the event async.\n// If the user was just flagged, the initial page load may have beaten\n// the verdict update. Re-check after a delay so newly flagged sessions\n// get caught without waiting for user interaction.\n// The endpoint always returns 200 so browsers don't log a scary red\n// network error — we inspect the body and dispatch the flagged event\n// ourselves when status==="flagged".\nfunction deferredCheck(){\n var uid=gC("__unshared_uid");\n if(!uid||isSentinelUid(uid))return;\n setTimeout(function(){\n try{fetch(pfx+"/status",{method:"GET",credentials:"same-origin"}).then(function(r){return r.json()}).then(function(b){if(b&&b.status==="flagged")emitFlagged(b)}).catch(function(){})}catch(e){}\n },500);\n}\n\n// --- SPA route change tracking (History API + popstate) ---\nvar oPush=history.pushState,oReplace=history.replaceState;\nhistory.pushState=function(){oPush.apply(this,arguments);try{collectAndSubmit()}catch(e){}};\nhistory.replaceState=function(){oReplace.apply(this,arguments);try{collectAndSubmit()}catch(e){}};\nwindow.addEventListener("popstate",function(){try{collectAndSubmit()}catch(e){}});\n\n// --- 403 interception: dispatch "unshared:flagged" event ---\nfunction emitFlagged(body){\n try{window.dispatchEvent(new CustomEvent("unshared:flagged",{detail:{email:body.email||""}}))}catch(e){}\n}\n\n// Patch fetch\nvar oFetch=window.fetch;\nif(oFetch){window.fetch=function(){return oFetch.apply(this,arguments).then(function(r){if(r.status===403){try{var cl=r.clone();cl.json().then(function(b){if(b&&b.error==="account_flagged")emitFlagged(b)}).catch(function(){})}catch(e){}}return r})}}\n\n// Patch XMLHttpRequest\nvar oXSend=XMLHttpRequest.prototype.send;\nXMLHttpRequest.prototype.send=function(){var x=this;x.addEventListener("load",function(){if(x.status===403){try{var b=JSON.parse(x.responseText);if(b&&b.error==="account_flagged")emitFlagged(b)}catch(e){}}});return oXSend.apply(this,arguments)};\n\n}catch(e){}\n})();\n<\/script>`}function escapeJavaScript(e){return e.replace(/\\/g,"\\\\").replace(/"/g,'\\"').replace(/'/g,"\\'")}
|
|
1
|
+
export function generateFingerprintScript(e,t){const n=t?`?v=${escapeJavaScript(t)}`:"";return`<script>\n(function(){\ntry{\n// --- Bot drop (defense-in-depth) ---\n// Must be the first statement: we do not want to write cookies, localStorage,\n// session IDs, or any network requests for known-bot traffic. Mirrors the\n// regex in unshared-fingerprint-lib/src/detect/bot.ts and Node middleware\n// utils/is-bot.ts. Keep all three in sync.\nvar BOT_RE=/googlebot|bingbot|slurp|baiduspider|duckduckbot|yandex|sogou|exabot|ia_archiver|curl|wget|python-requests|python-urllib|axios|node-fetch|go-http-client|java\\/|libwww-perl|okhttp|apache-httpclient|http_request|httpie|headlesschrome|phantomjs|puppeteer|playwright|cypress|selenium|webdriver|electron|jsdom|vercel-screenshot|screenshot|prerender|lighthouse|chrome-lighthouse|pagespeed|gtmetrix|pingdom|nessus|nikto|sqlmap|burp|zap|qualys|openvas|nmap|masscan|facebookexternalhit|twitterbot|linkedinbot|whatsapp|telegrambot|slackbot|discordbot|bot|crawl|spider|scrape|fetch|scan/i;\nif(typeof navigator!=="undefined"&&navigator.userAgent&&BOT_RE.test(navigator.userAgent))return;\n\nvar pfx="${escapeJavaScript(e)}";\nvar SS_FP="__unshared_fp";\nvar SS_LAST_SUBMIT="__unshared_last_submit";\n\n// Dedup state: skip submit if (user_id + URL) matches last submission.\n// Modern SPAs (Next.js App Router, React Router, etc.) call replaceState\n// 3-5 times during hydration with the same URL — without this guard,\n// each call generates a redundant FP row with identical stable_hash.\n// Persisted to sessionStorage so hard reloads and framework double-boots\n// inside the same tab still dedupe (the in-memory value resets on reload).\nvar lastSubmitKey="";\ntry{lastSubmitKey=sessionStorage.getItem(SS_LAST_SUBMIT)||""}catch(e){}\n\n// Page-scoped dedup state SHARED with the frontend SDK (browser.ts getSharedDedup).\n// On a Tier 1 page both this inline script and the SDK submit; each holds its own\n// in-memory lastSubmitKey, so both could pass the check below and POST. window.__unshared\n// is read+written synchronously in submitFP (no await between), so whichever fires\n// first claims the uid|route key and the other no-ops — killing the read-before-write\n// race that doubled events. KEEP IN SYNC with browser.ts: namespace, lastKey, key formula.\nvar shared=(window.__unshared=window.__unshared||{});\n\n// --- Helpers ---\nfunction gC(n){var m=document.cookie.match(new RegExp("(?:^|; )"+n+"=([^;]*)"));return m?decodeURIComponent(m[1]):null}\nfunction sC(n,v,d){var e="";if(d){var dt=new Date();dt.setTime(dt.getTime()+d*864e5);e="; expires="+dt.toUTCString()}document.cookie=n+"="+encodeURIComponent(v)+e+"; path=/; SameSite=Lax"}\nfunction uuid(){return(typeof crypto!=="undefined"&&crypto.randomUUID)?crypto.randomUUID():("xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g,function(c){var r=Math.random()*16|0;return(c==="x"?r:r&0x3|0x8).toString(16)}))}\n// Sentinel user IDs that must never be treated as real users. Mirrors\n// the Set in sentinel-user-id.ts — keep in sync. Empty string is handled\n// by the separate !uid checks below.\nvar SENTINEL_UIDS={"__pre_auth__":1,"anonymous":1,"guest":1,"undefined":1,"null":1};\nfunction isSentinelUid(v){return typeof v==="string"&&SENTINEL_UIDS.hasOwnProperty(v)}\n\n// --- Session + device IDs ---\n// Session ID is a UUID because it's supposed to be tab-scoped and random.\n// Device ID is intentionally NOT a UUID — Issue 9: random UUIDs wrote\n// meaningless device_ids to every fingerprint row. Instead we read the\n// stable fingerprint hash from localStorage if a previous submission\n// already persisted it; otherwise we leave did empty and let submitFP()\n// reconcile on the first successful collection. The Node middleware's\n// Issue 8 bootstrap-skip branch handles the empty-device_id window so we\n// never dispatch with a random or "unknown" value.\nvar sid=gC("__unshared_sid");\nif(!sid){sid=uuid();sC("__unshared_sid",sid,365)}\nvar did="";\ntry{did=localStorage.getItem("__unshared_device_id")||""}catch(e){}\nif(did){sC("__unshared_fp_id",did,365)}\n\n// --- Fingerprint cache (sessionStorage) ---\nfunction getFP(){try{var r=sessionStorage.getItem(SS_FP);return r?JSON.parse(r):null}catch(e){return null}}\nfunction setFP(fp){try{sessionStorage.setItem(SS_FP,JSON.stringify(fp))}catch(e){}}\n\n// --- Submit fingerprint to backend ---\nfunction submitFP(fp){\n var uid=gC("__unshared_uid");\n if(!uid||isSentinelUid(uid))return;\n // Issue 9: reconcile device_id to the stable fingerprint hash. This runs\n // before we send the X-Device-Id header so the very first submission\n // already carries the real value. Persist to localStorage so other tabs\n // (and future reloads) pick up the same stable ID without needing to\n // re-collect the fingerprint.\n if(fp.fingerprint_id){\n did=fp.fingerprint_id;\n try{localStorage.setItem("__unshared_device_id",did)}catch(e){}\n sC("__unshared_fp_id",did,365);\n }\n // event_type is the SPA route, not a fixed enum. Page-level event names\n // (page_load/route_change) collapsed every row into one of two buckets;\n // the URL is more useful for analytics and matches the frontend SDK.\n var route=(location.pathname||"/")+(location.search||"");\n var key=uid+"|"+route;\n // Check the shared window guard AND the in-memory key (last-key semantics, so an\n // SPA A->B->A revisit still submits the second A). The shared guard makes this\n // submitter and the frontend SDK see each other within the page.\n if(key===lastSubmitKey||shared.lastKey===key)return;\n shared.lastKey=key;\n lastSubmitKey=key;\n try{sessionStorage.setItem(SS_LAST_SUBMIT,key)}catch(e){}\n // collected_at is stamped fresh at submit time rather than carried from fp.timestamp,\n // because fp is cached per-tab in sessionStorage — reusing its original timestamp would\n // freeze collected_at at first load and drift against server created_at as the tab ages.\n // The server authoritatively overwrites this value again on ingress.\n var body={hash:fp.full_hash,stable_hash:fp.fingerprint_id,collected_at:(new Date()).toISOString(),is_incognito:fp.isIncognito,components:fp.components,version:fp.version,session_id:sid,user_id:uid,event_type:route};\n // Idempotency key derived from (stable_hash, user_id, route). NOTE: the\n // middleware appends |Date.now() before forwarding (submit-fp.ts), so the\n // backend sees a unique value per submission and dedups only PubSub\n // redeliveries — NOT two distinct POSTs. Cross-submitter / cross-reload dedup\n // is client-side (the shared window guard + sessionStorage above).\n var idem=fp.fingerprint_id+"|"+uid+"|"+route;\n var xhr=new XMLHttpRequest();\n xhr.open("POST",pfx+"/submit-fp",true);\n xhr.setRequestHeader("Content-Type","application/json");\n xhr.setRequestHeader("X-Session-Id",sid);\n if(did)xhr.setRequestHeader("X-Device-Id",did);\n xhr.setRequestHeader("X-Idempotency-Key",idem);\n xhr.send(JSON.stringify(body));\n}\n\n// --- Collect fingerprint (loads fp.js if needed) then submit ---\nvar fpReady=false;\nfunction collectAndSubmit(){\n var uid=gC("__unshared_uid");\n if(!uid||isSentinelUid(uid))return;\n var cached=getFP();\n if(cached){submitFP(cached);return}\n if(!fpReady)return;\n try{\n var c=new UnsharedBrowser.UnsharedBrowser({baseUrl:""});\n c.collect({exclude:["timing","speech"]}).then(function(fp){setFP(fp);submitFP(fp)});\n }catch(e){}\n}\n\n// --- Load fp.js (always — browser caches it for 1h) ---\n// Submit cached FP immediately if available; load fp.js for fresh collection\nvar pageLoadSubmitted=false;\nvar _boot_uid=gC("__unshared_uid");\nif(getFP()&&_boot_uid&&!isSentinelUid(_boot_uid)){submitFP(getFP());pageLoadSubmitted=true;deferredCheck()}\nvar s=document.createElement("script");\ns.src=pfx+"/fp.js${n}";\ns.onload=function(){fpReady=true;if(!pageLoadSubmitted){collectAndSubmit();deferredCheck()}};\ndocument.head.appendChild(s);\n\n// --- Deferred verdict check ---\n// After fingerprint submission, the backend processes the event async.\n// If the user was just flagged, the initial page load may have beaten\n// the verdict update. Re-check after a delay so newly flagged sessions\n// get caught without waiting for user interaction.\n// The endpoint always returns 200 so browsers don't log a scary red\n// network error — we inspect the body and dispatch the flagged event\n// ourselves when status==="flagged".\nfunction deferredCheck(){\n var uid=gC("__unshared_uid");\n if(!uid||isSentinelUid(uid))return;\n setTimeout(function(){\n try{fetch(pfx+"/status",{method:"GET",credentials:"same-origin"}).then(function(r){return r.json()}).then(function(b){if(b&&b.status==="flagged")emitFlagged(b)}).catch(function(){})}catch(e){}\n },500);\n}\n\n// --- SPA route change tracking (History API + popstate) ---\nvar oPush=history.pushState,oReplace=history.replaceState;\nhistory.pushState=function(){oPush.apply(this,arguments);try{collectAndSubmit()}catch(e){}};\nhistory.replaceState=function(){oReplace.apply(this,arguments);try{collectAndSubmit()}catch(e){}};\nwindow.addEventListener("popstate",function(){try{collectAndSubmit()}catch(e){}});\n\n// --- 403 interception: dispatch "unshared:flagged" event ---\nfunction emitFlagged(body){\n try{window.dispatchEvent(new CustomEvent("unshared:flagged",{detail:{email:body.email||""}}))}catch(e){}\n}\n\n// Patch fetch\nvar oFetch=window.fetch;\nif(oFetch){window.fetch=function(){return oFetch.apply(this,arguments).then(function(r){if(r.status===403){try{var cl=r.clone();cl.json().then(function(b){if(b&&b.error==="account_flagged")emitFlagged(b)}).catch(function(){})}catch(e){}}return r})}}\n\n// Patch XMLHttpRequest\nvar oXSend=XMLHttpRequest.prototype.send;\nXMLHttpRequest.prototype.send=function(){var x=this;x.addEventListener("load",function(){if(x.status===403){try{var b=JSON.parse(x.responseText);if(b&&b.error==="account_flagged")emitFlagged(b)}catch(e){}}});return oXSend.apply(this,arguments)};\n\n}catch(e){}\n})();\n<\/script>`}function escapeJavaScript(e){return e.replace(/\\/g,"\\\\").replace(/"/g,'\\"').replace(/'/g,"\\'")}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export const ACCOUNT_FLAGGED_ERROR="account_flagged";export function flaggedResponse(e){return{error:"account_flagged",email:e}}
|
|
@@ -65,7 +65,7 @@ export declare function assertTrustProxy(app: any): void;
|
|
|
65
65
|
*
|
|
66
66
|
* @example
|
|
67
67
|
* ```typescript
|
|
68
|
-
* import { createUnsharedMiddleware } from "
|
|
68
|
+
* import { createUnsharedMiddleware } from "unshared-clientjs-sdk";
|
|
69
69
|
*
|
|
70
70
|
* app.use(express.json()); // must come first
|
|
71
71
|
* app.use(createUnsharedMiddleware(client, {
|
package/dist/index.d.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
export { UnsharedClient } from './client';
|
|
2
2
|
export { createUnsharedMiddleware, assertTrustProxy } from './middleware';
|
|
3
3
|
export type { MiddlewareOptions } from './middleware';
|
|
4
|
-
export { unsharedBoundToUser, VerdictCache, } from './middleware/index';
|
|
4
|
+
export { unsharedBoundToUser, VerdictCache, flaggedResponse, ACCOUNT_FLAGGED_ERROR, } from './middleware/index';
|
|
5
5
|
export type { ProtectionConfig, Verdict } from './middleware/index';
|
|
6
6
|
export type { UnsharedClientConfig, ApiResult, UnsharedError, SubmitFingerprintOptions, SubmitFingerprintResult, ProcessUserEventParams, ProcessUserEventResult, CheckUserResult, TriggerEmailVerificationResult, VerifyResult, } from './client';
|
|
7
7
|
export type { UnsharedRequest, UnsharedResponse, UnsharedNextFunction } from './types';
|
package/dist/index.js
CHANGED
|
@@ -1 +1 @@
|
|
|
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}});
|
|
1
|
+
"use strict";Object.defineProperty(exports,"t",{value:!0}),exports.sendJson=exports.ACCOUNT_FLAGGED_ERROR=exports.flaggedResponse=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}}),Object.defineProperty(exports,"flaggedResponse",{enumerable:!0,get:function(){return index_1.flaggedResponse}}),Object.defineProperty(exports,"ACCOUNT_FLAGGED_ERROR",{enumerable:!0,get:function(){return index_1.ACCOUNT_FLAGGED_ERROR}});var http_helpers_1=require("./middleware/utils/http-helpers");Object.defineProperty(exports,"sendJson",{enumerable:!0,get:function(){return http_helpers_1.sendJson}});
|
|
@@ -2,6 +2,7 @@ import type { UnsharedRequest, UnsharedResponse, UnsharedNextFunction } from '..
|
|
|
2
2
|
import type { UnsharedClient } from '../client';
|
|
3
3
|
import { VerdictCache } from './verdict-cache';
|
|
4
4
|
import type { Verdict } from './verdict-cache';
|
|
5
|
+
export { flaggedResponse, ACCOUNT_FLAGGED_ERROR } from './utils/flagged-response';
|
|
5
6
|
export interface ProtectionConfig<TReq extends UnsharedRequest = UnsharedRequest> {
|
|
6
7
|
/**
|
|
7
8
|
* Required. Resolves the current user's ID from the request.
|
|
@@ -24,6 +25,10 @@ export interface ProtectionConfig<TReq extends UnsharedRequest = UnsharedRequest
|
|
|
24
25
|
skipPaths?: string[];
|
|
25
26
|
/** When set, only paths matching one of these prefixes get events dispatched and checkUser called. */
|
|
26
27
|
includePathPrefix?: string[];
|
|
28
|
+
/** Skip the bot/crawler UA filter. Set to true in test environments so automated browsers (Playwright, Puppeteer, etc.) can observe verdicts. @default false */
|
|
29
|
+
disableBotFilter?: boolean;
|
|
30
|
+
/** Hard timeout (ms) for checkUser on cache miss. Fails open on timeout. @default 500 */
|
|
31
|
+
checkUserTimeoutMs?: number;
|
|
27
32
|
/** Resolves a custom session ID. Falls back to __unshared_sid cookie. */
|
|
28
33
|
sessionId?: (req: TReq) => string | undefined;
|
|
29
34
|
/**
|
package/dist/middleware/index.js
CHANGED
|
@@ -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"),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,
|
|
1
|
+
"use strict";Object.defineProperty(exports,"t",{value:!0}),exports.VerdictCache=exports.ACCOUNT_FLAGGED_ERROR=exports.flaggedResponse=void 0,exports.unsharedBoundToUser=unsharedBoundToUser;const fs_1=require("fs"),verdict_cache_1=require("./verdict-cache");Object.defineProperty(exports,"VerdictCache",{enumerable:!0,get:function(){return verdict_cache_1.VerdictCache}});const rate_limit_backoff_1=require("./rate-limit-backoff"),dispatch_dedupe_1=require("./dispatch-dedupe"),response_interceptor_1=require("./response-interceptor"),fingerprint_script_1=require("./injection/fingerprint-script"),submit_fp_1=require("./routes/submit-fp"),verify_1=require("./routes/verify"),http_helpers_1=require("./utils/http-helpers"),content_type_1=require("./utils/content-type"),skip_paths_1=require("./utils/skip-paths"),include_path_1=require("./utils/include-path"),is_bot_1=require("./utils/is-bot"),client_ip_1=require("./utils/client-ip"),cookies_1=require("./utils/cookies"),device_id_1=require("./utils/device-id"),secure_1=require("./utils/secure");var flagged_response_1=require("./utils/flagged-response");Object.defineProperty(exports,"flaggedResponse",{enumerable:!0,get:function(){return flagged_response_1.flaggedResponse}}),Object.defineProperty(exports,"ACCOUNT_FLAGGED_ERROR",{enumerable:!0,get:function(){return flagged_response_1.ACCOUNT_FLAGGED_ERROR}});const sentinel_user_id_1=require("./utils/sentinel-user-id"),CHECK_USER_TIMEOUT_MS=500;function unsharedBoundToUser(e,r){if(!r.userId)throw new Error("[Unshared] userId resolver is required");if(!r.emailAddress){let e=!1;try{require.resolve("unshared-frontend-sdk"),e=!0}catch{}e||console.warn("[Unshared] Warning: emailAddress resolver is not configured and unshared-frontend-sdk is not installed.\nNo user events will be submitted. Either install unshared-frontend-sdk (Tier 1) or\nprovide emailAddress in your middleware config (Tier 2).")}const{userId:t,emailAddress:i,routePrefix:s="/__unshared",corsOrigins:n,cacheTTL:o=6e4,skipPaths:d,includePathPrefix:c,disableBotFilter:a=!1,checkUserTimeoutMs:u=CHECK_USER_TIMEOUT_MS,sessionId:_,deviceId:l,onFlagged:p,onError:h}=r,f=new verdict_cache_1.VerdictCache(o),v=new rate_limit_backoff_1.RateLimitBackoff,m=new dispatch_dedupe_1.DispatchDedupe,g=Date.now().toString(36),S=(0,fingerprint_script_1.generateFingerprintScript)(s,g);let I="";try{const e=require.resolve("unshared-frontend-sdk/dist/index.umd.js");I=(0,fs_1.readFileSync)(e,"utf8")}catch{}const k=(0,submit_fp_1.handleSubmitFingerprint)({client:e,verdictCache:f,rateLimitBackoff:v,dispatchDedupe:m,resolveUserId:t,resolveEmailAddress:i,resolveSessionId:_,resolveDeviceId:l,onError:h}),C=(0,verify_1.handleVerifyTrigger)({client:e,verdictCache:f,resolveEmailAddress:i,resolveDeviceId:l,onError:h}),A=(0,verify_1.handleVerify)({client:e,verdictCache:f,resolveEmailAddress:i,resolveDeviceId:l,onError:h}),y=n?Array.isArray(n)?n:[n]:null,x=`${s}/fp.js`,E=`${s}/submit-fp`,b=`${s}/verify-trigger`,T=`${s}/verify`,q=`${s}/status`;return function(r,n,o){const g=(0,http_helpers_1.getRequestPath)(r.url),U=r.url||g;if(g.startsWith(s+"/")){if(function(e,r){if(!y)return;const t=e.headers.origin??"",i=y.includes("*");(i||y.includes(t))&&(r.setHeader("Access-Control-Allow-Origin",i?"*":t),r.setHeader("Access-Control-Allow-Methods","POST, OPTIONS"),r.setHeader("Access-Control-Allow-Headers","Content-Type, X-Idempotency-Key, X-Session-Id, X-Device-Id"),r.setHeader("Access-Control-Allow-Credentials","true"))}(r,n),"OPTIONS"===r.method)return void(0,http_helpers_1.sendEmpty)(n,204);if("GET"===r.method&&g===x)return n.setHeader("Content-Type","application/javascript"),n.setHeader("Cache-Control","public, max-age=3600"),void(0,http_helpers_1.sendBody)(n,200,I);if("POST"===r.method&&(g===E||g===b||g===T))return void 0===r.body?void(0,http_helpers_1.sendJson)(n,400,{success:!1,error:{code:"BODY_PARSER_MISSING",message:"req.body is undefined. Mount a JSON body-parsing middleware (e.g., express.json()) before the Unshared middleware."}}):g===E?void k(r,n):g===b?void C(r,n):void A(r,n);if("GET"===r.method&&g===q){let s;try{s=t(r)}catch{}if(!s)return void(0,http_helpers_1.sendJson)(n,200,{status:"anonymous"});const o=resolveEmail(r,i);return void(async()=>{let t=f.get(s);if((!t||f.isStale(s))&&o&&!v.isPaused()&&!f.isRefreshing(s)){f.markRefreshing(s);try{const i=(0,device_id_1.extractDeviceIdOrUndefined)(r,l),n=extractFingerprintId(r),d=extractSessionId(r,_),c=i??n??"unknown";await fetchAndCacheVerdict(e,f,s,o,c,n,d,u),t=f.get(s)}catch(e){h&&h(e,{operation:"checkUser",userId:s,emailAddress:o})}finally{f.clearRefreshing(s)}}t&&t.isFlagged&&!t.isVerified&&p&&o?(0,http_helpers_1.sendJson)(n,200,{status:"flagged",email:o}):(0,http_helpers_1.sendJson)(n,200,{status:"ok"})})()}return void(0,http_helpers_1.sendJson)(n,404,{success:!1,error:{code:"NOT_FOUND",message:"Unknown route"}})}if((0,skip_paths_1.shouldSkipPath)(g,d))return void o();if(!(0,include_path_1.shouldIncludePath)(g,c))return interceptForInjection(r,n,S),void o();let w;try{w=t(r)}catch{}if((0,sentinel_user_id_1.isSentinelUserId)(w)){const e=(0,cookies_1.parseCookie)(r,"__unshared_uid"),t=(0,cookies_1.parseCookie)(r,"__unshared_uid_at"),i=t?Number(t):NaN,s=Number.isFinite(i)&&Date.now()-i<=sentinel_user_id_1.SENTINEL_STICKINESS_TTL_MS;w=e&&s?e:void 0}if(!w){const e=(0,secure_1.isSecureRequest)(r)?"; Secure":"";return appendSetCookie(n,`__unshared_uid=; Path=/; SameSite=Lax; Max-Age=0${e}`),appendSetCookie(n,`__unshared_uid_at=; Path=/; SameSite=Lax; Max-Age=0${e}`),appendSetCookie(n,`__unshared_sid=; Path=/; SameSite=Lax; Max-Age=0${e}`),appendSetCookie(n,`__unshared_email=; Path=/; SameSite=Lax; Max-Age=0${e}`),interceptForInjection(r,n,S),void o()}const O=resolveEmail(r,i);if(setUserIdCookie(r,n,w),O&&setEmailCookie(r,n,O),!O)return interceptForInjection(r,n,S),void o();const P=extractSessionId(r,_),F=(0,device_id_1.extractDeviceIdOrUndefined)(r,l),j=extractFingerprintId(r),M=r.headers["user-agent"]??"",$=(0,client_ip_1.extractClientIp)(r),N=F??j;if(!a&&(0,is_bot_1.isBot)(M))return void o();const D=f.get(w);function R(){"unknown"!==P&&N&&(v.isPaused()||dispatchUserEvent(e,f,v,m,{userId:w,emailAddress:O,sessionId:P,deviceId:N,fingerprintId:j,userAgent:M,ipAddress:$,eventType:U},h))}D?(f.isStale(w)&&!f.isRefreshing(w)&&(f.markRefreshing(w),fetchAndCacheVerdict(e,f,w,O,N??"unknown",j,P,u).finally(()=>f.clearRefreshing(w))),D.isFlagged||R(),applyVerdict(D,w,O,r,n,o,S,p)):fetchAndCacheVerdict(e,f,w,O,N??"unknown",j,P,u).then(e=>{e.isFlagged||R(),applyVerdict(e,w,O,r,n,o,S,p)}).catch(()=>{R(),interceptForInjection(r,n,S),o()})}}function resolveEmail(e,r){if(r)try{const t=r(e);if(t)return t}catch{}const t=(0,cookies_1.parseCookie)(e,"__unshared_email");if(t)return t;const i=e.body?.email;return"string"==typeof i&&i?i:void 0}function applyVerdict(e,r,t,i,s,n,o,d){if(interceptForInjection(i,s,o),e.isFlagged&&!e.isVerified&&d)try{d({userId:r,emailAddress:t,verdict:e,req:i,res:s,next:n})}catch{n()}else n()}function interceptForInjection(e,r,t){delete e.headers["if-none-match"],delete e.headers["if-modified-since"],(0,response_interceptor_1.interceptResponse)(r,(e,r)=>{if(!(0,content_type_1.isHtmlContentType)(r))return null;const i=e.toString("utf8"),s=i.lastIndexOf("</body>");return-1===s?i+t:i.slice(0,s)+t+i.slice(s)},{preventCaching:!0})}function dispatchUserEvent(e,r,t,i,s,n){i.mark(s.userId,s.eventType),e.processUserEvent({eventType:s.eventType,userId:s.userId,emailAddress:s.emailAddress,ipAddress:s.ipAddress,deviceId:s.deviceId,fingerprintId:s.fingerprintId,sessionHash:s.sessionId,userAgent:s.userAgent}).then(e=>{e.success&&e.data?.analysis&&r.update(s.userId,{isFlagged:e.data.analysis.is_user_flagged}),!e.success&&e.error?.retryAfter&&t.pause(1e3*e.error.retryAfter)}).catch(e=>{n&&n(e,{operation:"processUserEvent",userId:s.userId,emailAddress:s.emailAddress})})}async function fetchAndCacheVerdict(e,r,t,i,s,n,o,d=CHECK_USER_TIMEOUT_MS){const c={};let a;s&&"unknown"!==s&&(c.deviceId=s),n&&(c.fingerprintId=n);const u=await Promise.race([e.checkUser(i,c),new Promise(e=>{a=setTimeout(()=>e(null),d)})]);if(clearTimeout(a),!u)return{isFlagged:!1,isVerified:!1,emailAddress:i,sessionId:o,cachedAt:0,ttl:0};const _=u.data?.is_user_flagged??!1;return r.set(t,{isFlagged:_,isVerified:!1,emailAddress:i,sessionId:o}),r.get(t)}function extractSessionId(e,r){if(r)try{const t=r(e);if(t)return t}catch{}return(0,cookies_1.parseCookie)(e,"__unshared_sid")??"unknown"}function extractFingerprintId(e){return(0,cookies_1.parseCookie)(e,"__unshared_fingerprint_id")||void 0}function appendSetCookie(e,r){const t=e.getHeader("Set-Cookie");if(t){const i=Array.isArray(t)?[...t]:[String(t)];i.push(r),e.setHeader("Set-Cookie",i)}else e.setHeader("Set-Cookie",r)}function setUserIdCookie(e,r,t){const i=(0,secure_1.isSecureRequest)(e)?"; Secure":"";appendSetCookie(r,`__unshared_uid=${encodeURIComponent(t)}; Path=/; SameSite=Lax${i}`),appendSetCookie(r,`__unshared_uid_at=${Date.now()}; Path=/; SameSite=Lax${i}`)}function setEmailCookie(e,r,t){const i=(0,secure_1.isSecureRequest)(e)?"; Secure":"";appendSetCookie(r,`__unshared_email=${encodeURIComponent(t)}; HttpOnly; Path=/; SameSite=Lax${i}`)}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
"use strict";function generateFingerprintScript(e,t){const n=t?`?v=${escapeJavaScript(t)}`:"";return`<script>\n(function(){\ntry{\n// --- Bot drop (defense-in-depth) ---\n// Must be the first statement: we do not want to write cookies, localStorage,\n// session IDs, or any network requests for known-bot traffic. Mirrors the\n// regex in unshared-fingerprint-lib/src/detect/bot.ts and Node middleware\n// utils/is-bot.ts. Keep all three in sync.\nvar BOT_RE=/googlebot|bingbot|slurp|baiduspider|duckduckbot|yandex|sogou|exabot|ia_archiver|curl|wget|python-requests|python-urllib|axios|node-fetch|go-http-client|java\\/|libwww-perl|okhttp|apache-httpclient|http_request|httpie|headlesschrome|phantomjs|puppeteer|playwright|cypress|selenium|webdriver|electron|jsdom|vercel-screenshot|screenshot|prerender|lighthouse|chrome-lighthouse|pagespeed|gtmetrix|pingdom|nessus|nikto|sqlmap|burp|zap|qualys|openvas|nmap|masscan|facebookexternalhit|twitterbot|linkedinbot|whatsapp|telegrambot|slackbot|discordbot|bot|crawl|spider|scrape|fetch|scan/i;\nif(typeof navigator!=="undefined"&&navigator.userAgent&&BOT_RE.test(navigator.userAgent))return;\n\nvar pfx="${escapeJavaScript(e)}";\nvar SS_FP="__unshared_fp";\nvar SS_LAST_SUBMIT="__unshared_last_submit";\n\n// Dedup state: skip submit if (user_id + URL) matches last submission.\n// Modern SPAs (Next.js App Router, React Router, etc.) call replaceState\n// 3-5 times during hydration with the same URL — without this guard,\n// each call generates a redundant FP row with identical stable_hash.\n// Persisted to sessionStorage so hard reloads and framework double-boots\n// inside the same tab still dedupe (the in-memory value resets on reload).\nvar lastSubmitKey="";\ntry{lastSubmitKey=sessionStorage.getItem(SS_LAST_SUBMIT)||""}catch(e){}\n\n// --- 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
|
+
"use strict";function generateFingerprintScript(e,t){const n=t?`?v=${escapeJavaScript(t)}`:"";return`<script>\n(function(){\ntry{\n// --- Bot drop (defense-in-depth) ---\n// Must be the first statement: we do not want to write cookies, localStorage,\n// session IDs, or any network requests for known-bot traffic. Mirrors the\n// regex in unshared-fingerprint-lib/src/detect/bot.ts and Node middleware\n// utils/is-bot.ts. Keep all three in sync.\nvar BOT_RE=/googlebot|bingbot|slurp|baiduspider|duckduckbot|yandex|sogou|exabot|ia_archiver|curl|wget|python-requests|python-urllib|axios|node-fetch|go-http-client|java\\/|libwww-perl|okhttp|apache-httpclient|http_request|httpie|headlesschrome|phantomjs|puppeteer|playwright|cypress|selenium|webdriver|electron|jsdom|vercel-screenshot|screenshot|prerender|lighthouse|chrome-lighthouse|pagespeed|gtmetrix|pingdom|nessus|nikto|sqlmap|burp|zap|qualys|openvas|nmap|masscan|facebookexternalhit|twitterbot|linkedinbot|whatsapp|telegrambot|slackbot|discordbot|bot|crawl|spider|scrape|fetch|scan/i;\nif(typeof navigator!=="undefined"&&navigator.userAgent&&BOT_RE.test(navigator.userAgent))return;\n\nvar pfx="${escapeJavaScript(e)}";\nvar SS_FP="__unshared_fp";\nvar SS_LAST_SUBMIT="__unshared_last_submit";\n\n// Dedup state: skip submit if (user_id + URL) matches last submission.\n// Modern SPAs (Next.js App Router, React Router, etc.) call replaceState\n// 3-5 times during hydration with the same URL — without this guard,\n// each call generates a redundant FP row with identical stable_hash.\n// Persisted to sessionStorage so hard reloads and framework double-boots\n// inside the same tab still dedupe (the in-memory value resets on reload).\nvar lastSubmitKey="";\ntry{lastSubmitKey=sessionStorage.getItem(SS_LAST_SUBMIT)||""}catch(e){}\n\n// Page-scoped dedup state SHARED with the frontend SDK (browser.ts getSharedDedup).\n// On a Tier 1 page both this inline script and the SDK submit; each holds its own\n// in-memory lastSubmitKey, so both could pass the check below and POST. window.__unshared\n// is read+written synchronously in submitFP (no await between), so whichever fires\n// first claims the uid|route key and the other no-ops — killing the read-before-write\n// race that doubled events. KEEP IN SYNC with browser.ts: namespace, lastKey, key formula.\nvar shared=(window.__unshared=window.__unshared||{});\n\n// --- Helpers ---\nfunction gC(n){var m=document.cookie.match(new RegExp("(?:^|; )"+n+"=([^;]*)"));return m?decodeURIComponent(m[1]):null}\nfunction sC(n,v,d){var e="";if(d){var dt=new Date();dt.setTime(dt.getTime()+d*864e5);e="; expires="+dt.toUTCString()}document.cookie=n+"="+encodeURIComponent(v)+e+"; path=/; SameSite=Lax"}\nfunction uuid(){return(typeof crypto!=="undefined"&&crypto.randomUUID)?crypto.randomUUID():("xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g,function(c){var r=Math.random()*16|0;return(c==="x"?r:r&0x3|0x8).toString(16)}))}\n// Sentinel user IDs that must never be treated as real users. Mirrors\n// the Set in sentinel-user-id.ts — keep in sync. Empty string is handled\n// by the separate !uid checks below.\nvar SENTINEL_UIDS={"__pre_auth__":1,"anonymous":1,"guest":1,"undefined":1,"null":1};\nfunction isSentinelUid(v){return typeof v==="string"&&SENTINEL_UIDS.hasOwnProperty(v)}\n\n// --- Session + device IDs ---\n// Session ID is a UUID because it's supposed to be tab-scoped and random.\n// Device ID is intentionally NOT a UUID — Issue 9: random UUIDs wrote\n// meaningless device_ids to every fingerprint row. Instead we read the\n// stable fingerprint hash from localStorage if a previous submission\n// already persisted it; otherwise we leave did empty and let submitFP()\n// reconcile on the first successful collection. The Node middleware's\n// Issue 8 bootstrap-skip branch handles the empty-device_id window so we\n// never dispatch with a random or "unknown" value.\nvar sid=gC("__unshared_sid");\nif(!sid){sid=uuid();sC("__unshared_sid",sid,365)}\nvar did="";\ntry{did=localStorage.getItem("__unshared_device_id")||""}catch(e){}\nif(did){sC("__unshared_fp_id",did,365)}\n\n// --- Fingerprint cache (sessionStorage) ---\nfunction getFP(){try{var r=sessionStorage.getItem(SS_FP);return r?JSON.parse(r):null}catch(e){return null}}\nfunction setFP(fp){try{sessionStorage.setItem(SS_FP,JSON.stringify(fp))}catch(e){}}\n\n// --- Submit fingerprint to backend ---\nfunction submitFP(fp){\n var uid=gC("__unshared_uid");\n if(!uid||isSentinelUid(uid))return;\n // Issue 9: reconcile device_id to the stable fingerprint hash. This runs\n // before we send the X-Device-Id header so the very first submission\n // already carries the real value. Persist to localStorage so other tabs\n // (and future reloads) pick up the same stable ID without needing to\n // re-collect the fingerprint.\n if(fp.fingerprint_id){\n did=fp.fingerprint_id;\n try{localStorage.setItem("__unshared_device_id",did)}catch(e){}\n sC("__unshared_fp_id",did,365);\n }\n // event_type is the SPA route, not a fixed enum. Page-level event names\n // (page_load/route_change) collapsed every row into one of two buckets;\n // the URL is more useful for analytics and matches the frontend SDK.\n var route=(location.pathname||"/")+(location.search||"");\n var key=uid+"|"+route;\n // Check the shared window guard AND the in-memory key (last-key semantics, so an\n // SPA A->B->A revisit still submits the second A). The shared guard makes this\n // submitter and the frontend SDK see each other within the page.\n if(key===lastSubmitKey||shared.lastKey===key)return;\n shared.lastKey=key;\n lastSubmitKey=key;\n try{sessionStorage.setItem(SS_LAST_SUBMIT,key)}catch(e){}\n // collected_at is stamped fresh at submit time rather than carried from fp.timestamp,\n // because fp is cached per-tab in sessionStorage — reusing its original timestamp would\n // freeze collected_at at first load and drift against server created_at as the tab ages.\n // The server authoritatively overwrites this value again on ingress.\n var body={hash:fp.full_hash,stable_hash:fp.fingerprint_id,collected_at:(new Date()).toISOString(),is_incognito:fp.isIncognito,components:fp.components,version:fp.version,session_id:sid,user_id:uid,event_type:route};\n // Idempotency key derived from (stable_hash, user_id, route). NOTE: the\n // middleware appends |Date.now() before forwarding (submit-fp.ts), so the\n // backend sees a unique value per submission and dedups only PubSub\n // redeliveries — NOT two distinct POSTs. Cross-submitter / cross-reload dedup\n // is client-side (the shared window guard + sessionStorage above).\n var idem=fp.fingerprint_id+"|"+uid+"|"+route;\n var xhr=new XMLHttpRequest();\n xhr.open("POST",pfx+"/submit-fp",true);\n xhr.setRequestHeader("Content-Type","application/json");\n xhr.setRequestHeader("X-Session-Id",sid);\n if(did)xhr.setRequestHeader("X-Device-Id",did);\n xhr.setRequestHeader("X-Idempotency-Key",idem);\n xhr.send(JSON.stringify(body));\n}\n\n// --- Collect fingerprint (loads fp.js if needed) then submit ---\nvar fpReady=false;\nfunction collectAndSubmit(){\n var uid=gC("__unshared_uid");\n if(!uid||isSentinelUid(uid))return;\n var cached=getFP();\n if(cached){submitFP(cached);return}\n if(!fpReady)return;\n try{\n var c=new UnsharedBrowser.UnsharedBrowser({baseUrl:""});\n c.collect({exclude:["timing","speech"]}).then(function(fp){setFP(fp);submitFP(fp)});\n }catch(e){}\n}\n\n// --- Load fp.js (always — browser caches it for 1h) ---\n// Submit cached FP immediately if available; load fp.js for fresh collection\nvar pageLoadSubmitted=false;\nvar _boot_uid=gC("__unshared_uid");\nif(getFP()&&_boot_uid&&!isSentinelUid(_boot_uid)){submitFP(getFP());pageLoadSubmitted=true;deferredCheck()}\nvar s=document.createElement("script");\ns.src=pfx+"/fp.js${n}";\ns.onload=function(){fpReady=true;if(!pageLoadSubmitted){collectAndSubmit();deferredCheck()}};\ndocument.head.appendChild(s);\n\n// --- Deferred verdict check ---\n// After fingerprint submission, the backend processes the event async.\n// If the user was just flagged, the initial page load may have beaten\n// the verdict update. Re-check after a delay so newly flagged sessions\n// get caught without waiting for user interaction.\n// The endpoint always returns 200 so browsers don't log a scary red\n// network error — we inspect the body and dispatch the flagged event\n// ourselves when status==="flagged".\nfunction deferredCheck(){\n var uid=gC("__unshared_uid");\n if(!uid||isSentinelUid(uid))return;\n setTimeout(function(){\n try{fetch(pfx+"/status",{method:"GET",credentials:"same-origin"}).then(function(r){return r.json()}).then(function(b){if(b&&b.status==="flagged")emitFlagged(b)}).catch(function(){})}catch(e){}\n },500);\n}\n\n// --- SPA route change tracking (History API + popstate) ---\nvar oPush=history.pushState,oReplace=history.replaceState;\nhistory.pushState=function(){oPush.apply(this,arguments);try{collectAndSubmit()}catch(e){}};\nhistory.replaceState=function(){oReplace.apply(this,arguments);try{collectAndSubmit()}catch(e){}};\nwindow.addEventListener("popstate",function(){try{collectAndSubmit()}catch(e){}});\n\n// --- 403 interception: dispatch "unshared:flagged" event ---\nfunction emitFlagged(body){\n try{window.dispatchEvent(new CustomEvent("unshared:flagged",{detail:{email:body.email||""}}))}catch(e){}\n}\n\n// Patch fetch\nvar oFetch=window.fetch;\nif(oFetch){window.fetch=function(){return oFetch.apply(this,arguments).then(function(r){if(r.status===403){try{var cl=r.clone();cl.json().then(function(b){if(b&&b.error==="account_flagged")emitFlagged(b)}).catch(function(){})}catch(e){}}return r})}}\n\n// Patch XMLHttpRequest\nvar oXSend=XMLHttpRequest.prototype.send;\nXMLHttpRequest.prototype.send=function(){var x=this;x.addEventListener("load",function(){if(x.status===403){try{var b=JSON.parse(x.responseText);if(b&&b.error==="account_flagged")emitFlagged(b)}catch(e){}}});return oXSend.apply(this,arguments)};\n\n}catch(e){}\n})();\n<\/script>`}function escapeJavaScript(e){return e.replace(/\\/g,"\\\\").replace(/"/g,'\\"').replace(/'/g,"\\'")}Object.defineProperty(exports,"t",{value:!0}),exports.generateFingerprintScript=generateFingerprintScript;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"use strict";function flaggedResponse(e){return{error:exports.ACCOUNT_FLAGGED_ERROR,email:e}}Object.defineProperty(exports,"o",{value:!0}),exports.ACCOUNT_FLAGGED_ERROR=void 0,exports.flaggedResponse=flaggedResponse,exports.ACCOUNT_FLAGGED_ERROR="account_flagged";
|
package/dist/middleware.d.ts
CHANGED
|
@@ -65,7 +65,7 @@ export declare function assertTrustProxy(app: any): void;
|
|
|
65
65
|
*
|
|
66
66
|
* @example
|
|
67
67
|
* ```typescript
|
|
68
|
-
* import { createUnsharedMiddleware } from "
|
|
68
|
+
* import { createUnsharedMiddleware } from "unshared-clientjs-sdk";
|
|
69
69
|
*
|
|
70
70
|
* app.use(express.json()); // must come first
|
|
71
71
|
* app.use(createUnsharedMiddleware(client, {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "unshared-clientjs-sdk",
|
|
3
|
-
"version": "2.0.
|
|
3
|
+
"version": "2.0.2",
|
|
4
4
|
"description": "Server-side Node.js SDK for the Unshared Labs V2 API",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"module": "dist/esm/index.mjs",
|
|
@@ -52,7 +52,7 @@
|
|
|
52
52
|
"author": "",
|
|
53
53
|
"license": "MIT",
|
|
54
54
|
"dependencies": {
|
|
55
|
-
"unshared-frontend-sdk": "2.0.
|
|
55
|
+
"unshared-frontend-sdk": "2.0.2"
|
|
56
56
|
},
|
|
57
57
|
"devDependencies": {
|
|
58
58
|
"@types/express": "^4.17.21",
|