Skip to content
4 changes: 3 additions & 1 deletion libs/providers/ofrep-web/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,9 @@ The provider supports persistent local caching via `localStorage` to reduce late

**`cacheTTL`** — maximum age in seconds of a persisted cache entry before it is treated as a miss and removed. Defaults to `2_592_000` (30 days).

**`cacheKeyPrefix`** — a string included in the cache key to avoid collisions when multiple provider instances share the same browser origin. A good value is the OFREP base URL or a project key.
**`cacheKeyPrefix`** — an optional extra namespace prepended to the ADR-0009 cache key input (`baseUrl`, auth credential, bound OpenFeature `domain`, and `targetingKey`). Use it when you need additional separation across storage partitions you control directly.

The provider declares itself `domain-scoped`, so each instance is bound to at most one OpenFeature domain. The bound domain is supplied by the SDK at `initialize()` and included in the cache key.

```ts
import { OFREPWebProvider } from '@openfeature/ofrep-web-provider';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -65,12 +65,11 @@ export type OFREPWebProviderOptions = OFREPProviderBaseOptions & {
cacheTTL?: number;

/**
* cacheKeyPrefix is included in the cache key hash to prevent collisions when multiple
* OFREP provider instances share the same storage partition (e.g. the same browser origin).
* When set, the cache key becomes `hash(cacheKeyPrefix + ":" + targetingKey)`.
* cacheKeyPrefix is prepended to the ADR-0009 cache key input
* (`baseUrl`, auth credential, bound `domain`, and `targetingKey`) for additional namespacing
* across storage partitions an application controls directly.
*
* A sensible value is the OFREP base URL, a project key, or any other string that
* uniquely identifies this provider instance.
* A sensible value is a project key or any other string that uniquely identifies this provider instance.
*/
cacheKeyPrefix?: string;
};
62 changes: 50 additions & 12 deletions libs/providers/ofrep-web/src/lib/ofrep-web-provider.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import TestLogger from '../../test/test-logger';
import type { FlagCache } from './model/in-memory-cache';
import type { PersistedEntry } from './store/storage';
import { Storage } from './store/storage';
import { deriveAuthCredential } from './store/cache-key';
import {
ClientProviderEvents,
ClientProviderStatus,
Expand Down Expand Up @@ -36,6 +37,42 @@ describe('OFREPWebProvider', () => {
lastname: 'Doe',
};

function createTestStorage(domain = ''): Storage {
const storage = new Storage('local-cache-first', endpointBaseURL, () =>
deriveAuthCredential({ baseUrl: endpointBaseURL }),
);
storage.setDomain(domain);
return storage;
}

it('declares itself domain-scoped', () => {
const provider = new OFREPWebProvider({ baseUrl: endpointBaseURL });
expect(provider.domainScoped).toBe(true);
});

it('uses the bound domain from initialize for persisted cache lookup', async () => {
const boolFlagCache: FlagCache = {
'bool-flag': {
key: 'bool-flag',
value: true,
metadata: TEST_FLAG_METADATA,
reason: StandardResolutionReasons.STATIC,
},
};
const storage = createTestStorage('billing');
await storage.store(defaultContext.targetingKey, boolFlagCache, null);

const provider = new OFREPWebProvider({ baseUrl: endpointBaseURL, pollInterval: -1 }, new TestLogger());
await provider.initialize(defaultContext, 'billing');
// eslint-disable-next-line @typescript-eslint/no-explicit-any
expect((provider as any)._isUsingCache).toBe(true);

const otherDomainProvider = new OFREPWebProvider({ baseUrl: endpointBaseURL, pollInterval: -1 }, new TestLogger());
await otherDomainProvider.initialize(defaultContext, 'checkout');
// eslint-disable-next-line @typescript-eslint/no-explicit-any
expect((otherDomainProvider as any)._isUsingCache).toBe(false);
});

it('should call the READY handler, when the provider is ready', async () => {
const providerName = expect.getState().currentTestName || 'test-provider';
const provider = new OFREPWebProvider({ baseUrl: endpointBaseURL }, new TestLogger());
Expand Down Expand Up @@ -403,12 +440,12 @@ describe('OFREPWebProvider', () => {

it('connects SSE after the background refresh following a local-cache-first cache hit', async () => {
// Seed the cache so initialize() hits the cache-first path.
const storage = new Storage('local-cache-first');
const storage = createTestStorage();
const key = await storage.getStorageKey(defaultContext.targetingKey);
localStorage.setItem(
key,
JSON.stringify({
version: 1,
version: 2,
cacheKeyHash: key,
Comment thread
jonathannorris marked this conversation as resolved.
Outdated
etag: null,
writtenAt: new Date().toISOString(),
Expand Down Expand Up @@ -444,12 +481,12 @@ describe('OFREPWebProvider', () => {
// start where the flags are unchanged: the background refresh sends If-None-Match
// and the server responds 304 (no body, no eventStreams), so SSE must be established
// from the persisted configuration rather than from a 200 response.
const storage = new Storage('local-cache-first');
const storage = createTestStorage();
const key = await storage.getStorageKey(defaultContext.targetingKey);
localStorage.setItem(
key,
JSON.stringify({
version: 1,
version: 2,
cacheKeyHash: key,
Comment thread
jonathannorris marked this conversation as resolved.
Outdated
etag: '"cached-etag"',
writtenAt: new Date().toISOString(),
Expand Down Expand Up @@ -830,12 +867,13 @@ describe('OFREPWebProvider', () => {
etag: string | null = null,
writtenAt: Date = new Date(),
metadata?: Record<string, unknown>,
domain = '',
): Promise<void> {
const storage = new Storage('local-cache-first');
const storage = createTestStorage(domain);
const key = await storage.getStorageKey(targetingKey);
const entry: PersistedEntry = {
version: 1,
cacheKeyHash: key,
version: 2,
cacheKeyHash: key.split(':')[2],
etag,
writtenAt: writtenAt.toISOString(),
data: cache,
Expand Down Expand Up @@ -892,7 +930,7 @@ describe('OFREPWebProvider', () => {

it('does not read or write localStorage when cacheMode is disabled', async () => {
const providerName = expect.getState().currentTestName || 'test-provider';
const storage = new Storage('local-cache-first');
const storage = createTestStorage();
const seededKey = await storage.getStorageKey(defaultContext.targetingKey);
await seedPersistentCache(defaultContext.targetingKey, boolFlagCache);
expect(localStorage.getItem(seededKey)).not.toBeNull();
Expand Down Expand Up @@ -950,7 +988,7 @@ describe('OFREPWebProvider', () => {
it('keeps the persisted cache when a background fetch returns 401 (ADR 0009: TTL governs expiry, not auth errors)', async () => {
const providerName = expect.getState().currentTestName || 'test-provider';
await seedPersistentCache(defaultContext.targetingKey, boolFlagCache);
const storage = new Storage('local-cache-first');
const storage = createTestStorage();
const lsKey = await storage.getStorageKey(defaultContext.targetingKey);
expect(localStorage.getItem(lsKey)).not.toBeNull();

Expand All @@ -971,7 +1009,7 @@ describe('OFREPWebProvider', () => {
it('keeps the persisted cache when a background fetch returns 400 (ADR 0009: TTL governs expiry, not config errors)', async () => {
const providerName = expect.getState().currentTestName || 'test-provider';
await seedPersistentCache(defaultContext.targetingKey, boolFlagCache);
const storage = new Storage('local-cache-first');
const storage = createTestStorage();
const lsKey = await storage.getStorageKey(defaultContext.targetingKey);

server.use(
Expand All @@ -994,7 +1032,7 @@ describe('OFREPWebProvider', () => {
const expiredDate = new Date(Date.now() - 31 * 24 * 60 * 60 * 1000);
await seedPersistentCache(defaultContext.targetingKey, boolFlagCache, null, expiredDate);

const storage = new Storage('local-cache-first');
const storage = createTestStorage();
const lsKey = await storage.getStorageKey(defaultContext.targetingKey);
expect(localStorage.getItem(lsKey)).not.toBeNull(); // Exists before init.

Expand Down Expand Up @@ -1048,7 +1086,7 @@ describe('OFREPWebProvider', () => {
await OpenFeature.setContext({ ...defaultContext, targetingKey: user1 });
await OpenFeature.setProviderAndWait(providerName, provider);

const storage = new Storage('local-cache-first');
const storage = createTestStorage();
const user1Key = await storage.getStorageKey(user1);
// After init, user1's entry should have been written by the network fetch.
expect(localStorage.getItem(user1Key)).not.toBeNull();
Expand Down
14 changes: 12 additions & 2 deletions libs/providers/ofrep-web/src/lib/ofrep-web-provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ import { BulkEvaluationStatus } from './model/evaluate-flags-response';
import type { FlagCache, MetadataCache } from './model/in-memory-cache';
import type { CacheMode, OFREPWebProviderOptions } from './model/ofrep-web-provider-options';
import { DEFAULT_CACHE_TTL_SECONDS } from './model/ofrep-web-provider-options';
import { deriveAuthCredential } from './store/cache-key';
import { Storage } from './store/storage';
import { SseManager } from './sse-manager';

Expand All @@ -52,6 +53,7 @@ export class OFREPWebProvider implements Provider {
name: 'OpenFeature Remote Evaluation Protocol Web Provider',
};
readonly runsOn = 'client';
readonly domainScoped = true;
readonly events = new OpenFeatureEventEmitter();
readonly hooks?: Hook[] | undefined;

Expand Down Expand Up @@ -85,7 +87,13 @@ export class OFREPWebProvider implements Provider {
this._pollingInterval = this._options.pollInterval ?? this.DEFAULT_POLL_INTERVAL;
this._cacheMode = this._options.cacheMode ?? 'local-cache-first';
this._cacheTTL = this._options.cacheTTL ?? DEFAULT_CACHE_TTL_SECONDS;
this._storage = new Storage(this._cacheMode, this._options.cacheKeyPrefix, logger);
this._storage = new Storage(
this._cacheMode,
this._options.baseUrl,
() => deriveAuthCredential(this._options),
this._options.cacheKeyPrefix,
logger,
);
this._isUsingCache = false;
}

Expand All @@ -99,9 +107,11 @@ export class OFREPWebProvider implements Provider {
/**
* Initialize the provider, it will evaluate the flags and start the polling if it is not disabled.
* @param context - the context to use for the evaluation
* @param domain - the bound OpenFeature domain, if any
*/
async initialize(context?: EvaluationContext | undefined): Promise<void> {
async initialize(context?: EvaluationContext | undefined, domain?: string): Promise<void> {
try {
this._storage.setDomain(domain);
this._context = context;

let result: EvaluateFlagsResponse | undefined;
Expand Down
72 changes: 72 additions & 0 deletions libs/providers/ofrep-web/src/lib/store/cache-key.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import { deriveAuthCredential, encodeCacheKeyInput } from './cache-key';

describe('cache key encoding', () => {
it('uses JSON encoding so delimiter-like values do not collide', () => {
const keyA = encodeCacheKeyInput({
baseUrl: 'https://a:b',
auth: 'c',
domain: 'd',
targetingKey: 'e',
});
const keyB = encodeCacheKeyInput({
baseUrl: 'https://a',
auth: 'b:c',
domain: 'd:e',
targetingKey: '',
});
expect(keyA).not.toBe(keyB);
});

it('includes cacheKeyPrefix as the first component when set', () => {
const withPrefix = encodeCacheKeyInput({
cacheKeyPrefix: 'my-app',
baseUrl: 'https://example.com',
auth: '[]',
domain: 'billing',
targetingKey: 'user-1',
});
const withoutPrefix = encodeCacheKeyInput({
baseUrl: 'https://example.com',
auth: '[]',
domain: 'billing',
targetingKey: 'user-1',
});
expect(withPrefix).not.toBe(withoutPrefix);
});

it('serializes Authorization from static headers', async () => {
const auth = await deriveAuthCredential({
baseUrl: 'https://example.com',
headers: [
['Content-Type', 'application/json'],
['Authorization', 'Bearer token'],
['X-My-Header', 'ignored'],
],
});
expect(auth).toBe(JSON.stringify([['Authorization', 'Bearer token']]));
});

it('serializes known auth headers from headersFactory', async () => {
const auth = await deriveAuthCredential({
baseUrl: 'https://example.com',
headersFactory: () => Promise.resolve([['X-Api-Key', 'secret']]),
});
expect(auth).toBe(JSON.stringify([['X-Api-Key', 'secret']]));
});

it('returns an empty array when no auth headers are configured', async () => {
const auth = await deriveAuthCredential({
baseUrl: 'https://example.com',
headers: [['X-Custom', 'value']],
});
expect(auth).toBe('[]');
});

it('matches auth header names case-insensitively', async () => {
const auth = await deriveAuthCredential({
baseUrl: 'https://example.com',
headers: [['x-api-key', 'secret']],
});
expect(auth).toBe(JSON.stringify([['x-api-key', 'secret']]));
});
});
46 changes: 46 additions & 0 deletions libs/providers/ofrep-web/src/lib/store/cache-key.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import type { OFREPProviderBaseOptions } from '@openfeature/ofrep-core';

/** Header names treated as auth credentials for cache key derivation (matched case-insensitively). */
const AUTH_HEADER_NAMES = new Set([
Comment thread
jonathannorris marked this conversation as resolved.
'authorization',
'api-key',
'x-api-key',
'x-auth-token',
'x-access-token',
]);

export type CacheKeyParts = {
cacheKeyPrefix?: string;
baseUrl: string;
auth: string;
domain: string;
targetingKey: string;
};

function isAuthHeader(name: string): boolean {
return AUTH_HEADER_NAMES.has(name.toLowerCase());
}

/**
* Serializes known auth headers from static and factory-supplied options for cache keying.
* Rotating tokens will change the cache key on each rotation; stable credentials separate caches as intended.
*/
export async function deriveAuthCredential(options: OFREPProviderBaseOptions): Promise<string> {
const entries = [...(options.headers ?? []), ...((await options.headersFactory?.()) ?? [])];
const authHeaders = entries.filter(([name]) => isAuthHeader(name)).sort(([a], [b]) => a.localeCompare(b));
return JSON.stringify(authHeaders);
Comment thread
jonathannorris marked this conversation as resolved.
}

/**
* Encodes cache key components without ambiguous delimiter collisions.
* Order matches ADR-0009: optional prefix, base URL, auth, domain, targeting key.
*/
export function encodeCacheKeyInput(parts: CacheKeyParts): string {
return JSON.stringify([
parts.cacheKeyPrefix ?? '',
parts.baseUrl,
parts.auth,
parts.domain,
parts.targetingKey,
]);
}
Loading
Loading