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.
- package/README.md +349 -152
- package/dist/client.d.ts +54 -25
- package/dist/client.js +1 -1
- package/dist/esm/client.d.mts +54 -25
- package/dist/esm/client.mjs +1 -1
- package/dist/esm/index.d.mts +8 -8
- package/dist/esm/index.mjs +1 -1
- package/dist/esm/middleware/dispatch-processing.d.mts +11 -5
- package/dist/esm/middleware/dispatch-processing.mjs +1 -1
- package/dist/esm/middleware/index.d.mts +10 -6
- package/dist/esm/middleware/index.mjs +1 -1
- package/dist/esm/middleware/injection/fingerprint-script.d.mts +9 -0
- package/dist/esm/middleware/injection/fingerprint-script.mjs +1 -1
- package/dist/esm/middleware/injection/fp-bundle-source.d.mts +5 -4
- package/dist/esm/middleware/injection/fp-bundle-source.mjs +1 -1
- package/dist/esm/middleware/response-interceptor.d.mts +1 -1
- package/dist/esm/middleware/routes/interstitial.d.mts +2 -2
- package/dist/esm/middleware/routes/interstitial.mjs +1 -1
- package/dist/esm/middleware/routes/submit-fp.d.mts +5 -5
- package/dist/esm/middleware/routes/submit-fp.mjs +1 -1
- package/dist/esm/middleware/routes/verify.d.mts +5 -4
- package/dist/esm/middleware/routes/verify.mjs +1 -1
- package/dist/esm/middleware/utils/client-ip.d.mts +1 -1
- package/dist/esm/middleware/utils/cookies.d.mts +1 -1
- package/dist/esm/middleware/utils/device-id.d.mts +5 -3
- package/dist/esm/middleware/utils/device-id.mjs +1 -1
- package/dist/esm/middleware/utils/flagged-response.mjs +1 -1
- package/dist/esm/middleware/utils/http-helpers.d.mts +1 -1
- package/dist/esm/middleware/utils/permanent-device-id.d.mts +7 -0
- package/dist/esm/middleware/utils/permanent-device-id.mjs +1 -0
- package/dist/esm/middleware/utils/read-json-body.d.mts +3 -0
- package/dist/esm/middleware/utils/read-json-body.mjs +1 -0
- package/dist/esm/middleware/utils/secure.d.mts +1 -1
- package/dist/esm/middleware.d.mts +5 -6
- package/dist/esm/middleware.mjs +1 -1
- package/dist/esm/shared-types.d.mts +148 -0
- package/dist/esm/web/index.d.mts +6 -6
- package/dist/esm/web/index.mjs +1 -1
- package/dist/esm/web/protection-handler.d.mts +5 -5
- package/dist/esm/web/protection-handler.mjs +1 -1
- package/dist/esm/web/submit-handler.d.mts +2 -2
- package/dist/esm/web/submit-handler.mjs +1 -1
- package/dist/esm/web/types.d.mts +5 -3
- package/dist/esm/web/web-helpers.d.mts +13 -1
- package/dist/esm/web/web-helpers.mjs +1 -1
- package/dist/middleware/dispatch-processing.d.ts +8 -2
- package/dist/middleware/dispatch-processing.js +1 -1
- package/dist/middleware/index.d.ts +5 -1
- package/dist/middleware/index.js +1 -1
- package/dist/middleware/injection/fingerprint-script.d.ts +9 -0
- package/dist/middleware/injection/fingerprint-script.js +1 -1
- package/dist/middleware/injection/fp-bundle-source.d.ts +5 -4
- package/dist/middleware/injection/fp-bundle-source.js +1 -1
- package/dist/middleware/routes/submit-fp.js +1 -1
- package/dist/middleware/routes/verify.d.ts +2 -1
- package/dist/middleware/routes/verify.js +1 -1
- package/dist/middleware/utils/device-id.d.ts +4 -2
- package/dist/middleware/utils/device-id.js +1 -1
- package/dist/middleware/utils/permanent-device-id.d.ts +7 -0
- package/dist/middleware/utils/permanent-device-id.js +1 -0
- package/dist/middleware/utils/read-json-body.d.ts +3 -0
- package/dist/middleware/utils/read-json-body.js +1 -0
- package/dist/middleware.d.ts +3 -4
- package/dist/middleware.js +1 -1
- package/dist/shared-types.d.ts +148 -0
- package/dist/web/protection-handler.d.ts +1 -1
- package/dist/web/protection-handler.js +1 -1
- package/dist/web/submit-handler.js +1 -1
- package/dist/web/types.d.ts +4 -2
- package/dist/web/web-helpers.d.ts +13 -1
- package/dist/web/web-helpers.js +1 -1
- package/package.json +4 -4
package/README.md
CHANGED
|
@@ -1,240 +1,437 @@
|
|
|
1
1
|
# unshared-clientjs-sdk
|
|
2
2
|
|
|
3
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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(
|
|
38
|
+
### `processUserEvent(payload)`
|
|
32
39
|
|
|
33
|
-
|
|
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
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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
|
-
|
|
47
|
-
|
|
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
|
-
|
|
65
|
+
Ingestion is not automatically retried because an ambiguous failure may already have created an event.
|
|
52
66
|
|
|
53
|
-
|
|
67
|
+
### `checkUser(...)`
|
|
54
68
|
|
|
55
|
-
|
|
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',
|
|
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
|
-
//
|
|
81
|
+
// Block or challenge.
|
|
64
82
|
}
|
|
65
83
|
```
|
|
66
84
|
|
|
67
|
-
|
|
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
|
-
|
|
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
|
-
|
|
97
|
+
### Email Verification
|
|
74
98
|
|
|
75
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
122
|
+
## Compatibility Methods
|
|
102
123
|
|
|
103
|
-
|
|
124
|
+
Existing v2 package call sites remain valid while using v3 routes and encrypted ingestion envelopes.
|
|
104
125
|
|
|
105
126
|
```typescript
|
|
106
|
-
await client.
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
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
|
|
144
|
+
## Protection Middleware
|
|
116
145
|
|
|
117
|
-
`unsharedBoundToUser`
|
|
146
|
+
`unsharedBoundToUser` injects browser fingerprint collection, protects routes, caches verdicts, and hosts the proxy verification flow.
|
|
118
147
|
|
|
119
148
|
```typescript
|
|
120
|
-
import
|
|
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
|
-
|
|
128
|
-
|
|
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
|
-
|
|
138
|
-
|
|
139
|
-
**
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
361
|
+
## Web Standard Handler
|
|
192
362
|
|
|
193
|
-
|
|
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
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
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
|
-
|
|
224
|
-
|
|
225
|
-
|
|
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
|
-
|
|
232
|
-
|
|
233
|
-
|
|
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.
|