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 CHANGED
@@ -1,47 +1,26 @@
1
- # @unshared-labs/sdk
1
+ # unshared-clientjs-sdk
2
2
 
3
- Server-side Node.js SDK for the Unshared Labs V2 API. Handles authentication, AES-256-GCM field encryption, retry logic, and structured error responses.
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
- ## Installation
7
+ ## Install
10
8
 
11
9
  ```bash
12
- npm install @unshared-labs/sdk
10
+ npm install unshared-clientjs-sdk
13
11
  ```
14
12
 
15
- Requires Node.js 18.
13
+ **Requires Node.js 18+**
16
14
 
17
15
  ---
18
16
 
19
17
  ## Quick Start
20
18
 
21
19
  ```typescript
22
- import { UnsharedLabsClient } from '@unshared-labs/sdk';
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
- ### `submitFingerprintEvent(fingerprint, opts?)`
31
+ ### `processUserEvent(params)`
53
32
 
54
- Submit a browser fingerprint collected by `@unshared-labs/frontend-fingerprint`. Returns 202 Accepted; the event is stored asynchronously.
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.submitFingerprintEvent(fingerprintData, {
58
- userId: 'u_123',
59
- sessionHash: 'sess_abc',
60
- eventType: 'login',
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
- // result.data: { hash, stable_hash, collected_at, version }
45
+
46
+ if (result.success && result.data?.analysis.is_user_flagged) {
47
+ // Block or challenge the user
48
+ }
63
49
  ```
64
50
 
65
- An `X-Idempotency-Key` is sent automatically. Retries with the same key are deduplicated at the backend.
51
+ **Fields encrypted before sending:** `emailAddress`, `deviceId`
66
52
 
67
- ### `processUserEvent(params)`
53
+ ---
54
+
55
+ ### `checkUser(emailAddress, deviceId)`
68
56
 
69
- Record a user event and receive naughty-list analysis. `emailAddress` and `deviceId` are AES-256-GCM encrypted before sending.
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.processUserEvent({
73
- eventType: 'login',
74
- userId: 'u_123',
75
- ipAddress: req.ip,
76
- deviceId: 'device-uuid',
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
- > **Note:** This endpoint has no server-side idempotency. Retries may insert duplicate rows.
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
- ### `checkUser(emailAddress, deviceId)`
69
+ ---
88
70
 
89
- Check if a user is flagged in the naughty list. Always returns `{ is_user_flagged: false }` on any failure — a backend outage never blocks a legitimate user.
71
+ ### `triggerEmailVerification(emailAddress, deviceId)`
72
+
73
+ Send a 6-digit verification code to the user's email.
90
74
 
91
75
  ```typescript
92
- const result = await client.checkUser('user@example.com', 'device-uuid');
93
- if (result.data?.is_user_flagged) { /* … */ }
76
+ await client.triggerEmailVerification('user@example.com', 'device_abc');
94
77
  ```
95
78
 
96
- ### `triggerEmailVerification(emailAddress, deviceId)`
79
+ ---
97
80
 
98
- Send a 6-digit verification code. Rate-limited to one request per `(email_address, device_id)` pair per 2 minutes.
81
+ ### `verify(emailAddress, deviceId, code)`
82
+
83
+ Validate the code the user submitted.
99
84
 
100
85
  ```typescript
101
- const result = await client.triggerEmailVerification('user@example.com', 'device-uuid');
102
- if (!result.success && result.error?.code === 'RATE_LIMIT_EXCEEDED') {
103
- const waitSeconds = result.error.retryAfter ?? result.error.details?.retry_after_seconds;
104
- // back off for waitSeconds
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
- **Pre-condition:** sender settings must be configured via `createSender` + `validateSender` before this will succeed.
99
+ ---
109
100
 
110
- ### `verify(emailAddress, deviceId, code)`
101
+ ### `submitFingerprintEvent(fingerprint, opts?)`
111
102
 
112
- Verify the 6-digit code entered by the user. `success: true` with `verified: false` means the code was wrong not a transport error.
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
- const result = await client.verify('user@example.com', 'device-uuid', '123456');
116
- if (result.data?.verified) { /* proceed */ }
117
- else { /* result.data.reason: 'not_found' | 'code_mismatch' | 'code_expired' */ }
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 (Browser Proxy)
115
+ ## Express Middleware
123
116
 
124
- Mount this to handle fingerprint events forwarded from `@unshared-labs/frontend-fingerprint`:
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 '@unshared-labs/sdk/middleware';
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
- The middleware:
137
- - Handles `POST /unshared/submit-fingerprint-event`
138
- - Passes `next()` for all other routes
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
- ## Error Handling
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
- All methods return `ApiResult<T>`:
145
+ ---
146
+
147
+ ## Configuration
146
148
 
147
149
  ```typescript
148
- interface ApiResult<T> {
149
- success: boolean;
150
- data?: T;
151
- error?: {
152
- code: string;
153
- message: string;
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
- ## Encryption
160
+ ## Response shape
161
+
162
+ All methods return `ApiResult<T>`:
170
163
 
171
- `emailAddress`, `deviceId`, and `code` are encrypted client-side before transmission using AES-256-GCM with a key derived from `SHA256(apiKey)`. Wire format: `<base64(iv)>:<base64(authTag)>:<base64(ciphertext)>` (colon-separated, standard Base64). The backend decrypts with `DecryptSDKField` in `lib/crypto.go`.
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
- ## Environment Variables
175
+ ## Security
176
176
 
177
- ```bash
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: sk_live_ or sk_test_…
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
- * **Important:** `result.success` is `true` even when `result.data.verified`
144
- * is `false`. A `false` verified result means the code was wrong or expired
145
- * not a transport failure. Always check `result.data.verified` explicitly:
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) { /* transport/infra error *\/ }
150
- * else if (!result.data?.verified) { /* wrong or expired code *\/ }
151
- * else { /* verified *\/ }
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;
@@ -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: sk_live_ or sk_test_…
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
- * **Important:** `result.success` is `true` even when `result.data.verified`
144
- * is `false`. A `false` verified result means the code was wrong or expired
145
- * not a transport failure. Always check `result.data.verified` explicitly:
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) { /* transport/infra error *\/ }
150
- * else if (!result.data?.verified) { /* wrong or expired code *\/ }
151
- * else { /* verified *\/ }
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
  }
@@ -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
- * - Configure CORS on your `/unshared/*` routes separately this middleware
29
- * does not set any `Access-Control-*` headers.
30
- * - Disable request body logging on `/unshared/*` routes. `user_id` arrives in
31
- * plaintext from the browser and must not be captured by logging middleware.
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" } }.
@@ -1 +1 @@
1
- export function createUnsharedMiddleware(e,r){const{userIdExtractor:s,eventTypeExtractor:t,sessionIdExtractor:n,defaultEventType:c="browser_event",routePrefix:o="/unshared"}=r??{},i=`${o}/submit-fingerprint-event`;return async(r,o,a)=>{if("POST"===r.method&&r.path===i)try{const i=r.body??{};if(!i.hash||!i.stable_hash||!i.collected_at)return void o.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,u,f;try{d=(s?s(r):void 0)??i.user_id}catch{d=i.user_id}try{u=(t?t(r):void 0)??i.event_type??c}catch{u=i.event_type??c}try{f=(n?n(r):void 0)??r.headers["x-session-id"]?.toString()??i.session_id}catch{f=r.headers["x-session-id"]?.toString()??i.session_id}const h=await e.submitFingerprintEvent(a,{userId:d,sessionHash:f,eventType:u});if(!h.success)return void o.status(200).json({success:!1,error:{code:"UPSTREAM_ERROR",message:h.error?.message??"Upstream request failed"}});o.status(202).json({success:!0,data:h.data})}catch(e){o.status(200).json({success:!1,error:{code:"MIDDLEWARE_ERROR",message:e instanceof Error?e.message:"Middleware error"}})}else a()}}
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()}}
@@ -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
- * - Configure CORS on your `/unshared/*` routes separately this middleware
29
- * does not set any `Access-Control-*` headers.
30
- * - Disable request body logging on `/unshared/*` routes. `user_id` arrives in
31
- * plaintext from the browser and must not be captured by logging middleware.
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" } }.
@@ -1 +1 @@
1
- "use strict";function createUnsharedMiddleware(e,r){const{userIdExtractor:s,eventTypeExtractor:t,sessionIdExtractor:c,defaultEventType:n="browser_event",routePrefix:o="/unshared"}=r??{},a=`${o}/submit-fingerprint-event`;return async(r,o,i)=>{if("POST"===r.method&&r.path===a)try{const a=r.body??{};if(!a.hash||!a.stable_hash||!a.collected_at)return void o.status(400).json({success:!1,error:{code:"VALIDATION_ERROR",message:"Missing required fingerprint fields: hash, stable_hash, collected_at"}});const i={full_hash:a.hash,fingerprint_id:a.stable_hash,timestamp:a.collected_at,isIncognito:a.is_incognito??!1,components:a.components??{},version:a.version??"unknown"};let d,u,l;try{d=(s?s(r):void 0)??a.user_id}catch{d=a.user_id}try{u=(t?t(r):void 0)??a.event_type??n}catch{u=a.event_type??n}try{l=(c?c(r):void 0)??r.headers["x-session-id"]?.toString()??a.session_id}catch{l=r.headers["x-session-id"]?.toString()??a.session_id}const h=await e.submitFingerprintEvent(i,{userId:d,sessionHash:l,eventType:u});if(!h.success)return void o.status(200).json({success:!1,error:{code:"UPSTREAM_ERROR",message:h.error?.message??"Upstream request failed"}});o.status(202).json({success:!0,data:h.data})}catch(e){o.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;
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;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "unshared-clientjs-sdk",
3
- "version": "2.0.0-rc.2",
3
+ "version": "2.0.0-rc.3",
4
4
  "description": "Server-side Node.js SDK for the Unshared Labs V2 API",
5
5
  "main": "dist/index.js",
6
6
  "module": "dist/esm/index.mjs",