unshared-clientjs-sdk 1.0.13 → 2.0.0-rc.2

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,109 +1,179 @@
1
- # Unshared Labs SDK
1
+ # @unshared-labs/sdk
2
2
 
3
- A lightweight, drop-in SDK for sending secure session and event data to the **Unshared Labs** platform.
4
- This package is designed for server environments (e.g., Express, Fastify, Next.js API routes) that need to track account sharing activity, session behavior, or suspicious usage patterns.
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.**
5
6
 
6
7
  ---
7
8
 
8
9
  ## Installation
9
10
 
10
11
  ```bash
11
- npm install unshared-clientjs-sdk
12
- # or
13
- yarn add unshared-clientjs-sdk
12
+ npm install @unshared-labs/sdk
14
13
  ```
15
14
 
15
+ Requires Node.js ≥ 18.
16
+
16
17
  ---
17
18
 
18
- ## Quick Example
19
+ ## Quick Start
19
20
 
20
- ```ts
21
- import UnsharedLabsClient from "unshared-clientjs-sdk";
21
+ ```typescript
22
+ import { UnsharedLabsClient } from '@unshared-labs/sdk';
22
23
 
23
24
  const client = new UnsharedLabsClient({
24
- apiKey: process.env.UNSHARED_LABS_API_KEY!,
25
+ apiKey: process.env.UNSHARED_API_KEY!,
25
26
  });
26
27
  ```
27
28
 
28
- ---
29
+ ### CommonJS
29
30
 
30
- ## API
31
-
32
- ### `submitEvent(...)`
33
-
34
- ```ts
35
- submitEvent(
36
- eventType: string,
37
- userId: string,
38
- ipAddress: string,
39
- deviceId: string,
40
- sessionHash: string,
41
- userAgent: string,
42
- clientTimestamp: string,
43
- eventDetails?: map<String,any> | null
44
- ): Promise<any>
31
+ ```javascript
32
+ const { UnsharedLabsClient } = require('@unshared-labs/sdk');
45
33
  ```
46
34
 
47
- **Parameters**
35
+ ---
48
36
 
49
- * `eventType` — string, e.g. `"login"`, `"heartbeat"`, `"logout"`
50
- * `userId` — string, unique user identifier (encrypted before sending)
51
- * `ipAddress` — string, client IP (encrypted)
52
- * `deviceId` — string, device identifier (encrypted)
53
- * `sessionHash` — string, session identifier or fingerprint (encrypted)
54
- * `userAgent` — string
55
- * `clientTimestamp` — ISO timestamp string (e.g. `new Date().toISOString()`).
56
- * `eventDetails` — optional map of objects (String,any) of additional details
37
+ ## Configuration
57
38
 
58
- **Returns**: A promise resolving Unshared Lab's DB insert result or rejecting with an error.
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
45
+ });
46
+ ```
59
47
 
60
48
  ---
61
49
 
62
- ## Example (Express)
50
+ ## Methods
63
51
 
64
- ```ts
65
- import express from "express";
66
- import UnsharedLabsClient from "unshared-clientjs-sdk";
52
+ ### `submitFingerprintEvent(fingerprint, opts?)`
67
53
 
68
- const app = express();
69
- app.use(express.json());
54
+ Submit a browser fingerprint collected by `@unshared-labs/frontend-fingerprint`. Returns 202 Accepted; the event is stored asynchronously.
70
55
 
71
- const client = new UnsharedLabsClient({
72
- apiKey: process.env.UNSHARED_LABS_API_KEY!,
56
+ ```typescript
57
+ const result = await client.submitFingerprintEvent(fingerprintData, {
58
+ userId: 'u_123',
59
+ sessionHash: 'sess_abc',
60
+ eventType: 'login',
73
61
  });
62
+ // result.data: { hash, stable_hash, collected_at, version }
63
+ ```
64
+
65
+ An `X-Idempotency-Key` is sent automatically. Retries with the same key are deduplicated at the backend.
66
+
67
+ ### `processUserEvent(params)`
74
68
 
75
- app.post("/login", async (req, res) => {
76
- const { userId } = req.body;
77
-
78
- try {
79
- await client.submitEvent(
80
- "login",
81
- userId,
82
- req.ip,
83
- req.headers["x-device-id"]?.toString() || "unknown-device",
84
- req.headers["x-session-hash"]?.toString() || "unknown-session",
85
- req.headers["user-agent"] || "",
86
- new Date().toISOString(),
87
- new Map(Object.entries({"example": true, "source": 'test-server' }))
88
- );
89
-
90
- res.status(200).json({ message: "Login tracked" });
91
- } catch (err) {
92
- res.status(500).json({ error: err instanceof Error ? err.message : err });
93
- }
69
+ Record a user event and receive naughty-list analysis. `emailAddress` and `deviceId` are AES-256-GCM encrypted before sending.
70
+
71
+ ```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',
94
81
  });
82
+ // result.data: { event: { … }, analysis: { status, is_user_flagged } }
83
+ ```
84
+
85
+ > **Note:** This endpoint has no server-side idempotency. Retries may insert duplicate rows.
86
+
87
+ ### `checkUser(emailAddress, deviceId)`
88
+
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.
95
90
 
96
- app.listen(3000, () => console.log("Server running on port 3000"));
91
+ ```typescript
92
+ const result = await client.checkUser('user@example.com', 'device-uuid');
93
+ if (result.data?.is_user_flagged) { /* … */ }
97
94
  ```
98
95
 
96
+ ### `triggerEmailVerification(emailAddress, deviceId)`
97
+
98
+ Send a 6-digit verification code. Rate-limited to one request per `(email_address, device_id)` pair per 2 minutes.
99
+
100
+ ```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
105
+ }
106
+ ```
107
+
108
+ **Pre-condition:** sender settings must be configured via `createSender` + `validateSender` before this will succeed.
109
+
110
+ ### `verify(emailAddress, deviceId, code)`
111
+
112
+ Verify the 6-digit code entered by the user. `success: true` with `verified: false` means the code was wrong — not a transport error.
113
+
114
+ ```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' */ }
118
+ ```
119
+
120
+ ---
121
+
122
+ ## Express Middleware (Browser Proxy)
123
+
124
+ Mount this to handle fingerprint events forwarded from `@unshared-labs/frontend-fingerprint`:
125
+
126
+ ```typescript
127
+ import { createUnsharedMiddleware } from '@unshared-labs/sdk/middleware';
128
+
129
+ app.use(createUnsharedMiddleware(client, {
130
+ userIdExtractor: (req) => req.user?.id,
131
+ eventTypeExtractor: (req) => req.body.event_type,
132
+ routePrefix: '/unshared', // default
133
+ }));
134
+ ```
135
+
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 }`
140
+
141
+ ---
142
+
143
+ ## Error Handling
144
+
145
+ All methods return `ApiResult<T>`:
146
+
147
+ ```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
+ }
159
+ ```
160
+
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
+
99
167
  ---
100
168
 
101
- ## License
169
+ ## Encryption
102
170
 
103
- MIT © Unshared Labs
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`.
104
172
 
105
173
  ---
106
174
 
107
- ## Support
175
+ ## Environment Variables
108
176
 
109
- For issues or onboarding help, contact Unshared Labs support.
177
+ ```bash
178
+ UNSHARED_API_KEY=sk_live_…
179
+ ```
package/dist/client.d.ts CHANGED
@@ -1,207 +1,158 @@
1
- /**
2
- * Configuration object for initializing the UnsharedLabsClient.
3
- *
4
- * @interface UnsharedLabsClientConfig
5
- * @property {string} apiKey - Your API key for authenticating with UnsharedLabs services
6
- * @property {string} clientId - Encrypted client ID for authentication
7
- * @property {string} [baseUrl] - Optional custom base URL for the API service
8
- * @property {string} [emailServiceUrl] - Optional custom URL for the email service
9
- */
1
+ import type { FingerprintWireFormat } from '@unshared-labs/shared-types';
10
2
  export interface UnsharedLabsClientConfig {
3
+ /**
4
+ * Secret API key. Must be kept server-side.
5
+ * Format: sk_live_… or sk_test_…
6
+ */
11
7
  apiKey: string;
12
- clientId: string;
8
+ /**
9
+ * Base URL of the Unshared Labs V2 ingress.
10
+ * @default "https://api-ingress.unsharedlabs.com"
11
+ */
13
12
  baseUrl?: string;
14
- emailServiceUrl?: string;
13
+ /**
14
+ * Per-request timeout in milliseconds.
15
+ * @default 10_000
16
+ */
17
+ timeout?: number;
18
+ /**
19
+ * Max retries for 5xx / network failures after the first attempt.
20
+ * @default 3
21
+ */
22
+ maxRetries?: number;
15
23
  }
16
- /**
17
- * Client for interacting with UnsharedLabs API services.
18
- *
19
- * This client provides methods to:
20
- * - Process user events for fraud detection and analysis
21
- * - Check if a user is flagged as an account sharer
22
- * - Trigger email verification
23
- * - Verify email verification codes
24
- *
25
- * All sensitive data (user IDs, IP addresses, etc.) is automatically encrypted
26
- * before being sent to the API using the provided API key.
27
- *
28
- * @class UnsharedLabsClient
29
- * @example
30
- * ```typescript
31
- * const client = new UnsharedLabsClient({
32
- * apiKey: 'your-api-key',
33
- * clientId: 'base64-encoded-client-id'
34
- * });
35
- * ```
36
- */
37
- export default class UnsharedLabsClient {
38
- private _apiKey;
39
- private _clientId;
40
- private _apiBaseUrl;
41
- private _emailServiceUrl;
24
+ export interface UnsharedLabsError {
25
+ code: string;
26
+ message: string;
27
+ details?: Record<string, unknown>;
42
28
  /**
43
- * Creates a new instance of UnsharedLabsClient.
44
- *
45
- * @param {UnsharedLabsClientConfig} config - Configuration object containing API credentials and optional service URLs
46
- * @constructor
29
+ * Seconds to wait before retrying. Populated on 429 RATE_LIMIT_EXCEEDED
30
+ * responses by reading the `Retry-After` HTTP response header.
31
+ * Falls back to `details.retry_after_seconds` if the header is absent.
47
32
  */
33
+ retryAfter?: number;
34
+ }
35
+ export interface ApiResult<T = unknown> {
36
+ success: boolean;
37
+ data?: T;
38
+ error?: UnsharedLabsError;
39
+ status: number;
40
+ }
41
+ export interface SubmitFingerprintOptions {
42
+ userId?: string;
43
+ sessionHash?: string;
44
+ eventType?: string;
45
+ }
46
+ export interface SubmitFingerprintResult {
47
+ hash: string;
48
+ stable_hash: string;
49
+ collected_at: string;
50
+ /** Always present; set to "unknown" by the server if not provided. */
51
+ version: string;
52
+ }
53
+ export interface ProcessUserEventParams {
54
+ eventType: string;
55
+ userId: string;
56
+ /** Plaintext — SDK does not encrypt this field. */
57
+ ipAddress: string;
58
+ /** SDK encrypts before sending. */
59
+ deviceId: string;
60
+ sessionHash: string;
61
+ userAgent: string;
62
+ /** SDK encrypts before sending. */
63
+ emailAddress: string;
64
+ subscriptionStatus?: string | null;
65
+ eventDetails?: Record<string, unknown> | null;
66
+ }
67
+ export interface ProcessUserEventResult {
68
+ event: {
69
+ event_type: string;
70
+ user_id: string;
71
+ email_address: string;
72
+ ip_address: string;
73
+ device_id: string;
74
+ session_hash: string;
75
+ user_agent: string;
76
+ event_details?: string;
77
+ subscription_status?: string;
78
+ };
79
+ analysis: {
80
+ status: 'success' | 'error';
81
+ is_user_flagged: boolean;
82
+ status_details?: string;
83
+ };
84
+ }
85
+ export interface CheckUserResult {
86
+ is_user_flagged: boolean;
87
+ }
88
+ export interface TriggerEmailVerificationResult {
89
+ message: string;
90
+ }
91
+ export interface VerifyResult {
92
+ verified: boolean;
93
+ reason?: 'not_found' | 'code_mismatch' | 'code_expired';
94
+ }
95
+ export declare class UnsharedLabsClient {
96
+ private readonly _apiKey;
97
+ private readonly _baseUrl;
98
+ private readonly _timeout;
99
+ private readonly _maxRetries;
100
+ private readonly _encryptionKey;
48
101
  constructor(config: UnsharedLabsClientConfig);
49
- private _getBasicAuthHeader;
102
+ private _encrypt;
50
103
  /**
51
- * Submits a user event to the UnsharedLabs API for processing.
52
- *
53
- * @deprecated This method is deprecated and will be removed in a future version. Use `processUserEvent` instead.
54
- *
55
- * @param {string} eventType - Type of event being submitted (e.g., 'signup', 'login', 'purchase')
56
- * @param {string} userId - Unique identifier for the user. Use email address if available.
57
- * @param {string} ipAddress - IP address of the user
58
- * @param {string} deviceId - Unique identifier for the device
59
- * @param {string} sessionHash - Hash of the user's session
60
- * @param {string} userAgent - User agent string from the browser/client
61
- * @param {string} clientTimestamp - ISO 8601 timestamp when the event occurred on the client
62
- * @param {Map<string, any> | null} [eventDetails] - Optional map of additional event details to include
63
- *
64
- * @returns {Promise<{status: number, statusText: string, data: any}>} Response object containing HTTP status, status text, and response data
65
- *
66
- * @throws {Error} Throws an error if the API request fails or returns a non-OK status
67
- *
68
- * @example
69
- * ```typescript
70
- * const result = await client.submitEvent(
71
- * 'signup',
72
- * 'user@example.com',
73
- * '192.168.1.1',
74
- * 'device456',
75
- * 'session789',
76
- * 'Mozilla/5.0...',
77
- * '2024-01-01T00:00:00Z',
78
- * new Map([['source', 'web']])
79
- * );
80
- * ```
104
+ * Core HTTP method with retry logic.
105
+ * - 2xx → success result
106
+ * - 4xx non-retryable, error result returned immediately
107
+ * - 5xx → retried up to maxRetries, last error result returned
108
+ * - Network/timeout retried, NETWORK_ERROR result returned
81
109
  */
82
- submitEvent(eventType: string, userId: string, ipAddress: string, deviceId: string, sessionHash: string, userAgent: string, clientTimestamp: string, eventDetails?: Map<string, any> | null): Promise<any>;
110
+ private _fetch;
83
111
  /**
84
- * Processes a user event through the UnsharedLabs API, including fraud detection and analysis.
85
- *
86
- * This method encrypts all sensitive user data (user ID, IP address, device ID, etc.) before
87
- * sending it to the API. The response includes both event processing results and analysis
88
- * results that indicate if the user has been flagged for suspicious activity.
89
- *
90
- * @param {string} eventType - Type of event being processed (e.g., 'signup', 'login', 'purchase')
91
- * @param {string} userId - Unique identifier for the user. Use email address if available.
92
- * @param {string} ipAddress - IP address of the user
93
- * @param {string} deviceId - Unique identifier for the device
94
- * @param {string} sessionHash - Hash of the user's session
95
- * @param {string} userAgent - User agent string from the browser/client
96
- * @param {string} emailAddress - Email address of the user
97
- * @param {string | null} [subscriptionStatus] - Optional subscription status string, (e.g. 'paid', 'free', 'trial', 'discounted')
98
- * @param {Map<string, any> | null} [eventDetails] - Optional map of additional event details to include
99
- *
100
- * @returns {Promise<any>} Response object containing:
101
- * - success: Boolean indicating if the API call was successful
102
- * - event: Event processing result with status and event data (on success)
103
- * - analysis: Analysis result with flagging information (on success)
104
- * - error: Error message (on failure)
105
- *
106
- * @example
107
- * ```typescript
108
- * const response = await client.processUserEvent(
109
- * 'signup',
110
- * 'user@example.com',
111
- * '192.168.1.1',
112
- * 'device456',
113
- * 'session789',
114
- * 'Mozilla/5.0...',
115
- * 'user@example.com',
116
- * 'paid',
117
- * new Map([['source', 'web']])
118
- * );
119
- *
120
- * if (!response.success) {
121
- * console.error('Error:', response.error);
122
- * } else if (response.analysis?.is_user_flagged) {
123
- * console.log('User flagged for suspicious activity');
124
- * }
125
- * ```
112
+ * Submit a browser fingerprint event. Publishes asynchronously via Pub/Sub.
113
+ * Maps to: POST /v2/submit-fingerprint-event
126
114
  */
127
- processUserEvent(eventType: string, userId: string, ipAddress: string, deviceId: string, sessionHash: string, userAgent: string, emailAddress: string, subscriptionStatus?: string | null, eventDetails?: Map<string, any> | null): Promise<any>;
115
+ submitFingerprintEvent(fingerprint: FingerprintWireFormat, opts?: SubmitFingerprintOptions): Promise<ApiResult<SubmitFingerprintResult>>;
128
116
  /**
129
- * Checks if a user is flagged as an account sharer based on their email address and device ID.
130
- *
131
- * This method encrypts the email address and device ID before sending them to the API.
132
- * The response indicates whether the user has been flagged for suspicious activity.
133
- *
134
- * @param {string} emailAddress - Email address of the user to check
135
- * @param {string} deviceId - Unique identifier for the device
136
- *
137
- * @returns {Promise<any>} Response object containing:
138
- * - success: Boolean indicating if the API call was successful
139
- * - status: String indicating the status ("success" or "error")
140
- * - is_user_flagged: Boolean indicating if the user was flagged as an account sharer
141
- * - status_details: String with error details (only present on error)
142
- * - error: Error message (only present on failure)
143
- *
144
- * @example
145
- * ```typescript
146
- * const response = await client.checkUser(
147
- * 'user@example.com',
148
- * 'device456'
149
- * );
150
- *
151
- * if (!response.success) {
152
- * console.error('Error:', response.error || response.status_details);
153
- * } else if (response.is_user_flagged) {
154
- * console.log('User is flagged for suspicious activity');
155
- * }
156
- * ```
117
+ * Record a user event and receive naughty-list analysis.
118
+ * Maps to: POST /v2/process-user-event
157
119
  */
158
- checkUser(emailAddress: string, deviceId: string): Promise<any>;
120
+ processUserEvent(params: ProcessUserEventParams): Promise<ApiResult<ProcessUserEventResult>>;
159
121
  /**
160
- * Triggers an email verification to be sent to the user.
161
- *
162
- * This method sends a verification email to the specified email address.
163
- *
164
- * @param {string} emailAddress - Email address to send the verification to
165
- * @param {string} deviceId - device id for whom the code is being verified.
166
- * @returns {Promise<any>} Response object from the email service containing verification details
167
- *
168
- * @throws {Error} Throws an error if:
169
- * - The emailAddress is not a valid email address
170
- * - The email service request fails or returns a non-OK status
171
- *
172
- * @example
173
- * ```typescript
174
- * await client.triggerEmailVerification('user@example.com');
175
- * ```
122
+ * Check if a user is flagged in the naughty list.
123
+ * Maps to: GET /v2/check-user
124
+ *
125
+ * **Defensive default:** On any failure (network error, 4xx, 5xx, timeout) this
126
+ * method returns `{ success: true, data: { is_user_flagged: false } }` rather
127
+ * than surfacing the error. This ensures a backend outage never incorrectly
128
+ * blocks a legitimate user. As a consequence, a complete API outage is
129
+ * **invisible** to the caller — you cannot distinguish "user is clean" from
130
+ * "request failed" by inspecting the return value. Monitor API availability
131
+ * through your infrastructure metrics, not through this method's return value.
176
132
  */
177
- triggerEmailVerification(emailAddress: string, deviceId: string): Promise<any>;
133
+ checkUser(emailAddress: string, deviceId: string): Promise<ApiResult<CheckUserResult>>;
178
134
  /**
179
- * Verifies an email verification code for a user.
180
- *
181
- * This method validates a verification code that was sent to the user via email.
182
- * The code is typically obtained from the user after they receive the verification email.
183
- *
184
- * @param {string} emailAddress - Email address for whom the code is being verified.
185
- * @param {string} deviceId - device id for whom the code is being verified.
186
- * @param {string} code - Verification code that was sent to the user's email
187
- *
188
- * @returns {Promise<any>} Response object from the verification service containing:
189
- * - Verification status (success/failure)
190
- * - Additional verification details
135
+ * Send a 6-digit verification code to the user's email address.
136
+ * Maps to: POST /v2/trigger-email-verification
137
+ */
138
+ triggerEmailVerification(emailAddress: string, deviceId: string): Promise<ApiResult<TriggerEmailVerificationResult>>;
139
+ /**
140
+ * Verify a 6-digit code submitted by the user.
141
+ * Maps to: POST /v2/verify
191
142
  *
192
- * @throws {Error} Throws an error if:
193
- * - The verification service request fails
194
- * - The service returns a non-OK status (e.g., invalid code, expired code)
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:
195
146
  *
196
- * @example
197
147
  * ```typescript
198
- * try {
199
- * const result = await client.verify('user@example.com', '123456');
200
- * console.log('Verification successful:', result);
201
- * } catch (error) {
202
- * console.error('Verification failed:', error.message);
203
- * }
148
+ * 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 *\/ }
204
152
  * ```
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.
205
156
  */
206
- verify(emailAddress: string, deviceId: string, code: string): Promise<any>;
157
+ verify(emailAddress: string, deviceId: string, code: string): Promise<ApiResult<VerifyResult>>;
207
158
  }
package/dist/client.js CHANGED
@@ -1 +1 @@
1
- "use strict";Object.defineProperty(exports,"__esModule",{value:!0});const util_1=require("./util");class UnsharedLabsClient{constructor(t){this._apiBaseUrl="https://api.unsharedlabs.com",this._emailServiceUrl="https://emailservice.unsharedlabs.com",this._apiKey=t.apiKey,this._clientId=atob(t.clientId).trim(),t.baseUrl&&(this._apiBaseUrl=t.baseUrl),t.emailServiceUrl&&(this._emailServiceUrl=t.emailServiceUrl)}_getBasicAuthHeader(){const t=`${this._clientId}:${this._apiKey}`;return`Basic ${Buffer.from(t).toString("base64")}`}async submitEvent(t,e,i,r,a,s,n,o){try{const c=(0,util_1.encryptData)(e,this._apiKey),h=(0,util_1.encryptData)(i,this._apiKey),l=(0,util_1.encryptData)(r,this._apiKey),_=(0,util_1.encryptData)(a,this._apiKey),d=(0,util_1.encryptData)(s,this._apiKey),p={user_id:c,event_type:t,ip_address:h,device_id:l,session_hash:_,user_agent:d,client_timestamp:n,event_details:(0,util_1.encryptData)(o?JSON.stringify([...o.entries()]):null,this._apiKey)},y=await fetch(`${this._apiBaseUrl}/api/v1/submitEvent`,{method:"POST",headers:{"Content-Type":"application/json",Authorization:this._getBasicAuthHeader()},body:JSON.stringify(p)});if(!y.ok){const t=await y.json().catch(()=>({error:y.statusText}));throw new Error(`API returned error: ${JSON.stringify(t)}`)}const u=await y.json();return{status:y.status,statusText:y.statusText,data:u}}catch(t){throw new Error(`Failed to call UnsharedLabs API: ${t instanceof Error?t.message:JSON.stringify(t)}`)}}async processUserEvent(t,e,i,r,a,s,n,o,c){try{const h=(0,util_1.encryptData)(e,this._apiKey),l=(0,util_1.encryptData)(i,this._apiKey),_=(0,util_1.encryptData)(r,this._apiKey),d=(0,util_1.encryptData)(a,this._apiKey),p=(0,util_1.encryptData)(s,this._apiKey),y=(0,util_1.encryptData)(n,this._apiKey),u={user_id:h,event_type:t,ip_address:l,device_id:_,session_hash:d,user_agent:p,email_address:y,event_details:(0,util_1.encryptData)(c?JSON.stringify([...c.entries()]):null,this._apiKey)};null!=o&&"string"==typeof o&&(u.subscription_status=o.trim());const f=await fetch(`${this._apiBaseUrl}/api/v1/processUserEvent`,{method:"POST",headers:{"Content-Type":"application/json",Authorization:this._getBasicAuthHeader()},body:JSON.stringify(u)});return await f.json()}catch(t){return{success:!1,error:`Failed to process user event: ${t instanceof Error?t.message:JSON.stringify(t)}`}}}async checkUser(t,e){try{const i=(0,util_1.encryptData)(t,this._apiKey),r=(0,util_1.encryptData)(e,this._apiKey),a=new URLSearchParams({email_address:i||"",device_id:r||""}),s=await fetch(`${this._apiBaseUrl}/api/v1/checkUser?${a.toString()}`,{method:"GET",headers:{Authorization:this._getBasicAuthHeader()}});return await s.json()}catch(t){return{success:!1,error:`Failed to check user: ${t instanceof Error?t.message:JSON.stringify(t)}`}}}async triggerEmailVerification(t,e){try{if(!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(t))throw new Error("Invalid email address provided");const i=await fetch(`${this._emailServiceUrl}/api/send-email`,{method:"POST",headers:{"Content-Type":"application/json",Authorization:this._getBasicAuthHeader()},body:JSON.stringify({email_address:t,device_id:e})});if(!i.ok){const t=await i.json().catch(()=>({error:i.statusText}));throw new Error(`Email service returned error: ${JSON.stringify(t)}`)}return await i.json()}catch(t){throw new Error(`Failed to trigger email verification: ${t instanceof Error?t.message:JSON.stringify(t)}`)}}async verify(t,e,i){try{const r=await fetch(`${this._emailServiceUrl}/api/verify`,{method:"POST",headers:{"Content-Type":"application/json",Authorization:this._getBasicAuthHeader()},body:JSON.stringify({email_address:t,device_id:e,code:i})});if(!r.ok){const t=await r.json().catch(()=>({error:r.statusText}));throw new Error(`Verification service returned error: ${JSON.stringify(t)}`)}return await r.json()}catch(t){throw new Error(`Failed to verify code: ${t instanceof Error?t.message:JSON.stringify(t)}`)}}}exports.default=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}}exports.UnsharedLabsClient=UnsharedLabsClient;
@@ -0,0 +1,158 @@
1
+ import type { FingerprintWireFormat } from '@unshared-labs/shared-types';
2
+ export interface UnsharedLabsClientConfig {
3
+ /**
4
+ * Secret API key. Must be kept server-side.
5
+ * Format: sk_live_… or sk_test_…
6
+ */
7
+ apiKey: string;
8
+ /**
9
+ * Base URL of the Unshared Labs V2 ingress.
10
+ * @default "https://api-ingress.unsharedlabs.com"
11
+ */
12
+ baseUrl?: string;
13
+ /**
14
+ * Per-request timeout in milliseconds.
15
+ * @default 10_000
16
+ */
17
+ timeout?: number;
18
+ /**
19
+ * Max retries for 5xx / network failures after the first attempt.
20
+ * @default 3
21
+ */
22
+ maxRetries?: number;
23
+ }
24
+ export interface UnsharedLabsError {
25
+ code: string;
26
+ message: string;
27
+ details?: Record<string, unknown>;
28
+ /**
29
+ * Seconds to wait before retrying. Populated on 429 RATE_LIMIT_EXCEEDED
30
+ * responses by reading the `Retry-After` HTTP response header.
31
+ * Falls back to `details.retry_after_seconds` if the header is absent.
32
+ */
33
+ retryAfter?: number;
34
+ }
35
+ export interface ApiResult<T = unknown> {
36
+ success: boolean;
37
+ data?: T;
38
+ error?: UnsharedLabsError;
39
+ status: number;
40
+ }
41
+ export interface SubmitFingerprintOptions {
42
+ userId?: string;
43
+ sessionHash?: string;
44
+ eventType?: string;
45
+ }
46
+ export interface SubmitFingerprintResult {
47
+ hash: string;
48
+ stable_hash: string;
49
+ collected_at: string;
50
+ /** Always present; set to "unknown" by the server if not provided. */
51
+ version: string;
52
+ }
53
+ export interface ProcessUserEventParams {
54
+ eventType: string;
55
+ userId: string;
56
+ /** Plaintext — SDK does not encrypt this field. */
57
+ ipAddress: string;
58
+ /** SDK encrypts before sending. */
59
+ deviceId: string;
60
+ sessionHash: string;
61
+ userAgent: string;
62
+ /** SDK encrypts before sending. */
63
+ emailAddress: string;
64
+ subscriptionStatus?: string | null;
65
+ eventDetails?: Record<string, unknown> | null;
66
+ }
67
+ export interface ProcessUserEventResult {
68
+ event: {
69
+ event_type: string;
70
+ user_id: string;
71
+ email_address: string;
72
+ ip_address: string;
73
+ device_id: string;
74
+ session_hash: string;
75
+ user_agent: string;
76
+ event_details?: string;
77
+ subscription_status?: string;
78
+ };
79
+ analysis: {
80
+ status: 'success' | 'error';
81
+ is_user_flagged: boolean;
82
+ status_details?: string;
83
+ };
84
+ }
85
+ export interface CheckUserResult {
86
+ is_user_flagged: boolean;
87
+ }
88
+ export interface TriggerEmailVerificationResult {
89
+ message: string;
90
+ }
91
+ export interface VerifyResult {
92
+ verified: boolean;
93
+ reason?: 'not_found' | 'code_mismatch' | 'code_expired';
94
+ }
95
+ export declare class UnsharedLabsClient {
96
+ private readonly _apiKey;
97
+ private readonly _baseUrl;
98
+ private readonly _timeout;
99
+ private readonly _maxRetries;
100
+ private readonly _encryptionKey;
101
+ constructor(config: UnsharedLabsClientConfig);
102
+ private _encrypt;
103
+ /**
104
+ * Core HTTP method with retry logic.
105
+ * - 2xx → success result
106
+ * - 4xx → non-retryable, error result returned immediately
107
+ * - 5xx → retried up to maxRetries, last error result returned
108
+ * - Network/timeout → retried, NETWORK_ERROR result returned
109
+ */
110
+ private _fetch;
111
+ /**
112
+ * Submit a browser fingerprint event. Publishes asynchronously via Pub/Sub.
113
+ * Maps to: POST /v2/submit-fingerprint-event
114
+ */
115
+ submitFingerprintEvent(fingerprint: FingerprintWireFormat, opts?: SubmitFingerprintOptions): Promise<ApiResult<SubmitFingerprintResult>>;
116
+ /**
117
+ * Record a user event and receive naughty-list analysis.
118
+ * Maps to: POST /v2/process-user-event
119
+ */
120
+ processUserEvent(params: ProcessUserEventParams): Promise<ApiResult<ProcessUserEventResult>>;
121
+ /**
122
+ * Check if a user is flagged in the naughty list.
123
+ * Maps to: GET /v2/check-user
124
+ *
125
+ * **Defensive default:** On any failure (network error, 4xx, 5xx, timeout) this
126
+ * method returns `{ success: true, data: { is_user_flagged: false } }` rather
127
+ * than surfacing the error. This ensures a backend outage never incorrectly
128
+ * blocks a legitimate user. As a consequence, a complete API outage is
129
+ * **invisible** to the caller — you cannot distinguish "user is clean" from
130
+ * "request failed" by inspecting the return value. Monitor API availability
131
+ * through your infrastructure metrics, not through this method's return value.
132
+ */
133
+ checkUser(emailAddress: string, deviceId: string): Promise<ApiResult<CheckUserResult>>;
134
+ /**
135
+ * Send a 6-digit verification code to the user's email address.
136
+ * Maps to: POST /v2/trigger-email-verification
137
+ */
138
+ triggerEmailVerification(emailAddress: string, deviceId: string): Promise<ApiResult<TriggerEmailVerificationResult>>;
139
+ /**
140
+ * Verify a 6-digit code submitted by the user.
141
+ * Maps to: POST /v2/verify
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:
146
+ *
147
+ * ```typescript
148
+ * 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 *\/ }
152
+ * ```
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
+ */
157
+ verify(emailAddress: string, deviceId: string, code: string): Promise<ApiResult<VerifyResult>>;
158
+ }
@@ -0,0 +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}}
@@ -0,0 +1,2 @@
1
+ export { UnsharedLabsClient } from './client';
2
+ export type { UnsharedLabsClientConfig, ApiResult, UnsharedLabsError, SubmitFingerprintOptions, SubmitFingerprintResult, ProcessUserEventParams, ProcessUserEventResult, CheckUserResult, TriggerEmailVerificationResult, VerifyResult, } from './client';
@@ -0,0 +1 @@
1
+ export{UnsharedLabsClient}from"./client";
@@ -0,0 +1,48 @@
1
+ import type { Request, Response, NextFunction } from 'express';
2
+ import type { UnsharedLabsClient } from './client';
3
+ export interface MiddlewareOptions {
4
+ /** Override userId extractor. Falls back to req.body.user_id. */
5
+ userIdExtractor?: (req: Request) => string | undefined;
6
+ /** Override eventType extractor. Falls back to req.body.event_type. */
7
+ eventTypeExtractor?: (req: Request) => string | undefined;
8
+ /** Override sessionId extractor. Falls back to X-Session-Id header, then req.body.session_id. */
9
+ sessionIdExtractor?: (req: Request) => string | undefined;
10
+ /** Default event type when none is extractable. @default "browser_event" */
11
+ defaultEventType?: string;
12
+ /**
13
+ * Route prefix the middleware is mounted under.
14
+ * @default "/unshared"
15
+ */
16
+ routePrefix?: string;
17
+ }
18
+ /**
19
+ * Creates an Express middleware that proxies browser fingerprint events to
20
+ * Unshared Labs. Mount this to handle the browser fingerprint route contract (§4 of spec).
21
+ *
22
+ * Handles POST <routePrefix>/submit-fingerprint-event.
23
+ * Passes all other requests to next().
24
+ *
25
+ * **Prerequisites:**
26
+ * - Mount `express.json()` (or equivalent body-parser) **before** this middleware,
27
+ * 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.
32
+ *
33
+ * **Error contract:** Never returns 5xx to the browser. Upstream failures are
34
+ * returned as HTTP 200 with { success: false, error: { code: "UPSTREAM_ERROR" } }.
35
+ * Standard HTTP-level monitoring will not surface proxy errors — monitor the
36
+ * rate of `success: false` responses in your application layer instead.
37
+ *
38
+ * @example
39
+ * ```typescript
40
+ * import { createUnsharedMiddleware } from "@unshared-labs/sdk/middleware";
41
+ *
42
+ * app.use(express.json()); // must come first
43
+ * app.use(createUnsharedMiddleware(client, {
44
+ * userIdExtractor: (req) => req.user?.id,
45
+ * }));
46
+ * ```
47
+ */
48
+ export declare function createUnsharedMiddleware(client: UnsharedLabsClient, options?: MiddlewareOptions): (req: Request, res: Response, next: NextFunction) => Promise<void>;
@@ -0,0 +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()}}
@@ -0,0 +1 @@
1
+ export declare function encryptData(data: string, key: Buffer): string;
@@ -0,0 +1 @@
1
+ import{createCipheriv,randomBytes}from"crypto";export function encryptData(e,t){const r=randomBytes(12),a=createCipheriv("aes-256-gcm",t,r);let o=a.update(e,"utf8","base64");o+=a.final("base64");const s=a.getAuthTag();return r.toString("base64")+":"+s.toString("base64")+":"+o}
@@ -0,0 +1,2 @@
1
+ export { UnsharedLabsClient } from './client';
2
+ export type { UnsharedLabsClientConfig, ApiResult, UnsharedLabsError, SubmitFingerprintOptions, SubmitFingerprintResult, ProcessUserEventParams, ProcessUserEventResult, CheckUserResult, TriggerEmailVerificationResult, VerifyResult, } from './client';
package/dist/index.js ADDED
@@ -0,0 +1 @@
1
+ "use strict";Object.defineProperty(exports,"t",{value:!0}),exports.UnsharedLabsClient=void 0;var client_1=require("./client");Object.defineProperty(exports,"UnsharedLabsClient",{enumerable:!0,get:function(){return client_1.UnsharedLabsClient}});
@@ -0,0 +1,48 @@
1
+ import type { Request, Response, NextFunction } from 'express';
2
+ import type { UnsharedLabsClient } from './client';
3
+ export interface MiddlewareOptions {
4
+ /** Override userId extractor. Falls back to req.body.user_id. */
5
+ userIdExtractor?: (req: Request) => string | undefined;
6
+ /** Override eventType extractor. Falls back to req.body.event_type. */
7
+ eventTypeExtractor?: (req: Request) => string | undefined;
8
+ /** Override sessionId extractor. Falls back to X-Session-Id header, then req.body.session_id. */
9
+ sessionIdExtractor?: (req: Request) => string | undefined;
10
+ /** Default event type when none is extractable. @default "browser_event" */
11
+ defaultEventType?: string;
12
+ /**
13
+ * Route prefix the middleware is mounted under.
14
+ * @default "/unshared"
15
+ */
16
+ routePrefix?: string;
17
+ }
18
+ /**
19
+ * Creates an Express middleware that proxies browser fingerprint events to
20
+ * Unshared Labs. Mount this to handle the browser fingerprint route contract (§4 of spec).
21
+ *
22
+ * Handles POST <routePrefix>/submit-fingerprint-event.
23
+ * Passes all other requests to next().
24
+ *
25
+ * **Prerequisites:**
26
+ * - Mount `express.json()` (or equivalent body-parser) **before** this middleware,
27
+ * 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.
32
+ *
33
+ * **Error contract:** Never returns 5xx to the browser. Upstream failures are
34
+ * returned as HTTP 200 with { success: false, error: { code: "UPSTREAM_ERROR" } }.
35
+ * Standard HTTP-level monitoring will not surface proxy errors — monitor the
36
+ * rate of `success: false` responses in your application layer instead.
37
+ *
38
+ * @example
39
+ * ```typescript
40
+ * import { createUnsharedMiddleware } from "@unshared-labs/sdk/middleware";
41
+ *
42
+ * app.use(express.json()); // must come first
43
+ * app.use(createUnsharedMiddleware(client, {
44
+ * userIdExtractor: (req) => req.user?.id,
45
+ * }));
46
+ * ```
47
+ */
48
+ export declare function createUnsharedMiddleware(client: UnsharedLabsClient, options?: MiddlewareOptions): (req: Request, res: Response, next: NextFunction) => Promise<void>;
@@ -0,0 +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;
package/dist/util.d.ts CHANGED
@@ -1,2 +1 @@
1
- export declare function encryptData(data: string | null | undefined, clientCredentials: string): string;
2
- export declare function decryptData(encryptedData: string, clientCredentials: string): string | null;
1
+ export declare function encryptData(data: string, key: Buffer): string;
package/dist/util.js CHANGED
@@ -1 +1 @@
1
- "use strict";Object.defineProperty(exports,"__esModule",{value:!0}),exports.encryptData=encryptData,exports.decryptData=decryptData;const crypto_1=require("crypto");function encryptData(t,e){if(null==t)return"unknown";const r="string"==typeof t?t:JSON.stringify(t),a=(0,crypto_1.createHash)("sha256").update(e).digest(),n=(0,crypto_1.randomBytes)(16),c=(0,crypto_1.createCipheriv)("aes-256-cbc",a,n);let o=c.update(r,"utf8","base64");return o+=c.final("base64"),n.toString("base64")+":"+o}function decryptData(t,e){if("unknown"===t)return null;const r=t.split(":");if(2!==r.length)throw new Error("Invalid encrypted data format");const[a,n]=r,c=Buffer.from(a,"base64"),o=(0,crypto_1.createHash)("sha256").update(e).digest(),s=(0,crypto_1.createDecipheriv)("aes-256-cbc",o,c);let p=s.update(n,"base64","utf8");return p+=s.final("utf8"),p}
1
+ "use strict";Object.defineProperty(exports,"t",{value:!0}),exports.encryptData=encryptData;const crypto_1=require("crypto");function encryptData(t,e){const c=(0,crypto_1.randomBytes)(12),r=(0,crypto_1.createCipheriv)("aes-256-gcm",e,c);let s=r.update(t,"utf8","base64");s+=r.final("base64");const o=r.getAuthTag();return c.toString("base64")+":"+o.toString("base64")+":"+s}
package/package.json CHANGED
@@ -1,9 +1,10 @@
1
1
  {
2
2
  "name": "unshared-clientjs-sdk",
3
- "version": "1.0.13",
4
- "description": "",
5
- "main": "dist/client.js",
6
- "types": "dist/client.d.ts",
3
+ "version": "2.0.0-rc.2",
4
+ "description": "Server-side Node.js SDK for the Unshared Labs V2 API",
5
+ "main": "dist/index.js",
6
+ "module": "dist/esm/index.mjs",
7
+ "types": "dist/index.d.ts",
7
8
  "scripts": {
8
9
  "build": "node scripts/build.js",
9
10
  "build:tsc": "tsc",
@@ -17,9 +18,32 @@
17
18
  "dist",
18
19
  "README.md"
19
20
  ],
21
+ "exports": {
22
+ ".": {
23
+ "import": "./dist/esm/index.mjs",
24
+ "require": "./dist/index.js",
25
+ "types": "./dist/index.d.ts"
26
+ },
27
+ "./middleware": {
28
+ "import": "./dist/esm/middleware.mjs",
29
+ "require": "./dist/middleware.js",
30
+ "types": "./dist/middleware.d.ts"
31
+ }
32
+ },
33
+ "engines": {
34
+ "node": ">=18"
35
+ },
20
36
  "keywords": [],
21
37
  "author": "",
22
38
  "license": "MIT",
39
+ "peerDependencies": {
40
+ "@types/express": ">=4"
41
+ },
42
+ "peerDependenciesMeta": {
43
+ "@types/express": {
44
+ "optional": true
45
+ }
46
+ },
23
47
  "devDependencies": {
24
48
  "@types/express": "^4.17.21",
25
49
  "@types/node": "^24.10.1",