unshared-clientjs-sdk 3.0.0-rc.14 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. package/README.md +187 -15
  2. package/dist/esm/middleware/dispatch-processing.d.mts +5 -2
  3. package/dist/esm/middleware/dispatch-processing.mjs +1 -1
  4. package/dist/esm/middleware/index.d.mts +3 -1
  5. package/dist/esm/middleware/index.mjs +1 -1
  6. package/dist/esm/middleware/injection/fingerprint-script.d.mts +2 -0
  7. package/dist/esm/middleware/injection/fingerprint-script.mjs +1 -1
  8. package/dist/esm/middleware/routes/submit-fp.mjs +1 -1
  9. package/dist/esm/middleware/routes/verify.d.mts +2 -1
  10. package/dist/esm/middleware/routes/verify.mjs +1 -1
  11. package/dist/esm/middleware/utils/device-id.d.mts +4 -2
  12. package/dist/esm/middleware/utils/device-id.mjs +1 -1
  13. package/dist/esm/web/protection-handler.d.mts +1 -1
  14. package/dist/esm/web/protection-handler.mjs +1 -1
  15. package/dist/esm/web/types.d.mts +4 -2
  16. package/dist/esm/web/web-helpers.d.mts +2 -1
  17. package/dist/esm/web/web-helpers.mjs +1 -1
  18. package/dist/middleware/dispatch-processing.d.ts +5 -2
  19. package/dist/middleware/dispatch-processing.js +1 -1
  20. package/dist/middleware/index.d.ts +3 -1
  21. package/dist/middleware/index.js +1 -1
  22. package/dist/middleware/injection/fingerprint-script.d.ts +2 -0
  23. package/dist/middleware/injection/fingerprint-script.js +1 -1
  24. package/dist/middleware/routes/submit-fp.js +1 -1
  25. package/dist/middleware/routes/verify.d.ts +2 -1
  26. package/dist/middleware/routes/verify.js +1 -1
  27. package/dist/middleware/utils/device-id.d.ts +4 -2
  28. package/dist/middleware/utils/device-id.js +1 -1
  29. package/dist/web/protection-handler.d.ts +1 -1
  30. package/dist/web/protection-handler.js +1 -1
  31. package/dist/web/types.d.ts +4 -2
  32. package/dist/web/web-helpers.d.ts +2 -1
  33. package/dist/web/web-helpers.js +1 -1
  34. package/package.json +2 -2
package/README.md CHANGED
@@ -157,14 +157,17 @@ const app = express();
157
157
  app.set('trust proxy', 1);
158
158
  app.use(express.json()); // the default 100 KiB limit is supported
159
159
 
160
+ // Mount your authentication/session middleware here, before Unshared.
160
161
  const client = new UnsharedClient({ apiKey: process.env.UNSHARED_API_KEY! });
161
- app.use(unsharedBoundToUser(client, {
162
+ const middleware = unsharedBoundToUser(client, {
162
163
  userId: (req) => req.user?.id,
163
164
  emailAddress: (req) => req.user?.email,
164
165
  onFlagged: ({ emailAddress, res }) => {
165
166
  res.status(403).json(flaggedResponse(emailAddress));
166
167
  },
167
- }));
168
+ });
169
+ app.use(middleware);
170
+ // Call middleware.destroy() on shutdown, test teardown, or before re-mounting.
168
171
  ```
169
172
 
170
173
  Keep your existing parser order and limits. Mount authentication/session middleware
@@ -193,17 +196,84 @@ duplicate SDK fingerprint caches are excluded from storage snapshots. The encryp
193
196
  platform request retains its existing 1.5 MiB envelope limit; the full logical payload,
194
197
  including server-added context, must still fit within 1 MiB.
195
198
 
196
- | Option | Description |
197
- |---|---|
198
- | `userId` | Required server-side user resolver |
199
- | `emailAddress` | Server-side email resolver used for checks and verification |
200
- | `routePrefix` | Internal route prefix; default `/__unshared` |
201
- | `includePathPrefix` | Paths that trigger verdict checks and events |
202
- | `onFlagged` | Custom blocked-user response |
203
- | `disableBotFilter` | Include known bot user agents, useful in E2E |
204
- | `checkUserTimeoutMs` | Bounded verdict-check timeout |
205
- | `corsOrigins` | Allowed cross-origin frontend origins |
206
- | `onError` | Background SDK error observer |
199
+ ### ProtectionConfig
200
+
201
+ All public options for `unsharedBoundToUser(client, config)` are listed below.
202
+ Resolvers receive your Node/Express request (`TReq`). The `@internal`
203
+ `disableS3Fingerprint` switch is intentionally excluded from this public table.
204
+
205
+ | Option | Default | Behavior / Trade-off |
206
+ |---|---|---|
207
+ | `userId` | Required | Server-side resolver; return `undefined` on logout/anonymous requests. Do not derive authentication from SDK cookies. Sentinel hydration IDs can briefly reuse a fresh identity cookie, but are not real user IDs. |
208
+ | `emailAddress` | No resolver | Resolver first, then HttpOnly `__unshared_email`, then parsed `req.body.email`. Missing email skips ordinary checks/enforcement/events. Supply a trusted resolver for protection and verification instead of relying on body fallback. |
209
+ | `routePrefix` | `/__unshared` | Prefix for SDK-owned routes. Gate, overlay and automatic interstitial require the default; browser proxy paths are fixed to it. |
210
+ | `corsOrigins` | Unset (no CORS headers) | String or array of allowed origins for SDK routes. Specific matching origins allow credentials; `*` does not. Prefer an explicit allowlist; CORS is not authentication. |
211
+ | `cacheTTL` | `60000` ms | Per-user, in-process verdict TTL with stale-while-revalidate. Longer TTLs reduce checks but delay risk changes. Defensive fail-open verdicts use a separate 5000 ms TTL. |
212
+ | `maxCacheSize` | `10000` entries | Bounds verdict memory; evicts oldest-written entries at capacity. Smaller caches can cause more blocking misses. |
213
+ | `streamingIdleMs` | `50` ms | Budget from first buffered HTML write to response end. Beyond it, stream through without fingerprint injection. Raise for slow partial rendering, accepting longer buffering. |
214
+ | `skipPaths` | Built-in static skips only | Additional literal pathname prefixes that bypass identity reconciliation, injection, checks, enforcement and ordinary events entirely. Wins over includes; never put protected content here. See path rules below. |
215
+ | `includePathPrefix` | Unset (all non-skipped paths) | Literal pathname prefixes for ordinary checks, enforcement and user events; `[]` includes none. Does not disable identity cookies, logout reconciliation, HTML injection or SDK-owned routes. |
216
+ | `disableBotFilter` | `false` | Bypasses the middleware and injected-script UA bot filters, useful for Playwright/Puppeteer E2E. Does not disable platform-side filters; normally leave off in production. |
217
+ | `debug` | `false` | Injected-script diagnostics in `window.__unshared.lastDecision` and `console.debug('[Unshared]', ...)`; reason codes and optional HTTP status only. See diagnostics below. |
218
+ | `checkUserTimeoutMs` | `1500` ms | Hard check budget; underlying middleware checks use no retries. Lower values reduce latency but increase fail-open risk. |
219
+ | `cacheMissStrategy` | `'block'` | `'block'` waits for a verdict or timeout. `'async'` serves uncached requests while warming the cache, leaving those requests unenforced; reported as `async_miss`. |
220
+ | `sessionId` | Cookie fallback | Resolver first, then `__unshared_sid`. Missing/`unknown` sessions suppress ordinary user events, not verdict checks. |
221
+ | `deviceId` | Header/cookie fallback | Resolver, then `X-Device-Id`, then `__unshared_stable_hash`, then legacy `__unshared_fp_id`. Missing values are omitted, not fabricated or replaced with the full hash. Client-provided identifiers are correlation signals, not authentication. |
222
+ | `onFlagged` | Unset (pass through) | Receives `{ userId, emailAddress, verdict, req, res, next }` for flagged, unverified users. Own the response or call `next()`; exceptions pass through. Ignored by gate/overlay. |
223
+ | `blockFlagged` | `false` | Shorthand for gate mode when `flaggedMode` is unset. Requires installed `unshared-frontend-sdk` and the default prefix. |
224
+ | `flaggedMode` | Unset | `'gate'` withholds app content: HTML GET navigations receive a 200 verification-only page, other requests a 403 `account_flagged`. `'overlay'` delivers HTML with a cosmetic blur/modal but returns 403 for data requests. Wins over `blockFlagged`, ignores `onFlagged`; overlay implies `autoInterstitial`. Both require the browser SDK and default prefix. |
225
+ | `autoInterstitial` | `false` (implied by overlay) | Boots a proxy browser SDK to show the published modal on intercepted 403s/deferred status flagging and reload on completion. UX only; pair with server enforcement. Requires browser SDK and default prefix. |
226
+ | `interstitialFlowType` | `'email_verification'` | Flow requested by the automatic inline modal, including overlay. Does not override the standalone gate page's default flow. |
227
+ | `onError` | Unset | Observer `(error, { operation, userId?, emailAddress? })` for SDK operation failures. Does not turn fail-open into fail-closed; keep callbacks defensive and redact identity data. |
228
+ | `onFailOpen` | Throttled warning | Best-effort observer with `operation: 'checkUser'`, `reason`, optional `status`, `userId`, `emailAddress`. Reasons: `timeout`, `http_error`, `exception`, `no_device_id`, `async_miss`. Status 0 denotes transport failure. Without a callback, degraded reasons warn at most once/minute/reason; intentional `async_miss` does not warn. |
229
+
230
+ ### Scope and Enforcement
231
+
232
+ Processing order is SDK-owned routes, static/custom skips, identity reconciliation,
233
+ then the include filter and ordinary protection. Includes use `pathname.startsWith`,
234
+ not globs or segment matching; use trailing slashes deliberately. Built-in skips
235
+ cover JS/CSS/maps, images, fonts, WASM and prefixes `/static/`, `/assets/`, `/public/`,
236
+ `/_next/`, `/__vite/`, `/favicon`. Media (`mp3`, `mp4`, `webm`, `ogg`), XML, TXT and PDF
237
+ are also skipped unless gate/overlay enforcement is active. Custom skips always win.
238
+
239
+ For a publisher that protects article data but keeps the reader's saved-list API usable:
240
+
241
+ ```typescript
242
+ const middleware = unsharedBoundToUser(client, {
243
+ userId: (req) => req.user?.id,
244
+ emailAddress: (req) => req.user?.email,
245
+ flaggedMode: 'overlay',
246
+ includePathPrefix: ['/api/article/'],
247
+ });
248
+ app.use(middleware); // after authentication/session middleware
249
+ // /api/article/123: checks and 403 enforcement for flagged, unverified readers.
250
+ // /api/me/list: no ordinary check/enforcement/event; still reconciles identity.
251
+ // HTML outside the include list: still eligible for fingerprint script injection.
252
+ ```
253
+
254
+ An excluded HTML shell can still collect/submit fingerprints and poll `/__unshared/status`:
255
+ `includePathPrefix` is not a collection opt-out or a global API-call filter. Use
256
+ `skipPaths` only when the request should truly bypass the SDK. In overlay mode never
257
+ embed protected article text in delivered HTML; removing a modal reveals that HTML.
258
+ Use gate mode (and include the HTML route) when the server must withhold the document.
259
+ With no gate/overlay or blocking `onFlagged`, the default is collection, not blocking.
260
+ Checks remain fail-open on availability failures; an already cached flagged verdict
261
+ is preserved on a failed refresh. Neither SDK cookies nor a modal replace app auth.
262
+
263
+ Before enabling the built-in remediation UI, publish the required web interstitial
264
+ flow for your company in the Unshared dashboard (including `email_verification` for
265
+ the standalone gate). A draft or missing flow cannot render. Install
266
+ `unshared-frontend-sdk` and retain the default route prefix. Custom gate pages remain
267
+ application-owned via `onFlagged` when built-in enforcement is off. This middleware
268
+ does not add verification rate limiting. Confirm the platform's verification limits
269
+ for your deployment and apply per-user/IP limits and auth checks in your app or
270
+ gateway, including SDK verification routes. Dispatch backoff is not a verification
271
+ abuse control.
272
+
273
+ Retain the middleware instance and call `middleware.destroy()` on shutdown, test
274
+ teardown or before hot-reload re-mounts to stop verdict-cache and asset-refresh timers.
275
+
276
+ ### Assets and Diagnostics
207
277
 
208
278
  The middleware exposes:
209
279
 
@@ -212,9 +282,65 @@ The middleware exposes:
212
282
  - `POST /__unshared/verify-trigger`
213
283
  - `POST /__unshared/verify`
214
284
  - `GET /__unshared/fp.js`
285
+ - `GET /__unshared/fingerprint.js`
215
286
  - `GET /__unshared/interstitial-flow`
216
287
 
217
- The verification trigger stores `verification_id` in an HttpOnly, ten-minute `__unshared_verification_id` cookie. The permanent readable `__unshared_device_id=upid_<uuid-v4>` cookie rolls for 400 days and is not tied to mutable fingerprint hashes.
288
+ These are Node middleware routes (with the configured prefix). `fp.js` serves the
289
+ installed browser SDK UMD; a missing bundle returns **503 with `Cache-Control: no-store`**,
290
+ not an empty cacheable 200. `fingerprint.js` serves the S3-first fingerprint
291
+ agent with a bundled fallback; if neither is available, it also returns **503 with
292
+ `Cache-Control: no-store`**. Both support HEAD with the same status/headers and no body;
293
+ successful assets are cacheable for one hour. Renderer modes validate the SDK bundle
294
+ at startup and throw if it is missing.
295
+
296
+ For injected-script troubleshooting, set `debug: true` on the middleware and inspect
297
+ `window.__unshared.lastDecision` (`{ code, status? }`) or the `[Unshared]` debug log.
298
+ Only the latest decision is retained; repeated identical logs are suppressed. Codes
299
+ include `BOT_SKIPPED`, `MISSING_UID`, `SENTINEL_UID`, `COLLECTOR_NOT_READY`,
300
+ `COLLECTION_STARTED`, `COLLECTION_ERROR`, `DEDUP_SKIPPED`, `REQUEST_TOO_LARGE`,
301
+ `ASSET_LOAD_ERROR`, `SUBMIT_STARTED`, `SUBMIT_SUCCESS`, `SUBMIT_REJECTED`,
302
+ `SUBMIT_INVALID_RESPONSE`, `SUBMIT_HTTP_ERROR`,
303
+ `SUBMIT_NETWORK_ERROR`, `SUBMIT_ABORTED`, `SUBMIT_TIMEOUT`, `SUBMIT_FAILED` and
304
+ `OUTER_EXCEPTION`. An HTTP 200 response with `success: false` or `accepted: false`
305
+ is a rejection, not `SUBMIT_SUCCESS`. These diagnostics contain no PII, cookies, hashes, payloads or raw
306
+ exception messages; server observer contexts can contain identity and need redaction.
307
+ For automated-browser tests, also set `disableBotFilter: true`: it reaches the inline
308
+ submitter, not just the server check. It is not a `BrowserConfig` option and does not
309
+ turn off the standalone browser SDK's own bot filter.
310
+
311
+ ### Identity Retention
312
+
313
+ Canonical readable cookies are `__unshared_stable_hash` (stable fingerprint),
314
+ `__unshared_full_hash` (current full hash), and `__unshared_device_id` (independent
315
+ `upid_<uuid-v4>` permanent ID, also retained in localStorage). The readable legacy
316
+ `__unshared_fp_id` remains an alias for the stable hash. The server-written legacy
317
+ `__unshared_fingerprint_id` is HttpOnly and preserves the **first full hash** already
318
+ present, rather than replacing it with each collection. Checks, events and verification prefer
319
+ the canonical stable/full cookies, falling back to their respective legacy aliases;
320
+ stable, full and permanent identifiers are distinct and never substituted for one another.
321
+
322
+ On eligible browser init/route activity, correlation cookies (including
323
+ `__unshared_sid`) renew to a rolling **400 days, `Max-Age=34560000`**, before submission
324
+ deduplication. Renewal does not require a new network event. Successful proxy submit
325
+ handling also renews hash/permanent cookies; only a server response can renew the
326
+ HttpOnly legacy full-hash cookie. Cookies use `Path=/`, `SameSite=Lax`, and `Secure`
327
+ on HTTPS. Browser privacy controls, storage blocking or user deletion can shorten
328
+ retention; 400 days is a requested maximum, not a guarantee.
329
+
330
+ This does **not** extend authentication/session validity. SDK identity cookies
331
+ `__unshared_uid`, `__unshared_uid_at`, and HttpOnly `__unshared_email` retain their
332
+ existing 365-day server TTL; the HttpOnly `__unshared_verification_id` challenge
333
+ cookie remains ten minutes. Logged-out server requests clear user/session/email and
334
+ challenge cookies even outside the include list, but not on truly skipped paths.
335
+ Permanent device/hash cookies are correlation only, not proof of a logged-in user.
336
+
337
+ Collection caches retain both normalized wire data (`__unshared_fp`) and the complete
338
+ raw result (`__unshared_fp_raw`) in per-tab sessionStorage, with an in-memory fallback.
339
+ They are not 400-day collection caches. Browser init, MPA and SPA lifecycle paths reuse
340
+ the pair; legacy wire-only caches are recollected. Consecutive `(user, pathname + query)`
341
+ submissions share a last-key guard with the injected script; an A-to-B-to-A
342
+ revisit can submit again. This is deduplication, not a delivery guarantee: there is
343
+ no persistent replay queue, and ambiguous ingestion failures are not retried.
218
344
 
219
345
  ## Simple Proxy Middleware
220
346
 
@@ -240,7 +366,53 @@ Use the edge/serverless entry point with Next.js, Cloudflare Workers, Vercel Edg
240
366
  import { createWebProtectionMiddleware } from 'unshared-clientjs-sdk/web';
241
367
  ```
242
368
 
243
- It mirrors the protection and verification behavior using `Request` and `Response`.
369
+ It wraps `(request, next, ctx?)` using `Request` and `Response`. Supply
370
+ `ctx.waitUntil` on edge runtimes to keep background work alive after returning the
371
+ response. HTML injection requires an actual HTML response body; `NextResponse.next()`
372
+ has none, so mount the browser/React SDK in the app for collection in that setup.
373
+
374
+ ### WebProtectionConfig
375
+
376
+ This is the complete Web config, not the Node/Express interface. Shared scope,
377
+ identity retention and enforcement trade-offs above apply, subject to the explicit
378
+ Web differences here.
379
+
380
+ | Option | Default | Behavior / Trade-off |
381
+ |---|---|---|
382
+ | `userId` | Required | Synchronous trusted resolver `(request: Request) => string \| undefined`; return `undefined` for logout/anonymous visitors. Same sentinel handling as Node. |
383
+ | `emailAddress` | No resolver | Resolver, then HttpOnly `__unshared_email`. Parsed SDK POST handlers may additionally fall back to `body.email`; ordinary requests do not parse it. Prefer server-authenticated email. |
384
+ | `routePrefix` | `/__unshared` | SDK route prefix; renderer modes require the default. |
385
+ | `corsOrigins` | Unset | String/array allowlist for SDK routes. Matching explicit origins allow credentials; `*` does not. CORS does not authenticate callers. |
386
+ | `cacheTTL` | `60000` ms | In-process verdict TTL with stale-while-revalidate; longer TTL delays changed risk. Defensive fail-open TTL is 5000 ms; capacity is fixed at 10000 entries. |
387
+ | `skipPaths` | Built-in static skips only | Additional literal pathname prefixes that truly bypass identity, injection and protection; same built-in skip rules as Node. Wins over includes. |
388
+ | `includePathPrefix` | Unset (all non-skipped paths) | Scopes ordinary checks, enforcement and events, not identity reconciliation, eligible HTML injection or SDK-owned routes. `[]` includes none. |
389
+ | `disableBotFilter` | `false` | Bypasses middleware and injected-script UA filtering for E2E; does not disable platform/standalone-browser filters. |
390
+ | `debug` | `false` | Same PII-free injected-script `window.__unshared.lastDecision` and `[Unshared]` diagnostics as Node. |
391
+ | `checkUserTimeoutMs` | `1500` ms | Blocking cache-miss check budget, no middleware check retries; availability failures fail open. No async-miss strategy option. |
392
+ | `sessionId` | Cookie fallback | Request resolver, then `__unshared_sid`; missing/`unknown` suppresses ordinary events, not checks. |
393
+ | `deviceId` | Header/cookie fallback | Request resolver, `X-Device-Id`, canonical `__unshared_stable_hash`, then legacy `__unshared_fp_id`. Missing values omitted; these are not authentication credentials. |
394
+ | `fingerprintSdkBundle` | `''` | Manually supply browser SDK UMD text, typically a bundler raw import. Required for fresh inline collection and all renderer modes; installing the package alone does not load it. Missing `/fp.js` returns 503 `no-store`. |
395
+ | `blockFlagged` | `false` | Gate shorthand unless `flaggedMode` is set. Requires bundle and default prefix. |
396
+ | `flaggedMode` | Unset | `'gate'` withholds HTML/data; `'overlay'` delivers HTML but withholds data with 403 and implies automatic modal. Wins over `blockFlagged`; both ignore `onFlagged`. Bundle/default prefix required; flow routing must be supplied separately (below). |
397
+ | `autoInterstitial` | `false` (implied by overlay) | Automatic modal UX, not access control. Requires bundle, default prefix and your flow-route wiring. |
398
+ | `interstitialFlowType` | `'email_verification'` | Automatic inline/overlay flow type, not the standalone gate's default flow. Must be published. |
399
+ | `onFlagged` | Unset (pass through) | Receives `{ userId, emailAddress, verdict, request }`; return a `Response`/`Promise<Response>` to block or redirect, or `null` to pass through. Exceptions pass through. No Express `res`/`next` callback; ignored by gate/overlay. |
400
+ | `onError` | Unset | `(error, { operation, userId?, emailAddress? })` observer; keep defensive and redact identities. Not an enforcement switch. |
401
+ | `onFailOpen` | Unset (no default warning) | Best-effort observer with `operation: 'checkUser'`, `reason`, optional `status`, `userId`, `emailAddress`. Reasons: `timeout`, `http_error`, `exception`, `no_device_id`; no `async_miss`. Status 0 denotes transport failure. |
402
+
403
+ The Web handler automatically serves only `fp.js`, `submit-fp`, `status`,
404
+ `verify-trigger` and `verify` under the prefix (plus OPTIONS). It does **not** implement
405
+ `fingerprint.js` or `interstitial-flow`; those paths return 404 here. Arrange separate
406
+ asset/flow handlers **before** this middleware if your proxy collection or modal
407
+ needs them. Passing `fingerprintSdkBundle` does not add those routes or publish a flow.
408
+ For bundlers supporting raw imports, pass the string imported from
409
+ `unshared-frontend-sdk/dist/index.umd.js?raw`; otherwise load it using your runtime's
410
+ asset mechanism. There is no Node filesystem auto-discovery in this entry point.
411
+
412
+ There are no Web options for `maxCacheSize`, `streamingIdleMs`, `cacheMissStrategy`
413
+ or the internal `disableS3Fingerprint`, and no Express response interception or
414
+ `middleware.destroy()` method on the returned `WebMiddleware`. Do not copy those
415
+ Node-only features into a Web integration.
244
416
 
245
417
  ## Result and Error Handling
246
418
 
@@ -19,7 +19,10 @@ export interface DispatchIo {
19
19
  export declare function runCheckUser(io: DispatchIo, request: CheckUserRequest, userId: string, emailAddress: string, timeoutMs: number): Promise<void>;
20
20
  /**
21
21
  * Run processUserEvent with the standard bounded budget and backoff wiring.
22
- * Never rejects. All four dispatch sites (classic/web x dispatch/submit-fp)
22
+ * Never rejects. Both ordinary dispatch sites (classic/web)
23
23
  * MUST go through this helper so the cap and backoff can't drift per-site.
24
24
  */
25
- export declare function runProcessUserEvent(io: DispatchIo, params: ProcessUserEventParams, timeoutMs: number): Promise<void>;
25
+ export declare function runProcessUserEvent(io: DispatchIo, params: Omit<ProcessUserEventParams, 'deviceId'> & {
26
+ deviceId?: string;
27
+ fullHash?: string;
28
+ }, timeoutMs: number): Promise<void>;
@@ -1 +1 @@
1
- export const DISPATCH_TIMEOUT_MS=2e3;export const SUBMIT_FP_TIMEOUT_MS=3e3;export async function runCheckUser(e,t,r,s,o){try{const s=await e.client.checkUser(t,{timeoutMs:o,maxRetries:0});if(s.success&&s.data&&!s.failedOpen)return e.cache.update(r,{isFlagged:s.data.is_user_flagged}),void e.backoff.noteSuccess();if(s.failedOpen)return void e.backoff.noteFailure();s.error?.retryAfter?e.backoff.pause(1e3*s.error.retryAfter):e.backoff.noteFailure()}catch(t){e.backoff.noteFailure();try{e.onError&&e.onError(t,{operation:"processUserEvent",userId:r,emailAddress:s})}catch{}}}export async function runProcessUserEvent(e,t,r){try{const s=await e.client.processUserEvent(t,{timeoutMs:r,maxRetries:0});if(s.success&&s.data?.analysis&&!s.failedOpen&&e.cache.update(t.userId,{isFlagged:s.data.analysis.is_user_flagged}),s.failedOpen)return void e.backoff.noteFailure();s.success?e.backoff.noteSuccess():s.error?.retryAfter?e.backoff.pause(1e3*s.error.retryAfter):e.backoff.noteFailure()}catch(r){e.backoff.noteFailure();try{e.onError&&e.onError(r,{operation:"processUserEvent",userId:t.userId,emailAddress:t.emailAddress})}catch{}}}
1
+ export const DISPATCH_TIMEOUT_MS=2e3;export const SUBMIT_FP_TIMEOUT_MS=3e3;export async function runCheckUser(e,s,t,r,i){try{const r=await e.client.checkUser(s,{timeoutMs:i,maxRetries:0});if(r.success&&r.data&&!r.failedOpen)return e.cache.update(t,{isFlagged:r.data.is_user_flagged}),void e.backoff.noteSuccess();if(r.failedOpen)return void e.backoff.noteFailure();r.error?.retryAfter?e.backoff.pause(1e3*r.error.retryAfter):e.backoff.noteFailure()}catch(s){e.backoff.noteFailure();try{e.onError&&e.onError(s,{operation:"processUserEvent",userId:t,emailAddress:r})}catch{}}}export async function runProcessUserEvent(e,s,t){try{const r=Object.fromEntries(Object.entries({device_id:s.deviceId,stable_hash:s.fingerprintId,full_hash:s.fullHash,unshared_device_id:s.permanentDeviceId,session_hash:s.sessionHash}).filter(e=>!!e[1]&&"unknown"!==e[1])),i=await e.client.processUserEvent({event_type:s.eventType,identity:{user_id:s.userId,email_address:s.emailAddress},identifiers:r,context:{ip_address:s.ipAddress,user_agent:s.userAgent}},{timeoutMs:t,maxRetries:0});if(i.failedOpen)return void e.backoff.noteFailure();i.success?await runCheckUser(e,{user_id:s.userId,email_address:s.emailAddress,identifiers:r},s.userId,s.emailAddress,t):i.error?.retryAfter?e.backoff.pause(1e3*i.error.retryAfter):e.backoff.noteFailure()}catch(t){e.backoff.noteFailure();try{e.onError&&e.onError(t,{operation:"processUserEvent",userId:s.userId,emailAddress:s.emailAddress})}catch{}}}
@@ -38,6 +38,8 @@ export interface ProtectionConfig<TReq extends UnsharedRequest = UnsharedRequest
38
38
  includePathPrefix?: string[];
39
39
  /** Skip the bot/crawler UA filter. Set to true in test environments so automated browsers (Playwright, Puppeteer, etc.) can observe verdicts. @default false */
40
40
  disableBotFilter?: boolean;
41
+ /** Enable diagnostic logging in the injected fingerprint script. @default false */
42
+ debug?: boolean;
41
43
  /** @internal Serve only the bundled fingerprint library, without S3 fetches. @default false */
42
44
  disableS3Fingerprint?: boolean;
43
45
  /** Hard timeout (ms) for checkUser on cache miss. Fails open on timeout. @default 1500 */
@@ -58,7 +60,7 @@ export interface ProtectionConfig<TReq extends UnsharedRequest = UnsharedRequest
58
60
  sessionId?: (req: TReq) => string | undefined;
59
61
  /**
60
62
  * Resolves a device ID from the request.
61
- * Falls back to X-Device-Id header → __unshared_fp_id cookie.
63
+ * Falls back to X-Device-Id header, then the canonical or legacy stable-hash cookie.
62
64
  */
63
65
  deviceId?: (req: TReq) => string | undefined;
64
66
  /**
@@ -1 +1 @@
1
- import{readFileSync}from"fs";import{readJsonBody}from"./utils/read-json-body.mjs";import{VerdictCache}from"./verdict-cache.mjs";import{RateLimitBackoff}from"./rate-limit-backoff.mjs";import{DispatchDedupe}from"./dispatch-dedupe.mjs";import{interceptResponse}from"./response-interceptor.mjs";import{generateFingerprintScript}from"./injection/fingerprint-script.mjs";import{createFingerprintBundleSource}from"./injection/fp-bundle-source.mjs";import{handleSubmitFingerprint}from"./routes/submit-fp.mjs";import{handleVerifyTrigger,handleVerify}from"./routes/verify.mjs";import{handleGetInterstitialFlow}from"./routes/interstitial.mjs";import{generateGatePage}from"./injection/gate-page.mjs";import{runProcessUserEvent,DISPATCH_TIMEOUT_MS}from"./dispatch-processing.mjs";import{sendJson,sendEmpty,sendBodyForMethod,getRequestPath}from"./utils/http-helpers.mjs";import{isHtmlContentType,isHtmlNavigation}from"./utils/content-type.mjs";import{flaggedResponse}from"./utils/flagged-response.mjs";import{maskEmail}from"./utils/mask-email.mjs";import{shouldSkipPath}from"./utils/skip-paths.mjs";import{shouldIncludePath}from"./utils/include-path.mjs";import{isBot}from"./utils/is-bot.mjs";import{extractClientIp}from"./utils/client-ip.mjs";import{parseCookie}from"./utils/cookies.mjs";import{extractDeviceIdOrUndefined}from"./utils/device-id.mjs";import{extractPermanentDeviceId}from"./utils/permanent-device-id.mjs";import{isSecureRequest}from"./utils/secure.mjs";import{isSentinelUserId,SENTINEL_STICKINESS_TTL_MS}from"./utils/sentinel-user-id.mjs";export{VerdictCache};export{flaggedResponse,ACCOUNT_FLAGGED_ERROR}from"./utils/flagged-response.mjs";const CHECK_USER_TIMEOUT_MS=1500,FAIL_OPEN_TTL_MS=5e3,_lastFailOpenWarn={};function warnFailOpenThrottled(e){const t=Date.now();t-(_lastFailOpenWarn[e.reason]??0)<6e4||(_lastFailOpenWarn[e.reason]=t,console.warn(`[Unshared] Failing open (reason=${e.reason}${null!=e.status?`, status=${e.status}`:""}). Detection is not enforced for this request. Configure onFailOpen to observe this directly.`))}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,maxCacheSize:a,streamingIdleMs:d=50,skipPaths:c,includePathPrefix:l,disableBotFilter:u=!1,checkUserTimeoutMs:p=CHECK_USER_TIMEOUT_MS,cacheMissStrategy:m="block",sessionId:f,deviceId:h,onFlagged:g,onError:I,onFailOpen:v,blockFlagged:S=!1,flaggedMode:_,autoInterstitial:C=!1,interstitialFlowType:y="email_verification"}=t,k=_??(S?"gate":void 0),A=C||"overlay"===k,T=e=>{if(v)try{v(e)}catch{}else"async_miss"!==e.reason&&warnFailOpenThrottled(e)},E=new VerdictCache(o,a),x=new RateLimitBackoff,F=new Map,U=new DispatchDedupe,j=Date.now().toString(36),P=generateFingerprintScript(n,j,{autoInterstitial:A,interstitialFlowType:y});let w="";try{const e=require.resolve("unshared-frontend-sdk/dist/index.umd.js");w=readFileSync(e,"utf8")}catch{}const O=createFingerprintBundleSource({disabled:t.disableS3Fingerprint}),M=void 0!==k||A,D="gate"===k?"flaggedMode 'gate' (blockFlagged)":"overlay"===k?"flaggedMode 'overlay'":"autoInterstitial";if(M&&!w)throw new Error(`[Unshared] ${D} requires unshared-frontend-sdk to be installed (its UMD bundle renders the interstitial).`);if(M&&"/__unshared"!==n)throw new Error(`[Unshared] ${D} requires the default routePrefix ("/__unshared"); the browser SDK proxy routes are fixed to that prefix.`);const b="gate"===k?generateGatePage(n,j):"",R=handleSubmitFingerprint({client:e,verdictCache:E,rateLimitBackoff:x,dispatchDedupe:U,resolveUserId:r,resolveEmailAddress:i,resolveSessionId:f,resolveDeviceId:h,disableBotFilter:u,onError:I}),$=handleVerifyTrigger({client:e,verdictCache:E,resolveEmailAddress:i,resolveDeviceId:h,onError:I}),N=handleVerify({client:e,verdictCache:E,resolveUserId:r,resolveEmailAddress:i,resolveDeviceId:h,onError:I}),V=handleGetInterstitialFlow({client:e}),H=s?Array.isArray(s)?s:[s]:null,L=`${n}/fp.js`,B=`${n}/fingerprint.js`,q=`${n}/submit-fp`,G=`${n}/verify-trigger`,X=`${n}/verify`,J=`${n}/status`,K=`${n}/interstitial-flow`,W=function(t,s,o){const a=getRequestPath(t.url),v=t.url||a;if(a.startsWith(n+"/")){if(function(e,t){if(!H)return;const r=e.headers.origin??"";H.includes("*")?(t.setHeader("Access-Control-Allow-Origin","*"),t.setHeader("Access-Control-Allow-Methods","GET, POST, OPTIONS"),t.setHeader("Access-Control-Allow-Headers","Content-Type, X-Idempotency-Key, X-Session-Id, X-Device-Id, X-Permanent-Device-Id"),t.setHeader("Access-Control-Expose-Headers","X-Unshared-Enforcement-Reconciled")):H.includes(r)&&(t.setHeader("Access-Control-Allow-Origin",r),t.setHeader("Vary","Origin"),t.setHeader("Access-Control-Allow-Methods","GET, POST, OPTIONS"),t.setHeader("Access-Control-Allow-Headers","Content-Type, X-Idempotency-Key, X-Session-Id, X-Device-Id, X-Permanent-Device-Id"),t.setHeader("Access-Control-Expose-Headers","X-Unshared-Enforcement-Reconciled"),t.setHeader("Access-Control-Allow-Credentials","true"))}(t,s),"OPTIONS"===t.method)return void sendEmpty(s,204);if(("GET"===t.method||"HEAD"===t.method)&&a===L)return s.setHeader("Content-Type","application/javascript"),s.setHeader("Cache-Control","public, max-age=3600"),void sendBodyForMethod(s,t.method,200,w);if(("GET"===t.method||"HEAD"===t.method)&&a===B)return void O.get().then(e=>{e?(s.setHeader("Content-Type","application/javascript"),s.setHeader("Cache-Control","public, max-age=3600"),sendBodyForMethod(s,t.method,200,e)):sendBodyForMethod(s,t.method,503,"/* fingerprint agent unavailable */")});if("POST"===t.method&&(a===q||a===G||a===X))return void readJsonBody(t,s).then(e=>{if(e)return a===q?R(t,s):a===G?$(t,s):N(t,s)}).catch(o);if("GET"===t.method&&a===K)return void V(t,s);if("GET"===t.method&&a===J){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=E.get(n);if((!r||E.isStale(n))&&o&&!x.isPaused()&&!E.isRefreshing(n)){E.markRefreshing(n);try{const i=extractDeviceIdOrUndefined(t,h),s=extractFingerprintId(t),a=extractPermanentDeviceId(t),d=extractSessionId(t,f),c=i??s??"unknown";await fetchAndCacheVerdict(e,E,n,o,c,s,a,d,p,(e,t)=>T({operation:"checkUser",reason:e,status:t,userId:n,emailAddress:o})),r=E.get(n)}catch(e){I&&I(e,{operation:"checkUser",userId:n,emailAddress:o}),T({operation:"checkUser",reason:"exception",userId:n,emailAddress:o})}finally{E.clearRefreshing(n)}}r&&r.isFlagged&&!r.isVerified&&(void 0!==k||g)&&o?sendJson(s,200,{status:"flagged",email:maskEmail(o)}):sendJson(s,200,{status:"ok"})})()}return void sendJson(s,404,{success:!1,error:{code:"NOT_FOUND",message:"Unknown route"}})}if(shouldSkipPath(a,c,void 0!==k))return void o();if(!shouldIncludePath(a,l))return interceptForInjection(t,s,P,d),void o();let S;try{S=r(t)}catch{}if(isSentinelUserId(S)){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;S=e&&n?e:void 0}if(!S){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}`),appendSetCookie(s,`__unshared_verification_id=; HttpOnly; Path=/; SameSite=Lax; Max-Age=0${e}`),interceptForInjection(t,s,P,d),void o()}const _=resolveEmail(t,i);if(setUserIdCookie(t,s,S),_&&setEmailCookie(t,s,_),!_)return interceptForInjection(t,s,P,d),void o();const C=extractSessionId(t,f),y=extractDeviceIdOrUndefined(t,h),A=extractFingerprintId(t),j=extractPermanentDeviceId(t),M=t.headers["user-agent"]??"",D=extractClientIp(t),W=y??A;if(!u&&isBot(M))return void o();const z=E.get(S);if(!z){if("async"===m)return E.isRefreshing(S)||(E.markRefreshing(S),fetchAndCacheVerdict(e,E,S,_,W??"unknown",A,j,C,p,(e,t)=>T({operation:"checkUser",reason:e,status:t,userId:S,emailAddress:_})).catch(()=>T({operation:"checkUser",reason:"exception",userId:S,emailAddress:_})).finally(()=>E.clearRefreshing(S))),T({operation:"checkUser",reason:"async_miss",userId:S,emailAddress:_}),Q(),interceptForInjection(t,s,P,d),void o();let r=F.get(S);return r||(r=fetchAndCacheVerdict(e,E,S,_,W??"unknown",A,j,C,p,(e,t)=>T({operation:"checkUser",reason:e,status:t,userId:S,emailAddress:_})).finally(()=>F.delete(S)),F.set(S,r)),void r.then(e=>{e.isFlagged||Q(),applyVerdict(e,S,_,t,s,o,P,g,k,b,d)}).catch(()=>{T({operation:"checkUser",reason:"exception",userId:S,emailAddress:_}),Q(),interceptForInjection(t,s,P,d),o()})}function Q(){"unknown"!==C&&W&&(x.isPaused()||dispatchUserEvent(e,E,x,U,{userId:S,emailAddress:_,sessionId:C,deviceId:W,fingerprintId:A,permanentDeviceId:j,userAgent:M,ipAddress:D,eventType:v},I))}E.isStale(S)&&!E.isRefreshing(S)&&(E.markRefreshing(S),fetchAndCacheVerdict(e,E,S,_,W??"unknown",A,j,C,p,(e,t)=>T({operation:"checkUser",reason:e,status:t,userId:S,emailAddress:_})).catch(()=>T({operation:"checkUser",reason:"exception",userId:S,emailAddress:_})).finally(()=>E.clearRefreshing(S))),z.isFlagged||Q(),applyVerdict(z,S,_,t,s,o,P,g,k,b,d)};return W.destroy=()=>{E.destroy(),O.stop()},W}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,a,d,c,l){const u=e.isFlagged&&!e.isVerified;if("gate"===d&&u)isHtmlNavigation(i.method,i.headers.accept)?(n.statusCode=200,n.setHeader("Content-Type","text/html; charset=utf-8"),n.setHeader("Cache-Control","no-store"),n.end(c)):sendJson(n,403,flaggedResponse(r));else if("overlay"===d&&u)isHtmlNavigation(i.method,i.headers.accept)?(interceptForInjection(i,n,o,l),s()):sendJson(n,403,flaggedResponse(r));else if(interceptForInjection(i,n,o,l),e.isFlagged&&!e.isVerified&&a)try{a({userId:t,emailAddress:r,verdict:e,req:i,res:n,next:s})}catch{s()}else s()}function interceptForInjection(e,t,r,i){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,streamingIdleMs:i})}function dispatchUserEvent(e,t,r,i,n,s){i.mark(n.userId,n.eventType),runProcessUserEvent({client:e,cache:t,backoff:r,onError:s},{eventType:n.eventType,userId:n.userId,emailAddress:n.emailAddress,ipAddress:n.ipAddress,deviceId:n.deviceId,fingerprintId:n.fingerprintId,permanentDeviceId:n.permanentDeviceId,sessionHash:n.sessionId,userAgent:n.userAgent},DISPATCH_TIMEOUT_MS)}async function fetchAndCacheVerdict(e,t,r,i,n,s,o,a,d=CHECK_USER_TIMEOUT_MS,c){const l={timeoutMs:d,maxRetries:0},u=n&&"unknown"!==n?n:void 0,p=u??s,m=s??u;let f;p&&(l.deviceId=p),m&&(l.fullHash=m),o&&(l.permanentDeviceId=o);const h=await Promise.race([e.checkUser(i,l),new Promise(e=>{f=setTimeout(()=>e(null),d)})]);return clearTimeout(f),h?h.failedOpen?(c?.(h.failedOpen.reason??"http_error",h.failedOpen.status),cacheFailOpenVerdict(t,r,i,a)):(t.set(r,{isFlagged:h.data?.is_user_flagged??!1,isVerified:!1,emailAddress:i,sessionId:a}),t.get(r)):(c?.("timeout"),cacheFailOpenVerdict(t,r,i,a))}function cacheFailOpenVerdict(e,t,r,i){const n=e.get(t);return e.set(t,{isFlagged:n?.isFlagged??!1,isVerified:n?.isVerified??!1,emailAddress:r,sessionId:i},5e3),e.get(t)}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; Max-Age=31536000${i}`),appendSetCookie(t,`__unshared_uid_at=${Date.now()}; Path=/; SameSite=Lax; Max-Age=31536000${i}`)}function setEmailCookie(e,t,r){const i=isSecureRequest(e)?"; Secure":"";appendSetCookie(t,`__unshared_email=${encodeURIComponent(r)}; HttpOnly; Path=/; SameSite=Lax; Max-Age=31536000${i}`)}
1
+ import{readFileSync}from"fs";import{readJsonBody}from"./utils/read-json-body.mjs";import{VerdictCache}from"./verdict-cache.mjs";import{RateLimitBackoff}from"./rate-limit-backoff.mjs";import{DispatchDedupe}from"./dispatch-dedupe.mjs";import{interceptResponse}from"./response-interceptor.mjs";import{generateFingerprintScript}from"./injection/fingerprint-script.mjs";import{createFingerprintBundleSource}from"./injection/fp-bundle-source.mjs";import{handleSubmitFingerprint}from"./routes/submit-fp.mjs";import{handleVerifyTrigger,handleVerify}from"./routes/verify.mjs";import{handleGetInterstitialFlow}from"./routes/interstitial.mjs";import{generateGatePage}from"./injection/gate-page.mjs";import{runProcessUserEvent,DISPATCH_TIMEOUT_MS}from"./dispatch-processing.mjs";import{sendJson,sendEmpty,sendBodyForMethod,getRequestPath}from"./utils/http-helpers.mjs";import{isHtmlContentType,isHtmlNavigation}from"./utils/content-type.mjs";import{flaggedResponse}from"./utils/flagged-response.mjs";import{maskEmail}from"./utils/mask-email.mjs";import{shouldSkipPath}from"./utils/skip-paths.mjs";import{shouldIncludePath}from"./utils/include-path.mjs";import{isBot}from"./utils/is-bot.mjs";import{extractClientIp}from"./utils/client-ip.mjs";import{parseCookie}from"./utils/cookies.mjs";import{extractDeviceIdOrUndefined}from"./utils/device-id.mjs";import{extractPermanentDeviceId}from"./utils/permanent-device-id.mjs";import{isSecureRequest}from"./utils/secure.mjs";import{isSentinelUserId,SENTINEL_STICKINESS_TTL_MS}from"./utils/sentinel-user-id.mjs";export{VerdictCache};export{flaggedResponse,ACCOUNT_FLAGGED_ERROR}from"./utils/flagged-response.mjs";const CHECK_USER_TIMEOUT_MS=1500,FAIL_OPEN_TTL_MS=5e3,_lastFailOpenWarn={};function warnFailOpenThrottled(e){const t=Date.now();t-(_lastFailOpenWarn[e.reason]??0)<6e4||(_lastFailOpenWarn[e.reason]=t,console.warn(`[Unshared] Failing open (reason=${e.reason}${null!=e.status?`, status=${e.status}`:""}). Detection is not enforced for this request. Configure onFailOpen to observe this directly.`))}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,maxCacheSize:a,streamingIdleMs:d=50,skipPaths:c,includePathPrefix:l,disableBotFilter:u=!1,checkUserTimeoutMs:p=CHECK_USER_TIMEOUT_MS,cacheMissStrategy:m="block",sessionId:f,deviceId:h,onFlagged:g,onError:I,onFailOpen:_,blockFlagged:v=!1,flaggedMode:S,autoInterstitial:C=!1,interstitialFlowType:y="email_verification"}=t,k=S??(v?"gate":void 0),A=C||"overlay"===k,T=e=>{if(_)try{_(e)}catch{}else"async_miss"!==e.reason&&warnFailOpenThrottled(e)},E=new VerdictCache(o,a),x=new RateLimitBackoff,F=new Map,U=new DispatchDedupe,j=Date.now().toString(36),P=generateFingerprintScript(n,j,{autoInterstitial:A,interstitialFlowType:y,debug:t.debug,disableBotFilter:t.disableBotFilter});let M="";try{const e=require.resolve("unshared-frontend-sdk/dist/index.umd.js");M=readFileSync(e,"utf8")}catch{}const O=createFingerprintBundleSource({disabled:t.disableS3Fingerprint}),w=void 0!==k||A,D="gate"===k?"flaggedMode 'gate' (blockFlagged)":"overlay"===k?"flaggedMode 'overlay'":"autoInterstitial";if(w&&!M)throw new Error(`[Unshared] ${D} requires unshared-frontend-sdk to be installed (its UMD bundle renders the interstitial).`);if(w&&"/__unshared"!==n)throw new Error(`[Unshared] ${D} requires the default routePrefix ("/__unshared"); the browser SDK proxy routes are fixed to that prefix.`);const b="gate"===k?generateGatePage(n,j):"",R=handleSubmitFingerprint({client:e,verdictCache:E,rateLimitBackoff:x,dispatchDedupe:U,resolveUserId:r,resolveEmailAddress:i,resolveSessionId:f,resolveDeviceId:h,disableBotFilter:u,onError:I}),H=handleVerifyTrigger({client:e,verdictCache:E,resolveEmailAddress:i,resolveDeviceId:h,resolveSessionId:f,onError:I}),$=handleVerify({client:e,verdictCache:E,resolveUserId:r,resolveEmailAddress:i,resolveDeviceId:h,resolveSessionId:f,onError:I}),N=handleGetInterstitialFlow({client:e}),V=s?Array.isArray(s)?s:[s]:null,L=`${n}/fp.js`,B=`${n}/fingerprint.js`,q=`${n}/submit-fp`,G=`${n}/verify-trigger`,X=`${n}/verify`,J=`${n}/status`,K=`${n}/interstitial-flow`,W=function(t,s,o){const a=getRequestPath(t.url),_=t.url||a;if(a.startsWith(n+"/")){if(function(e,t){if(!V)return;const r=e.headers.origin??"";V.includes("*")?(t.setHeader("Access-Control-Allow-Origin","*"),t.setHeader("Access-Control-Allow-Methods","GET, POST, OPTIONS"),t.setHeader("Access-Control-Allow-Headers","Content-Type, X-Idempotency-Key, X-Session-Id, X-Device-Id, X-Permanent-Device-Id"),t.setHeader("Access-Control-Expose-Headers","X-Unshared-Enforcement-Reconciled")):V.includes(r)&&(t.setHeader("Access-Control-Allow-Origin",r),t.setHeader("Vary","Origin"),t.setHeader("Access-Control-Allow-Methods","GET, POST, OPTIONS"),t.setHeader("Access-Control-Allow-Headers","Content-Type, X-Idempotency-Key, X-Session-Id, X-Device-Id, X-Permanent-Device-Id"),t.setHeader("Access-Control-Expose-Headers","X-Unshared-Enforcement-Reconciled"),t.setHeader("Access-Control-Allow-Credentials","true"))}(t,s),"OPTIONS"===t.method)return void sendEmpty(s,204);if(("GET"===t.method||"HEAD"===t.method)&&a===L)return s.setHeader("Content-Type","application/javascript"),M?(s.setHeader("Cache-Control","public, max-age=3600"),void sendBodyForMethod(s,t.method,200,M)):(s.setHeader("Cache-Control","no-store"),void sendBodyForMethod(s,t.method,503,"/* fingerprint SDK unavailable */"));if(("GET"===t.method||"HEAD"===t.method)&&a===B)return void O.get().then(e=>{if(!e)return s.setHeader("Cache-Control","no-store"),void sendBodyForMethod(s,t.method,503,"/* fingerprint agent unavailable */");s.setHeader("Content-Type","application/javascript"),s.setHeader("Cache-Control","public, max-age=3600"),sendBodyForMethod(s,t.method,200,e)});if("POST"===t.method&&(a===q||a===G||a===X))return void readJsonBody(t,s).then(e=>{if(e)return a===q?R(t,s):a===G?H(t,s):$(t,s)}).catch(o);if("GET"===t.method&&a===K)return void N(t,s);if("GET"===t.method&&a===J){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=E.get(n);if((!r||E.isStale(n))&&o&&!x.isPaused()&&!E.isRefreshing(n)){E.markRefreshing(n);try{const i=extractDeviceIdOrUndefined(t,h),s=extractFingerprintId(t),a=extractFullHash(t),d=extractPermanentDeviceId(t),c=extractSessionId(t,f);await fetchAndCacheVerdict(e,E,n,o,i,s,a,d,c,p,(e,t)=>T({operation:"checkUser",reason:e,status:t,userId:n,emailAddress:o})),r=E.get(n)}catch(e){I&&I(e,{operation:"checkUser",userId:n,emailAddress:o}),T({operation:"checkUser",reason:"exception",userId:n,emailAddress:o})}finally{E.clearRefreshing(n)}}r&&r.isFlagged&&!r.isVerified&&(void 0!==k||g)&&o?sendJson(s,200,{status:"flagged",email:maskEmail(o)}):sendJson(s,200,{status:"ok"})})()}return void sendJson(s,404,{success:!1,error:{code:"NOT_FOUND",message:"Unknown route"}})}if(shouldSkipPath(a,c,void 0!==k))return void o();let v;try{v=r(t)}catch{}if(isSentinelUserId(v)){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;v=e&&n?e:void 0}if(!v){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}`),appendSetCookie(s,`__unshared_verification_id=; HttpOnly; Path=/; SameSite=Lax; Max-Age=0${e}`),interceptForInjection(t,s,P,d),void o()}const S=resolveEmail(t,i);if(setUserIdCookie(t,s,v),S&&setEmailCookie(t,s,S),!shouldIncludePath(a,l))return interceptForInjection(t,s,P,d),void o();if(!S)return interceptForInjection(t,s,P,d),void o();const C=extractSessionId(t,f),y=extractDeviceIdOrUndefined(t,h),A=extractFingerprintId(t),j=extractFullHash(t),w=extractPermanentDeviceId(t),D=t.headers["user-agent"]??"",W=extractClientIp(t);if(!u&&isBot(D))return void o();const z=E.get(v);if(!z){if("async"===m)return E.isRefreshing(v)||(E.markRefreshing(v),fetchAndCacheVerdict(e,E,v,S,y,A,j,w,C,p,(e,t)=>T({operation:"checkUser",reason:e,status:t,userId:v,emailAddress:S})).catch(()=>T({operation:"checkUser",reason:"exception",userId:v,emailAddress:S})).finally(()=>E.clearRefreshing(v))),T({operation:"checkUser",reason:"async_miss",userId:v,emailAddress:S}),Q(),interceptForInjection(t,s,P,d),void o();let r=F.get(v);return r||(r=fetchAndCacheVerdict(e,E,v,S,y,A,j,w,C,p,(e,t)=>T({operation:"checkUser",reason:e,status:t,userId:v,emailAddress:S})).finally(()=>F.delete(v)),F.set(v,r)),void r.then(e=>{e.isFlagged||Q(),applyVerdict(e,v,S,t,s,o,P,g,k,b,d)}).catch(()=>{T({operation:"checkUser",reason:"exception",userId:v,emailAddress:S}),Q(),interceptForInjection(t,s,P,d),o()})}function Q(){C&&"unknown"!==C&&(x.isPaused()||dispatchUserEvent(e,E,x,U,{userId:v,emailAddress:S,sessionId:C,deviceId:y,fingerprintId:A,fullHash:j,permanentDeviceId:w,userAgent:D,ipAddress:W,eventType:_},I))}E.isStale(v)&&!E.isRefreshing(v)&&(E.markRefreshing(v),fetchAndCacheVerdict(e,E,v,S,y,A,j,w,C,p,(e,t)=>T({operation:"checkUser",reason:e,status:t,userId:v,emailAddress:S})).catch(()=>T({operation:"checkUser",reason:"exception",userId:v,emailAddress:S})).finally(()=>E.clearRefreshing(v))),z.isFlagged||Q(),applyVerdict(z,v,S,t,s,o,P,g,k,b,d)};return W.destroy=()=>{E.destroy(),O.stop()},W}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,a,d,c,l){const u=e.isFlagged&&!e.isVerified;if("gate"===d&&u)isHtmlNavigation(i.method,i.headers.accept)?(n.statusCode=200,n.setHeader("Content-Type","text/html; charset=utf-8"),n.setHeader("Cache-Control","no-store"),n.end(c)):sendJson(n,403,flaggedResponse(r));else if("overlay"===d&&u)isHtmlNavigation(i.method,i.headers.accept)?(interceptForInjection(i,n,o,l),s()):sendJson(n,403,flaggedResponse(r));else if(interceptForInjection(i,n,o,l),e.isFlagged&&!e.isVerified&&a)try{a({userId:t,emailAddress:r,verdict:e,req:i,res:n,next:s})}catch{s()}else s()}function interceptForInjection(e,t,r,i){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,streamingIdleMs:i})}function dispatchUserEvent(e,t,r,i,n,s){i.mark(n.userId,n.eventType),runProcessUserEvent({client:e,cache:t,backoff:r,onError:s},{eventType:n.eventType,userId:n.userId,emailAddress:n.emailAddress,ipAddress:n.ipAddress,deviceId:n.deviceId,fingerprintId:n.fingerprintId,fullHash:n.fullHash,permanentDeviceId:n.permanentDeviceId,sessionHash:n.sessionId,userAgent:n.userAgent},DISPATCH_TIMEOUT_MS)}async function fetchAndCacheVerdict(e,t,r,i,n,s,o,a,d,c=CHECK_USER_TIMEOUT_MS,l){const u={timeoutMs:c,maxRetries:0};let p;n&&"unknown"!==n&&(u.deviceId=n),s&&"unknown"!==s&&(u.fingerprintId=s),o&&"unknown"!==o&&(u.fullHash=o),a&&(u.permanentDeviceId=a),d&&"unknown"!==d&&(u.sessionHash=d);const m=await Promise.race([e.checkUser(i,u),new Promise(e=>{p=setTimeout(()=>e(null),c)})]);return clearTimeout(p),m?m.failedOpen?(l?.(m.failedOpen.reason??"http_error",m.failedOpen.status),cacheFailOpenVerdict(t,r,i,d)):(t.set(r,{isFlagged:m.data?.is_user_flagged??!1,isVerified:!1,emailAddress:i,sessionId:d}),t.get(r)):(l?.("timeout"),cacheFailOpenVerdict(t,r,i,d))}function cacheFailOpenVerdict(e,t,r,i){const n=e.get(t);return e.set(t,{isFlagged:n?.isFlagged??!1,isVerified:n?.isVerified??!1,emailAddress:r,sessionId:i},5e3),e.get(t)}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_stable_hash")||parseCookie(e,"__unshared_fp_id")||void 0}function extractFullHash(e){return parseCookie(e,"__unshared_full_hash")||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; Max-Age=31536000${i}`),appendSetCookie(t,`__unshared_uid_at=${Date.now()}; Path=/; SameSite=Lax; Max-Age=31536000${i}`)}function setEmailCookie(e,t,r){const i=isSecureRequest(e)?"; Secure":"";appendSetCookie(t,`__unshared_email=${encodeURIComponent(r)}; HttpOnly; Path=/; SameSite=Lax; Max-Age=31536000${i}`)}
@@ -23,4 +23,6 @@
23
23
  export declare function generateFingerprintScript(routePrefix: string, version?: string, opts?: {
24
24
  autoInterstitial?: boolean;
25
25
  interstitialFlowType?: string;
26
+ debug?: boolean;
27
+ disableBotFilter?: boolean;
26
28
  }): string;
@@ -1 +1 @@
1
- export function generateFingerprintScript(e,t,n){const r=t?`?v=${escapeJavaScript(t)}`:"",a=n?.autoInterstitial?`\nvar _isdk=new UnsharedBrowser.UnsharedBrowser({baseUrl:""});\nvar _isdkShow=function(){try{_isdk.showInterstitial({flowType:"${escapeJavaScript(n?.interstitialFlowType??"email_verification")}",onComplete:function(){try{location.reload()}catch(e){}}})}catch(e){}};\nwindow.addEventListener("unshared:flagged",_isdkShow);\nif(shared.flagged){_isdkShow()}`:"";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_FP_RAW="__unshared_fp_raw";\nvar SS_LAST_SUBMIT="__unshared_last_submit";\nvar CAPTURE_SESSION_KEYS=["__unshared_email","__unshared_last_submit","__unshared_verification_id"];\nvar MAX_REQUEST_BYTES=102400;\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// Sticky flagged signal: emitFlagged() records shared.flagged and drains\n// shared.flaggedCbs, so listeners that attach AFTER the CustomEvent fired\n// (late hydration, fp.js still loading) can recover it via whenFlagged().\n// KEEP IN SYNC with browser.ts getSharedDedup: flagged, flaggedCbs, whenFlagged.\nshared.whenFlagged=shared.whenFlagged||function(cb){(shared.flaggedCbs=shared.flaggedCbs||[]).push(cb);if(shared.flagged){try{cb(shared.flagged)}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"+(location.protocol==="https:"?"; Secure":"")}\nfunction uuid(){return(typeof crypto!=="undefined"&&crypto.randomUUID)?crypto.randomUUID():("xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g,function(c){var r=Math.random()*16|0;return(c==="x"?r:r&0x3|0x8).toString(16)}))}\nfunction validPid(v){return typeof v==="string"&&/^upid_[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(v)}\nfunction bLen(v){return typeof TextEncoder!=="undefined"?new TextEncoder().encode(v).length:encodeURIComponent(v).replace(/%[0-9A-F]{2}/g,"x").length}\nfunction cCookies(){try{return document.cookie.split(";").map(function(v){return v.trim()}).filter(Boolean).map(function(v){var i=v.indexOf("="),n=i<0?v:v.slice(0,i),r=i<0?"":v.slice(i+1),d=r;try{d=decodeURIComponent(r)}catch(e){}return{name:n,value:d}}).sort(function(a,b){return a.name<b.name?-1:a.name>b.name?1:0})}catch(e){return[]}}\nfunction cStorage(s,keys){try{var out=[];for(var i=0;i<keys.length;i++){var v=s.getItem(keys[i]);if(v!==null)out.push({name:keys[i],value:v})}return out.sort(function(a,b){return a.name<b.name?-1:a.name>b.name?1:0})}catch(e){return[]}}\nfunction browserStorage(){var l=[],s=[];try{l=cStorage(localStorage,["__unshared_device_id"])}catch(e){}try{s=cStorage(sessionStorage,CAPTURE_SESSION_KEYS)}catch(e){}return{cookies:cCookies(),local_storage:l,session_storage:s}}\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="",pid="";\n\n// --- Fingerprint cache (sessionStorage) ---\n// Old normalized-only caches cannot recover overwritten raw fields; recollect them.\nfunction getFP(){try{var w=sessionStorage.getItem(SS_FP),r=sessionStorage.getItem(SS_FP_RAW);return w&&r?{wire:JSON.parse(w),raw:JSON.parse(r)}:null}catch(e){return null}}\nfunction setFP(collected){try{sessionStorage.removeItem(SS_FP_RAW);sessionStorage.setItem(SS_FP,JSON.stringify(collected.wire));sessionStorage.setItem(SS_FP_RAW,JSON.stringify(collected.raw))}catch(e){}}\n\n// --- Submit fingerprint to backend ---\nfunction submitFP(collected){\n var fp=collected.wire;\n var uid=gC("__unshared_uid");\n if(!uid||isSentinelUid(uid))return;\n try{pid=localStorage.getItem("__unshared_device_id")||""}catch(e){}\n if(!validPid(pid)){pid="upid_"+uuid();try{localStorage.setItem("__unshared_device_id",pid)}catch(e){}}\n sC("__unshared_device_id",pid,400);\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 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 // 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={fingerprint:collected.raw,context:{sdk:{name:"unshared-inline-sdk",version:"3.0.0",agent_source:fp.source||"unknown"},identity:{user_id:uid},identifiers:{unshared_device_id:pid,device_id:did,stable_hash:fp.fingerprint_id||"",full_hash:fp.full_hash||"",session_hash:sid},browser_storage:browserStorage(),event:{type:route,collected_at:(new Date()).toISOString()}}};\n var payload=JSON.stringify(body);\n // Keep in sync with the structured browser builder: full, cookies only, core only.\n if(bLen(payload)>MAX_REQUEST_BYTES){body.context.browser_storage.local_storage=[];body.context.browser_storage.session_storage=[];payload=JSON.stringify(body);}\n if(bLen(payload)>MAX_REQUEST_BYTES){delete body.context.browser_storage;payload=JSON.stringify(body);}\n if(bLen(payload)>MAX_REQUEST_BYTES){if(window.console&&console.warn)console.warn("[Unshared] REQUEST_TOO_LARGE: Request body exceeds 100 KiB");return;}\n shared.lastKey=key;\n lastSubmitKey=key;\n try{sessionStorage.setItem(SS_LAST_SUBMIT,key)}catch(e){}\n // Idempotency key derived from (stable_hash, user_id, route), SHA-256-hashed\n // when WebCrypto is available so the user_id never appears in the platform's\n // stored idempotency_key column (raw fallback on non-secure contexts — the\n // middleware accepts both). NOTE: the middleware appends |Date.now() before\n // forwarding (submit-fp.ts), so the backend sees a unique value per\n // submission and dedups only PubSub redeliveries — NOT two distinct POSTs.\n // Cross-submitter / cross-reload dedup is client-side (the shared window\n // guard + sessionStorage above).\n var idem=fp.fingerprint_id+"|"+uid+"|"+route;\n function sendFP(idemKey){\n var xhr=new XMLHttpRequest();\n xhr.open("POST",pfx+"/submit-fp",true);\n xhr.setRequestHeader("Content-Type","application/json");\n xhr.setRequestHeader("X-Session-Id",sid);\n if(did)xhr.setRequestHeader("X-Device-Id",did);\n if(pid)xhr.setRequestHeader("X-Permanent-Device-Id",pid);\n xhr.setRequestHeader("X-Idempotency-Key",idemKey);\n xhr.send(payload);\n }\n if(window.crypto&&crypto.subtle&&window.TextEncoder){\n crypto.subtle.digest("SHA-256",new TextEncoder().encode(idem)).then(function(d){\n var a=new Uint8Array(d),s="";\n for(var i=0;i<a.length;i++){s+=("0"+a[i].toString(16)).slice(-2)}\n sendFP(s);\n }).catch(function(){sendFP(idem)});\n }else{sendFP(idem)}\n}\n\n// --- Collect fingerprint (loads fp.js if needed) then submit ---\nvar fpReady=false;\nfunction collectAndSubmit(){\n var uid=gC("__unshared_uid");\n if(!uid||isSentinelUid(uid))return;\n var cached=getFP();\n if(cached){submitFP(cached);return}\n if(!fpReady)return;\n try{\n var c=new UnsharedBrowser.UnsharedBrowser({baseUrl:""});\n c.collectWithMetadata({exclude:["timing","speech"]}).then(function(fp){setFP(fp);submitFP(fp)}).catch(function(){});\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${r}";\ns.onload=function(){fpReady=true;${a}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: record sticky state + dispatch "unshared:flagged" ---\nfunction emitFlagged(body){\n var d={email:body.email||""};\n shared.flagged=d;\n var cbs=shared.flaggedCbs||[];\n for(var i=0;i<cbs.length;i++){try{cbs[i](d)}catch(e){}}\n try{window.dispatchEvent(new CustomEvent("unshared:flagged",{detail:d}))}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 JSON.stringify(e).slice(1,-1).replace(/<\/(script)/gi,"<\\/$1").replace(new RegExp("\u2028","g"),"\\u2028").replace(new RegExp("\u2029","g"),"\\u2029")}
1
+ export function generateFingerprintScript(e,n,t){const r=n?`?v=${escapeJavaScript(n)}`:"",i=t?.autoInterstitial?`\nvar _isdk=new UnsharedBrowser.UnsharedBrowser({baseUrl:""});\nvar _isdkShow=function(){try{_isdk.showInterstitial({flowType:"${escapeJavaScript(t?.interstitialFlowType??"email_verification")}",onComplete:function(){try{location.reload()}catch(e){}}})}catch(e){}};\nwindow.addEventListener("unshared:flagged",_isdkShow);\nif(shared.flagged){_isdkShow()}`:"";return`<script>\n(function(){\nvar debug=${!0===t?.debug},shared;\n// Diagnostics are bounded and contain only reason codes and HTTP status, never user data.\nfunction decision(code,status){\n if(!debug)return;\n var d={code:code};if(typeof status==="number")d.status=status;\n try{var prev=shared.lastDecision;shared.lastDecision=d;if(prev&&prev.code===code&&prev.status===status)return}catch(e){}\n try{if(window.console&&typeof window.console.debug==="function")window.console.debug("[Unshared]",d)}catch(e){}\n}\ntry{\nshared=(window.__unshared=window.__unshared||{});\n// --- Bot drop (defense-in-depth) ---\n// Initialize diagnostics first, but do not write cookies, localStorage,\n// session IDs, or make 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(!${!0===t?.disableBotFilter}&&typeof navigator!=="undefined"&&navigator.userAgent&&BOT_RE.test(navigator.userAgent)){decision("BOT_SKIPPED");return}\n\nvar pfx="${escapeJavaScript(e)}";\nvar SS_FP="__unshared_fp";\nvar SS_FP_RAW="__unshared_fp_raw";\nvar SS_LAST_SUBMIT="__unshared_last_submit";\nvar CAPTURE_SESSION_KEYS=["__unshared_email","__unshared_last_submit","__unshared_verification_id"];\nvar MAX_REQUEST_BYTES=102400;\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).\ntry{if(!shared.generation&&shared.lastKey===undefined)shared.lastKey=sessionStorage.getItem(SS_LAST_SUBMIT)||undefined}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. 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.\n// Sticky flagged signal: emitFlagged() records shared.flagged and drains\n// shared.flaggedCbs, so listeners that attach AFTER the CustomEvent fired\n// (late hydration, fp.js still loading) can recover it via whenFlagged().\n// KEEP IN SYNC with browser.ts getSharedDedup: flagged, flaggedCbs, whenFlagged.\nshared.whenFlagged=shared.whenFlagged||function(cb){(shared.flaggedCbs=shared.flaggedCbs||[]).push(cb);if(shared.flagged){try{cb(shared.flagged)}catch(e){}}};\n\n// --- Helpers ---\nfunction gC(n){try{var m=document.cookie.match(new RegExp("(?:^|; )"+n+"=([^;]*)"));return m?decodeURIComponent(m[1]):null}catch(e){return null}}\nfunction sC(n,v,d){try{var dt=new Date();dt.setTime(dt.getTime()+d*864e5);document.cookie=n+"="+encodeURIComponent(v)+"; Max-Age="+(d*86400)+"; expires="+dt.toUTCString()+"; path=/; SameSite=Lax"+(location.protocol==="https:"?"; Secure":"")}catch(e){}}\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)}))}\nfunction validPid(v){return typeof v==="string"&&/^upid_[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(v)}\nfunction bLen(v){return typeof TextEncoder!=="undefined"?new TextEncoder().encode(v).length:encodeURIComponent(v).replace(/%[0-9A-F]{2}/g,"x").length}\nfunction cCookies(){try{return document.cookie.split(";").map(function(v){return v.trim()}).filter(Boolean).map(function(v){var i=v.indexOf("="),n=i<0?v:v.slice(0,i),r=i<0?"":v.slice(i+1),d=r;try{d=decodeURIComponent(r)}catch(e){}return{name:n,value:d}}).sort(function(a,b){return a.name<b.name?-1:a.name>b.name?1:0})}catch(e){return[]}}\nfunction cStorage(s,keys){try{var out=[];for(var i=0;i<keys.length;i++){var v=s.getItem(keys[i]);if(v!==null)out.push({name:keys[i],value:v})}return out.sort(function(a,b){return a.name<b.name?-1:a.name>b.name?1:0})}catch(e){return[]}}\nfunction browserStorage(){var l=[],s=[];try{l=cStorage(localStorage,["__unshared_device_id"])}catch(e){}try{s=cStorage(sessionStorage,CAPTURE_SESSION_KEYS)}catch(e){}return{cookies:cCookies(),local_storage:l,session_storage:s}}\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// Permanent identity is independent of the collected stable hash. Keep an\n// in-memory fallback so blocked storage does not regenerate it on every route.\nvar sid=gC("__unshared_sid");\nvar did="",pid="";\n\n// --- Fingerprint cache (sessionStorage) ---\n// Old normalized-only caches cannot recover overwritten raw fields; recollect them.\n// Public page-memory fields match browser.ts and must not be property-mangled.\nfunction validFP(fp){return fp&&fp.wire&&typeof fp.wire.fingerprint_id==="string"&&typeof fp.wire.full_hash==="string"&&fp.raw&&typeof fp.raw==="object"&&!Array.isArray(fp.raw)}\nfunction getFP(){if(validFP(shared.collection))return shared.collection;if(shared.collectionPromise||shared.generation)return null;try{var w=sessionStorage.getItem(SS_FP),r=sessionStorage.getItem(SS_FP_RAW),fp=w&&r?{wire:JSON.parse(w),raw:JSON.parse(r)}:null;if(validFP(fp))return shared.collection=fp;return null}catch(e){return null}}\nfunction setFP(collected){shared.collection=collected;try{sessionStorage.removeItem(SS_FP_RAW);sessionStorage.setItem(SS_FP,JSON.stringify(collected.wire));sessionStorage.setItem(SS_FP_RAW,JSON.stringify(collected.raw))}catch(e){}}\n\n// --- Submit fingerprint to backend ---\nfunction submitFP(collected){\n var generation=shared.generation||0;\n var fp=collected.wire;\n var uid=gC("__unshared_uid");\n if(!uid){decision("MISSING_UID");return}\n if(isSentinelUid(uid)){decision("SENTINEL_UID");return}\n var storedPid="";try{storedPid=localStorage.getItem("__unshared_device_id")||""}catch(e){}\n var cookiePid=gC("__unshared_device_id");\n if(validPid(storedPid)){pid=storedPid}else if(validPid(cookiePid)){pid=cookiePid}else if(!validPid(pid)){pid="upid_"+uuid()}\n try{localStorage.setItem("__unshared_device_id",pid)}catch(e){}\n // Renew correlation cookies on eligible activity, even when submission dedups.\n sid=shared.sessionId||gC("__unshared_sid")||sid||uuid();shared.sessionId=sid;sC("__unshared_sid",sid,400);\n sC("__unshared_device_id",pid,400);\n if(fp.fingerprint_id){\n did=fp.fingerprint_id;\n sC("__unshared_fp_id",did,400);\n sC("__unshared_stable_hash",fp.fingerprint_id,400);\n }\n if(fp.full_hash)sC("__unshared_full_hash",fp.full_hash,400);\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 (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(shared.lastKey===key){decision("DEDUP_SKIPPED");return}\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={fingerprint:collected.raw,context:{sdk:{name:"unshared-inline-sdk",version:"3.0.0",agent_source:fp.source||"unknown"},identity:{user_id:uid},identifiers:{unshared_device_id:pid,device_id:did,stable_hash:fp.fingerprint_id||"",full_hash:fp.full_hash||"",session_hash:sid},browser_storage:browserStorage(),event:{type:route,collected_at:(new Date()).toISOString()}}};\n var payload=JSON.stringify(body);\n // Keep in sync with the structured browser builder: full, cookies only, core only.\n if(bLen(payload)>MAX_REQUEST_BYTES){body.context.browser_storage.local_storage=[];body.context.browser_storage.session_storage=[];payload=JSON.stringify(body);}\n if(bLen(payload)>MAX_REQUEST_BYTES){delete body.context.browser_storage;payload=JSON.stringify(body);}\n if(bLen(payload)>MAX_REQUEST_BYTES){decision("REQUEST_TOO_LARGE");try{if(window.console&&window.console.warn)window.console.warn("[Unshared] REQUEST_TOO_LARGE: Request body exceeds 100 KiB")}catch(e){}return;}\n shared.lastKey=key;\n try{sessionStorage.setItem(SS_LAST_SUBMIT,key)}catch(e){}\n // Idempotency key derived from (stable_hash, user_id, route), SHA-256-hashed\n // when WebCrypto is available so the user_id never appears in the platform's\n // stored idempotency_key column (raw fallback on non-secure contexts — the\n // middleware accepts both). NOTE: the middleware appends |Date.now() before\n // forwarding (submit-fp.ts), so the backend sees a unique value per\n // submission and dedups only PubSub redeliveries — NOT two distinct POSTs.\n // Cross-submitter / cross-reload dedup is client-side (the shared window\n // guard + sessionStorage above).\n var idem=fp.fingerprint_id+"|"+uid+"|"+route;\n function sendFP(idemKey){\n if(generation!==(shared.generation||0))return;\n try{\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 if(pid)xhr.setRequestHeader("X-Permanent-Device-Id",pid);\n xhr.setRequestHeader("X-Idempotency-Key",idemKey);\n xhr.addEventListener("load",function(){\n if(xhr.status<200||xhr.status>=300){decision("SUBMIT_HTTP_ERROR",xhr.status);return}\n var b;try{b=JSON.parse(xhr.responseText)}catch(e){decision("SUBMIT_INVALID_RESPONSE",xhr.status);return}\n if(b&&(b.success===false||b.accepted===false||(b.data&&(b.data.accepted===false||b.data.skipped===true)))){decision("SUBMIT_REJECTED",xhr.status);return}\n decision(b&&b.success===true&&b.data&&typeof b.data.event_id==="string"&&typeof b.data.collected_at==="string"&&b.data.endpoint_version==="v3"?"SUBMIT_SUCCESS":"SUBMIT_INVALID_RESPONSE",xhr.status);\n });\n xhr.addEventListener("error",function(){decision("SUBMIT_NETWORK_ERROR")});\n xhr.addEventListener("abort",function(){decision("SUBMIT_ABORTED")});\n xhr.addEventListener("timeout",function(){decision("SUBMIT_TIMEOUT")});\n decision("SUBMIT_STARTED");\n xhr.send(payload);\n }catch(e){decision("SUBMIT_FAILED")}\n }\n if(window.crypto&&crypto.subtle&&window.TextEncoder){\n crypto.subtle.digest("SHA-256",new TextEncoder().encode(idem)).then(function(d){\n var a=new Uint8Array(d),s="";\n for(var i=0;i<a.length;i++){s+=("0"+a[i].toString(16)).slice(-2)}\n sendFP(s);\n }).catch(function(){sendFP(idem)});\n }else{sendFP(idem)}\n}\n\n// --- Collect fingerprint (loads fp.js if needed) then submit ---\nvar fpReady=false;\nfunction collectAndSubmit(){\n var generation=shared.generation||0;\n var uid=gC("__unshared_uid");\n if(!uid){decision("MISSING_UID");return}\n if(isSentinelUid(uid)){decision("SENTINEL_UID");return}\n var cached=getFP();\n if(cached){submitFP(cached);return}\n try{\n var pending=shared.collectionPromise;\n if(!pending){\n if(!fpReady){decision("COLLECTOR_NOT_READY");return}\n var c=new UnsharedBrowser.UnsharedBrowser({baseUrl:""});\n decision("COLLECTION_STARTED");\n pending=c.collectWithMetadata({exclude:["timing","speech"]}).then(function(fp){\n if(generation!==(shared.generation||0)||shared.collectionPromise!==pending)throw new Error("Collection cancelled");\n if(!validFP(fp))throw new Error("Invalid collection");\n setFP(fp);return fp;\n });\n shared.collectionPromise=pending;\n var clear=function(){if(shared.collectionPromise===pending)delete shared.collectionPromise};\n pending.then(clear,clear);\n }\n pending.then(function(fp){if(generation===(shared.generation||0))submitFP(fp)}).catch(function(){if(generation===(shared.generation||0))decision("COLLECTION_ERROR")});\n }catch(e){decision("COLLECTION_ERROR")}\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()}else{collectAndSubmit()}\nvar s=document.createElement("script");\ns.src=pfx+"/fp.js${r}";\ns.onload=function(){fpReady=true;try{${i}if(!pageLoadSubmitted){collectAndSubmit();deferredCheck()}}catch(e){decision("OUTER_EXCEPTION")}};\ns.onerror=function(){decision("ASSET_LOAD_ERROR")};\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){decision("OUTER_EXCEPTION")}};\nhistory.replaceState=function(){oReplace.apply(this,arguments);try{collectAndSubmit()}catch(e){decision("OUTER_EXCEPTION")}};\nwindow.addEventListener("popstate",function(){try{collectAndSubmit()}catch(e){decision("OUTER_EXCEPTION")}});\n\n// --- 403 interception: record sticky state + dispatch "unshared:flagged" ---\nfunction emitFlagged(body){\n var d={email:body.email||""};\n shared.flagged=d;\n var cbs=shared.flaggedCbs||[];\n for(var i=0;i<cbs.length;i++){try{cbs[i](d)}catch(e){}}\n try{window.dispatchEvent(new CustomEvent("unshared:flagged",{detail:d}))}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){decision("OUTER_EXCEPTION")}\n})();\n<\/script>`}function escapeJavaScript(e){return JSON.stringify(e).slice(1,-1).replace(/<\/(script)/gi,"<\\/$1").replace(new RegExp("\u2028","g"),"\\u2028").replace(new RegExp("\u2029","g"),"\\u2029")}
@@ -1 +1 @@
1
- import{sha256Hex}from"../../util.mjs";import{isBot}from"../utils/is-bot.mjs";import{extractClientIp}from"../utils/client-ip.mjs";import{parseCookie}from"../utils/cookies.mjs";import{extractDeviceId}from"../utils/device-id.mjs";import{isSecureRequest}from"../utils/secure.mjs";import{isSentinelUserId}from"../utils/sentinel-user-id.mjs";import{sendJson}from"../utils/http-helpers.mjs";import{resolveOrCreatePermanentDeviceId}from"../utils/permanent-device-id.mjs";import{runCheckUser,SUBMIT_FP_TIMEOUT_MS}from"../dispatch-processing.mjs";function asRecord(e){return null===e||"object"!=typeof e||Array.isArray(e)?{}:e}export function handleSubmitFingerprint(e){return async(t,i)=>{try{const s=asRecord(t.body),r=asRecord(s.fingerprint??s),o=asRecord(s.context),n=asRecord(o.identity),a=asRecord(o.identifiers),d=asRecord(o.event),c={...r,full_hash:r.hash??r.full_hash??"",fingerprint_id:r.stableHash??r.stable_hash??r.fingerprint_id??"",timestamp:r.timestamp??r.collected_at??(new Date).toISOString(),isIncognito:r.isIncognito??r.is_incognito??!1,components:r.components??{},version:r.version??"unknown",..."remote"===r.source||"bundled"===r.source?{source:r.source}:{}};let p,m,u;try{const i=e.resolveUserId?e.resolveUserId(t):void 0;i&&!isSentinelUserId(i)&&(p=i)}catch{}if(!p){const e="string"==typeof n.user_id?n.user_id:"string"==typeof s.user_id?s.user_id:void 0;e&&!isSentinelUserId(e)&&(p=e)}if(!p){const e=parseCookie(t,"__unshared_uid");e&&!isSentinelUserId(e)&&(p=e)}try{m=e.resolveEmailAddress?e.resolveEmailAddress(t):void 0}catch{}m=m??parseCookie(t,"__unshared_email")??n.email_address??s.email??void 0;try{u=e.resolveSessionId?e.resolveSessionId(t):void 0}catch{}u=u??a.session_hash??s.session_id??parseCookie(t,"__unshared_sid");const f=extractClientIp(t),_=t.headers["user-agent"]??"";if(!e.disableBotFilter&&isBot(_))return void sendJson(i,200,{success:!0,data:{skipped:!0}});const l=(c.fingerprint_id&&c.fingerprint_id.length>0?c.fingerprint_id:void 0)??extractDeviceId(t,e.resolveDeviceId),h=c.fingerprint_id||void 0,y=c.full_hash||void 0,g=resolveOrCreatePermanentDeviceId(t,a.unshared_device_id??s.permanent_device_id),v=isSecureRequest(t)?"; Secure":"",S=[];S.push(`__unshared_device_id=${encodeURIComponent(g.id)}; Path=/; SameSite=Lax; Max-Age=34560000${v}`);const I=parseCookie(t,"__unshared_fingerprint_id")||y;if(I&&S.push(`__unshared_fingerprint_id=${encodeURIComponent(I)}; HttpOnly; Path=/; SameSite=Lax; Max-Age=31536000${v}`),h&&S.push(`__unshared_fp_id=${encodeURIComponent(h)}; Path=/; SameSite=Lax; Max-Age=31536000${v}`),m&&S.push(`__unshared_email=${encodeURIComponent(m)}; HttpOnly; Path=/; SameSite=Lax; Max-Age=31536000${v}`),S.length>0){const e=i.getHeader("Set-Cookie");if(e){const t=Array.isArray(e)?[...e]:[String(e)];t.push(...S),i.setHeader("Set-Cookie",t)}else i.setHeader("Set-Cookie",S)}let j;if("string"==typeof d.type&&d.type)j=d.type;else if("string"==typeof s.event_type&&s.event_type)j=s.event_type;else{const e=t.headers.referer??t.headers.referrer;let i="unknown";if("string"==typeof e&&e.length>0)try{const t=new URL(e);i=(t.pathname||"/")+(t.search||"")}catch{}j=i}const C=t.headers["x-idempotency-key"],x="string"==typeof C&&C.length>0?C:void 0,R=Date.now(),k=x?`${x}|${R}`:h&&p?`${sha256Hex(`${h}|${p}|${j}`)}|${R}`:void 0,b={...a,unshared_device_id:g.id,...l?{device_id:l}:{},...h?{stable_hash:h}:{},...y?{full_hash:y}:{},...u?{session_hash:u}:{}},U="string"==typeof asRecord(o.sdk).name?asRecord(o.sdk).name:void 0,$="string"==typeof U&&U.startsWith("unshared-")&&Object.prototype.hasOwnProperty.call(s,"fingerprint")&&s.context&&"object"==typeof s.context&&!Array.isArray(s.context)&&o.identity&&"object"==typeof o.identity&&!Array.isArray(o.identity)&&o.identifiers&&"object"==typeof o.identifiers&&!Array.isArray(o.identifiers)&&o.event&&"object"==typeof o.event&&!Array.isArray(o.event),A=!Object.prototype.hasOwnProperty.call(s,"context")&&"string"==typeof s.hash&&"string"==typeof s.stable_hash&&"string"==typeof s.collected_at&&s.components&&"object"==typeof s.components&&!Array.isArray(s.components),M=$||A?{...s,fingerprint:r,context:{...o,sdk:asRecord(o.sdk),identity:{...n,...p?{user_id:p}:{},...m?{email_address:m}:{}},identifiers:b,event:{...d,type:j,ip_address:f,user_agent:_}}}:s,O=await e.client.submitFingerprint(M,{idempotencyKey:k});O.success||e.onError?.(O.error,{operation:"submitFingerprintEvent",userId:p,emailAddress:m}),p&&m&&!e.rateLimitBackoff.isPaused()&&!e.dispatchDedupe.wasRecentlyDispatched(p,j)&&await runCheckUser({client:e.client,cache:e.verdictCache,backoff:e.rateLimitBackoff,onError:e.onError},{user_id:p,email_address:m,identifiers:b},p,m,SUBMIT_FP_TIMEOUT_MS),O.success?sendJson(i,200,{success:!0,data:O.data}):sendJson(i,200,{success:!1,error:O.error})}catch(t){e.onError&&e.onError(t,{operation:"submitFingerprintEvent"}),sendJson(i,200,{success:!0,data:{accepted:!1}})}}}
1
+ import{sha256Hex}from"../../util.mjs";import{isBot}from"../utils/is-bot.mjs";import{extractClientIp}from"../utils/client-ip.mjs";import{parseCookie}from"../utils/cookies.mjs";import{isSecureRequest}from"../utils/secure.mjs";import{isSentinelUserId}from"../utils/sentinel-user-id.mjs";import{sendJson}from"../utils/http-helpers.mjs";import{resolveOrCreatePermanentDeviceId}from"../utils/permanent-device-id.mjs";import{runCheckUser,SUBMIT_FP_TIMEOUT_MS}from"../dispatch-processing.mjs";function asRecord(e){return null===e||"object"!=typeof e||Array.isArray(e)?{}:e}export function handleSubmitFingerprint(e){return async(t,s)=>{try{const i=asRecord(t.body),n=asRecord(i.fingerprint??i),o=asRecord(i.context),r=asRecord(o.identity),a=asRecord(o.identifiers),d=asRecord(o.event),c={...n,full_hash:n.hash??n.full_hash??"",fingerprint_id:n.stableHash??n.stable_hash??n.fingerprint_id??"",timestamp:n.timestamp??n.collected_at??(new Date).toISOString(),isIncognito:n.isIncognito??n.is_incognito??!1,components:n.components??{},version:n.version??"unknown",..."remote"===n.source||"bundled"===n.source?{source:n.source}:{}};let p,_,u;try{const s=e.resolveUserId?e.resolveUserId(t):void 0;s&&!isSentinelUserId(s)&&(p=s)}catch{}if(!p){const e="string"==typeof r.user_id?r.user_id:"string"==typeof i.user_id?i.user_id:void 0;e&&!isSentinelUserId(e)&&(p=e)}if(!p){const e=parseCookie(t,"__unshared_uid");e&&!isSentinelUserId(e)&&(p=e)}try{_=e.resolveEmailAddress?e.resolveEmailAddress(t):void 0}catch{}_=_??parseCookie(t,"__unshared_email")??r.email_address??i.email??void 0;try{u=e.resolveSessionId?e.resolveSessionId(t):void 0}catch{}u=u??a.session_hash??i.session_id??parseCookie(t,"__unshared_sid");const m=extractClientIp(t),f=t.headers["user-agent"]??"";if(!e.disableBotFilter&&isBot(f))return void sendJson(s,200,{success:!0,data:{skipped:!0}});let h;try{h=e.resolveDeviceId?.(t)}catch{}const l=[h,t.headers["x-device-id"],a.device_id,c.fingerprint_id,parseCookie(t,"__unshared_stable_hash"),parseCookie(t,"__unshared_fp_id")].find(e=>"string"==typeof e&&""!==e.trim()&&"unknown"!==e.trim()),y=c.fingerprint_id||void 0,g=c.full_hash||void 0,S=resolveOrCreatePermanentDeviceId(t,a.unshared_device_id??i.permanent_device_id),v=isSecureRequest(t)?"; Secure":"",C=[];C.push(`__unshared_device_id=${encodeURIComponent(S.id)}; Path=/; SameSite=Lax; Max-Age=34560000${v}`);const k=parseCookie(t,"__unshared_fingerprint_id")||g;if(k&&C.push(`__unshared_fingerprint_id=${encodeURIComponent(k)}; HttpOnly; Path=/; SameSite=Lax; Max-Age=34560000${v}`),g&&C.push(`__unshared_full_hash=${encodeURIComponent(g)}; Path=/; SameSite=Lax; Max-Age=34560000${v}`),y&&(C.push(`__unshared_fp_id=${encodeURIComponent(y)}; Path=/; SameSite=Lax; Max-Age=34560000${v}`),C.push(`__unshared_stable_hash=${encodeURIComponent(y)}; Path=/; SameSite=Lax; Max-Age=34560000${v}`)),_&&C.push(`__unshared_email=${encodeURIComponent(_)}; HttpOnly; Path=/; SameSite=Lax; Max-Age=31536000${v}`),C.length>0){const e=s.getHeader("Set-Cookie");if(e){const t=Array.isArray(e)?[...e]:[String(e)];t.push(...C),s.setHeader("Set-Cookie",t)}else s.setHeader("Set-Cookie",C)}let I;if("string"==typeof d.type&&d.type)I=d.type;else if("string"==typeof i.event_type&&i.event_type)I=i.event_type;else{const e=t.headers.referer??t.headers.referrer;let s="unknown";if("string"==typeof e&&e.length>0)try{const t=new URL(e);s=(t.pathname||"/")+(t.search||"")}catch{}I=s}const x=t.headers["x-idempotency-key"],R="string"==typeof x&&x.length>0?x:void 0,$=Date.now(),b=R?`${R}|${$}`:y&&p?`${sha256Hex(`${y}|${p}|${I}`)}|${$}`:void 0,j={...a,unshared_device_id:S.id,...l&&"unknown"!==l?{device_id:l}:{},...y?{stable_hash:y}:{},...g?{full_hash:g}:{},...u?{session_hash:u}:{}};l||delete j.device_id;const U="string"==typeof asRecord(o.sdk).name?asRecord(o.sdk).name:void 0,A="string"==typeof U&&U.startsWith("unshared-")&&Object.prototype.hasOwnProperty.call(i,"fingerprint")&&i.context&&"object"==typeof i.context&&!Array.isArray(i.context)&&o.identity&&"object"==typeof o.identity&&!Array.isArray(o.identity)&&o.identifiers&&"object"==typeof o.identifiers&&!Array.isArray(o.identifiers)&&o.event&&"object"==typeof o.event&&!Array.isArray(o.event),M=!Object.prototype.hasOwnProperty.call(i,"context")&&"string"==typeof i.hash&&"string"==typeof i.stable_hash&&"string"==typeof i.collected_at&&i.components&&"object"==typeof i.components&&!Array.isArray(i.components),P=A||M?{...i,fingerprint:n,context:{...o,sdk:asRecord(o.sdk),identity:{...r,...p?{user_id:p}:{},..._?{email_address:_}:{}},identifiers:j,event:{...d,type:I,ip_address:m,user_agent:f}}}:i,w=await e.client.submitFingerprint(P,{idempotencyKey:b});w.success||e.onError?.(w.error,{operation:"submitFingerprintEvent",userId:p,emailAddress:_}),p&&_&&!e.rateLimitBackoff.isPaused()&&!e.dispatchDedupe.wasRecentlyDispatched(p,I)&&await runCheckUser({client:e.client,cache:e.verdictCache,backoff:e.rateLimitBackoff,onError:e.onError},{user_id:p,email_address:_,identifiers:j},p,_,SUBMIT_FP_TIMEOUT_MS),w.success?sendJson(s,200,{success:!0,data:w.data}):sendJson(s,200,{success:!1,error:w.error})}catch(t){e.onError&&e.onError(t,{operation:"submitFingerprintEvent"}),sendJson(s,200,{success:!0,data:{accepted:!1}})}}}
@@ -6,6 +6,7 @@ export interface VerificationDependencies<TReq extends UnsharedRequest = Unshare
6
6
  verdictCache: VerdictCache;
7
7
  resolveEmailAddress?: (req: TReq) => string | undefined;
8
8
  resolveDeviceId?: (req: TReq) => string | undefined;
9
+ resolveSessionId?: (req: TReq) => string | undefined;
9
10
  resolveUserId?: (req: TReq) => string | undefined;
10
11
  onError?: (error: unknown, context: {
11
12
  operation: 'processUserEvent' | 'submitFingerprintEvent' | 'checkUser' | 'verifyTrigger' | 'verify';
@@ -19,7 +20,7 @@ export interface VerificationDependencies<TReq extends UnsharedRequest = Unshare
19
20
  *
20
21
  * Body: { email: string }
21
22
  *
22
- * The deviceId is resolved via extractDeviceId (same as the middleware).
23
+ * Device and hash identifiers are resolved from the same sources as enforcement.
23
24
  */
24
25
  export declare function handleVerifyTrigger<TReq extends UnsharedRequest = UnsharedRequest>(dependencies: VerificationDependencies<TReq>): (req: TReq, res: UnsharedResponse) => Promise<void>;
25
26
  /**
@@ -1 +1 @@
1
- import{parseCookie}from"../utils/cookies.mjs";import{extractDeviceId}from"../utils/device-id.mjs";import{sendJson}from"../utils/http-helpers.mjs";import{extractPermanentDeviceId}from"../utils/permanent-device-id.mjs";import{isSecureRequest}from"../utils/secure.mjs";const VERIFICATION_COOKIE="__unshared_verification_id";function appendCookie(e,i){const s=e.getHeader("Set-Cookie"),r=s?Array.isArray(s)?[...s]:[String(s)]:[];r.push(i),e.setHeader("Set-Cookie",r)}export function handleVerifyTrigger(e){return async(i,s)=>{try{const r=resolveEmail(i,i.body??{},e.resolveEmailAddress);if(!r)return void sendJson(s,400,{success:!1,error:{code:"VALIDATION_ERROR",message:"Email is required"}});const o=extractDeviceId(i,e.resolveDeviceId),t=parseCookie(i,"__unshared_fp_id")||void 0,n=parseCookie(i,"__unshared_fingerprint_id")||void 0,a=extractPermanentDeviceId(i),c=await e.client.triggerEmailVerification(r,o,{fingerprintId:t,fullHash:n,permanentDeviceId:a});if(c.success){if(c.data?.verification_id){const e=isSecureRequest(i)?"; Secure":"";appendCookie(s,`${VERIFICATION_COOKIE}=${encodeURIComponent(c.data.verification_id)}; HttpOnly; Path=/; SameSite=Lax; Max-Age=600${e}`)}sendJson(s,200,{success:!0,data:c.data})}else sendJson(s,200,{success:!1,error:c.error??{code:"TRIGGER_FAILED",message:"Failed to send verification email"}})}catch(i){e.onError&&e.onError(i,{operation:"verifyTrigger"}),sendJson(s,200,{success:!1,error:{code:"INTERNAL_ERROR",message:"Failed to trigger verification"}})}}}export function handleVerify(e){return async(i,s)=>{try{const r=(i.body??{}).code,o=parseCookie(i,VERIFICATION_COOKIE);if(!o||!r)return void sendJson(s,400,{success:!1,error:{code:"VALIDATION_ERROR",message:"Verification ID and code are required"}});const t=await e.client.verifyEmail({verification_id:o,code:r});if(t.success&&t.data?.verified){let r,o=!1;try{r=e.resolveUserId?.(i)}catch{}const n=resolveEmail(i,{},e.resolveEmailAddress),a=extractDeviceId(i,e.resolveDeviceId),c=parseCookie(i,"__unshared_fp_id")||void 0,d=parseCookie(i,"__unshared_fingerprint_id")||void 0,u=extractPermanentDeviceId(i);if(r&&n){const i={...a?{device_id:a}:{},...c?{stable_hash:c}:{},...d?{full_hash:d}:{},...u?{unshared_device_id:u}:{}},s=await e.client.checkUser({user_id:r,email_address:n,identifiers:i},{maxRetries:0});s.success&&s.data&&!s.failedOpen&&!s.data.is_user_flagged&&(e.verdictCache.update(r,{isVerified:!0}),o=!0)}const f=isSecureRequest(i)?"; Secure":"";appendCookie(s,`${VERIFICATION_COOKIE}=; HttpOnly; Path=/; SameSite=Lax; Max-Age=0${f}`),o&&s.setHeader("X-Unshared-Enforcement-Reconciled","clean"),sendJson(s,200,{success:!0,data:t.data})}else t.success?sendJson(s,200,{success:!0,data:t.data}):sendJson(s,200,{success:!1,error:t.error??{code:"VERIFICATION_FAILED",message:"Verification failed"}})}catch(i){e.onError&&e.onError(i,{operation:"verify"}),sendJson(s,200,{success:!1,error:{code:"INTERNAL_ERROR",message:"Verification failed"}})}}}function resolveEmail(e,i,s){if(s)try{const i=s(e);if(i)return i}catch{}const r=parseCookie(e,"__unshared_email");if(r)return r;const o=i.email;return"string"==typeof o&&o?o:void 0}
1
+ import{parseCookie}from"../utils/cookies.mjs";import{extractDeviceIdOrUndefined}from"../utils/device-id.mjs";import{sendJson}from"../utils/http-helpers.mjs";import{extractPermanentDeviceId}from"../utils/permanent-device-id.mjs";import{isSecureRequest}from"../utils/secure.mjs";const VERIFICATION_COOKIE="__unshared_verification_id";function appendCookie(e,s){const i=e.getHeader("Set-Cookie"),r=i?Array.isArray(i)?[...i]:[String(i)]:[];r.push(s),e.setHeader("Set-Cookie",r)}export function handleVerifyTrigger(e){return async(s,i)=>{try{const r=resolveEmail(s,s.body??{},e.resolveEmailAddress);if(!r)return void sendJson(i,400,{success:!1,error:{code:"VALIDATION_ERROR",message:"Email is required"}});const n=extractDeviceIdOrUndefined(s,e.resolveDeviceId),o=parseCookie(s,"__unshared_stable_hash")||parseCookie(s,"__unshared_fp_id")||void 0,a=parseCookie(s,"__unshared_full_hash")||parseCookie(s,"__unshared_fingerprint_id")||void 0,t=extractPermanentDeviceId(s),d=resolveSessionHash(s,e.resolveSessionId),c=await e.client.triggerEmailVerification(r,n&&"unknown"!==n?n:"",{fingerprintId:"unknown"!==o?o:void 0,fullHash:"unknown"!==a?a:void 0,permanentDeviceId:t,sessionHash:d});if(c.success){if(c.data?.verification_id){const e=isSecureRequest(s)?"; Secure":"";appendCookie(i,`${VERIFICATION_COOKIE}=${encodeURIComponent(c.data.verification_id)}; HttpOnly; Path=/; SameSite=Lax; Max-Age=600${e}`)}sendJson(i,200,{success:!0,data:c.data})}else sendJson(i,200,{success:!1,error:c.error??{code:"TRIGGER_FAILED",message:"Failed to send verification email"}})}catch(s){e.onError&&e.onError(s,{operation:"verifyTrigger"}),sendJson(i,200,{success:!1,error:{code:"INTERNAL_ERROR",message:"Failed to trigger verification"}})}}}export function handleVerify(e){return async(s,i)=>{try{const r=(s.body??{}).code,n=parseCookie(s,VERIFICATION_COOKIE);if(!n||!r)return void sendJson(i,400,{success:!1,error:{code:"VALIDATION_ERROR",message:"Verification ID and code are required"}});const o=await e.client.verifyEmail({verification_id:n,code:r});if(o.success&&o.data?.verified){let r,n=!1;try{r=e.resolveUserId?.(s)}catch{}const a=resolveEmail(s,{},e.resolveEmailAddress),t=extractDeviceIdOrUndefined(s,e.resolveDeviceId),d=parseCookie(s,"__unshared_stable_hash")||parseCookie(s,"__unshared_fp_id")||void 0,c=parseCookie(s,"__unshared_full_hash")||parseCookie(s,"__unshared_fingerprint_id")||void 0,u=extractPermanentDeviceId(s),_=resolveSessionHash(s,e.resolveSessionId);if(r&&a){const s={...t&&"unknown"!==t?{device_id:t}:{},...d&&"unknown"!==d?{stable_hash:d}:{},...c&&"unknown"!==c?{full_hash:c}:{},...u?{unshared_device_id:u}:{},..._?{session_hash:_}:{}},i=await e.client.checkUser({user_id:r,email_address:a,identifiers:s},{maxRetries:0});i.success&&i.data&&!i.failedOpen&&!i.data.is_user_flagged&&(e.verdictCache.update(r,{isVerified:!0}),n=!0)}const f=isSecureRequest(s)?"; Secure":"";appendCookie(i,`${VERIFICATION_COOKIE}=; HttpOnly; Path=/; SameSite=Lax; Max-Age=0${f}`),n&&i.setHeader("X-Unshared-Enforcement-Reconciled","clean"),sendJson(i,200,{success:!0,data:o.data})}else o.success?sendJson(i,200,{success:!0,data:o.data}):sendJson(i,200,{success:!1,error:o.error??{code:"VERIFICATION_FAILED",message:"Verification failed"}})}catch(s){e.onError&&e.onError(s,{operation:"verify"}),sendJson(i,200,{success:!1,error:{code:"INTERNAL_ERROR",message:"Verification failed"}})}}}function resolveSessionHash(e,s){let i;try{i=s?.(e)}catch{}return i=i||parseCookie(e,"__unshared_sid"),i&&"unknown"!==i?i:void 0}function resolveEmail(e,s,i){if(i)try{const s=i(e);if(s)return s}catch{}const r=parseCookie(e,"__unshared_email");if(r)return r;const n=s.email;return"string"==typeof n&&n?n:void 0}
@@ -1,6 +1,7 @@
1
1
  import type { UnsharedRequest } from '../../types.mjs';
2
2
  /**
3
- * Resolves device ID from: custom resolver → X-Device-Id header → __unshared_fp_id cookie.
3
+ * Resolves device ID from the custom resolver, X-Device-Id header, then the
4
+ * canonical __unshared_stable_hash cookie or legacy __unshared_fp_id cookie.
4
5
  *
5
6
  * Returns the literal string `"unknown"` when no source provides a value.
6
7
  * Callers that can tolerate a tri-state should use `extractDeviceIdOrUndefined`
@@ -11,7 +12,8 @@ import type { UnsharedRequest } from '../../types.mjs';
11
12
  */
12
13
  export declare function extractDeviceId<TReq extends UnsharedRequest = UnsharedRequest>(req: TReq, resolveDeviceId?: (req: TReq) => string | undefined): string;
13
14
  /**
14
- * Resolves device ID from: custom resolver → X-Device-Id header → __unshared_fp_id cookie.
15
+ * Resolves device ID from the custom resolver, X-Device-Id header, then the
16
+ * canonical __unshared_stable_hash cookie or legacy __unshared_fp_id cookie.
15
17
  * Returns `undefined` when nothing is available. Use this at any call site
16
18
  * that can take a "skip dispatch" branch during the bootstrap window, so we
17
19
  * stop writing `device_id="unknown"` rows to the analytics table on first request.
@@ -1 +1 @@
1
- import{parseCookie}from"./cookies.mjs";export function extractDeviceId(e,r){return extractDeviceIdOrUndefined(e,r)??"unknown"}export function extractDeviceIdOrUndefined(e,r){if(r)try{const t=r(e);if(t)return t}catch{}const t=e.headers["x-device-id"];if("string"==typeof t&&t)return t;return parseCookie(e,"__unshared_fp_id")||void 0}
1
+ import{parseCookie}from"./cookies.mjs";export function extractDeviceId(e,r){return extractDeviceIdOrUndefined(e,r)??"unknown"}export function extractDeviceIdOrUndefined(e,r){if(r)try{const t=r(e);if(t)return t}catch{}const t=e.headers["x-device-id"];if("string"==typeof t&&t)return t;return parseCookie(e,"__unshared_stable_hash")||parseCookie(e,"__unshared_fp_id")||void 0}
@@ -49,7 +49,7 @@ interface InjectWarnState {
49
49
  * single middleware instance; when omitted, the module-level default is used.
50
50
  */
51
51
  declare function injectIntoHtmlResponse(response: Response, fingerprintScript: string, extraCookies?: string[], warnState?: InjectWarnState): Promise<Response>;
52
- declare function fetchAndCacheVerdict(client: UnsharedClient, cache: VerdictCache, userId: string, emailAddress: string, deviceId: string, fingerprintId: string | undefined, permanentDeviceId: string | undefined, sessionId: string, timeoutMs?: number, reportFailOpen?: (reason: 'timeout' | 'http_error' | 'no_device_id', status?: number) => void): Promise<Verdict>;
52
+ declare function fetchAndCacheVerdict(client: UnsharedClient, cache: VerdictCache, userId: string, emailAddress: string, deviceId: string | undefined, fingerprintId: string | undefined, fullHash: string | undefined, permanentDeviceId: string | undefined, sessionId: string, timeoutMs?: number, reportFailOpen?: (reason: 'timeout' | 'http_error' | 'no_device_id', status?: number) => void): Promise<Verdict>;
53
53
  export declare const __test_fetchAndCacheVerdict: typeof fetchAndCacheVerdict;
54
54
  export declare const __test_injectIntoHtmlResponse: typeof injectIntoHtmlResponse;
55
55
  export declare function __test_resetInjectWarning(): void;