unshared-clientjs-sdk 2.0.0-rc.2 → 2.0.0-rc.3
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 +100 -102
- package/dist/client.d.ts +9 -10
- package/dist/client.js +1 -1
- package/dist/esm/client.d.mts +9 -10
- package/dist/esm/client.mjs +1 -1
- package/dist/esm/middleware.d.mts +12 -4
- package/dist/esm/middleware.mjs +1 -1
- package/dist/middleware.d.ts +12 -4
- package/dist/middleware.js +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,47 +1,26 @@
|
|
|
1
|
-
#
|
|
1
|
+
# unshared-clientjs-sdk
|
|
2
2
|
|
|
3
|
-
Server-side Node.js SDK for
|
|
4
|
-
|
|
5
|
-
**Keep the API key server-side — never expose it in browser code.**
|
|
3
|
+
Server-side Node.js SDK for [Unshared Labs](https://unsharedlabs.com) — detect account sharing, analyze user events for fraud, and run email verification flows.
|
|
6
4
|
|
|
7
5
|
---
|
|
8
6
|
|
|
9
|
-
##
|
|
7
|
+
## Install
|
|
10
8
|
|
|
11
9
|
```bash
|
|
12
|
-
npm install
|
|
10
|
+
npm install unshared-clientjs-sdk
|
|
13
11
|
```
|
|
14
12
|
|
|
15
|
-
Requires Node.js
|
|
13
|
+
**Requires Node.js 18+**
|
|
16
14
|
|
|
17
15
|
---
|
|
18
16
|
|
|
19
17
|
## Quick Start
|
|
20
18
|
|
|
21
19
|
```typescript
|
|
22
|
-
import { UnsharedLabsClient } from '
|
|
20
|
+
import { UnsharedLabsClient } from 'unshared-clientjs-sdk';
|
|
23
21
|
|
|
24
22
|
const client = new UnsharedLabsClient({
|
|
25
|
-
apiKey: process.env.UNSHARED_API_KEY
|
|
26
|
-
});
|
|
27
|
-
```
|
|
28
|
-
|
|
29
|
-
### CommonJS
|
|
30
|
-
|
|
31
|
-
```javascript
|
|
32
|
-
const { UnsharedLabsClient } = require('@unshared-labs/sdk');
|
|
33
|
-
```
|
|
34
|
-
|
|
35
|
-
---
|
|
36
|
-
|
|
37
|
-
## Configuration
|
|
38
|
-
|
|
39
|
-
```typescript
|
|
40
|
-
const client = new UnsharedLabsClient({
|
|
41
|
-
apiKey: 'sk_live_…', // required — keep server-side
|
|
42
|
-
baseUrl: 'https://…', // optional, default: https://api-ingress.unsharedlabs.com
|
|
43
|
-
timeout: 10_000, // optional, ms — default: 10 s
|
|
44
|
-
maxRetries: 3, // optional — retries on 5xx / network errors
|
|
23
|
+
apiKey: process.env.UNSHARED_API_KEY, // usk_…
|
|
45
24
|
});
|
|
46
25
|
```
|
|
47
26
|
|
|
@@ -49,131 +28,150 @@ const client = new UnsharedLabsClient({
|
|
|
49
28
|
|
|
50
29
|
## Methods
|
|
51
30
|
|
|
52
|
-
### `
|
|
31
|
+
### `processUserEvent(params)`
|
|
53
32
|
|
|
54
|
-
|
|
33
|
+
Record a user event and get a fraud signal back. Call this on login, signup, or any high-value action.
|
|
55
34
|
|
|
56
35
|
```typescript
|
|
57
|
-
const result = await client.
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
36
|
+
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'],
|
|
61
44
|
});
|
|
62
|
-
|
|
45
|
+
|
|
46
|
+
if (result.success && result.data?.analysis.is_user_flagged) {
|
|
47
|
+
// Block or challenge the user
|
|
48
|
+
}
|
|
63
49
|
```
|
|
64
50
|
|
|
65
|
-
|
|
51
|
+
**Fields encrypted before sending:** `emailAddress`, `deviceId`
|
|
66
52
|
|
|
67
|
-
|
|
53
|
+
---
|
|
54
|
+
|
|
55
|
+
### `checkUser(emailAddress, deviceId)`
|
|
68
56
|
|
|
69
|
-
|
|
57
|
+
Quick check to see if a user is flagged. Useful in middleware or route guards.
|
|
70
58
|
|
|
71
59
|
```typescript
|
|
72
|
-
const result = await client.
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
sessionHash: 'sess_abc',
|
|
78
|
-
userAgent: req.headers['user-agent'] ?? '',
|
|
79
|
-
emailAddress: 'user@example.com',
|
|
80
|
-
subscriptionStatus: 'paid',
|
|
81
|
-
});
|
|
82
|
-
// result.data: { event: { … }, analysis: { status, is_user_flagged } }
|
|
60
|
+
const result = await client.checkUser('user@example.com', 'device_abc');
|
|
61
|
+
|
|
62
|
+
if (result.data?.is_user_flagged) {
|
|
63
|
+
// Deny access
|
|
64
|
+
}
|
|
83
65
|
```
|
|
84
66
|
|
|
85
|
-
> **
|
|
67
|
+
> **Safe default:** Returns `{ is_user_flagged: false }` on any failure (network error, outage). A backend outage will never accidentally block a legitimate user.
|
|
86
68
|
|
|
87
|
-
|
|
69
|
+
---
|
|
88
70
|
|
|
89
|
-
|
|
71
|
+
### `triggerEmailVerification(emailAddress, deviceId)`
|
|
72
|
+
|
|
73
|
+
Send a 6-digit verification code to the user's email.
|
|
90
74
|
|
|
91
75
|
```typescript
|
|
92
|
-
|
|
93
|
-
if (result.data?.is_user_flagged) { /* … */ }
|
|
76
|
+
await client.triggerEmailVerification('user@example.com', 'device_abc');
|
|
94
77
|
```
|
|
95
78
|
|
|
96
|
-
|
|
79
|
+
---
|
|
97
80
|
|
|
98
|
-
|
|
81
|
+
### `verify(emailAddress, deviceId, code)`
|
|
82
|
+
|
|
83
|
+
Validate the code the user submitted.
|
|
99
84
|
|
|
100
85
|
```typescript
|
|
101
|
-
const result = await client.
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
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
|
|
105
96
|
}
|
|
106
97
|
```
|
|
107
98
|
|
|
108
|
-
|
|
99
|
+
---
|
|
109
100
|
|
|
110
|
-
### `
|
|
101
|
+
### `submitFingerprintEvent(fingerprint, opts?)`
|
|
111
102
|
|
|
112
|
-
|
|
103
|
+
Submit a browser fingerprint collected by `unshared-frontend-sdk`. Typically called by the middleware — you usually won't call this directly.
|
|
113
104
|
|
|
114
105
|
```typescript
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
106
|
+
await client.submitFingerprintEvent(fingerprint, {
|
|
107
|
+
userId: 'user_123',
|
|
108
|
+
sessionHash: 'session_xyz',
|
|
109
|
+
eventType: 'page_view',
|
|
110
|
+
});
|
|
118
111
|
```
|
|
119
112
|
|
|
120
113
|
---
|
|
121
114
|
|
|
122
|
-
## Express Middleware
|
|
115
|
+
## Express Middleware
|
|
123
116
|
|
|
124
|
-
|
|
117
|
+
The middleware adds a proxy route (`POST /unshared/submit-fingerprint-event`) that the browser SDK calls. It handles forwarding fingerprints to Unshared Labs and attaching your API key.
|
|
125
118
|
|
|
126
119
|
```typescript
|
|
127
|
-
import { createUnsharedMiddleware } from '
|
|
120
|
+
import { createUnsharedMiddleware } from 'unshared-clientjs-sdk/middleware';
|
|
121
|
+
|
|
122
|
+
// express.json() must come before this middleware
|
|
123
|
+
app.use(express.json());
|
|
128
124
|
|
|
129
125
|
app.use(createUnsharedMiddleware(client, {
|
|
130
|
-
userIdExtractor: (req) => req.user?.id,
|
|
131
|
-
eventTypeExtractor: (req) => req.body.event_type,
|
|
132
|
-
routePrefix: '/unshared', // default
|
|
126
|
+
userIdExtractor: (req) => req.user?.id, // attach logged-in user
|
|
133
127
|
}));
|
|
134
128
|
```
|
|
135
129
|
|
|
136
|
-
|
|
137
|
-
-
|
|
138
|
-
-
|
|
139
|
-
- Never returns 5xx to the browser — upstream errors become `HTTP 200 { success: false }`
|
|
130
|
+
**Prerequisites:**
|
|
131
|
+
- Mount `express.json()` before the middleware
|
|
132
|
+
- `user_id` is automatically removed from `req.body` after being read — downstream logging won't capture it
|
|
140
133
|
|
|
141
|
-
|
|
134
|
+
**Options:**
|
|
142
135
|
|
|
143
|
-
|
|
136
|
+
| Option | Type | Default | Description |
|
|
137
|
+
|--------|------|---------|-------------|
|
|
138
|
+
| `userIdExtractor` | `(req) => string \| undefined` | — | Pull user ID from your auth session |
|
|
139
|
+
| `eventTypeExtractor` | `(req) => string \| undefined` | — | Override event type |
|
|
140
|
+
| `sessionIdExtractor` | `(req) => string \| undefined` | — | Override session ID |
|
|
141
|
+
| `defaultEventType` | `string` | `"browser_event"` | Fallback event type |
|
|
142
|
+
| `routePrefix` | `string` | `"/unshared"` | Route mount prefix |
|
|
143
|
+
| `corsOrigins` | `string \| string[]` | — | Allowed CORS origins; handles OPTIONS preflight automatically |
|
|
144
144
|
|
|
145
|
-
|
|
145
|
+
---
|
|
146
|
+
|
|
147
|
+
## Configuration
|
|
146
148
|
|
|
147
149
|
```typescript
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
details?: Record<string, unknown>;
|
|
155
|
-
retryAfter?: number; // seconds — present on 429 RATE_LIMIT_EXCEEDED
|
|
156
|
-
};
|
|
157
|
-
status: number; // HTTP status, 0 for network errors
|
|
158
|
-
}
|
|
150
|
+
new UnsharedLabsClient({
|
|
151
|
+
apiKey: 'usk_…', // required
|
|
152
|
+
baseUrl: 'https://api-ingress.unsharedlabs.com', // optional
|
|
153
|
+
timeout: 10_000, // optional, ms
|
|
154
|
+
maxRetries: 3, // optional
|
|
155
|
+
});
|
|
159
156
|
```
|
|
160
157
|
|
|
161
|
-
- **4xx** — returned immediately, not retried
|
|
162
|
-
- **5xx / network** — retried up to `maxRetries` with exponential backoff (base 1 s, ±25% jitter)
|
|
163
|
-
- `triggerEmailVerification` and `verify` map 5xx / network errors to `{ code: 'DELIVERY_FAILED' }`
|
|
164
|
-
|
|
165
|
-
See [Appendix A of the spec](../V2_CLIENT_SDK_SPEC.md) for the full error code list.
|
|
166
|
-
|
|
167
158
|
---
|
|
168
159
|
|
|
169
|
-
##
|
|
160
|
+
## Response shape
|
|
161
|
+
|
|
162
|
+
All methods return `ApiResult<T>`:
|
|
170
163
|
|
|
171
|
-
|
|
164
|
+
```typescript
|
|
165
|
+
{
|
|
166
|
+
success: boolean;
|
|
167
|
+
data?: T;
|
|
168
|
+
error?: { code: string; message: string; retryAfter?: number };
|
|
169
|
+
status: number; // HTTP status code
|
|
170
|
+
}
|
|
171
|
+
```
|
|
172
172
|
|
|
173
173
|
---
|
|
174
174
|
|
|
175
|
-
##
|
|
175
|
+
## Security
|
|
176
176
|
|
|
177
|
-
|
|
178
|
-
UNSHARED_API_KEY=sk_live_…
|
|
179
|
-
```
|
|
177
|
+
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.
|
package/dist/client.d.ts
CHANGED
|
@@ -2,7 +2,7 @@ import type { FingerprintWireFormat } from '@unshared-labs/shared-types';
|
|
|
2
2
|
export interface UnsharedLabsClientConfig {
|
|
3
3
|
/**
|
|
4
4
|
* Secret API key. Must be kept server-side.
|
|
5
|
-
* Format:
|
|
5
|
+
* Format: usk_…
|
|
6
6
|
*/
|
|
7
7
|
apiKey: string;
|
|
8
8
|
/**
|
|
@@ -140,19 +140,18 @@ export declare class UnsharedLabsClient {
|
|
|
140
140
|
* Verify a 6-digit code submitted by the user.
|
|
141
141
|
* Maps to: POST /v2/verify
|
|
142
142
|
*
|
|
143
|
-
*
|
|
144
|
-
*
|
|
145
|
-
*
|
|
143
|
+
* `result.success` reliably indicates whether verification succeeded:
|
|
144
|
+
* - `success: true` → code was correct, user is verified
|
|
145
|
+
* - `success: false, error.code: "VERIFICATION_FAILED"` → wrong or expired code
|
|
146
|
+
* - `success: false, error.code: "DELIVERY_FAILED"` → network/server error
|
|
146
147
|
*
|
|
147
148
|
* ```typescript
|
|
148
149
|
* const result = await client.verify(email, deviceId, code);
|
|
149
|
-
* if (!result.success) {
|
|
150
|
-
*
|
|
151
|
-
*
|
|
150
|
+
* if (!result.success) {
|
|
151
|
+
* if (result.error?.code === 'VERIFICATION_FAILED') { /* bad code *\/ }
|
|
152
|
+
* else { /* transport error *\/ }
|
|
153
|
+
* } else { /* verified *\/ }
|
|
152
154
|
* ```
|
|
153
|
-
*
|
|
154
|
-
* On network or 5xx errors, returns `{ success: false, error: { code: "DELIVERY_FAILED" } }`.
|
|
155
|
-
* On 4xx (e.g. rate limit), the server error is returned as-is.
|
|
156
155
|
*/
|
|
157
156
|
verify(emailAddress: string, deviceId: string, code: string): Promise<ApiResult<VerifyResult>>;
|
|
158
157
|
}
|
package/dist/client.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
"use strict";Object.defineProperty(exports,"t",{value:!0}),exports.UnsharedLabsClient=void 0;const crypto_1=require("crypto"),util_1=require("./util"),DEFAULT_BASE_URL="https://api-ingress.unsharedlabs.com",DEFAULT_TIMEOUT_MS=1e4,DEFAULT_MAX_RETRIES=3,MAX_DELAY_MS=3e4,BASE_DELAY_MS=1e3;function sleep(e){return new Promise(s=>setTimeout(s,e))}function retryDelay(e){const s=Math.min(1e3*Math.pow(2,e-1),3e4),t=s*(.5*Math.random()-.25);return Math.max(0,s+t)}async function parseErrorBody(e){const s=await e.text().catch(()=>"");try{const t=JSON.parse(s);return t?.error?.code?{code:t.error.code,message:t.error.message??"Unknown error",details:t.error.details}:{code:"UNKNOWN_ERROR",message:s||e.statusText}}catch{return{code:"UNKNOWN_ERROR",message:s||e.statusText}}}class UnsharedLabsClient{constructor(e){if(!e.apiKey||""===e.apiKey.trim())throw new Error("apiKey is required");this.i=e.apiKey,this.o=e.baseUrl?e.baseUrl.replace(/\/$/,""):DEFAULT_BASE_URL,this.h=e.timeout??1e4,this.u=e.maxRetries??3,this.l=(0,crypto_1.createHash)("sha256").update(e.apiKey).digest()}_(e){return(0,util_1.encryptData)(e,this.l)}async p(e,s){const t=this.u+1;let r={success:!1,status:0,error:{code:"NETWORK_ERROR",message:"Request failed"}};for(let i=1;i<=t;i++){i>1&&await sleep(retryDelay(i-1));const t=new AbortController,a=setTimeout(()=>t.abort(),this.h);try{const i=await fetch(e,{method:s.method,headers:{"X-API-Key":this.i,...s.headers},body:s.body,signal:t.signal});if(clearTimeout(a),i.ok){const e=await i.text().catch(()=>"{}");let s;try{s=JSON.parse(e)}catch{s={}}const t="data"in s?s.data:s;return{success:!0,status:i.status,data:t}}const n=await parseErrorBody(i);if(i.status>=400&&i.status<500){if(429===i.status){const e=i.headers.get("Retry-After");if(null!=e){const s=parseInt(e,10);isNaN(s)||(n.retryAfter=s)}}return{success:!1,status:i.status,error:n}}r={success:!1,status:i.status,error:n}}catch(e){clearTimeout(a),r={success:!1,status:0,error:{code:"NETWORK_ERROR",message:e instanceof Error?e.message:String(e)}}}}return r}async submitFingerprintEvent(e,s){const t={hash:e.full_hash,stable_hash:e.fingerprint_id,collected_at:e.timestamp,is_incognito:e.isIncognito,components:e.components,version:e.version};return null!=s?.userId&&(t.user_id=this._(s.userId)),null!=s?.sessionHash&&(t.session_hash=s.sessionHash),null!=s?.eventType&&(t.event_type=s.eventType),this.p(`${this.o}/v2/submit-fingerprint-event`,{method:"POST",headers:{"Content-Type":"application/json","X-Idempotency-Key":(0,crypto_1.randomUUID)()},body:JSON.stringify(t)})}async processUserEvent(e){const s={event_type:e.eventType,user_id:e.userId,ip_address:e.ipAddress,device_id:this._(e.deviceId),session_hash:e.sessionHash,user_agent:e.userAgent,email_address:this._(e.emailAddress)};return null!=e.subscriptionStatus&&(s.subscription_status=e.subscriptionStatus),null!=e.eventDetails&&(s.event_details=e.eventDetails),this.p(`${this.o}/v2/process-user-event`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(s)})}async checkUser(e,s){const t=new URLSearchParams({email_address:this._(e),device_id:this._(s)}),r=await this.p(`${this.o}/v2/check-user?${t}`,{method:"GET"});return r.success?r:{success:!0,status:200,data:{is_user_flagged:!1}}}async triggerEmailVerification(e,s){const t=await this.p(`${this.o}/v2/trigger-email-verification`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({email_address:this._(e),device_id:this._(s)})});return!t.success&&(0===t.status||t.status>=500)?{success:!1,status:t.status,error:{code:"DELIVERY_FAILED",message:t.error?.message??"Delivery failed"}}:t}async verify(e,s,t){const r=await this.p(`${this.o}/v2/verify`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({email_address:this._(e),device_id:this._(s),code:this._(t)})});return!r.success&&(0===r.status||r.status>=500)?{success:!1,status:r.status,error:{code:"DELIVERY_FAILED",message:r.error?.message??"Delivery failed"}}:r}}exports.UnsharedLabsClient=UnsharedLabsClient;
|
|
1
|
+
"use strict";Object.defineProperty(exports,"t",{value:!0}),exports.UnsharedLabsClient=void 0;const crypto_1=require("crypto"),util_1=require("./util"),DEFAULT_BASE_URL="https://api-ingress.unsharedlabs.com",DEFAULT_TIMEOUT_MS=1e4,DEFAULT_MAX_RETRIES=3,MAX_DELAY_MS=3e4,BASE_DELAY_MS=1e3;function sleep(e){return new Promise(s=>setTimeout(s,e))}function retryDelay(e){const s=Math.min(1e3*Math.pow(2,e-1),3e4),t=s*(.5*Math.random()-.25);return Math.max(0,s+t)}async function parseErrorBody(e){const s=await e.text().catch(()=>"");try{const t=JSON.parse(s);return t?.error?.code?{code:t.error.code,message:t.error.message??"Unknown error",details:t.error.details}:{code:"UNKNOWN_ERROR",message:s||e.statusText}}catch{return{code:"UNKNOWN_ERROR",message:s||e.statusText}}}class UnsharedLabsClient{constructor(e){if(!e.apiKey||""===e.apiKey.trim())throw new Error("apiKey is required");this.i=e.apiKey,this.o=e.baseUrl?e.baseUrl.replace(/\/$/,""):DEFAULT_BASE_URL,this.h=e.timeout??1e4,this.u=e.maxRetries??3,this.l=(0,crypto_1.createHash)("sha256").update(e.apiKey).digest()}_(e){return(0,util_1.encryptData)(e,this.l)}async p(e,s){const t=this.u+1;let r={success:!1,status:0,error:{code:"NETWORK_ERROR",message:"Request failed"}};for(let i=1;i<=t;i++){i>1&&await sleep(retryDelay(i-1));const t=new AbortController,a=setTimeout(()=>t.abort(),this.h);try{const i=await fetch(e,{method:s.method,headers:{"X-API-Key":this.i,...s.headers},body:s.body,signal:t.signal});if(clearTimeout(a),i.ok){const e=await i.text().catch(()=>"{}");let s;try{s=JSON.parse(e)}catch{s={}}const t="data"in s?s.data:s;return{success:!0,status:i.status,data:t}}const n=await parseErrorBody(i);if(i.status>=400&&i.status<500){if(429===i.status){const e=i.headers.get("Retry-After");if(null!=e){const s=parseInt(e,10);isNaN(s)||(n.retryAfter=s)}}return{success:!1,status:i.status,error:n}}r={success:!1,status:i.status,error:n}}catch(e){clearTimeout(a),r={success:!1,status:0,error:{code:"NETWORK_ERROR",message:e instanceof Error?e.message:String(e)}}}}return r}async submitFingerprintEvent(e,s){const t={hash:e.full_hash,stable_hash:e.fingerprint_id,collected_at:e.timestamp,is_incognito:e.isIncognito,components:e.components,version:e.version};return null!=s?.userId&&(t.user_id=this._(s.userId)),null!=s?.sessionHash&&(t.session_hash=s.sessionHash),null!=s?.eventType&&(t.event_type=s.eventType),this.p(`${this.o}/v2/submit-fingerprint-event`,{method:"POST",headers:{"Content-Type":"application/json","X-Idempotency-Key":(0,crypto_1.randomUUID)()},body:JSON.stringify(t)})}async processUserEvent(e){const s={event_type:e.eventType,user_id:e.userId,ip_address:e.ipAddress,device_id:this._(e.deviceId),session_hash:e.sessionHash,user_agent:e.userAgent,email_address:this._(e.emailAddress)};return null!=e.subscriptionStatus&&(s.subscription_status=e.subscriptionStatus),null!=e.eventDetails&&(s.event_details=e.eventDetails),this.p(`${this.o}/v2/process-user-event`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(s)})}async checkUser(e,s){const t=new URLSearchParams({email_address:this._(e),device_id:this._(s)}),r=await this.p(`${this.o}/v2/check-user?${t}`,{method:"GET"});return r.success?r:{success:!0,status:200,data:{is_user_flagged:!1}}}async triggerEmailVerification(e,s){const t=await this.p(`${this.o}/v2/trigger-email-verification`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({email_address:this._(e),device_id:this._(s)})});return!t.success&&(0===t.status||t.status>=500)?{success:!1,status:t.status,error:{code:"DELIVERY_FAILED",message:t.error?.message??"Delivery failed"}}:t}async verify(e,s,t){const r=await this.p(`${this.o}/v2/verify`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({email_address:this._(e),device_id:this._(s),code:this._(t)})});return!r.success&&(0===r.status||r.status>=500)?{success:!1,status:r.status,error:{code:"DELIVERY_FAILED",message:r.error?.message??"Delivery failed"}}:r.success&&!1===r.data?.verified?{success:!1,status:r.status,error:{code:"VERIFICATION_FAILED",message:"Code is incorrect or expired",details:r.data.reason?{reason:r.data.reason}:void 0}}:r}}exports.UnsharedLabsClient=UnsharedLabsClient;
|
package/dist/esm/client.d.mts
CHANGED
|
@@ -2,7 +2,7 @@ import type { FingerprintWireFormat } from '@unshared-labs/shared-types';
|
|
|
2
2
|
export interface UnsharedLabsClientConfig {
|
|
3
3
|
/**
|
|
4
4
|
* Secret API key. Must be kept server-side.
|
|
5
|
-
* Format:
|
|
5
|
+
* Format: usk_…
|
|
6
6
|
*/
|
|
7
7
|
apiKey: string;
|
|
8
8
|
/**
|
|
@@ -140,19 +140,18 @@ export declare class UnsharedLabsClient {
|
|
|
140
140
|
* Verify a 6-digit code submitted by the user.
|
|
141
141
|
* Maps to: POST /v2/verify
|
|
142
142
|
*
|
|
143
|
-
*
|
|
144
|
-
*
|
|
145
|
-
*
|
|
143
|
+
* `result.success` reliably indicates whether verification succeeded:
|
|
144
|
+
* - `success: true` → code was correct, user is verified
|
|
145
|
+
* - `success: false, error.code: "VERIFICATION_FAILED"` → wrong or expired code
|
|
146
|
+
* - `success: false, error.code: "DELIVERY_FAILED"` → network/server error
|
|
146
147
|
*
|
|
147
148
|
* ```typescript
|
|
148
149
|
* const result = await client.verify(email, deviceId, code);
|
|
149
|
-
* if (!result.success) {
|
|
150
|
-
*
|
|
151
|
-
*
|
|
150
|
+
* if (!result.success) {
|
|
151
|
+
* if (result.error?.code === 'VERIFICATION_FAILED') { /* bad code *\/ }
|
|
152
|
+
* else { /* transport error *\/ }
|
|
153
|
+
* } else { /* verified *\/ }
|
|
152
154
|
* ```
|
|
153
|
-
*
|
|
154
|
-
* On network or 5xx errors, returns `{ success: false, error: { code: "DELIVERY_FAILED" } }`.
|
|
155
|
-
* On 4xx (e.g. rate limit), the server error is returned as-is.
|
|
156
155
|
*/
|
|
157
156
|
verify(emailAddress: string, deviceId: string, code: string): Promise<ApiResult<VerifyResult>>;
|
|
158
157
|
}
|
package/dist/esm/client.mjs
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
import{createHash,randomUUID}from"crypto";import{encryptData}from"./util";const DEFAULT_BASE_URL="https://api-ingress.unsharedlabs.com",DEFAULT_TIMEOUT_MS=1e4,DEFAULT_MAX_RETRIES=3,MAX_DELAY_MS=3e4,BASE_DELAY_MS=1e3;function sleep(e){return new Promise(s=>setTimeout(s,e))}function retryDelay(e){const s=Math.min(1e3*Math.pow(2,e-1),3e4),t=s*(.5*Math.random()-.25);return Math.max(0,s+t)}async function parseErrorBody(e){const s=await e.text().catch(()=>"");try{const t=JSON.parse(s);return t?.error?.code?{code:t.error.code,message:t.error.message??"Unknown error",details:t.error.details}:{code:"UNKNOWN_ERROR",message:s||e.statusText}}catch{return{code:"UNKNOWN_ERROR",message:s||e.statusText}}}export class UnsharedLabsClient{constructor(e){if(!e.apiKey||""===e.apiKey.trim())throw new Error("apiKey is required");this.t=e.apiKey,this.i=e.baseUrl?e.baseUrl.replace(/\/$/,""):DEFAULT_BASE_URL,this.o=e.timeout??1e4,this.h=e.maxRetries??3,this.u=createHash("sha256").update(e.apiKey).digest()}l(e){return encryptData(e,this.u)}async _(e,s){const t=this.h+1;let r={success:!1,status:0,error:{code:"NETWORK_ERROR",message:"Request failed"}};for(let a=1;a<=t;a++){a>1&&await sleep(retryDelay(a-1));const t=new AbortController,i=setTimeout(()=>t.abort(),this.o);try{const a=await fetch(e,{method:s.method,headers:{"X-API-Key":this.t,...s.headers},body:s.body,signal:t.signal});if(clearTimeout(i),a.ok){const e=await a.text().catch(()=>"{}");let s;try{s=JSON.parse(e)}catch{s={}}const t="data"in s?s.data:s;return{success:!0,status:a.status,data:t}}const n=await parseErrorBody(a);if(a.status>=400&&a.status<500){if(429===a.status){const e=a.headers.get("Retry-After");if(null!=e){const s=parseInt(e,10);isNaN(s)||(n.retryAfter=s)}}return{success:!1,status:a.status,error:n}}r={success:!1,status:a.status,error:n}}catch(e){clearTimeout(i),r={success:!1,status:0,error:{code:"NETWORK_ERROR",message:e instanceof Error?e.message:String(e)}}}}return r}async submitFingerprintEvent(e,s){const t={hash:e.full_hash,stable_hash:e.fingerprint_id,collected_at:e.timestamp,is_incognito:e.isIncognito,components:e.components,version:e.version};return null!=s?.userId&&(t.user_id=this.l(s.userId)),null!=s?.sessionHash&&(t.session_hash=s.sessionHash),null!=s?.eventType&&(t.event_type=s.eventType),this._(`${this.i}/v2/submit-fingerprint-event`,{method:"POST",headers:{"Content-Type":"application/json","X-Idempotency-Key":randomUUID()},body:JSON.stringify(t)})}async processUserEvent(e){const s={event_type:e.eventType,user_id:e.userId,ip_address:e.ipAddress,device_id:this.l(e.deviceId),session_hash:e.sessionHash,user_agent:e.userAgent,email_address:this.l(e.emailAddress)};return null!=e.subscriptionStatus&&(s.subscription_status=e.subscriptionStatus),null!=e.eventDetails&&(s.event_details=e.eventDetails),this._(`${this.i}/v2/process-user-event`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(s)})}async checkUser(e,s){const t=new URLSearchParams({email_address:this.l(e),device_id:this.l(s)}),r=await this._(`${this.i}/v2/check-user?${t}`,{method:"GET"});return r.success?r:{success:!0,status:200,data:{is_user_flagged:!1}}}async triggerEmailVerification(e,s){const t=await this._(`${this.i}/v2/trigger-email-verification`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({email_address:this.l(e),device_id:this.l(s)})});return!t.success&&(0===t.status||t.status>=500)?{success:!1,status:t.status,error:{code:"DELIVERY_FAILED",message:t.error?.message??"Delivery failed"}}:t}async verify(e,s,t){const r=await this._(`${this.i}/v2/verify`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({email_address:this.l(e),device_id:this.l(s),code:this.l(t)})});return!r.success&&(0===r.status||r.status>=500)?{success:!1,status:r.status,error:{code:"DELIVERY_FAILED",message:r.error?.message??"Delivery failed"}}:r}}
|
|
1
|
+
import{createHash,randomUUID}from"crypto";import{encryptData}from"./util";const DEFAULT_BASE_URL="https://api-ingress.unsharedlabs.com",DEFAULT_TIMEOUT_MS=1e4,DEFAULT_MAX_RETRIES=3,MAX_DELAY_MS=3e4,BASE_DELAY_MS=1e3;function sleep(e){return new Promise(s=>setTimeout(s,e))}function retryDelay(e){const s=Math.min(1e3*Math.pow(2,e-1),3e4),t=s*(.5*Math.random()-.25);return Math.max(0,s+t)}async function parseErrorBody(e){const s=await e.text().catch(()=>"");try{const t=JSON.parse(s);return t?.error?.code?{code:t.error.code,message:t.error.message??"Unknown error",details:t.error.details}:{code:"UNKNOWN_ERROR",message:s||e.statusText}}catch{return{code:"UNKNOWN_ERROR",message:s||e.statusText}}}export class UnsharedLabsClient{constructor(e){if(!e.apiKey||""===e.apiKey.trim())throw new Error("apiKey is required");this.t=e.apiKey,this.i=e.baseUrl?e.baseUrl.replace(/\/$/,""):DEFAULT_BASE_URL,this.o=e.timeout??1e4,this.h=e.maxRetries??3,this.u=createHash("sha256").update(e.apiKey).digest()}l(e){return encryptData(e,this.u)}async _(e,s){const t=this.h+1;let r={success:!1,status:0,error:{code:"NETWORK_ERROR",message:"Request failed"}};for(let a=1;a<=t;a++){a>1&&await sleep(retryDelay(a-1));const t=new AbortController,i=setTimeout(()=>t.abort(),this.o);try{const a=await fetch(e,{method:s.method,headers:{"X-API-Key":this.t,...s.headers},body:s.body,signal:t.signal});if(clearTimeout(i),a.ok){const e=await a.text().catch(()=>"{}");let s;try{s=JSON.parse(e)}catch{s={}}const t="data"in s?s.data:s;return{success:!0,status:a.status,data:t}}const n=await parseErrorBody(a);if(a.status>=400&&a.status<500){if(429===a.status){const e=a.headers.get("Retry-After");if(null!=e){const s=parseInt(e,10);isNaN(s)||(n.retryAfter=s)}}return{success:!1,status:a.status,error:n}}r={success:!1,status:a.status,error:n}}catch(e){clearTimeout(i),r={success:!1,status:0,error:{code:"NETWORK_ERROR",message:e instanceof Error?e.message:String(e)}}}}return r}async submitFingerprintEvent(e,s){const t={hash:e.full_hash,stable_hash:e.fingerprint_id,collected_at:e.timestamp,is_incognito:e.isIncognito,components:e.components,version:e.version};return null!=s?.userId&&(t.user_id=this.l(s.userId)),null!=s?.sessionHash&&(t.session_hash=s.sessionHash),null!=s?.eventType&&(t.event_type=s.eventType),this._(`${this.i}/v2/submit-fingerprint-event`,{method:"POST",headers:{"Content-Type":"application/json","X-Idempotency-Key":randomUUID()},body:JSON.stringify(t)})}async processUserEvent(e){const s={event_type:e.eventType,user_id:e.userId,ip_address:e.ipAddress,device_id:this.l(e.deviceId),session_hash:e.sessionHash,user_agent:e.userAgent,email_address:this.l(e.emailAddress)};return null!=e.subscriptionStatus&&(s.subscription_status=e.subscriptionStatus),null!=e.eventDetails&&(s.event_details=e.eventDetails),this._(`${this.i}/v2/process-user-event`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(s)})}async checkUser(e,s){const t=new URLSearchParams({email_address:this.l(e),device_id:this.l(s)}),r=await this._(`${this.i}/v2/check-user?${t}`,{method:"GET"});return r.success?r:{success:!0,status:200,data:{is_user_flagged:!1}}}async triggerEmailVerification(e,s){const t=await this._(`${this.i}/v2/trigger-email-verification`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({email_address:this.l(e),device_id:this.l(s)})});return!t.success&&(0===t.status||t.status>=500)?{success:!1,status:t.status,error:{code:"DELIVERY_FAILED",message:t.error?.message??"Delivery failed"}}:t}async verify(e,s,t){const r=await this._(`${this.i}/v2/verify`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({email_address:this.l(e),device_id:this.l(s),code:this.l(t)})});return!r.success&&(0===r.status||r.status>=500)?{success:!1,status:r.status,error:{code:"DELIVERY_FAILED",message:r.error?.message??"Delivery failed"}}:r.success&&!1===r.data?.verified?{success:!1,status:r.status,error:{code:"VERIFICATION_FAILED",message:"Code is incorrect or expired",details:r.data.reason?{reason:r.data.reason}:void 0}}:r}}
|
|
@@ -14,6 +14,14 @@ export interface MiddlewareOptions {
|
|
|
14
14
|
* @default "/unshared"
|
|
15
15
|
*/
|
|
16
16
|
routePrefix?: string;
|
|
17
|
+
/**
|
|
18
|
+
* Allowed CORS origins for the fingerprint route.
|
|
19
|
+
* Use `"*"` to allow all origins, or pass a specific origin / array of origins.
|
|
20
|
+
* The middleware handles OPTIONS preflight automatically when this is set.
|
|
21
|
+
* @example corsOrigins: "https://app.example.com"
|
|
22
|
+
* @example corsOrigins: ["https://app.example.com", "https://staging.example.com"]
|
|
23
|
+
*/
|
|
24
|
+
corsOrigins?: string | string[];
|
|
17
25
|
}
|
|
18
26
|
/**
|
|
19
27
|
* Creates an Express middleware that proxies browser fingerprint events to
|
|
@@ -25,10 +33,10 @@ export interface MiddlewareOptions {
|
|
|
25
33
|
* **Prerequisites:**
|
|
26
34
|
* - Mount `express.json()` (or equivalent body-parser) **before** this middleware,
|
|
27
35
|
* otherwise `req.body` will be undefined and every request will return 400.
|
|
28
|
-
* -
|
|
29
|
-
*
|
|
30
|
-
* -
|
|
31
|
-
*
|
|
36
|
+
* - For cross-origin frontends, pass `corsOrigins` instead of configuring CORS
|
|
37
|
+
* separately — the middleware handles OPTIONS preflight automatically.
|
|
38
|
+
* - `user_id` is automatically scrubbed from `req.body` after it is read, so
|
|
39
|
+
* downstream logging middleware will not capture plaintext PII.
|
|
32
40
|
*
|
|
33
41
|
* **Error contract:** Never returns 5xx to the browser. Upstream failures are
|
|
34
42
|
* returned as HTTP 200 with { success: false, error: { code: "UPSTREAM_ERROR" } }.
|
package/dist/esm/middleware.mjs
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export function createUnsharedMiddleware(e,r){const{userIdExtractor:s,eventTypeExtractor:t,sessionIdExtractor:
|
|
1
|
+
export function createUnsharedMiddleware(e,r){const{userIdExtractor:s,eventTypeExtractor:t,sessionIdExtractor:o,defaultEventType:n="browser_event",routePrefix:c="/unshared",corsOrigins:i}=r??{},a=`${c}/submit-fingerprint-event`,d=i?Array.isArray(i)?i:[i]:null;return async(r,c,i)=>{if(d&&r.path===a){const e=r.headers.origin??"",s=d.includes("*");if((s||d.includes(e))&&(c.setHeader("Access-Control-Allow-Origin",s?"*":e),c.setHeader("Access-Control-Allow-Methods","POST, OPTIONS"),c.setHeader("Access-Control-Allow-Headers","Content-Type, X-Idempotency-Key, X-Session-Id")),"OPTIONS"===r.method)return void c.status(204).end()}if("POST"===r.method&&r.path===a)try{const i=r.body??{};if(!i.hash||!i.stable_hash||!i.collected_at)return void c.status(400).json({success:!1,error:{code:"VALIDATION_ERROR",message:"Missing required fingerprint fields: hash, stable_hash, collected_at"}});const a={full_hash:i.hash,fingerprint_id:i.stable_hash,timestamp:i.collected_at,isIncognito:i.is_incognito??!1,components:i.components??{},version:i.version??"unknown"};let d,l,u;try{d=(s?s(r):void 0)??i.user_id}catch{d=i.user_id}r.body&&"object"==typeof r.body&&"user_id"in r.body&&delete r.body.user_id;try{l=(t?t(r):void 0)??i.event_type??n}catch{l=i.event_type??n}try{u=(o?o(r):void 0)??r.headers["x-session-id"]?.toString()??i.session_id}catch{u=r.headers["x-session-id"]?.toString()??i.session_id}const f=await e.submitFingerprintEvent(a,{userId:d,sessionHash:u,eventType:l});if(!f.success)return void c.status(200).json({success:!1,error:{code:"UPSTREAM_ERROR",message:f.error?.message??"Upstream request failed"}});c.status(202).json({success:!0,data:f.data})}catch(e){c.status(200).json({success:!1,error:{code:"MIDDLEWARE_ERROR",message:e instanceof Error?e.message:"Middleware error"}})}else i()}}
|
package/dist/middleware.d.ts
CHANGED
|
@@ -14,6 +14,14 @@ export interface MiddlewareOptions {
|
|
|
14
14
|
* @default "/unshared"
|
|
15
15
|
*/
|
|
16
16
|
routePrefix?: string;
|
|
17
|
+
/**
|
|
18
|
+
* Allowed CORS origins for the fingerprint route.
|
|
19
|
+
* Use `"*"` to allow all origins, or pass a specific origin / array of origins.
|
|
20
|
+
* The middleware handles OPTIONS preflight automatically when this is set.
|
|
21
|
+
* @example corsOrigins: "https://app.example.com"
|
|
22
|
+
* @example corsOrigins: ["https://app.example.com", "https://staging.example.com"]
|
|
23
|
+
*/
|
|
24
|
+
corsOrigins?: string | string[];
|
|
17
25
|
}
|
|
18
26
|
/**
|
|
19
27
|
* Creates an Express middleware that proxies browser fingerprint events to
|
|
@@ -25,10 +33,10 @@ export interface MiddlewareOptions {
|
|
|
25
33
|
* **Prerequisites:**
|
|
26
34
|
* - Mount `express.json()` (or equivalent body-parser) **before** this middleware,
|
|
27
35
|
* otherwise `req.body` will be undefined and every request will return 400.
|
|
28
|
-
* -
|
|
29
|
-
*
|
|
30
|
-
* -
|
|
31
|
-
*
|
|
36
|
+
* - For cross-origin frontends, pass `corsOrigins` instead of configuring CORS
|
|
37
|
+
* separately — the middleware handles OPTIONS preflight automatically.
|
|
38
|
+
* - `user_id` is automatically scrubbed from `req.body` after it is read, so
|
|
39
|
+
* downstream logging middleware will not capture plaintext PII.
|
|
32
40
|
*
|
|
33
41
|
* **Error contract:** Never returns 5xx to the browser. Upstream failures are
|
|
34
42
|
* returned as HTTP 200 with { success: false, error: { code: "UPSTREAM_ERROR" } }.
|
package/dist/middleware.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
"use strict";function createUnsharedMiddleware(e,r){const{userIdExtractor:s,eventTypeExtractor:t,sessionIdExtractor:
|
|
1
|
+
"use strict";function createUnsharedMiddleware(e,r){const{userIdExtractor:s,eventTypeExtractor:t,sessionIdExtractor:o,defaultEventType:n="browser_event",routePrefix:c="/unshared",corsOrigins:i}=r??{},a=`${c}/submit-fingerprint-event`,d=i?Array.isArray(i)?i:[i]:null;return async(r,c,i)=>{if(d&&r.path===a){const e=r.headers.origin??"",s=d.includes("*");if((s||d.includes(e))&&(c.setHeader("Access-Control-Allow-Origin",s?"*":e),c.setHeader("Access-Control-Allow-Methods","POST, OPTIONS"),c.setHeader("Access-Control-Allow-Headers","Content-Type, X-Idempotency-Key, X-Session-Id")),"OPTIONS"===r.method)return void c.status(204).end()}if("POST"===r.method&&r.path===a)try{const i=r.body??{};if(!i.hash||!i.stable_hash||!i.collected_at)return void c.status(400).json({success:!1,error:{code:"VALIDATION_ERROR",message:"Missing required fingerprint fields: hash, stable_hash, collected_at"}});const a={full_hash:i.hash,fingerprint_id:i.stable_hash,timestamp:i.collected_at,isIncognito:i.is_incognito??!1,components:i.components??{},version:i.version??"unknown"};let d,l,u;try{d=(s?s(r):void 0)??i.user_id}catch{d=i.user_id}r.body&&"object"==typeof r.body&&"user_id"in r.body&&delete r.body.user_id;try{l=(t?t(r):void 0)??i.event_type??n}catch{l=i.event_type??n}try{u=(o?o(r):void 0)??r.headers["x-session-id"]?.toString()??i.session_id}catch{u=r.headers["x-session-id"]?.toString()??i.session_id}const f=await e.submitFingerprintEvent(a,{userId:d,sessionHash:u,eventType:l});if(!f.success)return void c.status(200).json({success:!1,error:{code:"UPSTREAM_ERROR",message:f.error?.message??"Upstream request failed"}});c.status(202).json({success:!0,data:f.data})}catch(e){c.status(200).json({success:!1,error:{code:"MIDDLEWARE_ERROR",message:e instanceof Error?e.message:"Middleware error"}})}else i()}}Object.defineProperty(exports,"t",{value:!0}),exports.createUnsharedMiddleware=createUnsharedMiddleware;
|