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

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
@@ -79,7 +85,7 @@ new UnsharedBrowser({
79
85
  | `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
86
  | `publishableKey` | `string` | — | Direct mode: publishable key (`upk_…`) issued by Unshared Labs. When set, events bypass your backend and go straight to the platform. |
81
87
  | `apiUrl` | `string` | `https://api.unshared.ai` | Direct mode: Unshared Labs platform origin. |
82
- | `maxRetries` | `number` | `3` | How many times to retry on failure |
88
+ | `maxRetries` | `number` | `3` | Retry budget for eligible operations |
83
89
  | `timeout` | `number` | `30000` | Per-attempt timeout in milliseconds |
84
90
  | `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
91
  | `interstitialFlowType` | `string` | `email_verification` | Flow type requested for the auto-shown interstitial. |
@@ -125,6 +131,14 @@ Collect a browser fingerprint. Returns a `FingerprintWireFormat` object ready to
125
131
  const fingerprint = await client.collect();
126
132
  ```
127
133
 
134
+ ### `collectRaw()`
135
+
136
+ Collect the fingerprint library's complete JSON object without reducing it to the compatibility wire type.
137
+
138
+ ```typescript
139
+ const rawFingerprint = await client.collectRaw();
140
+ ```
141
+
128
142
  ---
129
143
 
130
144
  ### `submitFingerprintEvent(fingerprint, opts?)`
@@ -154,17 +168,50 @@ if (!result.success) {
154
168
 
155
169
  ---
156
170
 
171
+ ### `submitFingerprint(payload)`
172
+
173
+ 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.
174
+
175
+ ```typescript
176
+ await client.submitFingerprint({
177
+ fingerprint: await client.collectRaw(),
178
+ context: {
179
+ identity: { user_id: currentUser?.id },
180
+ event: { type: location.pathname + location.search },
181
+ },
182
+ });
183
+ ```
184
+
185
+ 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.
186
+
187
+ Proxy submissions have a fixed **100 KiB (102,400 serialized UTF-8 bytes)** limit,
188
+ compatible with an upstream default `express.json()` without configuration or
189
+ middleware reordering. Structured submissions send the full snapshot if it fits;
190
+ otherwise they empty local/session storage lists while retaining all cookies, then
191
+ omit `context.browser_storage` if still too large. The complete raw fingerprint,
192
+ including future fields, and core identity/device/session/SDK/event metadata are
193
+ never trimmed. Oversized core payloads return `REQUEST_TOO_LARGE` without sending;
194
+ the inline submitter logs that code. Low-level `submitFingerprint(payload)` never
195
+ reduces arbitrary caller objects: it sends them exactly or rejects above 100 KiB.
196
+ Direct mode keeps full snapshots and its existing 1 MiB logical / 1.5 MiB encrypted
197
+ limits. Duplicate fingerprint caches and legacy queues are excluded from snapshots.
198
+
199
+ ---
200
+
157
201
  ### Direct-mode API (requires `publishableKey`)
158
202
 
159
203
  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
204
 
161
205
  | Method | Retries | Returns |
162
206
  |--------|---------|---------|
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' }` |
207
+ | `checkUser(opts?)` | bounded availability failures only | `{ is_user_flagged, decision_id, decision_code }` |
208
+ | `triggerEmailVerification(opts?)` | **never** (each call emails a code) | `{ verification_id, next_allowed_at, retry_after_seconds }`; the SDK retains the challenge for `verify(...)` |
209
+ | `verify(code, opts?)` | **never** (attempts are budgeted) | Exact verified/not-verified result; UUIDv4 challenges and 6-digit OTPs are validated locally |
210
+ | `verifyEmail({ verification_id, code })` | **never** | Verification for an explicit challenge ID; reconciles flagged state when retained identity is available |
166
211
  | `emailVerificationStatus(opts?)` | yes | `{ can_send: boolean, next_allowed_at }` |
167
212
 
213
+ 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`.
214
+
168
215
  ---
169
216
 
170
217
  ## Interstitial modal
@@ -248,7 +295,7 @@ const result = await client.submitFingerprintEvent(fingerprint);
248
295
  // result.error.code === 'DELIVERY_FAILED'
249
296
  ```
250
297
 
251
- Retries happen automatically on network errors, timeouts, and server errors (5xx). Client errors (4xx) are not retried.
298
+ 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
299
 
253
300
  ---
254
301
 
@@ -257,7 +304,7 @@ Retries happen automatically on network errors, timeouts, and server errors (5xx
257
304
  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
305
 
259
306
  ```html
260
- <script src="https://unpkg.com/unshared-frontend-sdk@2.0.0-rc.4/dist/index.umd.js"></script>
307
+ <script src="https://unpkg.com/unshared-frontend-sdk@3.0.0/dist/index.umd.js"></script>
261
308
  <script>
262
309
  const { UnsharedBrowser } = window.UnsharedBrowser;
263
310
 
@@ -276,29 +323,6 @@ For proxy mode instead, pass `baseUrl` explicitly.
276
323
 
277
324
  ---
278
325
 
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
326
  ## Real-browser acceptance coverage
303
327
 
304
328
  For Boston Globe style script-tag integration, split client-side browser tests into host-site flows and SDK-owned delivery behavior.
@@ -313,7 +337,4 @@ For Boston Globe style script-tag integration, split client-side browser tests i
313
337
  | 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
338
  | 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
339
  | 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. |
340
+ | 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,12 @@ 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;
162
175
  /** Interstitial auto-show config + live state. */
163
176
  private readonly _enableInterstitial;
164
177
  private readonly _interstitialFlowType;
@@ -211,17 +224,32 @@ export declare class UnsharedBrowser {
211
224
  */
212
225
  collect(options?: {
213
226
  exclude?: string[];
227
+ [key: string]: unknown;
214
228
  }): Promise<FingerprintWireFormat>;
229
+ /** Returns the fingerprint agent's JSON object without selecting or renaming fields. */
230
+ collectRaw(options?: {
231
+ exclude?: string[];
232
+ [key: string]: unknown;
233
+ }): Promise<JSONObject>;
234
+ /** Collect once, keeping the agent's raw object separate from normalized SDK metadata. */
235
+ collectWithMetadata(options?: {
236
+ exclude?: string[];
237
+ [key: string]: unknown;
238
+ }): Promise<CollectedFingerprint>;
239
+ private _collectFingerprint;
215
240
  /**
216
241
  * Submit a fingerprint event directly.
217
242
  * @deprecated Prefer sdk.init() and sdk.onRouteChange().
218
243
  */
219
244
  submitFingerprintEvent(fingerprint: FingerprintWireFormat, opts: SubmitFingerprintOptions): Promise<BrowserApiResult<SubmitFingerprintResult>>;
245
+ /** Submit any JSON object to the v3 fingerprint endpoint without changing it. */
246
+ submitFingerprint(payload: JSONObject): Promise<BrowserApiResult<IngestResult>>;
220
247
  /**
221
248
  * Check whether the current user is flagged for account sharing.
222
249
  * Direct mode only — proxy-mode apps get verdicts from their own backend.
223
250
  */
224
251
  checkUser(opts?: DirectIdentityOptions): Promise<BrowserApiResult<CheckUserData>>;
252
+ private _checkUser;
225
253
  /**
226
254
  * Send a 6-digit verification code to the user's email.
227
255
  * Never retried — each attempt sends a real email. Rate-limited responses
@@ -233,6 +261,8 @@ export declare class UnsharedBrowser {
233
261
  * budgeted server-side to block brute force.
234
262
  */
235
263
  verify(code: string, opts?: DirectIdentityOptions): Promise<BrowserApiResult<VerifyData>>;
264
+ verifyEmail(request: VerifyEmailRequest): Promise<BrowserApiResult<VerifyEmailResult>>;
265
+ private _verifyEmail;
236
266
  /** Report the email-send cooldown state without sending anything. */
237
267
  emailVerificationStatus(opts?: DirectIdentityOptions): Promise<BrowserApiResult<VerificationStatusData>>;
238
268
  /**
@@ -268,7 +298,11 @@ export declare class UnsharedBrowser {
268
298
  private _directApi;
269
299
  private _getStoredEmail;
270
300
  private _getCachedFingerprint;
301
+ private _getCachedRawFingerprint;
271
302
  private _cacheFingerprint;
303
+ private _cacheRawFingerprint;
304
+ private _storedVerificationId;
305
+ private _storedVerificationIdentity;
272
306
  private _shouldProcessPath;
273
307
  private _submitEvent;
274
308
  /**
@@ -278,39 +312,10 @@ export declare class UnsharedBrowser {
278
312
  private _submitUrl;
279
313
  private _attachMpaListener;
280
314
  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;
315
+ private _buildIdentifiers;
316
+ private _buildEventPayload;
317
+ private _ensurePermanentDeviceId;
318
+ private _isSameOriginProxy;
314
319
  }
315
320
  /**
316
321
  * Creates an Axios response error interceptor that calls onFlagged
@@ -338,3 +343,4 @@ export declare function createAxiosInterceptor(opts: FlaggedInterceptorOptions):
338
343
  * ```
339
344
  */
340
345
  export declare function createFetchInterceptor(originalFetch: typeof fetch, opts: FlaggedInterceptorOptions): typeof fetch;
346
+ 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';