From 19e40ef0b1b111e1e02c301bb1c83736f47a8515 Mon Sep 17 00:00:00 2001 From: Dominik Ferber Date: Sat, 10 Oct 2026 18:13:36 +0300 Subject: [PATCH 1/2] Add OFREP endpoint app for Vercel Flags --- apps/ofrep/.gitignore | 1 + apps/ofrep/README.md | 125 +++++ .../ofrep/v1/evaluate/flags/[key]/route.ts | 13 + .../app/ofrep/v1/evaluate/flags/route.ts | 9 + apps/ofrep/app/route.ts | 52 ++ apps/ofrep/lib/datafile.ts | 160 ++++++ apps/ofrep/lib/evaluate.ts | 71 +++ apps/ofrep/lib/handler.test.ts | 525 ++++++++++++++++++ apps/ofrep/lib/handler.ts | 112 ++++ apps/ofrep/lib/http.ts | 58 ++ apps/ofrep/next.config.ts | 7 + apps/ofrep/package.json | 28 + apps/ofrep/tsconfig.json | 29 + apps/ofrep/vitest.config.ts | 5 + pnpm-lock.yaml | 55 ++ vitest.config.ts | 2 +- 16 files changed, 1251 insertions(+), 1 deletion(-) create mode 100644 apps/ofrep/.gitignore create mode 100644 apps/ofrep/README.md create mode 100644 apps/ofrep/app/ofrep/v1/evaluate/flags/[key]/route.ts create mode 100644 apps/ofrep/app/ofrep/v1/evaluate/flags/route.ts create mode 100644 apps/ofrep/app/route.ts create mode 100644 apps/ofrep/lib/datafile.ts create mode 100644 apps/ofrep/lib/evaluate.ts create mode 100644 apps/ofrep/lib/handler.test.ts create mode 100644 apps/ofrep/lib/handler.ts create mode 100644 apps/ofrep/lib/http.ts create mode 100644 apps/ofrep/next.config.ts create mode 100644 apps/ofrep/package.json create mode 100644 apps/ofrep/tsconfig.json create mode 100644 apps/ofrep/vitest.config.ts diff --git a/apps/ofrep/.gitignore b/apps/ofrep/.gitignore new file mode 100644 index 000000000..43370fa91 --- /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 000000000..0906fe01a --- /dev/null +++ b/apps/ofrep/README.md @@ -0,0 +1,125 @@ +# 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 a SHA-256 hash of the exact credential and source project header. +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 000000000..80e342d66 --- /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 000000000..55df2ccd5 --- /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 000000000..51463a3af --- /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 000000000..517b43aad --- /dev/null +++ b/apps/ofrep/lib/datafile.ts @@ -0,0 +1,160 @@ +import { createHash } 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): 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: createHash('sha256') + .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; +} = {}) { + 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()); + // 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 000000000..bd3fc3911 --- /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 000000000..a77ed4430 --- /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 000000000..4defc4afb --- /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 000000000..bd96f1ac6 --- /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 000000000..980c85072 --- /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 000000000..066c64cd0 --- /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 000000000..34ac92f70 --- /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 000000000..d09e4d203 --- /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 4c180ac9a..945344421 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 b32eddb98..451449735 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'], }, }); From 9ea1dfcaf0e14bdd498129aacb205b977e4c0022 Mon Sep 17 00:00:00 2001 From: Dominik Ferber Date: Sat, 10 Oct 2026 18:23:13 +0300 Subject: [PATCH 2/2] Use keyed hashes for OFREP credential cache entries --- apps/ofrep/README.md | 3 ++- apps/ofrep/lib/datafile.ts | 16 ++++++++++++---- 2 files changed, 14 insertions(+), 5 deletions(-) diff --git a/apps/ofrep/README.md b/apps/ofrep/README.md index 0906fe01a..e37d61e3d 100644 --- a/apps/ofrep/README.md +++ b/apps/ofrep/README.md @@ -99,7 +99,8 @@ optional event stream; clients use polling. ## Cache and limits The in-memory LRU cache holds successful datafiles for five seconds. Entries are -separated by a SHA-256 hash of the exact credential and source project header. +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. diff --git a/apps/ofrep/lib/datafile.ts b/apps/ofrep/lib/datafile.ts index 517b43aad..06753f1f4 100644 --- a/apps/ofrep/lib/datafile.ts +++ b/apps/ofrep/lib/datafile.ts @@ -1,4 +1,4 @@ -import { createHash } from 'node:crypto'; +import { createHmac, randomBytes } from 'node:crypto'; import type { DatafileInput } from '@vercel/flags-core'; import { HttpError, isObject, readJson } from './http'; @@ -26,7 +26,11 @@ function unauthorized(): HttpError { ); } -function credentials(headers: Headers, now: number): Credentials { +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); @@ -56,7 +60,7 @@ function credentials(headers: Headers, now: number): Credentials { authorization: `Bearer ${token}`, projectId, expiresAt, - cacheKey: createHash('sha256') + cacheKey: createHmac('sha256', cacheSecret) .update(JSON.stringify([token, projectId])) .digest('hex'), }; @@ -71,6 +75,10 @@ export function createDatafileLoader({ 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>(); @@ -131,7 +139,7 @@ export function createDatafileLoader({ } return async (request: Request): Promise => { - const auth = credentials(request.headers, now()); + 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 =