diff --git a/src/client/eppo-client.ts b/src/client/eppo-client.ts index c0dcde9..0147509 100644 --- a/src/client/eppo-client.ts +++ b/src/client/eppo-client.ts @@ -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, @@ -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, @@ -63,6 +65,7 @@ import { FlagKey, ValueType, } from '../types'; +import { shallowClone } from '../util'; import { validateNotBlank } from '../validation'; import { LIB_VERSION } from '../version'; @@ -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(), @@ -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 | 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): EppoClient { + if (overrides && Object.keys(overrides).length) { + const copy = shallowClone(this); + copy.overrideStore = new MemoryOnlyConfigurationStore(); + copy.overrideStore.setEntries(overrides); + return copy; + } + return this; + } + setConfigurationRequestParameters( configurationRequestParameters: FlagConfigurationRequestParameters, ) { diff --git a/src/override-validator.ts b/src/override-validator.ts new file mode 100644 index 0000000..2351a78 --- /dev/null +++ b/src/override-validator.ts @@ -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; +} + +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}`); + } + } +} diff --git a/src/util.ts b/src/util.ts index e63e50c..d66961f 100644 --- a/src/util.ts +++ b/src/util.ts @@ -2,3 +2,7 @@ export async function waitForMs(ms: number) { await new Promise((resolve) => setTimeout(resolve, ms)); } + +export function shallowClone(original: T): T { + return Object.assign(Object.create(Object.getPrototypeOf(original)), original); +}