diff --git a/apps/ofrep/.gitignore b/apps/ofrep/.gitignore new file mode 100644 index 00000000..43370fa9 --- /dev/null +++ b/apps/ofrep/.gitignore @@ -0,0 +1 @@ +*.tsbuildinfo diff --git a/apps/ofrep/README.md b/apps/ofrep/README.md new file mode 100644 index 00000000..e37d61e3 --- /dev/null +++ b/apps/ofrep/README.md @@ -0,0 +1,126 @@ +# OFREP + +A public HTTP service for Vercel Flags. It implements the two required endpoints +in the [OFREP 0.4.0 specification](https://github.com/open-feature/protocol/blob/main/service/openapi.yaml). +Each request selects its project and environment through the caller's credentials. +One deployment can serve multiple Vercel Flags projects. + +| Method | Path | Function | +| --- | --- | --- | +| POST | `/ofrep/v1/evaluate/flags/{key}` | Evaluate one flag | +| POST | `/ofrep/v1/evaluate/flags` | Evaluate all flags | +| OPTIONS | Both paths | Browser CORS preflight | + +## Run + +From the repository root: + +```sh +pnpm install +pnpm exec turbo dev --filter=ofrep +``` + +The app listens on port 3031. For a production build: + +```sh +pnpm exec turbo build --filter=ofrep +pnpm --filter ofrep start +``` + +For Vercel, select `apps/ofrep` as the project root. Keep access to workspace +packages enabled. The app uses the Node.js runtime. It needs no project-specific +environment variables and makes no authenticated requests during the build. + +## Authentication + +Send a Vercel Flags SDK key or a Vercel OIDC token in `Authorization: Bearer `. +Alternatively, send the credential in `X-API-Key`. Do not send different credentials +in both headers. Use a raw SDK key, not a `flags:` connection string. + +The app forwards the credential to `https://flags.vercel.com/v1/datafile`. That +service validates the token and selects the project and environment. The app does +not use its deployment's OIDC token or `FLAGS` environment variable. An arbitrary +identity provider's OIDC token does not grant access to Vercel Flags. + +For permitted cross-project access with Vercel OIDC, also send +`X-Vercel-Flags-Project-Id: prj_...`. The app forwards this header. The data service +checks whether the caller can access that source project. Live OIDC acceptance +depends on the data service and the caller's project configuration. + +Browser CORS is enabled without cookies. Use only credentials intended for browser +exposure in browser applications. A server SDK key or OIDC token must stay on the +server. Upstream permission checks also apply to client SDK keys. + +## Requests and responses + +```sh +curl http://localhost:3031/ofrep/v1/evaluate/flags/banner \ + -H "Authorization: Bearer $FLAGS_SDK_KEY" \ + -H 'Content-Type: application/json' \ + -d '{"context":{"targetingKey":"user-123","user":{"id":"user-123","plan":"pro"}}}' +``` + +The JSON body must contain a `context` object. An empty object is permitted. +`targetingKey`, when present, must be a string. The app passes context directly to +`@vercel/flags-core` as `entities`, as the existing Vercel OpenFeature provider does. +For rules that use `user.id`, send `context.user.id`. The app does not copy +`targetingKey` into `user.id`. Rules can use `targetingKey` directly. + +Evaluation uses the datafile's environment, definitions, and segments. Success +responses contain `key`, `value`, `reason`, and `variant` when available. + +| Core result | OFREP reason | +| --- | --- | +| Paused flag | `DISABLED` | +| Split, rollout, or experiment outcome | `SPLIT` | +| Target or rule match with a fixed outcome | `TARGETING_MATCH` | +| Fallthrough with a fixed outcome | `UNKNOWN` | + +OFREP 0.4.0 excludes `DEFAULT` from success reasons. Its value schemas permit +booleans, strings, numbers, and objects. A top-level array or `null` produces a +`GENERAL` flag error. Arrays and nulls inside an object are permitted. The core +does not produce the optional OFREP code-default result, so this app does not +synthesize it. + +Missing flags return HTTP 404 with `FLAG_NOT_FOUND`. Invalid JSON and context +return HTTP 400 with `PARSE_ERROR` or `INVALID_CONTEXT`. Single-flag evaluation +failures return HTTP 500. Bulk evaluation keeps individual `GENERAL` failures +in the `flags` array and returns HTTP 200. Upstream 401, 403, and 429 statuses pass +through; 429 responses retain `Retry-After`. Other upstream errors return HTTP 500 +with a generic message. Raw upstream errors and tokens are not returned or logged. + +Bulk responses include an ETag calculated from the full evaluated response. A +matching `If-None-Match` returns HTTP 304 without a body, as OFREP specifies for +this POST endpoint. A context change that changes a result changes the ETag. +Authentication is checked before conditional responses. Optional change-event +query parameters force a fresh datafile read. This app does not advertise an +optional event stream; clients use polling. + +## Cache and limits + +The in-memory LRU cache holds successful datafiles for five seconds. Entries are +separated by an HMAC-SHA-256 digest of the exact credential and source project +header. Each cache instance uses its own random secret, which stays in memory. +The cache does not retain raw credentials. Concurrent requests with the same +credentials share one datafile fetch. Each process has its own cache. + +For JWTs with an `exp` claim, the cache lifetime cannot exceed that expiry. Reading +this claim does not validate the JWT; the data service validates it on cache +misses. Permission changes and SDK key revocation can take up to five seconds to +take effect. Expired entries are not used after an upstream failure. + +Limits per process: 64 cached datafiles, 64 concurrent datafile fetches, 2 MiB per +datafile, 64 KiB per request body, and a 10-second upstream deadline. Configure +deployment-level rate limits for public traffic. Evaluation telemetry and +experiment exposure reporting are not emitted by the pure core evaluator. + +## Checks + +```sh +pnpm exec turbo test type-check check build --filter=ofrep +``` + +Tests cover evaluation, protocol errors, authentication forwarding, cache +isolation and expiry, ETags, CORS, and the official OpenFeature OFREP server +provider. Tests use a local data service substitute. Live credentials are not +required. diff --git a/apps/ofrep/app/ofrep/v1/evaluate/flags/[key]/route.ts b/apps/ofrep/app/ofrep/v1/evaluate/flags/[key]/route.ts new file mode 100644 index 00000000..80e342d6 --- /dev/null +++ b/apps/ofrep/app/ofrep/v1/evaluate/flags/[key]/route.ts @@ -0,0 +1,13 @@ +import { handleEvaluation } from '../../../../../../lib/handler'; +import { options } from '../../../../../../lib/http'; + +export const runtime = 'nodejs'; +export const OPTIONS = options; + +export async function POST( + request: Request, + { params }: { params: Promise<{ key: string }> }, +) { + const { key } = await params; + return handleEvaluation(request, key); +} diff --git a/apps/ofrep/app/ofrep/v1/evaluate/flags/route.ts b/apps/ofrep/app/ofrep/v1/evaluate/flags/route.ts new file mode 100644 index 00000000..55df2ccd --- /dev/null +++ b/apps/ofrep/app/ofrep/v1/evaluate/flags/route.ts @@ -0,0 +1,9 @@ +import { handleEvaluation } from '../../../../../lib/handler'; +import { options } from '../../../../../lib/http'; + +export const runtime = 'nodejs'; +export const OPTIONS = options; + +export function POST(request: Request) { + return handleEvaluation(request); +} diff --git a/apps/ofrep/app/route.ts b/apps/ofrep/app/route.ts new file mode 100644 index 00000000..51463a3a --- /dev/null +++ b/apps/ofrep/app/route.ts @@ -0,0 +1,52 @@ +const introduction = String.raw`OFREP for Vercel Flags + +This service evaluates Vercel Flags through the OpenFeature Remote Evaluation +Protocol (OFREP). Use it with an OFREP provider in your OpenFeature SDK. +Set the provider's base URL to this site's origin, without a path. + +Send a Vercel Flags SDK key or Vercel OIDC token in Authorization: Bearer . +You can also use the X-API-Key header. Your credentials select the project and +environment. Keep server credentials on the server. + +Endpoints: + POST /ofrep/v1/evaluate/flags/{key} Evaluate one flag. + POST /ofrep/v1/evaluate/flags Evaluate all flags. + +Examples: replace and banner with your values. + +SDK key authentication: +Set FLAGS_SDK_KEY to your raw Vercel Flags SDK key. + +curl '/ofrep/v1/evaluate/flags/banner' \ + -H "Authorization: Bearer $FLAGS_SDK_KEY" \ + -H 'Content-Type: application/json' \ + -d '{"context":{"targetingKey":"user-123","user":{"id":"user-123"}}}' + +Vercel OIDC authentication: +Use a current Vercel OIDC token in VERCEL_OIDC_TOKEN. + +curl '/ofrep/v1/evaluate/flags/banner' \ + -H "Authorization: Bearer $VERCEL_OIDC_TOKEN" \ + -H 'Content-Type: application/json' \ + -d '{"context":{"targetingKey":"user-123","user":{"id":"user-123"}}}' + +To read another project's flags with OIDC, also add: + -H 'X-Vercel-Flags-Project-Id: prj_...' +The caller must have permission to access that project. + +The body must contain a context object. Use {"context":{}} if no context is needed. +For rules that use user.id, supply context.user.id. targetingKey is not copied +to user.id. Responses contain the flag key, value, reason, and optional variant. + +Specification: +https://openfeature.dev/docs/reference/other-technologies/ofrep/ +`; + +export function GET() { + return new Response(introduction, { + headers: { + 'Content-Type': 'text/plain; charset=utf-8', + 'X-Content-Type-Options': 'nosniff', + }, + }); +} diff --git a/apps/ofrep/lib/datafile.ts b/apps/ofrep/lib/datafile.ts new file mode 100644 index 00000000..06753f1f --- /dev/null +++ b/apps/ofrep/lib/datafile.ts @@ -0,0 +1,168 @@ +import { createHmac, randomBytes } from 'node:crypto'; +import type { DatafileInput } from '@vercel/flags-core'; +import { HttpError, isObject, readJson } from './http'; + +const DATAFILE_URL = 'https://flags.vercel.com/v1/datafile'; +const CACHE_TTL_MS = 5_000; +const MAX_CACHE_ENTRIES = 64; +const MAX_DATAFILE_BYTES = 2 * 1024 * 1024; +const SOURCE_PROJECT_HEADER = 'X-Vercel-Flags-Project-Id'; + +type Credentials = { + authorization: string; + projectId: string | null; + expiresAt: number; + cacheKey: string; +}; + +function unauthorized(): HttpError { + return new HttpError( + 401, + 'GENERAL', + 'Authentication is required or has expired.', + { + 'WWW-Authenticate': 'Bearer', + }, + ); +} + +function credentials( + headers: Headers, + now: number, + cacheSecret: Buffer, +): Credentials { + const authorization = headers.get('authorization'); + const apiKey = headers.get('x-api-key'); + const match = authorization?.match(/^Bearer ([^\s]+)$/i); + if ((authorization && !match) || (!match && !apiKey)) throw unauthorized(); + const token = match?.[1] ?? apiKey!; + if (/\s/.test(token) || (apiKey && match && apiKey !== token)) { + throw unauthorized(); + } + + let expiresAt = Number.POSITIVE_INFINITY; + // This is only a cache lifetime bound. The upstream service validates the JWT. + if (token.split('.').length === 3) { + try { + const payload: unknown = JSON.parse( + Buffer.from(token.split('.')[1]!, 'base64url').toString('utf8'), + ); + if (isObject(payload) && typeof payload.exp === 'number') { + expiresAt = payload.exp * 1000; + } + } catch { + // Forward opaque credentials; never infer authorization from JWT claims. + } + } + if (expiresAt <= now) throw unauthorized(); + const projectId = headers.get(SOURCE_PROJECT_HEADER); + return { + authorization: `Bearer ${token}`, + projectId, + expiresAt, + cacheKey: createHmac('sha256', cacheSecret) + .update(JSON.stringify([token, projectId])) + .digest('hex'), + }; +} + +type Entry = { data: DatafileInput; expiresAt: number }; + +export function createDatafileLoader({ + fetch: fetchData = globalThis.fetch, + now = Date.now, +}: { + fetch?: typeof globalThis.fetch; + now?: () => number; +} = {}) { + // These are transient cache identifiers, not stored password verifiers. + // A private key prevents credential guesses from being checked against an + // isolated cache identifier. Its lifetime is limited to this cache instance. + const cacheSecret = randomBytes(32); + const cache = new Map(); + const pending = new Map>(); + + async function load(auth: Credentials): Promise { + const response = await fetchData(DATAFILE_URL, { + headers: { + Authorization: auth.authorization, + ...(auth.projectId ? { [SOURCE_PROJECT_HEADER]: auth.projectId } : {}), + Accept: 'application/json', + }, + cache: 'no-store', + redirect: 'error', + signal: AbortSignal.timeout(10_000), + }); + if (!response.ok) { + void response.body?.cancel().catch(() => {}); + if (response.status === 401) throw unauthorized(); + if (response.status === 403) { + throw new HttpError( + 403, + 'GENERAL', + 'Access to these flags is forbidden.', + ); + } + if (response.status === 429) { + const retryAfter = response.headers.get('retry-after'); + throw new HttpError( + 429, + 'GENERAL', + 'Too many requests.', + retryAfter ? { 'Retry-After': retryAfter } : undefined, + ); + } + throw new Error('Data service request failed'); + } + const data = await readJson(response.body, MAX_DATAFILE_BYTES); + if ( + !isObject(data) || + !isObject(data.definitions) || + typeof data.environment !== 'string' || + !data.environment || + typeof data.projectId !== 'string' || + !data.projectId || + (data.segments !== undefined && !isObject(data.segments)) + ) { + throw new Error('Invalid datafile'); + } + if (auth.expiresAt <= now()) throw unauthorized(); + const datafile = data as DatafileInput; + if (cache.size >= MAX_CACHE_ENTRIES) { + cache.delete(cache.keys().next().value!); + } + cache.set(auth.cacheKey, { + data: datafile, + expiresAt: Math.min(now() + CACHE_TTL_MS, auth.expiresAt), + }); + return datafile; + } + + return async (request: Request): Promise => { + const auth = credentials(request.headers, now(), cacheSecret); + // Change-event metadata requires a fresh read, independent of the HTTP ETag. + const query = new URL(request.url).searchParams; + const forceRefresh = + query.has('flagConfigEtag') || query.has('flagConfigLastModified'); + const cached = cache.get(auth.cacheKey); + if (!forceRefresh && cached && cached.expiresAt > now()) { + cache.delete(auth.cacheKey); + cache.set(auth.cacheKey, cached); + return cached.data; + } + cache.delete(auth.cacheKey); + let operation = pending.get(auth.cacheKey); + if (!operation) { + if (pending.size >= MAX_CACHE_ENTRIES) { + throw new HttpError(429, 'GENERAL', 'Too many requests.', { + 'Retry-After': '1', + }); + } + operation = load(auth).finally(() => pending.delete(auth.cacheKey)); + pending.set(auth.cacheKey, operation); + } + const data = await operation; + if (auth.expiresAt <= now()) throw unauthorized(); + return data; + }; +} diff --git a/apps/ofrep/lib/evaluate.ts b/apps/ofrep/lib/evaluate.ts new file mode 100644 index 00000000..bd3fc391 --- /dev/null +++ b/apps/ofrep/lib/evaluate.ts @@ -0,0 +1,71 @@ +import { + type DatafileInput, + evaluate, + Reason, + type Value, +} from '@vercel/flags-core'; + +type Success = { + key: string; + value: Value; + reason: 'STATIC' | 'TARGETING_MATCH' | 'SPLIT' | 'DISABLED' | 'UNKNOWN'; + variant?: string; +}; +type Failure = { key: string; errorCode: 'GENERAL'; errorDetails: string }; + +export function evaluateFlag( + data: DatafileInput, + key: string, + context: Record, +): Success | Failure { + try { + const result = evaluate({ + definition: data.definitions[key]!, + environment: data.environment, + segments: data.segments, + entities: context, + }); + if (result.reason === Reason.ERROR) { + return { + key, + errorCode: 'GENERAL', + errorDetails: 'Flag evaluation failed.', + }; + } + // OFREP 0.4.0 permits JSON objects but does not define null or array values. + if ( + result.value === null || + Array.isArray(result.value) || + result.value === undefined + ) { + return { + key, + errorCode: 'GENERAL', + errorDetails: 'The flag value type is not supported by OFREP.', + }; + } + const reason: Success['reason'] = + result.reason === Reason.PAUSED + ? 'DISABLED' + : result.outcomeType === 'split' || + result.outcomeType === 'rollout' || + result.outcomeType === 'experiment' + ? 'SPLIT' + : result.reason === Reason.TARGET_MATCH || + result.reason === Reason.RULE_MATCH + ? 'TARGETING_MATCH' + : 'UNKNOWN'; + return { + key, + value: result.value, + reason, + ...(result.variantId !== null ? { variant: result.variantId } : {}), + }; + } catch { + return { + key, + errorCode: 'GENERAL', + errorDetails: 'Flag evaluation failed.', + }; + } +} diff --git a/apps/ofrep/lib/handler.test.ts b/apps/ofrep/lib/handler.test.ts new file mode 100644 index 00000000..a77ed443 --- /dev/null +++ b/apps/ofrep/lib/handler.test.ts @@ -0,0 +1,525 @@ +import { OFREPProvider } from '@openfeature/ofrep-provider'; +import type { DatafileInput, Packed } from '@vercel/flags-core'; +import { describe, expect, it, vi } from 'vitest'; +import { createDatafileLoader } from './datafile'; +import { createHandler } from './handler'; +import { options } from './http'; + +function datafile(): DatafileInput { + return { + projectId: 'prj_test', + environment: 'production', + definitions: { + banner: { + variants: [false, true], + variantIds: ['off', 'on'], + environments: { + production: { + rules: [ + { + conditions: [ + [['user', 'plan'], 'eq' as Packed.Condition[1], 'pro'], + ], + outcome: 1, + }, + ], + fallthrough: 0, + }, + }, + }, + paused: { variants: ['blue'], environments: { production: 0 } }, + }, + }; +} + +function request( + body: unknown = { context: {} }, + headers: HeadersInit = {}, + query = '', +) { + return new Request(`https://ofrep.example/ofrep/v1/evaluate/flags${query}`, { + method: 'POST', + headers: { + 'content-type': 'application/json', + authorization: 'Bearer vf_server_test', + ...Object.fromEntries(new Headers(headers)), + }, + body: JSON.stringify(body), + }); +} + +function setup(data = datafile()) { + const fetchData = vi + .fn() + .mockImplementation(async () => Response.json(data)); + let time = 1_000_000; + const load = createDatafileLoader({ fetch: fetchData, now: () => time }); + return { + fetchData, + handle: createHandler(load), + advance: (ms: number) => { + time += ms; + }, + }; +} + +describe('OFREP evaluations', () => { + it('passes nested entities to flags-core and returns the variant and standard reason', async () => { + const { handle } = setup(); + const response = await handle( + request({ context: { targetingKey: 'u1', user: { plan: 'pro' } } }), + 'banner', + ); + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ + key: 'banner', + value: true, + variant: 'on', + reason: 'TARGETING_MATCH', + }); + expect(response.headers.get('cache-control')).toBe('private, no-store'); + }); + + it('returns all flags and uses DISABLED for paused flags', async () => { + const response = await setup().handle(request()); + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ + flags: [ + { key: 'banner', value: false, variant: 'off', reason: 'UNKNOWN' }, + { key: 'paused', value: 'blue', reason: 'DISABLED' }, + ], + }); + }); + + it.each([ + true, + 'value', + 42, + 1.5, + { nested: [null, 'ok'] }, + ])('returns the supported value %j', async (value) => { + const data = datafile(); + data.definitions.paused!.variants = [value]; + const response = await setup(data).handle(request(), 'paused'); + expect(response.status).toBe(200); + expect((await response.json()).value).toEqual(value); + }); + + it.each([ + null, + [true, false], + ])('reports unsupported top-level values %j without losing other flags', async (value) => { + const data = datafile(); + data.definitions.paused!.variants = [value]; + const { handle } = setup(data); + const single = await handle(request(), 'paused'); + expect(single.status).toBe(500); + expect(await single.json()).toMatchObject({ + errorDetails: 'The flag value type is not supported by OFREP.', + }); + const bulk = await handle(request()); + expect(bulk.status).toBe(200); + expect((await bulk.json()).flags).toEqual([ + expect.objectContaining({ key: 'banner', value: false }), + expect.objectContaining({ key: 'paused', errorCode: 'GENERAL' }), + ]); + }); + + it('uses targetingKey for splits without changing the context shape', async () => { + const data = datafile(); + data.definitions.banner!.environments.production = { + fallthrough: { + type: 'split', + base: ['targetingKey'], + weights: [0, 100], + defaultVariant: 0, + }, + }; + const response = await setup(data).handle( + request({ context: { targetingKey: 'u1' } }), + 'banner', + ); + expect(await response.json()).toMatchObject({ + value: true, + reason: 'SPLIT', + }); + }); + + it('evaluates segments and reused environments', async () => { + const data = datafile(); + data.segments = { pro: { include: { user: { id: ['u1'] } } } }; + data.definitions.banner!.environments.production = { reuse: 'preview' }; + data.definitions.banner!.environments.preview = { + rules: [ + { + conditions: [['segment', 'eq' as Packed.Condition[1], 'pro']], + outcome: 1, + }, + ], + fallthrough: 0, + }; + const response = await setup(data).handle( + request({ context: { user: { id: 'u1' } } }), + 'banner', + ); + expect(await response.json()).toMatchObject({ + value: true, + reason: 'TARGETING_MATCH', + }); + }); + + it.each([ + 'missing', + '__proto__', + 'constructor', + 'toString', + ])('returns 404 for absent own key %s', async (key) => { + const response = await setup().handle(request(), key); + expect(response.status).toBe(404); + expect(await response.json()).toEqual({ key, errorCode: 'FLAG_NOT_FOUND' }); + }); + + it('isolates broken flag definitions in a bulk result', async () => { + const data = datafile(); + data.definitions.paused!.environments.production = 100; + const response = await setup(data).handle(request()); + expect(response.status).toBe(200); + expect((await response.json()).flags[1]).toMatchObject({ + key: 'paused', + errorCode: 'GENERAL', + }); + }); + + it('returns an empty flags array for an empty project', async () => { + const response = await setup({ ...datafile(), definitions: {} }).handle( + request(), + ); + expect(await response.json()).toEqual({ flags: [] }); + expect(response.headers.get('etag')).toBeTruthy(); + }); +}); + +describe('request validation', () => { + it.each([ + null, + [], + {}, + { context: null }, + { context: [] }, + { context: 1 }, + { context: { targetingKey: 5 } }, + ])('rejects invalid context %j', async (body) => { + const { handle, fetchData } = setup(); + const response = await handle(request(body), 'banner'); + expect(response.status).toBe(400); + expect(await response.json()).toMatchObject({ + key: 'banner', + errorCode: 'INVALID_CONTEXT', + }); + expect(fetchData).not.toHaveBeenCalled(); + }); + + it('does not include a key on bulk request errors', async () => { + const response = await setup().handle(request({})); + expect(await response.json()).toMatchObject({ + errorCode: 'INVALID_CONTEXT', + }); + expect( + await setup() + .handle(request({})) + .then((r) => r.json()), + ).not.toHaveProperty('key'); + }); + + it.each([ + '{', + '', + 'x'.repeat(65 * 1024), + ])('rejects invalid or oversized JSON bodies (%#)', async (body) => { + const req = new Request('https://ofrep.example', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body, + }); + const response = await setup().handle(req, 'banner'); + expect(response.status).toBe(400); + expect(await response.json()).toMatchObject({ + key: 'banner', + errorCode: 'PARSE_ERROR', + }); + }); + + it('requires JSON media type', async () => { + const response = await setup().handle( + request({ context: {} }, { 'Content-Type': 'text/plain' }), + ); + expect(response.status).toBe(400); + }); + + it('supports browser preflight without credentials', () => { + const response = options(); + expect(response.status).toBe(204); + expect(response.headers.get('access-control-allow-headers')).toContain( + 'X-API-Key', + ); + expect(response.headers.get('access-control-allow-origin')).toBe('*'); + }); +}); + +describe('authentication and cache isolation', () => { + it('forwards bearer and source project headers only to the fixed data service', async () => { + const { handle, fetchData } = setup(); + await handle( + request( + { context: {} }, + { + Authorization: 'Bearer caller-oidc-token', + 'X-Vercel-Flags-Project-Id': 'prj_source', + Cookie: 'secret', + 'X-Vercel-Env': 'preview', + }, + ), + ); + expect(fetchData).toHaveBeenCalledWith( + 'https://flags.vercel.com/v1/datafile', + expect.objectContaining({ + headers: { + Authorization: 'Bearer caller-oidc-token', + 'X-Vercel-Flags-Project-Id': 'prj_source', + Accept: 'application/json', + }, + cache: 'no-store', + redirect: 'error', + signal: expect.any(AbortSignal), + }), + ); + }); + + it('accepts X-API-Key authentication', async () => { + const { handle, fetchData } = setup(); + const req = request(); + req.headers.delete('authorization'); + req.headers.set('x-api-key', 'vf_server_key'); + expect((await handle(req)).status).toBe(200); + expect(fetchData.mock.calls[0]![1]!.headers).toMatchObject({ + Authorization: 'Bearer vf_server_key', + }); + }); + + it.each([ + '', + 'Basic secret', + 'Bearer', + 'Bearer a b', + ])('rejects missing or invalid authorization %s', async (authorization) => { + const { handle, fetchData } = setup(); + const response = await handle( + request(undefined, { Authorization: authorization }), + ); + expect(response.status).toBe(401); + expect(response.headers.get('www-authenticate')).toBe('Bearer'); + expect(fetchData).not.toHaveBeenCalled(); + }); + + it('rejects conflicting credential headers', async () => { + const response = await setup().handle( + request(undefined, { 'X-API-Key': 'another-key' }), + ); + expect(response.status).toBe(401); + }); + + it('reuses data for five seconds and separates credentials and source projects', async () => { + const { handle, fetchData, advance } = setup(); + await handle(request()); + await handle(request(), 'banner'); + expect(fetchData).toHaveBeenCalledTimes(1); + await handle(request(undefined, { Authorization: 'Bearer other' })); + await handle( + request(undefined, { 'X-Vercel-Flags-Project-Id': 'prj_other' }), + ); + expect(fetchData).toHaveBeenCalledTimes(3); + advance(5000); + await handle(request()); + expect(fetchData).toHaveBeenCalledTimes(4); + }); + + it('does not serve cached data beyond the caller JWT expiry', async () => { + const { handle, fetchData, advance } = setup(); + const token = `header.${Buffer.from(JSON.stringify({ exp: 1002 })).toString('base64url')}.signature`; + const headers = { Authorization: `Bearer ${token}` }; + expect((await handle(request(undefined, headers))).status).toBe(200); + advance(2000); + expect((await handle(request(undefined, headers))).status).toBe(401); + expect(fetchData).toHaveBeenCalledTimes(1); + }); + + it('shares concurrent data loads for the same credentials', async () => { + const { handle, fetchData } = setup(); + const responses = await Promise.all( + Array.from({ length: 10 }, () => handle(request())), + ); + expect(responses.every((r) => r.status === 200)).toBe(true); + expect(fetchData).toHaveBeenCalledTimes(1); + }); + + it('evicts old entries when the cache limit is reached', async () => { + const { handle, fetchData } = setup(); + for (let n = 0; n < 65; n++) + await handle(request(undefined, { Authorization: `Bearer token-${n}` })); + await handle(request(undefined, { Authorization: 'Bearer token-0' })); + expect(fetchData).toHaveBeenCalledTimes(66); + }); + + it.each([ + 401, 403, 429, 500, 503, + ])('maps upstream status %s without caching a failure', async (status) => { + const { handle, fetchData } = setup(); + fetchData.mockResolvedValueOnce( + new Response('upstream secret', { + status, + headers: { 'Retry-After': '10' }, + }), + ); + const response = await handle(request(), 'banner'); + expect(response.status).toBe(status === 503 ? 500 : status); + expect(await response.text()).not.toContain('upstream secret'); + if (status === 429) expect(response.headers.get('retry-after')).toBe('10'); + expect((await handle(request())).status).toBe(200); + expect(fetchData).toHaveBeenCalledTimes(2); + }); + + it('does not use stale flags after an authentication failure', async () => { + const { handle, fetchData, advance } = setup(); + await handle(request()); + advance(5000); + fetchData.mockResolvedValueOnce(new Response(null, { status: 403 })); + expect((await handle(request())).status).toBe(403); + }); + + it.each([ + {}, + { definitions: [] }, + { ...datafile(), environment: null }, + ])('rejects an invalid datafile', async (data) => { + const { handle, fetchData } = setup(); + fetchData.mockResolvedValueOnce(Response.json(data)); + expect((await handle(request())).status).toBe(500); + }); + + it('sanitizes network errors', async () => { + const { handle, fetchData } = setup(); + fetchData.mockRejectedValueOnce(new Error('secret token')); + const response = await handle(request()); + expect(response.status).toBe(500); + expect(await response.text()).not.toContain('secret token'); + }); +}); + +describe('bulk ETags', () => { + it('returns a bodyless 304 for matching, weak, listed, and wildcard tags', async () => { + const { handle } = setup(); + const response = await handle(request()); + const etag = response.headers.get('etag')!; + for (const tag of [etag, `W/${etag}`, `"other", ${etag}`, '*']) { + const cached = await handle(request(undefined, { 'If-None-Match': tag })); + expect(cached.status).toBe(304); + expect(cached.headers.get('etag')).toBe(etag); + expect(await cached.text()).toBe(''); + } + }); + + it('returns new results when the context changes the evaluated values', async () => { + const { handle, fetchData } = setup(); + const previous = await handle(request()); + const response = await handle( + request( + { context: { user: { plan: 'pro' } } }, + { 'If-None-Match': previous.headers.get('etag')! }, + ), + ); + expect(response.status).toBe(200); + expect(response.headers.get('etag')).not.toBe(previous.headers.get('etag')); + expect(fetchData).toHaveBeenCalledTimes(1); + }); + + it('authenticates before returning a conditional response', async () => { + const { handle, fetchData } = setup(); + await handle(request()); + fetchData.mockResolvedValueOnce(new Response(null, { status: 403 })); + expect( + ( + await handle( + request(undefined, { + Authorization: 'Bearer other', + 'If-None-Match': '*', + }), + ) + ).status, + ).toBe(403); + }); + + it.each([ + 'flagConfigEtag=new', + 'flagConfigLastModified=2000', + ])('refreshes the datafile for change metadata %s', async (query) => { + const { handle, fetchData } = setup(); + await handle(request()); + await handle(request(undefined, {}, `?${query}`)); + expect(fetchData).toHaveBeenCalledTimes(2); + }); +}); + +describe('official OFREP server provider', () => { + function providerFor(data = datafile()) { + const { handle } = setup(data); + return new OFREPProvider({ + baseUrl: 'https://ofrep.example', + headers: [['Authorization', 'Bearer vf_server_test']], + fetchImplementation: async (input, init) => { + const req = new Request(input, init); + const path = new URL(req.url).pathname; + const prefix = '/ofrep/v1/evaluate/flags/'; + expect(path.startsWith(prefix)).toBe(true); + return handle(req, decodeURIComponent(path.slice(prefix.length))); + }, + }); + } + + it('reads evaluated booleans, strings, numbers, and objects', async () => { + const data = datafile(); + data.definitions.count = { + variants: [42], + environments: { production: 0 }, + }; + data.definitions.config = { + variants: [{ enabled: true }], + environments: { production: 0 }, + }; + const provider = providerFor(data); + expect( + await provider.resolveBooleanEvaluation('banner', false, { + user: { plan: 'pro' }, + }), + ).toMatchObject({ value: true, reason: 'TARGETING_MATCH', variant: 'on' }); + expect( + await provider.resolveStringEvaluation('paused', 'default', {}), + ).toMatchObject({ value: 'blue', reason: 'DISABLED' }); + expect( + await provider.resolveNumberEvaluation('count', 0, {}), + ).toMatchObject({ value: 42 }); + expect( + await provider.resolveObjectEvaluation('config', {}, {}), + ).toMatchObject({ value: { enabled: true } }); + }); + + it('reports FLAG_NOT_FOUND to the caller', async () => { + const provider = providerFor(); + await expect( + provider.resolveBooleanEvaluation('missing', false, {}), + ).resolves.toMatchObject({ + value: false, + reason: 'ERROR', + errorCode: 'FLAG_NOT_FOUND', + }); + }); +}); diff --git a/apps/ofrep/lib/handler.ts b/apps/ofrep/lib/handler.ts new file mode 100644 index 00000000..4defc4af --- /dev/null +++ b/apps/ofrep/lib/handler.ts @@ -0,0 +1,112 @@ +import { createHash } from 'node:crypto'; +import { createDatafileLoader } from './datafile'; +import { evaluateFlag } from './evaluate'; +import { HttpError, isObject, readJson, responseHeaders } from './http'; + +async function readContext(request: Request): Promise> { + if ( + request.headers.get('content-type')?.split(';')[0]?.trim().toLowerCase() !== + 'application/json' + ) { + throw new HttpError( + 400, + 'PARSE_ERROR', + 'Use an application/json request body.', + ); + } + let body: unknown; + try { + body = await readJson(request.body, 64 * 1024); + } catch { + throw new HttpError( + 400, + 'PARSE_ERROR', + 'The request must contain valid JSON of at most 64 KiB.', + ); + } + if ( + !isObject(body) || + !isObject(body.context) || + ('targetingKey' in body.context && + typeof body.context.targetingKey !== 'string') + ) { + throw new HttpError( + 400, + 'INVALID_CONTEXT', + 'Provide a context object with an optional string targetingKey.', + ); + } + return body.context; +} + +export function createHandler(loadDatafile = createDatafileLoader()) { + return async (request: Request, key?: string): Promise => { + try { + const context = await readContext(request); + const data = await loadDatafile(request); + if (key !== undefined) { + if (!Object.hasOwn(data.definitions, key)) { + return Response.json( + { key, errorCode: 'FLAG_NOT_FOUND' }, + { status: 404, headers: responseHeaders }, + ); + } + const result = evaluateFlag(data, key, context); + if ('errorCode' in result) { + return Response.json( + { errorDetails: result.errorDetails }, + { status: 500, headers: responseHeaders }, + ); + } + return Response.json(result, { + headers: responseHeaders, + }); + } + + const body = JSON.stringify({ + flags: Object.keys(data.definitions) + .sort() + .map((flagKey) => evaluateFlag(data, flagKey, context)), + }); + // Hash evaluated values, reasons, variants and failures, not just the datafile. + // Thus a context change cannot reuse results for another subject. + const etag = `"${createHash('sha256').update(body).digest('hex')}"`; + const headers = { ...responseHeaders, ETag: etag }; + const matches = request.headers + .get('if-none-match') + ?.split(',') + .some((tag) => { + const candidate = tag.trim().replace(/^W\//, ''); + return candidate === '*' || candidate === etag; + }); + if (matches) return new Response(null, { status: 304, headers }); + return new Response(body, { + headers: { ...headers, 'Content-Type': 'application/json' }, + }); + } catch (error) { + const known = error instanceof HttpError; + const status = known ? error.status : 500; + const body = + known && status === 400 + ? { + ...(key !== undefined ? { key } : {}), + errorCode: error.code, + errorDetails: error.message, + } + : { + errorDetails: known + ? error.message + : 'An internal server error prevented flag evaluation.', + }; + return Response.json(body, { + status, + headers: { + ...responseHeaders, + ...(known ? Object.fromEntries(new Headers(error.headers)) : {}), + }, + }); + } + }; +} + +export const handleEvaluation = createHandler(); diff --git a/apps/ofrep/lib/http.ts b/apps/ofrep/lib/http.ts new file mode 100644 index 00000000..bd96f1ac --- /dev/null +++ b/apps/ofrep/lib/http.ts @@ -0,0 +1,58 @@ +export class HttpError extends Error { + constructor( + readonly status: number, + readonly code: 'PARSE_ERROR' | 'INVALID_CONTEXT' | 'GENERAL', + message: string, + readonly headers?: HeadersInit, + ) { + super(message); + } +} + +export function isObject(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +/** Enforce the limit while reading, including bodies without Content-Length. */ +export async function readJson( + body: ReadableStream | null, + maxBytes: number, +): Promise { + if (!body) throw new Error('Missing JSON body'); + const reader = body.getReader(); + const chunks: Uint8Array[] = []; + let size = 0; + try { + for (;;) { + const { value, done } = await reader.read(); + if (done) break; + size += value.byteLength; + if (size > maxBytes) throw new Error('JSON body exceeds size limit'); + chunks.push(value); + } + return JSON.parse(Buffer.concat(chunks).toString('utf8')); + } finally { + void reader.cancel().catch(() => {}); + reader.releaseLock(); + } +} + +export const responseHeaders = { + 'Cache-Control': 'private, no-store', + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Expose-Headers': 'ETag, Retry-After, WWW-Authenticate', +}; + +export function options(): Response { + return new Response(null, { + status: 204, + headers: { + ...responseHeaders, + Allow: 'POST, OPTIONS', + 'Access-Control-Allow-Methods': 'POST, OPTIONS', + 'Access-Control-Allow-Headers': + 'Authorization, X-API-Key, Content-Type, If-None-Match, X-Vercel-Flags-Project-Id', + 'Access-Control-Max-Age': '86400', + }, + }); +} diff --git a/apps/ofrep/next.config.ts b/apps/ofrep/next.config.ts new file mode 100644 index 00000000..980c8507 --- /dev/null +++ b/apps/ofrep/next.config.ts @@ -0,0 +1,7 @@ +import type { NextConfig } from 'next'; + +const nextConfig: NextConfig = { + poweredByHeader: false, +}; + +export default nextConfig; diff --git a/apps/ofrep/package.json b/apps/ofrep/package.json new file mode 100644 index 00000000..066c64cd --- /dev/null +++ b/apps/ofrep/package.json @@ -0,0 +1,28 @@ +{ + "name": "ofrep", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "build": "next build", + "dev": "next dev --port 3031", + "start": "next start --port 3031", + "check": "biome check", + "type-check": "next typegen && tsc --noEmit", + "test": "vitest run" + }, + "dependencies": { + "@vercel/flags-core": "workspace:*", + "next": "16.3.8", + "react": "19.2.4", + "react-dom": "19.2.4" + }, + "devDependencies": { + "@openfeature/ofrep-provider": "0.2.5", + "@openfeature/server-sdk": "^1.17.1", + "@types/node": "22.14.0", + "@types/react": "catalog:", + "typescript": "^5.9.3", + "vitest": "4.1.11" + } +} diff --git a/apps/ofrep/tsconfig.json b/apps/ofrep/tsconfig.json new file mode 100644 index 00000000..34ac92f7 --- /dev/null +++ b/apps/ofrep/tsconfig.json @@ -0,0 +1,29 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["dom", "dom.iterable", "esnext"], + "skipLibCheck": true, + "strict": true, + "noEmit": true, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "react-jsx", + "plugins": [ + { + "name": "next" + } + ], + "allowJs": true, + "incremental": true + }, + "include": [ + "next-env.d.ts", + "**/*.ts", + ".next/types/**/*.ts", + ".next/dev/types/**/*.ts" + ], + "exclude": ["node_modules"] +} diff --git a/apps/ofrep/vitest.config.ts b/apps/ofrep/vitest.config.ts new file mode 100644 index 00000000..d09e4d20 --- /dev/null +++ b/apps/ofrep/vitest.config.ts @@ -0,0 +1,5 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { environment: 'node' }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4c180ac9..94534442 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -208,6 +208,40 @@ importers: specifier: 4.1.11 version: 4.1.11(@opentelemetry/api@1.9.0)(@types/node@24.10.13)(msw@2.6.4(@types/node@24.10.13)(typescript@5.9.3))(vite@8.1.5(@types/node@24.10.13)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0)) + apps/ofrep: + dependencies: + '@vercel/flags-core': + specifier: workspace:* + version: link:../../packages/vercel-flags-core + next: + specifier: 16.3.8 + version: 16.3.8(@opentelemetry/api@1.9.0)(@playwright/test@1.58.1)(@types/node@22.14.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + react: + specifier: 19.2.4 + version: 19.2.4 + react-dom: + specifier: 19.2.4 + version: 19.2.4(react@19.2.4) + devDependencies: + '@openfeature/ofrep-provider': + specifier: 0.2.5 + version: 0.2.5(@openfeature/core@1.9.1)(@openfeature/server-sdk@1.18.0(@openfeature/core@1.9.1)) + '@openfeature/server-sdk': + specifier: ^1.17.1 + version: 1.18.0(@openfeature/core@1.9.1) + '@types/node': + specifier: 22.14.0 + version: 22.14.0 + '@types/react': + specifier: 'catalog:' + version: 19.2.14 + typescript: + specifier: ^5.9.3 + version: 5.9.3 + vitest: + specifier: 4.1.11 + version: 4.1.11(@opentelemetry/api@1.9.0)(@types/node@22.14.0)(msw@2.6.4(@types/node@22.14.0)(typescript@5.9.3))(vite@8.1.5(@types/node@22.14.0)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0)) + apps/playground: dependencies: '@flags-sdk/vercel': @@ -2394,6 +2428,16 @@ packages: '@openfeature/core@1.9.1': resolution: {integrity: sha512-YySPtH4s/rKKnHRU0xyFGrqMU8XA+OIPNWDrlEFxE6DCVWCIrxE5YpiB94YD2jMFn6SSdA0cwQ8vLkCkl8lm8A==} + '@openfeature/ofrep-core@2.3.0': + resolution: {integrity: sha512-PgUoEp0nGDYUeZ2fIU2IczEe/5B+gQVDpGXc5o05wpo3sj69pbcpq/a3p8MT/mhv9fqODiqqwu29g/YEq/huaw==} + peerDependencies: + '@openfeature/core': ^1.6.0 + + '@openfeature/ofrep-provider@0.2.5': + resolution: {integrity: sha512-UMQiTdJYJdBMPWs+AvIhEdIgutCTbk0sMlcrlQeH99Ieacka0GKG0oeGtDB0hHPVGaNGHSGLfGYXSfBoXRXSQA==} + peerDependencies: + '@openfeature/server-sdk': ^1.6.0 + '@openfeature/server-sdk@1.18.0': resolution: {integrity: sha512-uP8nqEGBS58s3iWXx6d8vnJ6ZVt3DACJL4PWADOAuwIS4xXpID91783e9f6zQ0i1ijQpj3yx+3ZuCB2LfQMUMA==} engines: {node: '>=18'} @@ -10955,6 +10999,17 @@ snapshots: '@openfeature/core@1.9.1': {} + '@openfeature/ofrep-core@2.3.0(@openfeature/core@1.9.1)': + dependencies: + '@openfeature/core': 1.9.1 + + '@openfeature/ofrep-provider@0.2.5(@openfeature/core@1.9.1)(@openfeature/server-sdk@1.18.0(@openfeature/core@1.9.1))': + dependencies: + '@openfeature/ofrep-core': 2.3.0(@openfeature/core@1.9.1) + '@openfeature/server-sdk': 1.18.0(@openfeature/core@1.9.1) + transitivePeerDependencies: + - '@openfeature/core' + '@openfeature/server-sdk@1.18.0(@openfeature/core@1.9.1)': dependencies: '@openfeature/core': 1.9.1 diff --git a/vitest.config.ts b/vitest.config.ts index b32eddb9..45144973 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -2,6 +2,6 @@ import { defineConfig } from 'vitest/config'; export default defineConfig({ test: { - projects: ['./packages/*', './apps/docs'], + projects: ['./packages/*', './apps/docs', './apps/ofrep'], }, });