unshared-clientjs-sdk 3.0.0-rc.14 → 3.0.0-rc.15
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +187 -15
- package/dist/esm/middleware/dispatch-processing.d.mts +5 -2
- package/dist/esm/middleware/dispatch-processing.mjs +1 -1
- package/dist/esm/middleware/index.d.mts +3 -1
- package/dist/esm/middleware/index.mjs +1 -1
- package/dist/esm/middleware/injection/fingerprint-script.d.mts +2 -0
- package/dist/esm/middleware/injection/fingerprint-script.mjs +1 -1
- package/dist/esm/middleware/routes/submit-fp.mjs +1 -1
- package/dist/esm/middleware/routes/verify.d.mts +2 -1
- package/dist/esm/middleware/routes/verify.mjs +1 -1
- package/dist/esm/middleware/utils/device-id.d.mts +4 -2
- package/dist/esm/middleware/utils/device-id.mjs +1 -1
- package/dist/esm/web/protection-handler.d.mts +1 -1
- package/dist/esm/web/protection-handler.mjs +1 -1
- package/dist/esm/web/types.d.mts +4 -2
- package/dist/esm/web/web-helpers.d.mts +2 -1
- package/dist/esm/web/web-helpers.mjs +1 -1
- package/dist/middleware/dispatch-processing.d.ts +5 -2
- package/dist/middleware/dispatch-processing.js +1 -1
- package/dist/middleware/index.d.ts +3 -1
- package/dist/middleware/index.js +1 -1
- package/dist/middleware/injection/fingerprint-script.d.ts +2 -0
- package/dist/middleware/injection/fingerprint-script.js +1 -1
- package/dist/middleware/routes/submit-fp.js +1 -1
- package/dist/middleware/routes/verify.d.ts +2 -1
- package/dist/middleware/routes/verify.js +1 -1
- package/dist/middleware/utils/device-id.d.ts +4 -2
- package/dist/middleware/utils/device-id.js +1 -1
- package/dist/web/protection-handler.d.ts +1 -1
- package/dist/web/protection-handler.js +1 -1
- package/dist/web/types.d.ts +4 -2
- package/dist/web/web-helpers.d.ts +2 -1
- package/dist/web/web-helpers.js +1 -1
- 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
|
-
|
|
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
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
|
203
|
-
|
|
204
|
-
| `
|
|
205
|
-
| `
|
|
206
|
-
| `
|
|
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
|
-
|
|
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
|
|
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.
|
|
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,
|
|
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,
|
|
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
|
|
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}`)}
|
|
@@ -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{
|
|
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
|
-
*
|
|
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{
|
|
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
|
|
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
|
|
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;
|