Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/ofrep/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
*.tsbuildinfo
126 changes: 126 additions & 0 deletions apps/ofrep/README.md
Original file line number Diff line number Diff line change
@@ -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 <token>`.
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.
13 changes: 13 additions & 0 deletions apps/ofrep/app/ofrep/v1/evaluate/flags/[key]/route.ts
Original file line number Diff line number Diff line change
@@ -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);
}
9 changes: 9 additions & 0 deletions apps/ofrep/app/ofrep/v1/evaluate/flags/route.ts
Original file line number Diff line number Diff line change
@@ -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);
}
52 changes: 52 additions & 0 deletions apps/ofrep/app/route.ts
Original file line number Diff line number Diff line change
@@ -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 <token>.
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 <base-url> and banner with your values.

SDK key authentication:
Set FLAGS_SDK_KEY to your raw Vercel Flags SDK key.

curl '<base-url>/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 '<base-url>/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',
},
});
}
168 changes: 168 additions & 0 deletions apps/ofrep/lib/datafile.ts
Original file line number Diff line number Diff line change
@@ -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<string, Entry>();
const pending = new Map<string, Promise<DatafileInput>>();

async function load(auth: Credentials): Promise<DatafileInput> {
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<DatafileInput> => {
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;
};
}
Loading
Loading