unshared-frontend-sdk 2.3.0 → 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 CHANGED
@@ -20,7 +20,7 @@ await client.init({
20
20
  const { data } = await client.checkUser();
21
21
  if (data?.is_user_flagged) {
22
22
  await client.triggerEmailVerification(); // emails a 6-digit code
23
- const result = await client.verify(codeFromUser); // → { verified: true }
23
+ const result = await client.verify(codeFromUser); // -> { verified: true }
24
24
  }
25
25
  ```
26
26
 
@@ -34,6 +34,12 @@ Direct-mode reads are protected server-side: the publishable key only answers fo
34
34
  npm install unshared-frontend-sdk
35
35
  ```
36
36
 
37
+ The bundled fallback uses exact public npm dependency `unshared-fingerprint-lib@1.5.2`
38
+ (Node >=18.20 for installation/tooling). No vendored tarball is required. S3-first
39
+ loading remains the default. Version 1.5.2 filters components only in the library's
40
+ own `sendFingerprint` reporter; this SDK preserves complete raw collection results
41
+ and applies its existing payload-size policy instead.
42
+
37
43
  Or via CDN (no build step) — see [CDN / UMD usage](#cdn--umd-usage) below for the complete example.
38
44
 
39
45
  ---
@@ -54,7 +60,7 @@ const fingerprint = await client.collect();
54
60
  client.submitFingerprintEvent(fingerprint, { userId: currentUser?.id });
55
61
  ```
56
62
 
57
- That's it. The SDK handles retries, timeouts, and errors silently.
63
+ That's it. The SDK handles timeouts and errors without throwing. Read-only calls may retry; Trigger and Verify never retry.
58
64
 
59
65
  > **Using this SDK alongside the Node `unsharedBoundToUser` middleware (Tier 1)?** The middleware
60
66
  > also auto-injects an inline fingerprint script into your HTML. That is expected — the two
@@ -74,13 +80,19 @@ new UnsharedBrowser({
74
80
  });
75
81
  ```
76
82
 
83
+ ### BrowserConfig
84
+
77
85
  | Option | Type | Default | Description |
78
86
  |--------|------|---------|-------------|
79
87
  | `baseUrl` | `string` | `undefined` | Proxy mode: base URL of your backend. Use `""` when your frontend and backend share the same domain. Omitting both `baseUrl` and `publishableKey` defaults to same-origin proxy mode (unless a key was baked into a client-specific build — see below). |
80
88
  | `publishableKey` | `string` | — | Direct mode: publishable key (`upk_…`) issued by Unshared Labs. When set, events bypass your backend and go straight to the platform. |
81
89
  | `apiUrl` | `string` | `https://api.unshared.ai` | Direct mode: Unshared Labs platform origin. |
82
- | `maxRetries` | `number` | `3` | How many times to retry on failure |
90
+ | `maxRetries` | `number` | `3` | Retry budget for eligible operations |
83
91
  | `timeout` | `number` | `30000` | Per-attempt timeout in milliseconds |
92
+ | `includePathPrefix` | `string[]` | Unset (all paths) | Literal `location.pathname` prefixes for lifecycle event submission; `[]` submits on none. This is browser-side filtering, not server enforcement. |
93
+ | `skipPaths` | `string[]` | Unset | Literal pathname prefixes excluded from lifecycle submission; wins over includes. Does not prevent explicit `init()` from establishing identity/collecting, or disable low-level collection methods. |
94
+ | `sessionId` | `() => string \| undefined` | Cookie, then UUID | Resolver runs at construction; a defined value wins over `__unshared_sid`/generated UUID and is persisted to the correlation cookie. Not an auth-session TTL setting. |
95
+ | `deviceId` | `() => string \| undefined` | Legacy stored device, then collected stable hash | Resolver runs at construction and init; a nonempty value overrides the device identifier, not the canonical collected hashes or separate permanent ID. |
84
96
  | `enableInterstitial` | `boolean` | `false` | Auto-render the interstitial modal when the user is flagged (via the `unshared:flagged` event); works in direct or proxy mode. See [Interstitial modal](#interstitial-modal). |
85
97
  | `interstitialFlowType` | `string` | `email_verification` | Flow type requested for the auto-shown interstitial. |
86
98
 
@@ -98,7 +110,14 @@ Build a client-specific distribution with:
98
110
  UNSHARED_PUBLISHABLE_API_KEY=upk_test_or_client_key npm --prefix sdks/javascript/browser run build
99
111
  ```
100
112
 
101
- Passing `baseUrl`, including `baseUrl: ""`, keeps proxy mode and suppresses the packaged-key fallback.
113
+ Passing `baseUrl`, including `baseUrl: ""`, suppresses the packaged-key fallback
114
+ and keeps proxy mode unless an explicit nonempty `publishableKey` is supplied.
115
+
116
+ An explicit nonempty `publishableKey` takes precedence even when `baseUrl` is also
117
+ passed; `baseUrl` suppresses only the packaged-key fallback. Use HTTPS origins
118
+ (HTTP is accepted only for loopback development), and never put a secret `usk_` key
119
+ in browser config. `maxRetries` applies only to eligible reads, not ingestion,
120
+ Trigger or Verify.
102
121
 
103
122
  The build applies targeted obfuscation before minification to the packaged publishable key and the default API URL so they are not present as obvious raw string literals in `dist/index.umd.js`. This is only a friction layer against casual source inspection; the browser still reconstructs those values at runtime, and server-side origin allowlists/rate limits remain the real security controls.
104
123
 
@@ -117,6 +136,44 @@ Use the confidential-docs override only for local investigation; do not commit m
117
136
 
118
137
  ## Methods
119
138
 
139
+ ### Identity and Lifecycle
140
+
141
+ Call `init({ userId, emailAddress, isPaidSubscriber })` after authenticated identity
142
+ is available, `onRouteChange()` on SPA navigation, and `client.destroy()` on logout
143
+ or teardown. `isPaidSubscriber: false` suppresses collection/submission for that
144
+ user. `destroy()` clears cached collection/login state and detaches listeners; it
145
+ does not erase permanent device/hash cookies or change your application's auth TTL.
146
+
147
+ Init and MPA collection reuse a paired raw/wire fingerprint cache: the complete raw
148
+ JSON is in per-tab sessionStorage `__unshared_fp_raw`, and the normalized wire view
149
+ is in `__unshared_fp`. SPA events reuse this pair rather than recollecting; overlapping
150
+ init/MPA collection shares one pending collection per SDK instance. Old wire-only
151
+ caches are recollected. In-memory fallback keeps the current instance usable when
152
+ storage is blocked, but cannot persist across reloads.
153
+
154
+ Eligible init/route submission activity refreshes readable correlation cookies to
155
+ rolling **400 days (`Max-Age=34560000`) before deduplication**, even if no new event
156
+ is sent: `__unshared_stable_hash`, `__unshared_full_hash`, `__unshared_device_id`,
157
+ legacy stable alias `__unshared_fp_id`, and `__unshared_sid`. The permanent
158
+ `__unshared_device_id=upid_<uuid-v4>` is independent of mutable hashes, backed by
159
+ localStorage; an existing valid localStorage ID takes precedence over a cookie when
160
+ restoring it. Cookies use `Path=/`, `SameSite=Lax` and `Secure` on HTTPS. Browser
161
+ privacy controls, storage blocking and user deletion can shorten retention.
162
+
163
+ With the protection proxy, the server also writes the legacy HttpOnly
164
+ `__unshared_fingerprint_id`: it preserves the first full hash, while canonical
165
+ `__unshared_full_hash` tracks the current collection. Only a server response can
166
+ renew that HttpOnly alias. These correlation lifetimes do not extend authentication:
167
+ the SDK UID/email server cookies retain their existing 365-day TTL and the
168
+ verification challenge remains ten minutes. The per-tab collection cache is not a
169
+ 400-day fingerprint snapshot.
170
+
171
+ Lifecycle submissions share `window.__unshared.lastKey` with the inline middleware
172
+ script and mirror the last `(user, pathname + query)` in sessionStorage. Consecutive
173
+ duplicates are suppressed, including reloads in the tab; A-to-B-to-A can submit
174
+ again. This is not guaranteed delivery: no persistent replay queue is maintained,
175
+ old queue entries are discarded, and failed ingestion is not retried.
176
+
120
177
  ### `collect()`
121
178
 
122
179
  Collect a browser fingerprint. Returns a `FingerprintWireFormat` object ready to pass to `submitFingerprintEvent`.
@@ -125,6 +182,14 @@ Collect a browser fingerprint. Returns a `FingerprintWireFormat` object ready to
125
182
  const fingerprint = await client.collect();
126
183
  ```
127
184
 
185
+ ### `collectRaw()`
186
+
187
+ Collect the fingerprint library's complete JSON object without reducing it to the compatibility wire type.
188
+
189
+ ```typescript
190
+ const rawFingerprint = await client.collectRaw();
191
+ ```
192
+
128
193
  ---
129
194
 
130
195
  ### `submitFingerprintEvent(fingerprint, opts?)`
@@ -154,17 +219,50 @@ if (!result.success) {
154
219
 
155
220
  ---
156
221
 
222
+ ### `submitFingerprint(payload)`
223
+
224
+ Submit any JSON object without reshaping or filtering unknown fields. Direct mode encrypts the complete object with the publishable key; proxy mode keeps the browser-to-customer hop plaintext so middleware can add trusted context before encrypting the final request to Unshared.
225
+
226
+ ```typescript
227
+ await client.submitFingerprint({
228
+ fingerprint: await client.collectRaw(),
229
+ context: {
230
+ identity: { user_id: currentUser?.id },
231
+ event: { type: location.pathname + location.search },
232
+ },
233
+ });
234
+ ```
235
+
236
+ The payload must be a non-null JSON object; arrays and primitives fail locally with `VALIDATION_ERROR`. Direct requests larger than 1 MiB fail locally with `REQUEST_TOO_LARGE`. Direct ingestion succeeds only on a strict `202` v3 acknowledgement.
237
+
238
+ Proxy submissions have a fixed **100 KiB (102,400 serialized UTF-8 bytes)** limit,
239
+ compatible with an upstream default `express.json()` without configuration or
240
+ middleware reordering. Structured submissions send the full snapshot if it fits;
241
+ otherwise they empty local/session storage lists while retaining all cookies, then
242
+ omit `context.browser_storage` if still too large. The complete raw fingerprint,
243
+ including future fields, and core identity/device/session/SDK/event metadata are
244
+ never trimmed. Oversized core payloads return `REQUEST_TOO_LARGE` without sending;
245
+ the inline submitter logs that code. Low-level `submitFingerprint(payload)` never
246
+ reduces arbitrary caller objects: it sends them exactly or rejects above 100 KiB.
247
+ Direct mode keeps full snapshots and its existing 1 MiB logical / 1.5 MiB encrypted
248
+ limits. Duplicate fingerprint caches and legacy queues are excluded from snapshots.
249
+
250
+ ---
251
+
157
252
  ### Direct-mode API (requires `publishableKey`)
158
253
 
159
254
  All four methods default to the identity captured by `init()` (email + stable fingerprint hash as deviceId); pass `{ email, deviceId }` to override. In proxy mode they return `error.code === 'DIRECT_MODE_REQUIRED'` — proxy-mode apps get these flows from their own backend middleware.
160
255
 
161
256
  | Method | Retries | Returns |
162
257
  |--------|---------|---------|
163
- | `checkUser(opts?)` | yes | `{ is_user_flagged: boolean }` |
164
- | `triggerEmailVerification(opts?)` | **never** (each call emails a code) | `{ next_allowed_at, retry_after_seconds }`; rate-limited calls fail with `error.retryAfter` / `error.nextAllowedAt` for countdown UIs |
165
- | `verify(code, opts?)` | **never** (attempts are budgeted) | `{ verified: boolean, reason?: 'invalid_code' }` |
258
+ | `checkUser(opts?)` | bounded availability failures only | `{ is_user_flagged, decision_id, decision_code }` |
259
+ | `triggerEmailVerification(opts?)` | **never** (each call emails a code) | `{ verification_id, next_allowed_at, retry_after_seconds }`; the SDK retains the challenge for `verify(...)` |
260
+ | `verify(code, opts?)` | **never** (attempts are budgeted) | Exact verified/not-verified result; UUIDv4 challenges and 6-digit OTPs are validated locally |
261
+ | `verifyEmail({ verification_id, code })` | **never** | Verification for an explicit challenge ID; reconciles flagged state when retained identity is available |
166
262
  | `emailVerificationStatus(opts?)` | yes | `{ can_send: boolean, next_allowed_at }` |
167
263
 
264
+ V3 methods require exact success/error envelopes and endpoint-specific status/result shapes. After `verified: true`, the SDK performs one no-retry `checkUser` reconciliation; sticky flagged state is cleared only by a strict available unflagged decision, never by Verify alone or `CHECK_UNAVAILABLE`.
265
+
168
266
  ---
169
267
 
170
268
  ## Interstitial modal
@@ -175,9 +273,15 @@ flow is authored in the Unshared dashboard and stored per company; the SDK fetch
175
273
  published definition from `GET /v2/browser/interstitial-flow`, renders it in a
176
274
  Shadow-DOM-isolated modal, and routes the flow's actions back through
177
275
  `triggerEmailVerification()` / `verify()` above — so all verification logic stays
178
- server-side. Direct mode only (requires `publishableKey`).
276
+ server-side. Direct mode uses `publishableKey`; proxy mode is described below.
179
277
 
180
- **Proxy mode is supported too.** When the SDK is constructed with `baseUrl` (no `publishableKey`) and your backend runs the Node middleware, `showInterstitial()` fetches the flow through `GET {baseUrl}/__unshared/interstitial-flow` and runs the modal's actions through the middleware's `/__unshared/verify-trigger` and `/__unshared/verify` routes. The secret key never leaves your server, and the user's identity is resolved server-side — the browser sends only the OTP. This requires your middleware to resolve the email server-side (a `resolveEmailAddress` resolver or the `__unshared_email` cookie), the same requirement as the verify routes.
278
+ **Proxy mode is supported too.** When the SDK is constructed with `baseUrl` (no `publishableKey`) and your backend runs the Node middleware, `showInterstitial()` fetches the flow through `GET {baseUrl}/__unshared/interstitial-flow` and runs the modal's actions through the middleware's `/__unshared/verify-trigger` and `/__unshared/verify` routes. The secret key never leaves your server, and the user's identity is resolved server-side; the browser sends only the OTP. Configure the middleware's trusted `emailAddress` resolver (preferred over the `__unshared_email` cookie fallback).
279
+
280
+ Publish the requested web flow for your company in the dashboard before enabling
281
+ the modal; a draft/missing flow cannot render. A modal or blur alone is not access
282
+ control: use server-side gate/403 enforcement for protected content. Verification
283
+ rate limits and attempt budgets are existing platform controls, not new browser
284
+ functionality; additional custom gates/rate limits belong to your app or gateway.
181
285
 
182
286
  ### `showInterstitial(opts?)`
183
287
 
@@ -232,9 +336,34 @@ const app = express();
232
336
  const client = new UnsharedClient({ apiKey: process.env.UNSHARED_API_KEY });
233
337
 
234
338
  app.use(express.json());
235
- app.use(unsharedBoundToUser(client, { userId: (req) => req.session?.user?.id }));
339
+ // Mount your authentication/session middleware before the SDK.
340
+ const middleware = unsharedBoundToUser(client, {
341
+ userId: (req) => req.session?.user?.id,
342
+ emailAddress: (req) => req.session?.user?.email,
343
+ });
344
+ app.use(middleware);
345
+ // Call middleware.destroy() on server shutdown/test teardown/before re-mounting.
236
346
  ```
237
347
 
348
+ Node `includePathPrefix` scopes ordinary checks/enforcement/events, not identity
349
+ bootstrap or eligible HTML injection. For example, include `/api/article/` to gate
350
+ article data while `/api/me/list` remains outside enforcement; the surrounding HTML
351
+ can still bootstrap fingerprinting. Node `skipPaths` truly bypasses the SDK for
352
+ matching application requests. These server options are separate from browser
353
+ lifecycle path filters. See the [complete protection config](../node/README.md#protectionconfig).
354
+
355
+ The Node middleware serves both `/__unshared/fp.js` (browser SDK) and
356
+ `/__unshared/fingerprint.js` (agent); unavailable bundles return 503 with
357
+ `Cache-Control: no-store`. The Web Standard handler instead requires a manually
358
+ supplied `fingerprintSdkBundle` for `fp.js` and does not automatically serve
359
+ `fingerprint.js` or `interstitial-flow`; wire those separately when needed.
360
+
361
+ For the injected script, middleware `debug: true` exposes PII-free reason codes and
362
+ optional HTTP status in `window.__unshared.lastDecision` and `[Unshared]` debug logs
363
+ (for example `MISSING_UID`, `DEDUP_SKIPPED`, `ASSET_LOAD_ERROR`, `SUBMIT_SUCCESS`).
364
+ Middleware `disableBotFilter: true` also reaches the inline submitter for E2E tests.
365
+ Neither is a `BrowserConfig` option; the standalone SDK's bot filter is unchanged.
366
+
238
367
  ---
239
368
 
240
369
  ## Error handling
@@ -248,7 +377,7 @@ const result = await client.submitFingerprintEvent(fingerprint);
248
377
  // result.error.code === 'DELIVERY_FAILED'
249
378
  ```
250
379
 
251
- Retries happen automatically on network errors, timeouts, and server errors (5xx). Client errors (4xx) are not retried.
380
+ Read-only direct calls retry eligible network and 5xx failures. Fingerprint ingestion, Trigger, and Verify are never retried because an ambiguous request may already have committed. Failed ingestion is not queued for replay.
252
381
 
253
382
  ---
254
383
 
@@ -257,7 +386,7 @@ Retries happen automatically on network errors, timeouts, and server errors (5xx
257
386
  The UMD bundle exposes a `window.UnsharedBrowser` namespace object. Destructure the class from it first. If the bundle was built with `UNSHARED_PUBLISHABLE_API_KEY`, the client does not pass a key:
258
387
 
259
388
  ```html
260
- <script src="https://unpkg.com/unshared-frontend-sdk@2.0.0-rc.4/dist/index.umd.js"></script>
389
+ <script src="https://unpkg.com/unshared-frontend-sdk@3.0.0/dist/index.umd.js"></script>
261
390
  <script>
262
391
  const { UnsharedBrowser } = window.UnsharedBrowser;
263
392
 
@@ -276,29 +405,6 @@ For proxy mode instead, pass `baseUrl` explicitly.
276
405
 
277
406
  ---
278
407
 
279
- ## Local retry queue encryption
280
-
281
- When a fingerprint event still fails after all retry attempts, the browser SDK stores it in `localStorage` under `__unshared_event_queue` and retries it before the next regular fingerprint submission. Queue records are encrypted before storage so the event payload is not directly readable from browser devtools.
282
-
283
- Encryption details:
284
-
285
- - Algorithm: AES-GCM.
286
- - IV: random 12-byte IV per queued event.
287
- - Stored format: `v1:<base64 iv>:<base64 ciphertext>`.
288
- - Queue key derivation:
289
-
290
- ```text
291
- SHA-256("unshared-browser-queue:" + (publishableKey || baseUrl) + ":" + sessionId)
292
- ```
293
-
294
- That SHA-256 digest is imported as the AES-GCM key through Web Crypto. The SDK does **not** use `API_KEY_ENCRYPTION_SECRET`; that kind of secret must stay server-side and must not be shipped in browser code.
295
-
296
- This is intentionally best-effort local confidentiality, not a strong security boundary. In direct mode, the publishable key is public, and the session ID is browser-local state. That means the queue encryption protects against casual plaintext inspection of `localStorage`, but it does not protect against a user who can run JavaScript in their own browser context or inspect the loaded SDK. The browser SDK therefore treats queue encryption as obfuscation plus integrity protection for local retry data, not as secret storage.
297
-
298
- Using the publishable key in this derivation is acceptable for this limited purpose because no server secret is available in the browser. It should not be described as making local data unrecoverable from the end user. If stronger browser-local protection is required, the SDK would need a different design, such as server-held queued events, short-lived server-issued wrapping keys, or platform storage that never exposes plaintext to arbitrary page JavaScript.
299
-
300
- ---
301
-
302
408
  ## Real-browser acceptance coverage
303
409
 
304
410
  For Boston Globe style script-tag integration, split client-side browser tests into host-site flows and SDK-owned delivery behavior.
@@ -313,7 +419,4 @@ For Boston Globe style script-tag integration, split client-side browser tests i
313
419
  | Password reset completion failures | Wrong current password; mismatched new-password entries; password-rule failures; missing required values. | Host-site auth behavior. Test in the client's browser suite. |
314
420
  | Page navigation | Full page loads with script tag; link clicks; forward/back; refresh with same script tag injected. | SDK supports script-tag load, `init`, MPA `DOMContentLoaded`, and SPA route-change submission. Real-browser tests should assert the UMD bundle loads and the client page still renders. |
315
421
  | Event firing | Event fires where applicable after user identity is present. | Implemented by `init`, `onRouteChange`, MPA listener, and direct/proxy submit endpoints. Covered by unit tests; should also be verified in real browser against mocked or test backend responses. |
316
- | Retry | Failed event sends retry until `maxRetries` is exhausted. | Implemented and unit-tested. |
317
- | Encrypted local queue | After retry exhaustion, failed events are stored in local cache and are not directly readable as plaintext. | Implemented with AES-GCM encrypted `localStorage` queue and unit-tested. |
318
- | Local queue storage failure | Storage unavailable, quota exceeded, or crypto unavailable. | Implemented as never-throw best effort; event returns delivery failure and storage failure is swallowed. Unit-tested for storage failure. |
319
- | Queue flush | Stored events transmit on the next regular event opportunity. | Implemented by flushing the encrypted queue before the next fingerprint submission. Unit-tested. |
422
+ | Retry | Read-only checks may retry; ingestion, Trigger, and Verify make one attempt and are never queued. | Implemented and unit-tested. |
package/dist/browser.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { FingerprintWireFormat } from '@unshared-labs/shared-types';
1
+ import type { CheckUserResult, FingerprintWireFormat, IngestResult, JSONObject, VerifyEmailRequest, VerifyEmailResult } from './shared-types';
2
2
  export interface BrowserConfig {
3
3
  /**
4
4
  * Base URL of the customer's own backend.
@@ -78,11 +78,15 @@ export interface InitOptions {
78
78
  export interface SubmitFingerprintOptions {
79
79
  userId: string;
80
80
  eventType?: string;
81
+ permanentDeviceId?: string;
81
82
  }
82
83
  export interface SubmitFingerprintResult {
83
84
  hash: string;
84
85
  stable_hash: string;
85
86
  collected_at: string;
87
+ version: string;
88
+ event_id?: string;
89
+ endpoint_version?: 'v3';
86
90
  }
87
91
  export interface BrowserApiResult<T = unknown> {
88
92
  success: boolean;
@@ -101,18 +105,22 @@ export interface BrowserApiResult<T = unknown> {
101
105
  export interface DirectIdentityOptions {
102
106
  email?: string;
103
107
  deviceId?: string;
108
+ permanentDeviceId?: string;
109
+ stableHash?: string;
110
+ fullHash?: string;
104
111
  }
105
- export interface CheckUserData {
106
- is_user_flagged: boolean;
112
+ export interface CheckUserData extends CheckUserResult {
107
113
  }
108
114
  export interface TriggerVerificationData {
109
115
  message?: string;
116
+ verification_id?: string;
110
117
  next_allowed_at?: string;
111
118
  retry_after_seconds?: number;
112
119
  }
113
120
  export interface VerifyData {
114
121
  verified: boolean;
115
122
  reason?: string;
123
+ verification_scope?: 'email';
116
124
  }
117
125
  export interface VerificationStatusData {
118
126
  can_send: boolean;
@@ -121,6 +129,11 @@ export interface VerificationStatusData {
121
129
  export interface FlaggedInterceptorOptions {
122
130
  onFlagged: () => void;
123
131
  }
132
+ interface CollectedFingerprint {
133
+ raw: JSONObject;
134
+ wire: FingerprintWireFormat;
135
+ source: 'remote' | 'bundled';
136
+ }
124
137
  /**
125
138
  * Browser SDK for Unshared Labs.
126
139
  *
@@ -153,12 +166,13 @@ export declare class UnsharedBrowser {
153
166
  private readonly _resolveDeviceId?;
154
167
  private _sessionId;
155
168
  private _deviceId;
169
+ private _permanentDeviceId;
156
170
  private _userId;
157
171
  private _emailAddress;
158
172
  /** True after init() with isPaidSubscriber:false — blocks every submission path. */
159
173
  private _doNotCollect;
160
174
  private _mpaHandler;
161
- private _flushingQueue;
175
+ private readonly _lifecycleState;
162
176
  /** Interstitial auto-show config + live state. */
163
177
  private readonly _enableInterstitial;
164
178
  private readonly _interstitialFlowType;
@@ -166,22 +180,6 @@ export declare class UnsharedBrowser {
166
180
  private _interstitialHandle;
167
181
  /** Guards against rendering the modal more than once per flagged signal. */
168
182
  private _interstitialOpen;
169
- /**
170
- * Dedup key for the last fingerprint submission: `${userId}|${route}`.
171
- * Modern SPAs fire pushState/replaceState multiple times during hydration
172
- * with the same URL — without this guard each call would generate a
173
- * redundant FP row with an identical stable_hash.
174
- *
175
- * This is the IN-MEMORY layer (collapses bursts from this one instance). It
176
- * works alongside two other client-side layers keyed on the same identity:
177
- * the sessionStorage `__unshared_last_submit` mirror (survives hard reloads
178
- * and SDK re-instantiation within the tab) and the page-scoped
179
- * `window.__unshared.lastKey` guard shared with the auto-injected inline
180
- * script (see getSharedDedup). Dedup is CLIENT-SIDE ONLY: the middleware
181
- * appends a timestamp to X-Idempotency-Key (submit-fp.ts), so the backend
182
- * drops only PubSub redeliveries, never two distinct submissions.
183
- */
184
- private _lastSubmitKey;
185
183
  constructor(config?: BrowserConfig);
186
184
  /**
187
185
  * Initialize the SDK after user login.
@@ -211,17 +209,32 @@ export declare class UnsharedBrowser {
211
209
  */
212
210
  collect(options?: {
213
211
  exclude?: string[];
212
+ [key: string]: unknown;
214
213
  }): Promise<FingerprintWireFormat>;
214
+ /** Returns the fingerprint agent's JSON object without selecting or renaming fields. */
215
+ collectRaw(options?: {
216
+ exclude?: string[];
217
+ [key: string]: unknown;
218
+ }): Promise<JSONObject>;
219
+ /** Collect once, keeping the agent's raw object separate from normalized SDK metadata. */
220
+ collectWithMetadata(options?: {
221
+ exclude?: string[];
222
+ [key: string]: unknown;
223
+ }): Promise<CollectedFingerprint>;
224
+ private _collectFingerprint;
215
225
  /**
216
226
  * Submit a fingerprint event directly.
217
227
  * @deprecated Prefer sdk.init() and sdk.onRouteChange().
218
228
  */
219
229
  submitFingerprintEvent(fingerprint: FingerprintWireFormat, opts: SubmitFingerprintOptions): Promise<BrowserApiResult<SubmitFingerprintResult>>;
230
+ /** Submit any JSON object to the v3 fingerprint endpoint without changing it. */
231
+ submitFingerprint(payload: JSONObject): Promise<BrowserApiResult<IngestResult>>;
220
232
  /**
221
233
  * Check whether the current user is flagged for account sharing.
222
234
  * Direct mode only — proxy-mode apps get verdicts from their own backend.
223
235
  */
224
236
  checkUser(opts?: DirectIdentityOptions): Promise<BrowserApiResult<CheckUserData>>;
237
+ private _checkUser;
225
238
  /**
226
239
  * Send a 6-digit verification code to the user's email.
227
240
  * Never retried — each attempt sends a real email. Rate-limited responses
@@ -233,6 +246,8 @@ export declare class UnsharedBrowser {
233
246
  * budgeted server-side to block brute force.
234
247
  */
235
248
  verify(code: string, opts?: DirectIdentityOptions): Promise<BrowserApiResult<VerifyData>>;
249
+ verifyEmail(request: VerifyEmailRequest): Promise<BrowserApiResult<VerifyEmailResult>>;
250
+ private _verifyEmail;
236
251
  /** Report the email-send cooldown state without sending anything. */
237
252
  emailVerificationStatus(opts?: DirectIdentityOptions): Promise<BrowserApiResult<VerificationStatusData>>;
238
253
  /**
@@ -268,7 +283,11 @@ export declare class UnsharedBrowser {
268
283
  private _directApi;
269
284
  private _getStoredEmail;
270
285
  private _getCachedFingerprint;
271
- private _cacheFingerprint;
286
+ private _getCachedRawFingerprint;
287
+ private _getLifecycleCollection;
288
+ private _getOrCollectFingerprint;
289
+ private _storedVerificationId;
290
+ private _storedVerificationIdentity;
272
291
  private _shouldProcessPath;
273
292
  private _submitEvent;
274
293
  /**
@@ -278,39 +297,10 @@ export declare class UnsharedBrowser {
278
297
  private _submitUrl;
279
298
  private _attachMpaListener;
280
299
  private _sendWithRetry;
281
- private _piiKeyPromise;
282
- /** AES-GCM key derived as SHA-256(publishable key) — the same derivation the
283
- * platform applies to SDK ciphertext, so it can decrypt at ingress. */
284
- private _piiKey;
285
- /**
286
- * Encrypt a PII field (user_id, email_address) for direct-mode requests:
287
- * AES-256-GCM keyed by SHA-256(publishable key), emitted as
288
- * `base64(iv):base64(authTag):base64(ciphertext)` — byte-compatible with the
289
- * Node SDK's encryptData, so the platform's existing ciphertext detection and
290
- * decryption handle it unchanged. Returns the plaintext when WebCrypto is
291
- * unavailable or fails: the ingress accepts both and always (re-)encrypts PII
292
- * with the company secret key before anything is stored.
293
- */
294
- private _encryptPII;
295
- private _queueKey;
296
- private _encryptQueuedDelivery;
297
- private _decryptQueuedDelivery;
298
- private _readQueueRecords;
299
- private _writeQueueRecords;
300
- private _queueFailedDelivery;
301
- /**
302
- * Recover a batch orphaned by a crashed/killed tab mid-flush. A flush claims
303
- * its batch by leasing it here BEFORE clearing the main queue (see
304
- * _flushQueuedEvents); if the process dies before the `finally` that removes
305
- * the lease runs, the claimed records would otherwise be lost forever. A
306
- * STALE lease (older than INFLIGHT_LEASE_MS) is presumed orphaned and merged
307
- * back into the main queue. A FRESH lease means another tab is genuinely
308
- * mid-flight — leave it untouched, or this would recreate the double-send
309
- * that the claim-then-clear scheme exists to prevent.
310
- */
311
- private _recoverStaleInflightLease;
312
- private _flushQueuedEvents;
313
- private _buildBody;
300
+ private _buildIdentifiers;
301
+ private _buildEventPayload;
302
+ private _ensurePermanentDeviceId;
303
+ private _isSameOriginProxy;
314
304
  }
315
305
  /**
316
306
  * Creates an Axios response error interceptor that calls onFlagged
@@ -338,3 +328,4 @@ export declare function createAxiosInterceptor(opts: FlaggedInterceptorOptions):
338
328
  * ```
339
329
  */
340
330
  export declare function createFetchInterceptor(originalFetch: typeof fetch, opts: FlaggedInterceptorOptions): typeof fetch;
331
+ export {};
@@ -1,5 +1,6 @@
1
- import type { FingerprintConfig, FingerprintResult } from 'unshared-fingerprint-lib';
2
- export type GetFingerprint = (config?: FingerprintConfig) => Promise<FingerprintResult>;
1
+ import type { FingerprintConfig } from 'unshared-fingerprint-lib';
2
+ import type { JSONObject } from './shared-types';
3
+ export type GetFingerprint = (config?: FingerprintConfig) => Promise<JSONObject>;
3
4
  export interface ResolvedAgent {
4
5
  getFingerprint: GetFingerprint;
5
6
  source: 'remote' | 'bundled';