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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -80,6 +80,8 @@ new UnsharedBrowser({
80
80
  });
81
81
  ```
82
82
 
83
+ ### BrowserConfig
84
+
83
85
  | Option | Type | Default | Description |
84
86
  |--------|------|---------|-------------|
85
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). |
@@ -87,6 +89,10 @@ new UnsharedBrowser({
87
89
  | `apiUrl` | `string` | `https://api.unshared.ai` | Direct mode: Unshared Labs platform origin. |
88
90
  | `maxRetries` | `number` | `3` | Retry budget for eligible operations |
89
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. |
90
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). |
91
97
  | `interstitialFlowType` | `string` | `email_verification` | Flow type requested for the auto-shown interstitial. |
92
98
 
@@ -104,7 +110,14 @@ Build a client-specific distribution with:
104
110
  UNSHARED_PUBLISHABLE_API_KEY=upk_test_or_client_key npm --prefix sdks/javascript/browser run build
105
111
  ```
106
112
 
107
- 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.
108
121
 
109
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.
110
123
 
@@ -123,6 +136,44 @@ Use the confidential-docs override only for local investigation; do not commit m
123
136
 
124
137
  ## Methods
125
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
+
126
177
  ### `collect()`
127
178
 
128
179
  Collect a browser fingerprint. Returns a `FingerprintWireFormat` object ready to pass to `submitFingerprintEvent`.
@@ -222,9 +273,15 @@ flow is authored in the Unshared dashboard and stored per company; the SDK fetch
222
273
  published definition from `GET /v2/browser/interstitial-flow`, renders it in a
223
274
  Shadow-DOM-isolated modal, and routes the flow's actions back through
224
275
  `triggerEmailVerification()` / `verify()` above — so all verification logic stays
225
- server-side. Direct mode only (requires `publishableKey`).
276
+ server-side. Direct mode uses `publishableKey`; proxy mode is described below.
226
277
 
227
- **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.
228
285
 
229
286
  ### `showInterstitial(opts?)`
230
287
 
@@ -279,9 +336,34 @@ const app = express();
279
336
  const client = new UnsharedClient({ apiKey: process.env.UNSHARED_API_KEY });
280
337
 
281
338
  app.use(express.json());
282
- 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.
283
346
  ```
284
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
+
285
367
  ---
286
368
 
287
369
  ## Error handling
package/dist/browser.d.ts CHANGED
@@ -172,6 +172,7 @@ export declare class UnsharedBrowser {
172
172
  /** True after init() with isPaidSubscriber:false — blocks every submission path. */
173
173
  private _doNotCollect;
174
174
  private _mpaHandler;
175
+ private readonly _lifecycleState;
175
176
  /** Interstitial auto-show config + live state. */
176
177
  private readonly _enableInterstitial;
177
178
  private readonly _interstitialFlowType;
@@ -179,22 +180,6 @@ export declare class UnsharedBrowser {
179
180
  private _interstitialHandle;
180
181
  /** Guards against rendering the modal more than once per flagged signal. */
181
182
  private _interstitialOpen;
182
- /**
183
- * Dedup key for the last fingerprint submission: `${userId}|${route}`.
184
- * Modern SPAs fire pushState/replaceState multiple times during hydration
185
- * with the same URL — without this guard each call would generate a
186
- * redundant FP row with an identical stable_hash.
187
- *
188
- * This is the IN-MEMORY layer (collapses bursts from this one instance). It
189
- * works alongside two other client-side layers keyed on the same identity:
190
- * the sessionStorage `__unshared_last_submit` mirror (survives hard reloads
191
- * and SDK re-instantiation within the tab) and the page-scoped
192
- * `window.__unshared.lastKey` guard shared with the auto-injected inline
193
- * script (see getSharedDedup). Dedup is CLIENT-SIDE ONLY: the middleware
194
- * appends a timestamp to X-Idempotency-Key (submit-fp.ts), so the backend
195
- * drops only PubSub redeliveries, never two distinct submissions.
196
- */
197
- private _lastSubmitKey;
198
183
  constructor(config?: BrowserConfig);
199
184
  /**
200
185
  * Initialize the SDK after user login.
@@ -299,8 +284,8 @@ export declare class UnsharedBrowser {
299
284
  private _getStoredEmail;
300
285
  private _getCachedFingerprint;
301
286
  private _getCachedRawFingerprint;
302
- private _cacheFingerprint;
303
- private _cacheRawFingerprint;
287
+ private _getLifecycleCollection;
288
+ private _getOrCollectFingerprint;
304
289
  private _storedVerificationId;
305
290
  private _storedVerificationIdentity;
306
291
  private _shouldProcessPath;