Skip to content
Closed
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
33 changes: 33 additions & 0 deletions src/client/eppo-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import {
} from '../configuration';
import ConfigurationRequestor from '../configuration-requestor';
import { IConfigurationStore, ISyncStore } from '../configuration-store/configuration-store';
import { MemoryOnlyConfigurationStore } from '../configuration-store/memory.store';
import {
DEFAULT_INITIAL_CONFIG_REQUEST_RETRIES,
DEFAULT_POLL_CONFIG_REQUEST_RETRIES,
Expand Down Expand Up @@ -53,6 +54,7 @@ import {
VariationType,
} from '../interfaces';
import { getMD5Hash } from '../obfuscation';
import { OverridePayload, OverrideValidator } from '../override-validator';
import initPoller, { IPoller } from '../poller';
import {
Attributes,
Expand All @@ -63,6 +65,7 @@ import {
FlagKey,
ValueType,
} from '../types';
import { shallowClone } from '../util';
import { validateNotBlank } from '../validation';
import { LIB_VERSION } from '../version';

Expand Down Expand Up @@ -125,6 +128,7 @@ export default class EppoClient {
private isObfuscated: boolean;
private requestPoller?: IPoller;
private readonly evaluator = new Evaluator();
private readonly overrideValidator = new OverrideValidator();

constructor({
eventDispatcher = new NoOpEventDispatcher(),
Expand Down Expand Up @@ -154,6 +158,35 @@ export default class EppoClient {
this.isObfuscated = isObfuscated;
}

/**
* Validates and parses x-eppo-overrides header sent by Eppo's Chrome extension
*/
async parseOverrides(
overridePayload: string | undefined,
): Promise<Record<FlagKey, Variation> | undefined> {
if (!overridePayload) {
return undefined;
}
const payload: OverridePayload = this.overrideValidator.parseOverridePayload(overridePayload);
await this.overrideValidator.validateOverrideApiKey(payload.apiKey);
return payload.overrides;
}

/**
* Creates an EppoClient instance that has the specified overrides applied
* to it without affecting the original EppoClient singleton. Useful for
* applying overrides in a shared Node instance, such as a web server.
*/
withOverrides(overrides: Record<FlagKey, Variation>): EppoClient {
if (overrides && Object.keys(overrides).length) {
const copy = shallowClone(this);
copy.overrideStore = new MemoryOnlyConfigurationStore<Variation>();
copy.overrideStore.setEntries(overrides);
return copy;
}
return this;
}

setConfigurationRequestParameters(
configurationRequestParameters: FlagConfigurationRequestParameters,
) {
Expand Down
70 changes: 70 additions & 0 deletions src/override-validator.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import { TLRUCache } from './cache/tlru-cache';
import { Variation } from './interfaces';
import { FlagKey } from './types';

const FIVE_MINUTES_IN_MS = 5 * 3600 * 1000;
const EPPO_API_URL = 'https://eppo.cloud/api/v1/feature-flags';

export interface OverridePayload {
apiKey: string;
overrides: Record<FlagKey, Variation>;
}

export class OverrideValidator {
private validApiKeyCache = new TLRUCache(100, FIVE_MINUTES_IN_MS);

parseOverridePayload(overridePayload: string): OverridePayload {
const errorMsg = (msg: string) => `Unable to parse overridePayload: ${msg}`;
try {
const parsed = JSON.parse(overridePayload);
this.validateParsedOverridePayload(parsed);
return parsed as OverridePayload;
} catch (err: unknown) {
const message: string = (err as any)?.message ?? 'unknown error';
throw new Error(errorMsg(message));
}
}

private validateParsedOverridePayload(parsed: any) {
if (typeof parsed !== 'object') {
throw new Error(`Expected object, but received ${typeof parsed}`);
}
const keys = Object.keys(parsed);
if (!keys.includes('apiKey')) {
throw new Error(`Missing required field: 'apiKey'`);
}
if (!keys.includes('overrides')) {
throw new Error(`Missing required field: 'overrides'`);
}
if (typeof parsed['apiKey'] !== 'string') {
throw new Error(
`Invalid type for 'apiKeys'. Expected string, but received ${typeof parsed['apiKey']}`,
);
}
if (typeof parsed['overrides'] !== 'object') {
throw new Error(
`Invalid type for 'overrides'. Expected object, but received ${typeof parsed['overrides']}.`,
);
}
}

async validateOverrideApiKey(overrideApiKey: string) {
if (this.validApiKeyCache.get(overrideApiKey) === 'true') {
return true;
}
await this.sendValidationRequest(overrideApiKey);
this.validApiKeyCache.set(overrideApiKey, 'true');
}

private async sendValidationRequest(overrideApiKey: string) {
const response = await fetch(EPPO_API_URL, {
headers: {
'X-Eppo-Token': overrideApiKey,
'Content-Type': 'application/json',
},
});
if (response.status !== 200) {
throw new Error(`Unable to authorize API token: ${response.statusText}`);
}
Comment on lines +59 to +68
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The final version of this will hit a new endpoint that validates the "browser extension" key

}
}
4 changes: 4 additions & 0 deletions src/util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,7 @@
export async function waitForMs(ms: number) {
await new Promise((resolve) => setTimeout(resolve, ms));
}

export function shallowClone<T>(original: T): T {
return Object.assign(Object.create(Object.getPrototypeOf(original)), original);
}
Loading