Skip to content
Merged
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
8 changes: 8 additions & 0 deletions .changeset/fix-sdk-key-detection.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
"@vercel/prepare-flags-definitions": patch
"@vercel/flags-core": patch
---

Fix SDK key detection to avoid false positives with third-party identifiers.

The SDK key validation now uses a regex to require the format `vf_server_*` or `vf_client_*` instead of accepting any string starting with `vf_`. This prevents false positives with third-party service identifiers that happen to start with `vf_` (e.g., Stripe identity flow IDs like `vf_1PyHgVLpWuMxVFx...`).
18 changes: 9 additions & 9 deletions packages/adapter-vercel/src/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ describe('createVercelAdapter', () => {
originalFlagsSecret = process.env.FLAGS_SECRET;
originalFlags = process.env.FLAGS;
process.env.FLAGS_SECRET = 'a'.repeat(32);
process.env.FLAGS = 'vf_test_sdk_key';
process.env.FLAGS = 'vf_server_test_sdk_key';
});

afterAll(() => {
Expand All @@ -89,18 +89,18 @@ describe('createVercelAdapter', () => {
expect(amended).toHaveProperty('decide');
expect(amended).toHaveProperty('origin', {
provider: 'vercel',
sdkKey: 'vf_test_sdk_key',
sdkKey: 'vf_server_test_sdk_key',
} satisfies Origin);
});

it('returns origin when created with sdkKey string', () => {
const adapter = createVercelAdapter('vf_my_sdk_key');
const adapter = createVercelAdapter('vf_client_my_sdk_key');

const amended = adapter();
expect(amended).toHaveProperty('decide');
expect(amended).toHaveProperty('origin', {
provider: 'vercel',
sdkKey: 'vf_my_sdk_key',
sdkKey: 'vf_client_my_sdk_key',
} satisfies Origin);
});

Expand All @@ -124,7 +124,7 @@ describe('when used with getProviderData', () => {

beforeAll(() => {
originalFlags = process.env.FLAGS;
process.env.FLAGS = 'vf_test_sdk_key';
process.env.FLAGS = 'vf_server_test_sdk_key';
});

afterAll(() => {
Expand Down Expand Up @@ -181,7 +181,7 @@ describe('vercelAdapter', () => {
originalFlagsSecret = process.env.FLAGS_SECRET;
originalFlags = process.env.FLAGS;
process.env.FLAGS_SECRET = 'a'.repeat(32);
process.env.FLAGS = 'vf_test_sdk_key';
process.env.FLAGS = 'vf_server_test_sdk_key';

resetDefaultFlagsClient();
resetDefaultVercelAdapter();
Expand Down Expand Up @@ -229,16 +229,16 @@ describe('vercelAdapter', () => {
const testFlag = flag({ key: 'test-flag', adapter: vercelAdapter() });
expect(testFlag.origin).toEqual({
provider: 'vercel',
sdkKey: 'vf_test_sdk_key',
sdkKey: 'vf_server_test_sdk_key',
} satisfies Origin);
});

it('sets vercel origin when using adapter created with sdkKey', () => {
const adapter = createVercelAdapter('vf_my_sdk_key');
const adapter = createVercelAdapter('vf_client_my_sdk_key');
const testFlag = flag({ key: 'test-flag', adapter: adapter() });
expect(testFlag.origin).toEqual({
provider: 'vercel',
sdkKey: 'vf_my_sdk_key',
sdkKey: 'vf_client_my_sdk_key',
} satisfies Origin);
});
});
Expand Down
43 changes: 43 additions & 0 deletions packages/prepare-flags-definitions/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
# `@vercel/prepare-flags-definitions`

A build-time utility for [Vercel Flags](https://vercel.com/docs/flags/vercel-flags) that fetches flag definitions and bundles them into a synthetic `@vercel/flags-definitions` package inside `node_modules`. This allows `@vercel/flags-core` to access flag definitions instantly at runtime, even when the network is unavailable.

This package is used by the Vercel CLI and other build tools. You typically do not need to install it directly.

## Installation

```bash
npm i @vercel/prepare-flags-definitions
```

## Usage

```ts
import { prepareFlagsDefinitions } from '@vercel/prepare-flags-definitions';

const result = await prepareFlagsDefinitions({
cwd: process.cwd(),
env: process.env,
userAgentSuffix: 'my-build-tool/1.0.0',
});

if (result.created) {
console.log(`Bundled definitions for ${result.sdkKeysCount} SDK keys`);
} else {
console.log(`No definitions created: ${result.reason}`);
}
```

## How It Works

1. Scans environment variables for SDK keys (matching `vf_server_*` or `vf_client_*`)
2. Fetches flag definitions from `flags.vercel.com` for each key
3. Generates an optimized JavaScript module with deduplication and lazy parsing
4. Writes the module to `node_modules/@vercel/flags-definitions/`

At runtime, `@vercel/flags-core` imports this module as a fallback when streaming or polling is unavailable.

## Documentation

- [Embedded Definitions](https://vercel.com/docs/flags/vercel-flags/sdks/core#embedded-definitions)
- [Vercel Flags](https://vercel.com/docs/flags/vercel-flags)
82 changes: 69 additions & 13 deletions packages/prepare-flags-definitions/src/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,33 +8,33 @@ import {

describe('hashSdkKey', () => {
it('returns a SHA-256 hex digest', () => {
const hash = hashSdkKey('vf_test_key');
const hash = hashSdkKey('vf_server_test_key');
expect(hash).toMatch(/^[a-f0-9]{64}$/);
});

it('returns the same hash for the same input', () => {
expect(hashSdkKey('vf_abc')).toBe(hashSdkKey('vf_abc'));
expect(hashSdkKey('vf_server_abc')).toBe(hashSdkKey('vf_server_abc'));
});

it('returns different hashes for different inputs', () => {
expect(hashSdkKey('vf_abc')).not.toBe(hashSdkKey('vf_xyz'));
expect(hashSdkKey('vf_server_abc')).not.toBe(hashSdkKey('vf_client_xyz'));
});
});

describe('generateDefinitionsModule', () => {
it('generates a valid JS module', () => {
const sdkKeys = ['vf_key1'];
const sdkKeys = ['vf_server_key1'];
const values = [{ flag_a: { value: true } }];
const result = generateDefinitionsModule(sdkKeys, values);

expect(result).toContain('const memo');
expect(result).toContain('export function get(hashedSdkKey)');
expect(result).toContain('export const version');
expect(result).toContain(hashSdkKey('vf_key1'));
expect(result).toContain(hashSdkKey('vf_server_key1'));
});

it('deduplicates identical definitions', () => {
const sdkKeys = ['vf_key1', 'vf_key2'];
const sdkKeys = ['vf_server_key1', 'vf_client_key2'];
const sharedDef = { flag_a: { value: true } };
const values = [sharedDef, sharedDef];
const result = generateDefinitionsModule(sdkKeys, values);
Expand All @@ -44,7 +44,7 @@ describe('generateDefinitionsModule', () => {
});

it('keeps separate definitions when values differ', () => {
const sdkKeys = ['vf_key1', 'vf_key2'];
const sdkKeys = ['vf_server_key1', 'vf_client_key2'];
const values = [{ flag_a: { value: true } }, { flag_b: { value: false } }];
const result = generateDefinitionsModule(sdkKeys, values);

Expand All @@ -53,12 +53,16 @@ describe('generateDefinitionsModule', () => {
});

it('maps each SDK key hash to the correct definition index', () => {
const sdkKeys = ['vf_key1', 'vf_key2'];
const sdkKeys = ['vf_server_key1', 'vf_client_key2'];
const values = [{ flag_a: true }, { flag_b: false }];
const result = generateDefinitionsModule(sdkKeys, values);

expect(result).toContain(`${JSON.stringify(hashSdkKey('vf_key1'))}: _d0`);
expect(result).toContain(`${JSON.stringify(hashSdkKey('vf_key2'))}: _d1`);
expect(result).toContain(
`${JSON.stringify(hashSdkKey('vf_server_key1'))}: _d0`,
);
expect(result).toContain(
`${JSON.stringify(hashSdkKey('vf_client_key2'))}: _d1`,
);
});

it('handles empty input', () => {
Expand Down Expand Up @@ -86,7 +90,7 @@ describe('prepareFlagsDefinitions', () => {

const result = await prepareFlagsDefinitions({
cwd: '/tmp/test-definitions',
env: { FLAGS_SECRET: 'vf_test_key_123' },
env: { FLAGS_SECRET: 'vf_server_test_key_123' },
fetch: mockFetch,
});

Expand All @@ -101,7 +105,7 @@ describe('prepareFlagsDefinitions', () => {

await prepareFlagsDefinitions({
cwd: '/tmp/test-ua',
env: { FLAGS_SECRET: 'vf_test_key' },
env: { FLAGS_SECRET: 'vf_server_test_key' },
fetch: mockFetch,
});

Expand All @@ -119,7 +123,7 @@ describe('prepareFlagsDefinitions', () => {

await prepareFlagsDefinitions({
cwd: '/tmp/test-ua',
env: { FLAGS_SECRET: 'vf_test_key' },
env: { FLAGS_SECRET: 'vf_client_test_key' },
userAgentSuffix: 'vercel-cli/35.0.0',
fetch: mockFetch,
});
Expand All @@ -129,4 +133,56 @@ describe('prepareFlagsDefinitions', () => {
`@vercel/prepare-flags-definitions/${pkgVersion} vercel-cli/35.0.0`,
);
});

it('ignores third-party identifiers that start with vf_ but are not SDK keys', async () => {
const mockFetch = vi.fn();

const result = await prepareFlagsDefinitions({
cwd: '/tmp/test-third-party',
env: {
STRIPE_FLOW_ID: 'vf_1PyHgVLpWuMxVFxAbCdEfGhIjKlMn',
STRIPE_LIVE_ID: 'vf_live_test_12345',
OTHER_SERVICE: 'vf_something_else',
},
fetch: mockFetch,
});

expect(result).toEqual({ created: false, reason: 'no-sdk-keys' });
expect(mockFetch).not.toHaveBeenCalled();
});

it('extracts SDK keys from flags: connection string format', async () => {
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve({ flag_a: { value: true } }),
});

const result = await prepareFlagsDefinitions({
cwd: '/tmp/test-flags-format',
env: {
FLAGS_CONNECTION: 'flags:sdkKey=vf_server_my_key&other=value',
},
fetch: mockFetch,
});

expect(result).toEqual({ created: true, sdkKeysCount: 1 });
expect(mockFetch).toHaveBeenCalledTimes(1);
const headers = mockFetch.mock.calls[0]?.[1]?.headers;
expect(headers.authorization).toBe('Bearer vf_server_my_key');
});

it('ignores invalid SDK keys in flags: connection string', async () => {
const mockFetch = vi.fn();

const result = await prepareFlagsDefinitions({
cwd: '/tmp/test-invalid-flags',
env: {
FLAGS_CONNECTION: 'flags:sdkKey=vf_invalid_key&other=value',
},
fetch: mockFetch,
});

expect(result).toEqual({ created: false, reason: 'no-sdk-keys' });
expect(mockFetch).not.toHaveBeenCalled();
});
});
14 changes: 11 additions & 3 deletions packages/prepare-flags-definitions/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,14 @@ export function hashSdkKey(sdkKey: string): string {
return createHash('sha256').update(sdkKey).digest('hex');
}

/**
* Regex to match valid Vercel Flags SDK keys.
* SDK keys must follow the format: vf_server_* or vf_client_*
* This avoids false positives with third-party identifiers that happen
* to start with 'vf_' (e.g., Stripe identity flow IDs like 'vf_1PyH...').
*/
const SDK_KEY_REGEX = /^vf_(?:server|client)_/;

/**
* Generates a JS module with deduplicated, lazily-parsed definitions.
*
Expand Down Expand Up @@ -131,16 +139,16 @@ export async function prepareFlagsDefinitions(options: {
output?.debug('vercel-flags: checking env vars for SDK Keys');

// Collect unique SDK keys from environment variables
// Supports both direct SDK keys (vf_ prefix) and flags: format
// Supports both direct SDK keys (vf_server_*/vf_client_*) and flags: format
const sdkKeys = Array.from(
Object.values(env).reduce<Set<string>>((acc, value) => {
if (typeof value === 'string') {
if (value.startsWith('vf_')) {
if (SDK_KEY_REGEX.test(value)) {
acc.add(value);
} else if (value.startsWith('flags:')) {
const params = new URLSearchParams(value.slice('flags:'.length));
const sdkKey = params.get('sdkKey');
if (sdkKey?.startsWith('vf_')) {
if (sdkKey && SDK_KEY_REGEX.test(sdkKey)) {
acc.add(sdkKey);
}
}
Expand Down
10 changes: 5 additions & 5 deletions packages/vercel-flags-core/src/black-box.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ vi.mock('./lib/report-value', () => ({
internalReportValue: vi.fn(),
}));

const sdkKey = 'vf_fake';
const sdkKey = 'vf_server_fake';
const fetchMock = vi.fn<typeof fetch>();

/**
Expand Down Expand Up @@ -80,21 +80,21 @@ function makeBundled(
}

const ingestRequestHeaders = Object.freeze({
Authorization: 'Bearer vf_fake',
Authorization: 'Bearer vf_server_fake',
'Content-Type': 'application/json',
'User-Agent': `VercelFlagsCore/${version}`,
'X-Vercel-Env': 'production',
});

const streamRequestHeaders = Object.freeze({
Authorization: 'Bearer vf_fake',
Authorization: 'Bearer vf_server_fake',
'User-Agent': `VercelFlagsCore/${version}`,
'X-Retry-Attempt': '0',
'X-Vercel-Env': 'production',
});

const datafileRequestHeaders = Object.freeze({
Authorization: 'Bearer vf_fake',
Authorization: 'Bearer vf_server_fake',
'User-Agent': `VercelFlagsCore/${version}`,
'X-Vercel-Env': 'production',
});
Expand Down Expand Up @@ -168,7 +168,7 @@ describe('Controller (black-box)', () => {

it('should accept valid SDK key', () => {
expect(() =>
createClient('vf_valid_key', {
createClient('vf_server_valid_key', {
fetch: fetchMock,
stream: false,
polling: false,
Expand Down
Loading
Loading