unshared-clientjs-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.
Files changed (72) hide show
  1. package/README.md +349 -152
  2. package/dist/client.d.ts +54 -25
  3. package/dist/client.js +1 -1
  4. package/dist/esm/client.d.mts +54 -25
  5. package/dist/esm/client.mjs +1 -1
  6. package/dist/esm/index.d.mts +8 -8
  7. package/dist/esm/index.mjs +1 -1
  8. package/dist/esm/middleware/dispatch-processing.d.mts +11 -5
  9. package/dist/esm/middleware/dispatch-processing.mjs +1 -1
  10. package/dist/esm/middleware/index.d.mts +10 -6
  11. package/dist/esm/middleware/index.mjs +1 -1
  12. package/dist/esm/middleware/injection/fingerprint-script.d.mts +9 -0
  13. package/dist/esm/middleware/injection/fingerprint-script.mjs +1 -1
  14. package/dist/esm/middleware/injection/fp-bundle-source.d.mts +5 -4
  15. package/dist/esm/middleware/injection/fp-bundle-source.mjs +1 -1
  16. package/dist/esm/middleware/response-interceptor.d.mts +1 -1
  17. package/dist/esm/middleware/routes/interstitial.d.mts +2 -2
  18. package/dist/esm/middleware/routes/interstitial.mjs +1 -1
  19. package/dist/esm/middleware/routes/submit-fp.d.mts +5 -5
  20. package/dist/esm/middleware/routes/submit-fp.mjs +1 -1
  21. package/dist/esm/middleware/routes/verify.d.mts +5 -4
  22. package/dist/esm/middleware/routes/verify.mjs +1 -1
  23. package/dist/esm/middleware/utils/client-ip.d.mts +1 -1
  24. package/dist/esm/middleware/utils/cookies.d.mts +1 -1
  25. package/dist/esm/middleware/utils/device-id.d.mts +5 -3
  26. package/dist/esm/middleware/utils/device-id.mjs +1 -1
  27. package/dist/esm/middleware/utils/flagged-response.mjs +1 -1
  28. package/dist/esm/middleware/utils/http-helpers.d.mts +1 -1
  29. package/dist/esm/middleware/utils/permanent-device-id.d.mts +7 -0
  30. package/dist/esm/middleware/utils/permanent-device-id.mjs +1 -0
  31. package/dist/esm/middleware/utils/read-json-body.d.mts +3 -0
  32. package/dist/esm/middleware/utils/read-json-body.mjs +1 -0
  33. package/dist/esm/middleware/utils/secure.d.mts +1 -1
  34. package/dist/esm/middleware.d.mts +5 -6
  35. package/dist/esm/middleware.mjs +1 -1
  36. package/dist/esm/shared-types.d.mts +148 -0
  37. package/dist/esm/web/index.d.mts +6 -6
  38. package/dist/esm/web/index.mjs +1 -1
  39. package/dist/esm/web/protection-handler.d.mts +5 -5
  40. package/dist/esm/web/protection-handler.mjs +1 -1
  41. package/dist/esm/web/submit-handler.d.mts +2 -2
  42. package/dist/esm/web/submit-handler.mjs +1 -1
  43. package/dist/esm/web/types.d.mts +5 -3
  44. package/dist/esm/web/web-helpers.d.mts +13 -1
  45. package/dist/esm/web/web-helpers.mjs +1 -1
  46. package/dist/middleware/dispatch-processing.d.ts +8 -2
  47. package/dist/middleware/dispatch-processing.js +1 -1
  48. package/dist/middleware/index.d.ts +5 -1
  49. package/dist/middleware/index.js +1 -1
  50. package/dist/middleware/injection/fingerprint-script.d.ts +9 -0
  51. package/dist/middleware/injection/fingerprint-script.js +1 -1
  52. package/dist/middleware/injection/fp-bundle-source.d.ts +5 -4
  53. package/dist/middleware/injection/fp-bundle-source.js +1 -1
  54. package/dist/middleware/routes/submit-fp.js +1 -1
  55. package/dist/middleware/routes/verify.d.ts +2 -1
  56. package/dist/middleware/routes/verify.js +1 -1
  57. package/dist/middleware/utils/device-id.d.ts +4 -2
  58. package/dist/middleware/utils/device-id.js +1 -1
  59. package/dist/middleware/utils/permanent-device-id.d.ts +7 -0
  60. package/dist/middleware/utils/permanent-device-id.js +1 -0
  61. package/dist/middleware/utils/read-json-body.d.ts +3 -0
  62. package/dist/middleware/utils/read-json-body.js +1 -0
  63. package/dist/middleware.d.ts +3 -4
  64. package/dist/middleware.js +1 -1
  65. package/dist/shared-types.d.ts +148 -0
  66. package/dist/web/protection-handler.d.ts +1 -1
  67. package/dist/web/protection-handler.js +1 -1
  68. package/dist/web/submit-handler.js +1 -1
  69. package/dist/web/types.d.ts +4 -2
  70. package/dist/web/web-helpers.d.ts +13 -1
  71. package/dist/web/web-helpers.js +1 -1
  72. package/package.json +4 -4
package/README.md CHANGED
@@ -1,240 +1,437 @@
1
1
  # unshared-clientjs-sdk
2
2
 
3
- Server-side Node.js SDK for [Unshared](https://unshared.ai) — detect account sharing, analyze user events for fraud, and run email verification flows.
4
-
5
- ---
3
+ Node.js SDK for Unshared v3 ingestion, risk checks, email verification, and application protection middleware.
6
4
 
7
5
  ## Install
8
6
 
9
7
  ```bash
10
- npm install unshared-clientjs-sdk
8
+ npm install unshared-clientjs-sdk@^3
11
9
  ```
12
10
 
13
- **Requires Node.js 18+**
14
-
15
- ---
16
-
17
- ## Quick Start
11
+ Requires Node.js 20 or newer.
18
12
 
19
13
  ```typescript
20
14
  import { UnsharedClient } from 'unshared-clientjs-sdk';
21
15
 
22
16
  const client = new UnsharedClient({
23
- apiKey: process.env.UNSHARED_API_KEY, // usk_…
17
+ apiKey: process.env.UNSHARED_API_KEY!, // usk_...
24
18
  });
25
19
  ```
26
20
 
27
- ---
21
+ V3 fingerprint and user-event ingestion encrypts the complete JSON object with AES-256-GCM before sending it to Unshared. Check, Trigger, and Verify remain structured plaintext JSON over HTTPS.
22
+
23
+ ## V3 Methods
24
+
25
+ ### `submitFingerprint(payload)`
28
26
 
29
- ## Methods
27
+ Sends any JSON object to `POST /v3/submit-fingerprint-event` without reshaping it.
28
+
29
+ ```typescript
30
+ const result = await client.submitFingerprint({
31
+ hash: 'full-456',
32
+ stable_hash: 'stable-123',
33
+ components: {},
34
+ custom_data: { nested: ['preserved', 1, false] },
35
+ });
36
+ ```
30
37
 
31
- ### `processUserEvent(params)`
38
+ ### `processUserEvent(payload)`
32
39
 
33
- Record a user event and get a fraud signal back. Call this on login, signup, or any high-value action.
40
+ Sends any JSON object to `POST /v3/process-user-event` without reshaping it.
34
41
 
35
42
  ```typescript
36
43
  const result = await client.processUserEvent({
37
- eventType: 'login',
38
- userId: 'user_123',
39
- emailAddress: 'user@example.com',
40
- deviceId: 'device_abc',
41
- sessionHash: 'session_xyz',
42
- ipAddress: '1.2.3.4', // plaintext — not encrypted
43
- userAgent: req.headers['user-agent'],
44
+ event_type: 'LOGIN',
45
+ identity: { user_id: user.id, email_address: user.email },
46
+ identifiers: {
47
+ unshared_device_id: permanentDeviceId,
48
+ device_id: deviceId,
49
+ session_hash: sessionId,
50
+ },
51
+ custom_data: { plan: 'pro' },
44
52
  });
53
+ ```
45
54
 
46
- if (result.success && result.data?.analysis.is_user_flagged) {
47
- // Block or challenge the user
55
+ Both ingestion calls accept objects up to 1 MiB and return:
56
+
57
+ ```typescript
58
+ {
59
+ event_id: string;
60
+ collected_at: string;
61
+ endpoint_version: 'v3';
48
62
  }
49
63
  ```
50
64
 
51
- **Fields encrypted before sending:** `emailAddress`, `deviceId`
65
+ Ingestion is not automatically retried because an ambiguous failure may already have created an event.
52
66
 
53
- ---
67
+ ### `checkUser(...)`
54
68
 
55
- ### `checkUser(emailAddress, deviceId)`
56
-
57
- Quick check to see if a user is flagged. Useful in middleware or route guards.
69
+ Checks existing risk state through `POST /v3/check-user`.
58
70
 
59
71
  ```typescript
60
- const result = await client.checkUser('user@example.com', 'device_abc');
72
+ const result = await client.checkUser('user@example.com', {
73
+ permanentDeviceId,
74
+ deviceId,
75
+ fingerprintId: stableHash,
76
+ fullHash,
77
+ sessionHash,
78
+ });
61
79
 
62
80
  if (result.data?.is_user_flagged) {
63
- // Deny access
81
+ // Block or challenge.
64
82
  }
65
83
  ```
66
84
 
67
- > **Safe default:** Returns `{ is_user_flagged: false }` on any failure (network error, outage). A backend outage will never accidentally block a legitimate user.
85
+ You can also pass the v3 request object directly:
68
86
 
69
- ---
87
+ ```typescript
88
+ await client.checkUser({
89
+ user_id: user.id,
90
+ email_address: user.email,
91
+ identifiers: { device_id: deviceId },
92
+ });
93
+ ```
70
94
 
71
- ### `triggerEmailVerification(emailAddress, deviceId)`
95
+ `checkUser` is read-only and can retry. On transport or HTTP failure it deliberately returns a clean verdict with `failedOpen` metadata so an outage does not accidentally block users.
72
96
 
73
- Send a 6-digit verification code to the user's email.
97
+ ### Email Verification
74
98
 
75
- ```typescript
76
- await client.triggerEmailVerification('user@example.com', 'device_abc');
77
- ```
99
+ Trigger returns a challenge ID that Verify must send back:
78
100
 
79
- ---
101
+ ```typescript
102
+ const trigger = await client.triggerEmailVerification(user.email, deviceId, {
103
+ permanentDeviceId,
104
+ fingerprintId: stableHash,
105
+ });
80
106
 
81
- ### `verify(emailAddress, deviceId, code)`
107
+ const verify = await client.verifyEmail({
108
+ verification_id: trigger.data!.verification_id!,
109
+ code: submittedCode, // string preserves leading zeroes
110
+ });
111
+ ```
82
112
 
83
- Validate the code the user submitted.
113
+ `verifyEmail(...)` is stateless and recommended for distributed deployments. The source-compatible methods retain the challenge in the current `UnsharedClient` instance:
84
114
 
85
115
  ```typescript
86
- const result = await client.verify('user@example.com', 'device_abc', '123456');
87
-
88
- if (!result.success) {
89
- if (result.error?.code === 'VERIFICATION_FAILED') {
90
- // Wrong or expired code — ask user to retry
91
- } else {
92
- // Transport error (DELIVERY_FAILED) — retry or show generic error
93
- }
94
- } else {
95
- // Verified — success: true means the code was correct
96
- }
116
+ await client.triggerEmailVerification(user.email, deviceId);
117
+ const verify = await client.verify(user.email, deviceId, submittedCode);
97
118
  ```
98
119
 
99
- ---
120
+ Trigger and Verify are never automatically retried because the first request may have committed before an ambiguous response failure.
100
121
 
101
- ### `submitFingerprintEvent(fingerprint, opts?)`
122
+ ## Compatibility Methods
102
123
 
103
- Submit a browser fingerprint collected by `unshared-frontend-sdk`. Typically called by the middleware — you usually won't call this directly.
124
+ Existing v2 package call sites remain valid while using v3 routes and encrypted ingestion envelopes.
104
125
 
105
126
  ```typescript
106
- await client.submitFingerprintEvent(fingerprint, {
107
- userId: 'user_123',
108
- sessionHash: 'session_xyz',
109
- eventType: 'page_view',
127
+ const result = await client.processUserEvent({
128
+ eventType: 'login',
129
+ userId: user.id,
130
+ emailAddress: user.email,
131
+ deviceId,
132
+ sessionHash: sessionId,
133
+ ipAddress: req.ip,
134
+ userAgent: req.headers['user-agent'] ?? '',
110
135
  });
136
+
137
+ if (result.success && result.data?.analysis.is_user_flagged) {
138
+ // Block or challenge.
139
+ }
111
140
  ```
112
141
 
113
- ---
142
+ `submitFingerprintEvent(fingerprint, opts)` is also retained. New proxy implementations should prefer `submitFingerprint(body)` so unknown future fields cannot be lost.
114
143
 
115
- ## Protection Middleware (Recommended)
144
+ ## Protection Middleware
116
145
 
117
- `unsharedBoundToUser` is the full-featured middleware: auto-injects the fingerprint script, enforces verdicts, handles email verification flows, and dispatches events.
146
+ `unsharedBoundToUser` injects browser fingerprint collection, protects routes, caches verdicts, and hosts the proxy verification flow.
118
147
 
119
148
  ```typescript
120
- import { UnsharedClient, unsharedBoundToUser, flaggedResponse } from 'unshared-clientjs-sdk';
121
-
149
+ import express from 'express';
150
+ import {
151
+ UnsharedClient,
152
+ unsharedBoundToUser,
153
+ flaggedResponse,
154
+ } from 'unshared-clientjs-sdk';
155
+
156
+ const app = express();
122
157
  app.set('trust proxy', 1);
123
- app.use(express.json());
158
+ app.use(express.json()); // the default 100 KiB limit is supported
124
159
 
160
+ // Mount your authentication/session middleware here, before Unshared.
125
161
  const client = new UnsharedClient({ apiKey: process.env.UNSHARED_API_KEY! });
126
-
127
- app.use(unsharedBoundToUser(client, {
128
- userId: (req) => req.cookies?.userId,
129
- emailAddress: (req) => req.cookies?.email,
130
- includePathPrefix: ['/api/'],
162
+ const middleware = unsharedBoundToUser(client, {
163
+ userId: (req) => req.user?.id,
164
+ emailAddress: (req) => req.user?.email,
131
165
  onFlagged: ({ emailAddress, res }) => {
132
166
  res.status(403).json(flaggedResponse(emailAddress));
133
167
  },
134
- }));
168
+ });
169
+ app.use(middleware);
170
+ // Call middleware.destroy() on shutdown, test teardown, or before re-mounting.
135
171
  ```
136
172
 
137
- **Smoke test:** `curl http://localhost:3000/__unshared/status` — returns `{ "status": "anonymous" | "ok" | "flagged" }`.
138
-
139
- **Key options:**
140
-
141
- | Option | Type | Default | Description |
142
- |--------|------|---------|-------------|
143
- | `userId` | `(req) => string \| undefined` | — | **Required.** Resolve the current user's ID |
144
- | `emailAddress` | `(req) => string \| undefined` | — | Resolve the current user's email |
145
- | `routePrefix` | `string` | `"/__unshared"` | Route mount prefix |
146
- | `includePathPrefix` | `string[]` | — | Only these path prefixes trigger verdicts and events |
147
- | `onFlagged` | `(ctx) => void` | — | Called when a flagged user makes a request |
148
- | `disableBotFilter` | `boolean` | `false` | Skip bot UA filter (enable for E2E testing) |
149
- | `checkUserTimeoutMs` | `number` | `500` | Timeout (ms) for checkUser API calls; fails open on timeout |
150
- | `skipPaths` | `string[]` | — | Paths to skip entirely (static assets, health checks) |
151
- | `corsOrigins` | `string \| string[]` | — | Allowed CORS origins; handles OPTIONS preflight |
152
- | `onError` | `(error, ctx) => void` | — | Called on background SDK errors for observability |
153
-
154
- `flaggedResponse(email)` formats the 403 body the inline script expects to trigger the `unshared:flagged` browser event. `ACCOUNT_FLAGGED_ERROR` is the error code string it uses, also exported for server-side checks.
155
-
156
- ### Interstitial (proxy mode)
157
-
158
- `unsharedBoundToUser` mounts a `GET /__unshared/interstitial-flow` route that returns the company's published interstitial flow. It calls `client.getInterstitialFlow()` with the secret key and carries no user data. The browser SDK's `showInterstitial()` calls this route automatically when constructed with `baseUrl` (proxy mode), then runs the modal's OTP actions through the existing `/__unshared/verify-trigger` and `/__unshared/verify` routes. Identity is resolved server-side via your `emailAddress` resolver or the `__unshared_email` cookie — the browser sends only the OTP.
159
-
160
- ---
173
+ Keep your existing parser order and limits. Mount authentication/session middleware
174
+ needed by your resolvers before Unshared. The SDK also reads its own unparsed POST
175
+ routes automatically, up to **1 MiB (1,048,576 UTF-8 bytes)**, without a configuration
176
+ option or an Express dependency. Ordinary application routes are not parsed by the
177
+ SDK. Existing parsed bodies are respected and checked against the same limit.
178
+ Structured browser-to-proxy submissions (including the inline script) fit Express's
179
+ default **100 KiB (102,400 serialized UTF-8 bytes)** without public configuration:
180
+ send the full payload if it fits; otherwise empty both local/session storage lists
181
+ while keeping every cookie; if still too large, omit `context.browser_storage`.
182
+ The complete raw fingerprint, including future fields, and all core identity,
183
+ device, session, SDK and event context are never trimmed. If those alone exceed
184
+ 100 KiB, the browser returns `REQUEST_TOO_LARGE`; the inline script logs that code
185
+ and sends nothing. Cookies are never partially selected or truncated.
186
+
187
+ Low-level browser `submitFingerprint(payload)` preserves arbitrary objects exactly
188
+ and rejects proxy payloads above 100 KiB rather than reducing them. Direct browser
189
+ submission retains full snapshots and its 1 MiB logical limit. An earlier parser
190
+ or reverse proxy with a smaller limit can still reject a request before Unshared.
191
+
192
+ Oversized SDK request bodies return 413, malformed JSON returns 400, and unfinished
193
+ reads time out after 30 seconds with 408. Raw bodies must use identity encoding
194
+ (compressed bodies return 415). Fingerprints and retained cookies are not truncated;
195
+ duplicate SDK fingerprint caches are excluded from storage snapshots. The encrypted
196
+ platform request retains its existing 1.5 MiB envelope limit; the full logical payload,
197
+ including server-added context, must still fit within 1 MiB.
198
+
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:
161
240
 
162
- ## Simple Fingerprint Middleware
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
+ ```
163
253
 
164
- `createUnsharedMiddleware` is a lightweight alternative that only proxies fingerprint events — no verdicts, no script injection, no verification flows. Use `unsharedBoundToUser` unless you have a specific reason not to.
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
277
+
278
+ The middleware exposes:
279
+
280
+ - `POST /__unshared/submit-fp`
281
+ - `GET /__unshared/status`
282
+ - `POST /__unshared/verify-trigger`
283
+ - `POST /__unshared/verify`
284
+ - `GET /__unshared/fp.js`
285
+ - `GET /__unshared/fingerprint.js`
286
+ - `GET /__unshared/interstitial-flow`
287
+
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.
344
+
345
+ ## Simple Proxy Middleware
346
+
347
+ `createUnsharedMiddleware` only proxies fingerprint events. It preserves arbitrary fields and augments trusted request context.
165
348
 
166
349
  ```typescript
167
- import { createUnsharedMiddleware } from 'unshared-clientjs-sdk';
350
+ import { createUnsharedMiddleware } from 'unshared-clientjs-sdk/middleware';
168
351
 
169
- app.use(express.json());
170
352
  app.use(createUnsharedMiddleware(client, {
171
353
  userIdExtractor: (req) => req.user?.id,
354
+ corsOrigins: 'https://app.example.com',
172
355
  }));
356
+ app.use(express.json());
173
357
  ```
174
358
 
175
- **Options:**
176
-
177
- | Option | Type | Default | Description |
178
- |--------|------|---------|-------------|
179
- | `userIdExtractor` | `(req) => string \| undefined` | — | Pull user ID from your auth session |
180
- | `eventTypeExtractor` | `(req) => string \| undefined` | — | Override event type |
181
- | `sessionIdExtractor` | `(req) => string \| undefined` | — | Override session ID |
182
- | `ipAddressExtractor` | `(req) => string \| undefined` | — | Override IP address |
183
- | `defaultEventType` | `string` | `"browser_event"` | Fallback event type |
184
- | `routePrefix` | `string` | `"/unshared"` | Route mount prefix |
185
- | `corsOrigins` | `string \| string[]` | — | Allowed CORS origins; handles OPTIONS preflight automatically |
186
-
187
- > **Note:** This middleware uses a different default prefix (`/unshared`) and route (`submit-fingerprint-event`) than `unsharedBoundToUser` (`/__unshared`, `submit-fp`).
188
-
189
- ---
359
+ Its default route is `POST /unshared/submit-fingerprint-event`.
190
360
 
191
- ## Web / Edge Handler
361
+ ## Web Standard Handler
192
362
 
193
- For serverless and edge environments (Next.js App Router, Vercel Edge Functions, Cloudflare Workers), import from the `unshared-clientjs-sdk/web` entry point:
363
+ Use the edge/serverless entry point with Next.js, Cloudflare Workers, Vercel Edge, or other Web Standard runtimes:
194
364
 
195
365
  ```typescript
196
366
  import { createWebProtectionMiddleware } from 'unshared-clientjs-sdk/web';
197
367
  ```
198
368
 
199
- `createWebProtectionMiddleware` accepts the same `disableBotFilter` and `checkUserTimeoutMs` options as `unsharedBoundToUser`, with Web Standard `Request`/`Response` types instead of Express ones.
200
-
201
- ---
202
-
203
- ## Configuration
204
-
205
- ```typescript
206
- new UnsharedClient({
207
- apiKey: 'usk_…', // required
208
- baseUrl: 'https://api.unshared.ai', // optional
209
- timeout: 10_000, // optional, ms
210
- maxRetries: 3, // optional
211
- });
212
- ```
213
-
214
- ---
215
-
216
- ## Response shape
217
-
218
- All methods return `ApiResult<T>`:
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.
416
+
417
+ ## Result and Error Handling
219
418
 
220
419
  ```typescript
221
- {
420
+ type ApiResult<T> = {
222
421
  success: boolean;
223
- data?: T;
224
- error?: { code: string; message: string; retryAfter?: number };
225
- status: number; // HTTP status code
226
- }
422
+ status: number; // 0 for a network failure
423
+ data?: T;
424
+ error?: {
425
+ code: string;
426
+ message: string;
427
+ retryAfter?: number;
428
+ };
429
+ failedOpen?: { status: number; reason?: 'no_device_id' };
430
+ };
227
431
  ```
228
432
 
229
- ---
230
-
231
- ## Security
232
-
233
- All PII is encrypted with **AES-256-GCM** before leaving your server. The encryption key is derived from your API key with SHA-256. Your API key is sent as the `X-API-Key` header — never in a URL or browser context.
234
-
235
- ---
236
-
237
- ## See Also
238
-
239
- - [Quickstart](./docs/quickstart.md) — step-by-step Express setup from zero
240
- - [Flag semantics and testing](./docs/flag-semantics.md) — how flags work, E2E testing tips, timeout behavior
433
+ - Success requires a valid `{ success: true, data }` response envelope.
434
+ - Bodies larger than 1 MiB fail locally with `REQUEST_TOO_LARGE`.
435
+ - `Retry-After` is exposed as `error.retryAfter` on 429 responses.
436
+ - Protected same-origin submission routes never return 5xx to browser code.
437
+ - Keep the secret API key server-side and exclude logical identity/event bodies from request logs before SDK encryption.