diff --git a/packages/javascript/README.md b/packages/javascript/README.md index 59c787d..529b3ba 100644 --- a/packages/javascript/README.md +++ b/packages/javascript/README.md @@ -121,14 +121,15 @@ new SupaClient(config: SupaClientConfig) **Configuration Options:** -| Option | Type | Required | Description | -| --------------- | ----------------------- | -------- | --------------------------------------------------------------- | -| `apiKey` | `string` | Yes | Your Supaship API key (Project Settings -> API Keys) | -| `environment` | `string` | Yes | Environment slug (e.g., `production`, `staging`, `development`) | -| `features` | `FeaturesWithFallbacks` | Yes | Feature definitions with fallback values | -| `context` | `FeatureContext` | Yes | Default context for feature evaluation | -| `networkConfig` | `NetworkConfig` | No | Network settings (endpoints, retry, timeout, custom fetch) | -| `plugins` | `SupaPlugin[]` | No | Plugins for observability, caching, etc. | +| Option | Type | Required | Description | +| ---------------------------- | ----------------------- | -------- | ---------------------------------------------------------------------------------- | +| `apiKey` | `string` | Yes | Your Supaship API key (Project Settings -> API Keys) | +| `environment` | `string` | Yes | Environment slug (e.g., `production`, `staging`, `development`) | +| `features` | `FeaturesWithFallbacks` | Yes | Feature definitions with fallback values | +| `context` | `FeatureContext` | Yes | Default context for feature evaluation | +| `sensitiveContextProperties` | `string[]` | No | Hash sensitive context properties such as PII on the client before sending to Edge | +| `networkConfig` | `NetworkConfig` | No | Network settings (endpoints, retry, timeout, custom fetch) | +| `plugins` | `SupaPlugin[]` | No | Plugins for observability, caching, etc. | **Feature Context:** @@ -136,6 +137,24 @@ new SupaClient(config: SupaClientConfig) | --------------- | ---------------------------------- | ------------------------------------------- | | `[key: string]` | `string` `number` `boolean` `null` | Key value pairs for feature flag evaluation | +**Privacy / PII hashing (client-side):** + +Use `sensitiveContextProperties` to hash selected context property values on the client before requests are sent to Edge. + +```typescript +const client = new SupaClient({ + apiKey: 'your-api-key', + environment: 'production', + features, + context: { + userID: 'user-123', + email: 'user@example.com', + plan: 'premium', + }, + sensitiveContextProperties: ['email', 'userID'], +}) +``` + **Network Configuration:** | Field | Type | Required | Default | Description | diff --git a/packages/javascript/src/__tests__/client.test.ts b/packages/javascript/src/__tests__/client.test.ts index 9f8eff3..8a1b53f 100644 --- a/packages/javascript/src/__tests__/client.test.ts +++ b/packages/javascript/src/__tests__/client.test.ts @@ -1,6 +1,7 @@ import { SupaClient } from '../client' import { FeatureContext, FeaturesWithFallbacks } from '../types' import '../types/jest.d.ts' +import { createHash } from 'node:crypto' // Mock Response type for fetch interface MockResponse { @@ -378,6 +379,70 @@ describe('SupaClient', () => { expect(mockPlugin.beforeRequest).toHaveBeenCalled() expect(mockPlugin.afterResponse).toHaveBeenCalled() }) + + it('should hash configured sensitive context fields with sha256 by default', async (): Promise => { + const mockFetch = jest.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ features: { feature1: { variation: 'true' } } }), + } as MockResponse) + const testClient = new SupaClient({ + apiKey: mockApiKey, + environment: 'test-environment', + features: { feature1: null } satisfies FeaturesWithFallbacks, + context: { + email: 'user@example.com', + plan: 'pro', + }, + sensitiveContextProperties: ['email'], + networkConfig: { + fetchFn: mockFetch, + }, + }) + + await testClient.getFeatures(['feature1']) + + expect(mockFetch).toHaveBeenCalledTimes(1) + const requestInit = mockFetch.mock.calls[0][1] as RequestInit + const requestBody = JSON.parse(requestInit.body as string) as { context: FeatureContext } + const expectedHashedEmail = createHash('sha256').update('user@example.com').digest('hex') + + expect(requestBody.context).toEqual({ + email: expectedHashedEmail, + plan: 'pro', + }) + }) + + it('should always hash sensitive context fields with sha256', async (): Promise => { + const mockFetch = jest.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ features: { feature1: { variation: 'true' } } }), + } as MockResponse) + const testClient = new SupaClient({ + apiKey: mockApiKey, + environment: 'test-environment', + features: { feature1: null } satisfies FeaturesWithFallbacks, + context: { + userId: 'abc-123', + cohort: 'beta', + }, + sensitiveContextProperties: ['userId'], + networkConfig: { + fetchFn: mockFetch, + }, + }) + + await testClient.getFeatures(['feature1']) + + expect(mockFetch).toHaveBeenCalledTimes(1) + const requestInit = mockFetch.mock.calls[0][1] as RequestInit + const requestBody = JSON.parse(requestInit.body as string) as { context: FeatureContext } + const expectedHashedUserId = createHash('sha256').update('abc-123').digest('hex') + + expect(requestBody.context).toEqual({ + userId: expectedHashedUserId, + cohort: 'beta', + }) + }) }) describe('edge cases and error handling', () => { diff --git a/packages/javascript/src/client.ts b/packages/javascript/src/client.ts index b9d9c80..feea018 100644 --- a/packages/javascript/src/client.ts +++ b/packages/javascript/src/client.ts @@ -26,6 +26,7 @@ export class SupaClient { private plugins: SupaPlugin[] private featureDefinitions: Features private clientId: string + private sensitiveContextProperties: Set private fetchImpl: (input: RequestInfo | URL, init?: RequestInit) => Promise private networkConfig: ResolvedNetworkConfig @@ -35,6 +36,7 @@ export class SupaClient { this.environment = config.environment this.defaultContext = config.context this.featureDefinitions = config.features as Features + this.sensitiveContextProperties = new Set(config.sensitiveContextProperties ?? []) // Generate unique client ID this.clientId = this.generateClientId() @@ -80,6 +82,52 @@ export class SupaClient { ).catch(console.error) } + /** + * Hashes configured sensitive context fields before sending requests. + */ + private async hashSensitiveContext( + context?: FeatureContext + ): Promise { + if (!context || this.sensitiveContextProperties.size === 0) { + return context + } + + const transformedContext = { ...context } + const sensitiveEntries = Object.entries(context).filter(([key]) => + this.sensitiveContextProperties.has(key) + ) + + for (const [key, value] of sensitiveEntries) { + if (value === null || value === undefined) { + continue + } + + transformedContext[key] = await this.hashStringValue(String(value)) + } + + return transformedContext + } + + private async hashStringValue(value: string): Promise { + const subtleCrypto = (globalThis as unknown as { crypto?: { subtle?: SubtleCrypto } }).crypto + ?.subtle + const subtleAlgorithm = 'SHA-256' + + if (subtleCrypto && typeof TextEncoder !== 'undefined') { + const digest = await subtleCrypto.digest(subtleAlgorithm, new TextEncoder().encode(value)) + return this.toHexString(digest) + } + + const { createHash } = await import('node:crypto') + return createHash('sha256').update(value).digest('hex') + } + + private toHexString(buffer: ArrayBuffer): string { + return Array.from(new Uint8Array(buffer)) + .map(byte => byte.toString(16).padStart(2, '0')) + .join('') + } + /** * Generate a unique client ID */ @@ -239,10 +287,11 @@ export class SupaClient { 'Content-Type': 'application/json', Authorization: `Bearer ${this.apiKey}`, } + const requestContext = await this.hashSensitiveContext(mergedContext) const body = JSON.stringify({ environment: this.environment, features: featureNamesArray, - context: mergedContext, + context: requestContext, }) // Notify plugins before request diff --git a/packages/javascript/src/types.ts b/packages/javascript/src/types.ts index a6f7951..b0a41d2 100644 --- a/packages/javascript/src/types.ts +++ b/packages/javascript/src/types.ts @@ -24,6 +24,11 @@ export interface SupaClientConfig { * Can be merged/overridden per-call via options.context. */ context: FeatureContext + /** + * Optional privacy controls for hashing sensitive context fields before sending + * requests to Supaship APIs. + */ + sensitiveContextProperties?: string[] /** * Optional network configuration allowing you to override API endpoints. * If omitted, defaults are used. @@ -43,6 +48,7 @@ export interface SupaClientConfig { */ toolbar?: false | SupaToolbarPluginConfig } + export interface FeatureContext { [key: string]: string | number | boolean | null | undefined } diff --git a/packages/react/README.md b/packages/react/README.md index 3e07f44..94baba9 100644 --- a/packages/react/README.md +++ b/packages/react/README.md @@ -133,6 +133,8 @@ const config = { email: 'user@example.com', plan: 'premium', }, + // Hash sensitive context properties such as PII on the client before sending to Edge + sensitiveContextProperties: ['email', 'userID'], networkConfig: { // Optional: network settings featuresAPIUrl: 'https://api.supashiphq.com/features', @@ -146,6 +148,8 @@ const config = { } ``` +> Privacy note: set `sensitiveContextProperties` to hash PII/sensitive context property values on the client before requests are sent to the Edge API. + **Supported Feature Value Types:** | Type | Example | Description | diff --git a/packages/vue/README.md b/packages/vue/README.md index af40120..879460d 100644 --- a/packages/vue/README.md +++ b/packages/vue/README.md @@ -111,6 +111,8 @@ const config = { email: 'user@example.com', plan: 'premium', }, + // Hash sensitive context properties such as PII on the client before sending to Edge + sensitiveContextProperties: ['email', 'userID'], networkConfig: { // Optional: network settings featuresAPIUrl: 'https://api.supashiphq.com/features', @@ -124,6 +126,8 @@ const config = { } ``` +> Privacy note: set `sensitiveContextProperties` to hash PII/sensitive context property values on the client before requests are sent to the Edge API. + **Supported Feature Value Types:** | Type | Example | Description |