Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 27 additions & 8 deletions packages/javascript/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -121,21 +121,40 @@ 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:**

| Field | Type | Description |
| --------------- | ---------------------------------- | ------------------------------------------- |
| `[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 |
Expand Down
65 changes: 65 additions & 0 deletions packages/javascript/src/__tests__/client.test.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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<void> => {
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<void> => {
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', () => {
Expand Down
51 changes: 50 additions & 1 deletion packages/javascript/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ export class SupaClient<TFeatures extends FeaturesWithFallbacks> {
private plugins: SupaPlugin[]
private featureDefinitions: Features<TFeatures>
private clientId: string
private sensitiveContextProperties: Set<string>

private fetchImpl: (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>
private networkConfig: ResolvedNetworkConfig
Expand All @@ -35,6 +36,7 @@ export class SupaClient<TFeatures extends FeaturesWithFallbacks> {
this.environment = config.environment
this.defaultContext = config.context
this.featureDefinitions = config.features as Features<TFeatures>
this.sensitiveContextProperties = new Set(config.sensitiveContextProperties ?? [])

// Generate unique client ID
this.clientId = this.generateClientId()
Expand Down Expand Up @@ -80,6 +82,52 @@ export class SupaClient<TFeatures extends FeaturesWithFallbacks> {
).catch(console.error)
}

/**
* Hashes configured sensitive context fields before sending requests.
*/
private async hashSensitiveContext(
context?: FeatureContext
): Promise<FeatureContext | undefined> {
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<string> {
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
*/
Expand Down Expand Up @@ -239,10 +287,11 @@ export class SupaClient<TFeatures extends FeaturesWithFallbacks> {
'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
Expand Down
6 changes: 6 additions & 0 deletions packages/javascript/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -43,6 +48,7 @@ export interface SupaClientConfig {
*/
toolbar?: false | SupaToolbarPluginConfig
}

export interface FeatureContext {
[key: string]: string | number | boolean | null | undefined
}
Expand Down
4 changes: 4 additions & 0 deletions packages/react/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -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 |
Expand Down
4 changes: 4 additions & 0 deletions packages/vue/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -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 |
Expand Down
Loading