diff --git a/.changeset/twelve-shoes-behave.md b/.changeset/twelve-shoes-behave.md new file mode 100644 index 00000000..4dbe53f8 --- /dev/null +++ b/.changeset/twelve-shoes-behave.md @@ -0,0 +1,13 @@ +--- +"@flags-sdk/vercel": minor +"@vercel/prepare-flags-definitions": minor +"@vercel/flags-core": minor +--- + +Add OIDC authentication support for Vercel Flags clients and generated flag definitions. + +`@vercel/flags-core` can now create clients without an SDK key and authenticate with a Vercel OIDC token, while still supporting SDK keys and connection strings. Bundled definitions can be looked up by SDK key hash or OIDC project ID. + +`@vercel/prepare-flags-definitions` now collects both SDK keys and `VERCEL_OIDC_TOKEN`, fetches definitions for each auth entry, deduplicates identical definitions across SDK keys and OIDC project IDs, and writes generated maps keyed by SDK key hash or project ID. + +`@flags-sdk/vercel` now supports provider data lookup for Vercel flag origins that do not include an SDK key, allowing OIDC-backed clients to resolve project metadata. diff --git a/packages/adapter-vercel/src/index.ts b/packages/adapter-vercel/src/index.ts index db495de3..b6e6c98a 100644 --- a/packages/adapter-vercel/src/index.ts +++ b/packages/adapter-vercel/src/index.ts @@ -23,10 +23,10 @@ export type VercelAdapterDeclaration = Omit< */ export function createVercelAdapter( // usually a connection string, but can also be a pre-configured FlagsClient - sdkKeyOrFlagsClient: string | FlagsClient, + sdkKeyOrFlagsClient?: string | FlagsClient, ) { const flagsClient = - typeof sdkKeyOrFlagsClient === 'string' + typeof sdkKeyOrFlagsClient === 'string' || sdkKeyOrFlagsClient === undefined ? createClient(sdkKeyOrFlagsClient) : sdkKeyOrFlagsClient; @@ -86,9 +86,9 @@ export function vercelAdapter(): Adapter< return defaultVercelAdapter(); } -const flagsClients = new Map(); +const flagsClients = new Map(); -function getOrCreateClient(sdkKey: string): FlagsClient { +function getOrCreateClient(sdkKey?: string): FlagsClient { let client = flagsClients.get(sdkKey); if (!client) { client = createClient(sdkKey); @@ -99,14 +99,12 @@ function getOrCreateClient(sdkKey: string): FlagsClient { function isVercelOrigin( origin: unknown, -): origin is { provider: 'vercel'; sdkKey: string } { +): origin is { provider: 'vercel'; sdkKey?: string } { return ( typeof origin === 'object' && origin !== null && 'provider' in origin && - (origin as Record).provider === 'vercel' && - 'sdkKey' in origin && - typeof (origin as Record).sdkKey === 'string' + (origin as Record).provider === 'vercel' ); } @@ -122,14 +120,14 @@ export async function getProviderData( .filter((i): i is KeyedFlagDefinitionType => !Array.isArray(i)); // Collect unique sdkKeys and resolve their projectIds - const sdkKeys = new Set(); + const sdkKeys = new Set(); for (const d of flagDefs) { if (isVercelOrigin(d.origin)) { sdkKeys.add(d.origin.sdkKey); } } - const projectIdBySdkKey = new Map(); + const projectIdBySdkKey = new Map(); await Promise.all( Array.from(sdkKeys).map(async (sdkKey) => { const client = getOrCreateClient(sdkKey); diff --git a/packages/prepare-flags-definitions/README.md b/packages/prepare-flags-definitions/README.md index 3a84c9cc..98c62ae8 100644 --- a/packages/prepare-flags-definitions/README.md +++ b/packages/prepare-flags-definitions/README.md @@ -22,7 +22,7 @@ const result = await prepareFlagsDefinitions({ }); if (result.created) { - console.log(`Bundled definitions for ${result.sdkKeysCount} SDK keys`); + console.log(`Bundled definitions for ${result.entryCount} SDK keys`); } else { console.log(`No definitions created: ${result.reason}`); } diff --git a/packages/prepare-flags-definitions/src/index.test.ts b/packages/prepare-flags-definitions/src/index.test.ts index 2dc557a2..d61445d8 100644 --- a/packages/prepare-flags-definitions/src/index.test.ts +++ b/packages/prepare-flags-definitions/src/index.test.ts @@ -1,11 +1,23 @@ +import { readFile } from 'node:fs/promises'; import { describe, expect, it, vi } from 'vitest'; import { version as pkgVersion } from '../package.json'; import { generateDefinitionsModule, + getProjectIdFromOidcToken, hashSdkKey, prepareFlagsDefinitions, } from './index'; +function createOidcToken(projectId: string): string { + const header = Buffer.from(JSON.stringify({ alg: 'none' })).toString( + 'base64url', + ); + const payload = Buffer.from( + JSON.stringify({ project_id: projectId, exp: 4_102_444_800 }), + ).toString('base64url'); + return `${header}.${payload}.signature`; +} + describe('hashSdkKey', () => { it('returns a SHA-256 hex digest', () => { const hash = hashSdkKey('vf_server_test_key'); @@ -21,80 +33,133 @@ describe('hashSdkKey', () => { }); }); +describe('getProjectIdFromOidcToken', () => { + it('reads the project_id claim', () => { + expect(getProjectIdFromOidcToken(createOidcToken('prj_test'))).toBe( + 'prj_test', + ); + }); +}); + describe('generateDefinitionsModule', () => { it('generates a valid JS module', () => { - const sdkKeys = ['vf_server_key1']; - const values = [{ flag_a: { value: true } }]; - const result = generateDefinitionsModule(sdkKeys, values); + const result = generateDefinitionsModule( + [{ key: 'vf_server_key1', definitions: { flag_a: { value: true } } }], + undefined, + ); expect(result).toContain('const memo'); - expect(result).toContain('export function get(hashedSdkKey)'); + expect(result).toContain('export function get(key)'); expect(result).toContain('export const version'); - expect(result).toContain(hashSdkKey('vf_server_key1')); + expect(result).toContain('vf_server_key1'); }); it('deduplicates identical definitions', () => { - const sdkKeys = ['vf_server_key1', 'vf_client_key2']; const sharedDef = { flag_a: { value: true } }; - const values = [sharedDef, sharedDef]; - const result = generateDefinitionsModule(sdkKeys, values); + const result = generateDefinitionsModule( + [ + { key: 'vf_server_key1', definitions: sharedDef }, + { key: 'vf_client_key2', definitions: sharedDef }, + ], + undefined, + ); const memoMatches = result.match(/const _d\d+ = memo/g); expect(memoMatches).toHaveLength(1); }); it('keeps separate definitions when values differ', () => { - const sdkKeys = ['vf_server_key1', 'vf_client_key2']; - const values = [{ flag_a: { value: true } }, { flag_b: { value: false } }]; - const result = generateDefinitionsModule(sdkKeys, values); + const result = generateDefinitionsModule( + [ + { key: 'vf_server_key1', definitions: { flag_a: { value: true } } }, + { key: 'vf_client_key2', definitions: { flag_b: { value: false } } }, + ], + undefined, + ); const memoMatches = result.match(/const _d\d+ = memo/g); expect(memoMatches).toHaveLength(2); }); it('maps each SDK key hash to the correct definition index', () => { - 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_server_key1'))}: _d0`, - ); - expect(result).toContain( - `${JSON.stringify(hashSdkKey('vf_client_key2'))}: _d1`, + const result = generateDefinitionsModule( + [ + { key: 'vf_server_key1', definitions: { flag_a: true } }, + { key: 'vf_client_key2', definitions: { flag_b: false } }, + ], + undefined, ); + + expect(result).toContain(`${JSON.stringify('vf_server_key1')}: _d0`); + expect(result).toContain(`${JSON.stringify('vf_client_key2')}: _d1`); }); it('handles empty input', () => { - const result = generateDefinitionsModule([], []); + const result = generateDefinitionsModule([], undefined); expect(result).toContain('const map = {'); - expect(result).toContain('export function get(hashedSdkKey)'); + expect(result).toContain('export function get(key)'); + }); + + it('deduplicates definitions across SDK keys and OIDC project IDs', () => { + const sharedDef = { flag_a: { value: true } }; + const result = generateDefinitionsModule( + [ + { key: 'vf_server_key1', definitions: sharedDef }, + { key: 'prj_test', definitions: sharedDef }, + ], + undefined, + ); + + const memoMatches = result.match(/const _d\d+ = memo/g); + expect(memoMatches).toHaveLength(1); + expect(result).toContain(`${JSON.stringify('vf_server_key1')}: _d0`); + expect(result).toContain(`${JSON.stringify('prj_test')}: _d0`); }); }); describe('prepareFlagsDefinitions', () => { - it('returns { created: false, reason: "no-sdk-keys" } when no SDK keys in env', async () => { + it('returns { created: false, reason: "no-flags-entries" } when no flags auth is in env', async () => { const result = await prepareFlagsDefinitions({ cwd: '/tmp/test', env: { SOME_VAR: 'hello' }, }); - expect(result).toEqual({ created: false, reason: 'no-sdk-keys' }); + expect(result).toEqual({ created: false, reason: 'no-flags-entries' }); }); - it('returns { created: true, sdkKeysCount: N } when definitions are created', async () => { + it('returns { created: true, entryCount: N } when definitions are created', async () => { const mockFetch = async () => new Response(JSON.stringify({ flag_a: { value: true } }), { status: 200, }); + const cwd = '/tmp/test-definitions'; const result = await prepareFlagsDefinitions({ - cwd: '/tmp/test-definitions', + cwd, env: { FLAGS_SECRET: 'vf_server_test_key_123' }, fetch: mockFetch, }); - expect(result).toEqual({ created: true, sdkKeysCount: 1 }); + expect(result).toEqual({ created: true, entryCount: 1 }); + const definitionsJs = await readFile( + `${cwd}/node_modules/@vercel/flags-definitions/index.js`, + 'utf8', + ); + expect(definitionsJs).toMatchInlineSnapshot(` + "const memo = (fn) => { let cached; return () => (cached ??= fn()); }; + + const _d0 = memo(() => JSON.parse("{\\"flag_a\\":{\\"value\\":true}}")); + + const map = { + "faab116281fa4201059a73f3ca8b7cad7fce9e1132988008784883fa2c78d64a": _d0, + }; + + export function get(key) { + return map[key]?.() ?? null; + } + + export const version = "1.0.1";" + `); }); it('sends default user-agent with package version', async () => { @@ -147,7 +212,7 @@ describe('prepareFlagsDefinitions', () => { fetch: mockFetch, }); - expect(result).toEqual({ created: false, reason: 'no-sdk-keys' }); + expect(result).toEqual({ created: false, reason: 'no-flags-entries' }); expect(mockFetch).not.toHaveBeenCalled(); }); @@ -157,18 +222,38 @@ describe('prepareFlagsDefinitions', () => { json: () => Promise.resolve({ flag_a: { value: true } }), }); + const cwd = '/tmp/test-flags-format'; const result = await prepareFlagsDefinitions({ - cwd: '/tmp/test-flags-format', + cwd, env: { FLAGS_CONNECTION: 'flags:sdkKey=vf_server_my_key&other=value', }, fetch: mockFetch, }); - expect(result).toEqual({ created: true, sdkKeysCount: 1 }); + expect(result).toEqual({ created: true, entryCount: 1 }); expect(mockFetch).toHaveBeenCalledTimes(1); const headers = mockFetch.mock.calls[0]?.[1]?.headers; expect(headers.authorization).toBe('Bearer vf_server_my_key'); + const definitionsJs = await readFile( + `${cwd}/node_modules/@vercel/flags-definitions/index.js`, + 'utf8', + ); + expect(definitionsJs).toMatchInlineSnapshot(` + "const memo = (fn) => { let cached; return () => (cached ??= fn()); }; + + const _d0 = memo(() => JSON.parse("{\\"flag_a\\":{\\"value\\":true}}")); + + const map = { + "3790790d2dc9b23c4539a9f3c49eb5820e4216daebdd7eeee9136f3ceccc31a3": _d0, + }; + + export function get(key) { + return map[key]?.() ?? null; + } + + export const version = "1.0.1";" + `); }); it('ignores invalid SDK keys in flags: connection string', async () => { @@ -182,7 +267,137 @@ describe('prepareFlagsDefinitions', () => { fetch: mockFetch, }); - expect(result).toEqual({ created: false, reason: 'no-sdk-keys' }); + expect(result).toEqual({ created: false, reason: 'no-flags-entries' }); expect(mockFetch).not.toHaveBeenCalled(); }); + + it('stores OIDC definitions under the token project_id', async () => { + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ flag_a: { value: true } }), + }); + + const cwd = '/tmp/test-oidc-definitions'; + const result = await prepareFlagsDefinitions({ + cwd, + env: { VERCEL_OIDC_TOKEN: createOidcToken('prj_oidc_test') }, + fetch: mockFetch, + }); + + expect(result).toEqual({ created: true, entryCount: 1 }); + expect(mockFetch).toHaveBeenCalledTimes(1); + const headers = mockFetch.mock.calls[0]?.[1]?.headers; + expect(headers.authorization).toBe( + `Bearer ${createOidcToken('prj_oidc_test')}`, + ); + + const definitionsJs = await readFile( + `${cwd}/node_modules/@vercel/flags-definitions/index.js`, + 'utf8', + ); + expect(definitionsJs).toMatchInlineSnapshot(` + "const memo = (fn) => { let cached; return () => (cached ??= fn()); }; + + const _d0 = memo(() => JSON.parse("{\\"flag_a\\":{\\"value\\":true}}")); + + const map = { + "prj_oidc_test": _d0, + }; + + export function get(key) { + return map[key]?.() ?? null; + } + + export const version = "1.0.1";" + `); + }); + + it('stores OIDC definitions alongside SDK Keys', async () => { + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ flag_a: { value: true } }), + }); + + const cwd = '/tmp/test-oidc-sdk-key-mix'; + const result = await prepareFlagsDefinitions({ + cwd, + env: { + VERCEL_OIDC_TOKEN: createOidcToken('prj_oidc_test'), + FLAGS: 'vf_server_test_key_123', + }, + fetch: mockFetch, + }); + + expect(result).toEqual({ created: true, entryCount: 2 }); + expect(mockFetch).toHaveBeenCalledTimes(2); + + const definitionsJs = await readFile( + `${cwd}/node_modules/@vercel/flags-definitions/index.js`, + 'utf8', + ); + expect(definitionsJs).toMatchInlineSnapshot(` + "const memo = (fn) => { let cached; return () => (cached ??= fn()); }; + + const _d0 = memo(() => JSON.parse("{\\"flag_a\\":{\\"value\\":true}}")); + + const map = { + "faab116281fa4201059a73f3ca8b7cad7fce9e1132988008784883fa2c78d64a": _d0, + "prj_oidc_test": _d0, + }; + + export function get(key) { + return map[key]?.() ?? null; + } + + export const version = "1.0.1";" + `); + }); + + it('stores OIDC definitions alongside SDK Keys with different datafiles', async () => { + const mockFetch = vi + .fn() + .mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ flag_a: { value: true } }), + }) + .mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ flag_b: { value: true } }), + }); + + const cwd = '/tmp/test-oidc-sdk-key-mix'; + const result = await prepareFlagsDefinitions({ + cwd, + env: { + VERCEL_OIDC_TOKEN: createOidcToken('prj_oidc_test'), + FLAGS: 'vf_server_test_key_123', + }, + fetch: mockFetch, + }); + + expect(result).toEqual({ created: true, entryCount: 2 }); + expect(mockFetch).toHaveBeenCalledTimes(2); + + const definitionsJs = await readFile( + `${cwd}/node_modules/@vercel/flags-definitions/index.js`, + 'utf8', + ); + expect(definitionsJs).toMatchInlineSnapshot(` + "const memo = (fn) => { let cached; return () => (cached ??= fn()); }; + + const _d0 = memo(() => JSON.parse("{\\"flag_a\\":{\\"value\\":true}}")); + const _d1 = memo(() => JSON.parse("{\\"flag_b\\":{\\"value\\":true}}")); + + const map = { + "faab116281fa4201059a73f3ca8b7cad7fce9e1132988008784883fa2c78d64a": _d0, + "prj_oidc_test": _d1, + }; + + export function get(key) { + return map[key]?.() ?? null; + } + + export const version = "1.0.1";" + `); + }); }); diff --git a/packages/prepare-flags-definitions/src/index.ts b/packages/prepare-flags-definitions/src/index.ts index 47798a43..bc5d18e9 100644 --- a/packages/prepare-flags-definitions/src/index.ts +++ b/packages/prepare-flags-definitions/src/index.ts @@ -14,8 +14,8 @@ export interface Output { } export type PrepareFlagsDefinitionsResult = - | { created: false; reason: 'no-sdk-keys' } - | { created: true; sdkKeysCount: number }; + | { created: false; reason: 'no-flags-entries' } + | { created: true; entryCount: number }; /** * Obfuscates SDK key for logging (shows first 18 chars) @@ -34,13 +34,128 @@ export function hashSdkKey(sdkKey: string): string { return createHash('sha256').update(sdkKey).digest('hex'); } +export function getProjectIdFromOidcToken(oidcToken: string) { + const tokenParts = oidcToken.split('.'); + if (tokenParts.length !== 3 || !tokenParts[1]) { + return; + } + + const payload = JSON.parse( + Buffer.from(tokenParts[1], 'base64url').toString('utf8'), + ) as { project_id?: unknown }; + + if (typeof payload.project_id !== 'string' || !payload.project_id) { + return; + } + + return payload.project_id; +} + +type DefinitionsEntry = { + key: string; + definitions: BundledDefinitions; +}; + +type MapEntry = { + key: string; + value: string; +}; + /** - * 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...'). + * Creates js constants pointing to memoized deduplicated flag definitions. + * Output format: + * ```js + * const _d0 = memo(() => JSON.parse('...')); + * const _d1 = memo(() => JSON.parse('...')); + * ```` */ -const SDK_KEY_REGEX = /^vf_(?:server|client)_/; +function generateDefinitionConstants( + lines: string[], + entries: DefinitionsEntry[], +): MapEntry[] { + const stringToConst = new Map(); + + return entries.map((entry) => { + const stringified = JSON.stringify(entry.definitions); + let definitionConst = stringToConst.get(stringified); + + if (!definitionConst) { + definitionConst = `_d${stringToConst.size}`; + stringToConst.set(stringified, definitionConst); + lines.push( + `const ${definitionConst} = memo(() => JSON.parse(${JSON.stringify(stringified)}));`, + ); + } + + return { key: entry.key, value: definitionConst }; + }); +} + +/** + * Creates a js map and getter function for exposing key value pairs. + * Output format: + * ```js + * const map = { "": _d0 }; + * export function get(key) { return map[key]?.() ?? null; } + * ```` + */ +function generateMap(lines: string[], entries: MapEntry[]): void { + lines.push(''); + lines.push('const map = {'); + for (const entry of entries) { + lines.push(` ${JSON.stringify(entry.key)}: ${entry.value},`); + } + lines.push('};'); + lines.push(''); + lines.push('export function get(key) {'); + lines.push(' return map[key]?.() ?? null;'); + lines.push('}'); +} + +async function fetchDatafile( + token: string, + env: Record, + fetchFn: typeof globalThis.fetch, + userAgentSuffix?: string, +): Promise { + const headers: Record = { + authorization: `Bearer ${token}`, + 'user-agent': [ + `@vercel/prepare-flags-definitions/${PACKAGE_VERSION}`, + userAgentSuffix, + ] + .filter(Boolean) + .join(' '), + }; + + // Add Vercel metadata headers if available + if (env.VERCEL_PROJECT_ID) { + headers['x-vercel-project-id'] = env.VERCEL_PROJECT_ID; + } + if (env.VERCEL_ENV) { + headers['x-vercel-env'] = env.VERCEL_ENV; + } + if (env.VERCEL_DEPLOYMENT_ID) { + headers['x-vercel-deployment-id'] = env.VERCEL_DEPLOYMENT_ID; + } + if (env.VERCEL_REGION) { + headers['x-vercel-region'] = env.VERCEL_REGION; + } + + const res = await fetchFn(`${FLAGS_HOST}/v1/datafile`, { headers }); + + if (!res.ok) { + if (res.status === 404) { + return undefined; + } + + throw new Error( + `Failed to fetch flag definitions for ${obfuscate(token)}: ${res.status} ${res.statusText}`, + ); + } + + return res.json() as Promise; +} /** * Generates a JS module with deduplicated, lazily-parsed definitions. @@ -57,61 +172,92 @@ const SDK_KEY_REGEX = /^vf_(?:server|client)_/; * ``` */ export function generateDefinitionsModule( - sdkKeys: string[], - values: BundledDefinitions[], + entries: DefinitionsEntry[], + output: Output | undefined, ): string { - // Stringify each definition - const stringified = sdkKeys.map((_, i) => JSON.stringify(values[i])); - - // Deduplicate: map unique strings to indices - const uniqueStrings: string[] = []; - const stringToIndex = new Map(); - for (const s of stringified) { - if (!stringToIndex.has(s)) { - stringToIndex.set(s, uniqueStrings.length); - uniqueStrings.push(s); - } - } - - // Map SDK keys to their definition index - const keyToIndex = sdkKeys.map( - (_, i) => stringToIndex.get(stringified[i]!) ?? 0, + output?.debug( + `vercel-flags: writing flag definitions for "${entries.map(({ key }) => obfuscate(key)).join(', ')}"`, ); - // Hash each SDK key - const hashedKeys = sdkKeys.map(hashSdkKey); - - // Generate JS + // generate shared js const lines: string[] = [ 'const memo = (fn) => { let cached; return () => (cached ??= fn()); };', '', ]; - // Add definition constants - for (let i = 0; i < uniqueStrings.length; i++) { - lines.push( - `const _d${i} = memo(() => JSON.parse(${JSON.stringify(uniqueStrings[i])}));`, - ); - } + // generate js const and capture the const names + const generatedDefinitions = generateDefinitionConstants(lines, entries); + + // generate a map wiring keys to const names + generateMap(lines, generatedDefinitions); - lines.push(''); - lines.push('const map = {'); - for (let i = 0; i < sdkKeys.length; i++) { - lines.push(` ${JSON.stringify(hashedKeys[i])}: _d${keyToIndex[i]},`); - } - lines.push('};'); - lines.push(''); - lines.push('export function get(hashedSdkKey) {'); - lines.push(' return map[hashedSdkKey]?.() ?? null;'); - lines.push('}'); - lines.push(''); lines.push( + '', `export const version = ${JSON.stringify(FLAGS_DEFINITIONS_VERSION)};`, ); return lines.join('\n'); } +type FlagEntry = { + type: 'oidcToken' | 'sdkKey'; + key: string; +}; + +/** + * 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)_/; + +/** + * Collect all possible flag entries the need embedding from the environment + */ +function collectFlagEntries( + env: Record, + output: Output | undefined, +): FlagEntry[] { + const entries: FlagEntry[] = []; + + // Collect unique SDK keys from environment variables + // Supports both direct SDK keys (vf_server_*/vf_client_*) and flags: format + const sdkKeys = Array.from( + Object.values(env).reduce>((acc, value) => { + if (typeof value === 'string') { + 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 && SDK_KEY_REGEX.test(sdkKey)) { + acc.add(sdkKey); + } + } + } + return acc; + }, new Set()), + ); + + if (sdkKeys.length > 0) { + output?.debug(`vercel-flags: found ${sdkKeys.length} SDK keys`); + + for (const key of sdkKeys) { + entries.push({ type: 'sdkKey', key }); + } + } + + const oidcToken = env.VERCEL_OIDC_TOKEN; + if (oidcToken && oidcToken?.length > 0) { + output?.debug(`vercel-flags: found OIDC token`); + + entries.push({ type: 'oidcToken', key: oidcToken }); + } + + return entries; +} + /** * Prepares flag definitions by reading SDK keys from environment variables, * fetching definitions from flags.vercel.com, and writing them into a @@ -136,78 +282,49 @@ export async function prepareFlagsDefinitions(options: { output, } = options; - output?.debug('vercel-flags: checking env vars for SDK Keys'); - - // Collect unique SDK keys from environment variables - // Supports both direct SDK keys (vf_server_*/vf_client_*) and flags: format - const sdkKeys = Array.from( - Object.values(env).reduce>((acc, value) => { - if (typeof value === 'string') { - 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 && SDK_KEY_REGEX.test(sdkKey)) { - acc.add(sdkKey); - } - } - } - return acc; - }, new Set()), - ); - - output?.debug(`vercel-flags: found ${sdkKeys.length} SDK keys`); + output?.debug('vercel-flags: checking env vars for SDK Keys and OIDC Token'); - if (sdkKeys.length === 0) { - return { created: false, reason: 'no-sdk-keys' }; + const entries = collectFlagEntries(env, output); + if (entries.length === 0) { + return { created: false, reason: 'no-flags-entries' }; } - // Fetch definitions for each SDK key - const fetchPromise = Promise.all( - sdkKeys.map(async (sdkKey) => { - const headers: Record = { - authorization: `Bearer ${sdkKey}`, - 'user-agent': [ - `@vercel/prepare-flags-definitions/${PACKAGE_VERSION}`, - userAgentSuffix, - ] - .filter(Boolean) - .join(' '), - }; - - // Add Vercel metadata headers if available - if (env.VERCEL_PROJECT_ID) { - headers['x-vercel-project-id'] = env.VERCEL_PROJECT_ID; - } - if (env.VERCEL_ENV) { - headers['x-vercel-env'] = env.VERCEL_ENV; - } - if (env.VERCEL_DEPLOYMENT_ID) { - headers['x-vercel-deployment-id'] = env.VERCEL_DEPLOYMENT_ID; - } - if (env.VERCEL_REGION) { - headers['x-vercel-region'] = env.VERCEL_REGION; + // fetch all datafiles for sdk keys and oidc tokens + const resolvedEntries = await Promise.all( + entries.map(async ({ key, type }) => { + const definitions = await fetchDatafile( + key, + env, + fetchFn, + userAgentSuffix, + ); + if (!definitions) { + return; } - const res = await fetchFn(`${FLAGS_HOST}/v1/datafile`, { headers }); + if (type === 'oidcToken') { + const projectId = getProjectIdFromOidcToken(key); - if (!res.ok) { - throw new Error( - `Failed to fetch flag definitions for ${obfuscate(sdkKey)}: ${res.status} ${res.statusText}`, - ); + if (projectId) { + return { + key: projectId, + definitions, + }; + } } - return res.json() as Promise; + if (type === 'sdkKey') { + return { + key: hashSdkKey(key), + definitions, + }; + } }), ); - const values = output - ? await output.time('vercel-flags: load datafiles', fetchPromise) - : await fetchPromise; + const validEntries = resolvedEntries.filter((entry) => !!entry); - // Generate the JS module - const definitionsJs = generateDefinitionsModule(sdkKeys, values); + const definitionsJs = generateDefinitionsModule(validEntries, output); // Write to node_modules/@vercel/flags-definitions/ const storageDir = join(cwd, 'node_modules', '@vercel', 'flags-definitions'); @@ -216,7 +333,7 @@ export async function prepareFlagsDefinitions(options: { const packageJsonPath = join(storageDir, 'package.json'); const dts = [ - 'export function get(hashedSdkKey: string): Record | null;', + 'export function get(key: string): Record | null;', 'export const version: string;', '', ].join('\n'); @@ -246,9 +363,6 @@ export async function prepareFlagsDefinitions(options: { output?.debug(` → ${indexPath}`); output?.debug(` → ${dtsPath}`); output?.debug(` → ${packageJsonPath}`); - output?.debug( - ` → included definitions for keys "${sdkKeys.map((key) => obfuscate(key)).join(', ')}"`, - ); - return { created: true, sdkKeysCount: sdkKeys.length }; + return { created: true, entryCount: entries.length }; } diff --git a/packages/vercel-flags-core/CLAUDE.md b/packages/vercel-flags-core/CLAUDE.md index 69fb0314..a2409216 100644 --- a/packages/vercel-flags-core/CLAUDE.md +++ b/packages/vercel-flags-core/CLAUDE.md @@ -84,7 +84,7 @@ type FlagsClient = { ```typescript type ControllerOptions = { - sdkKey: string; + sdkKey?: string; datafile?: Datafile; // Initial datafile for immediate reads stream?: boolean | { initTimeoutMs: number }; // default: true (3000ms) polling?: boolean | { intervalMs: number; initTimeoutMs: number }; // default: true (30s interval, 3s timeout) diff --git a/packages/vercel-flags-core/package.json b/packages/vercel-flags-core/package.json index b0350227..943ce449 100644 --- a/packages/vercel-flags-core/package.json +++ b/packages/vercel-flags-core/package.json @@ -64,6 +64,7 @@ }, "dependencies": { "@vercel/functions": "^3.4.3", + "@vercel/oidc": "3.4.0", "jose": "5.2.1", "js-xxhash": "4.0.0" }, diff --git a/packages/vercel-flags-core/src/controller/auth.ts b/packages/vercel-flags-core/src/controller/auth.ts new file mode 100644 index 00000000..2adf2c74 --- /dev/null +++ b/packages/vercel-flags-core/src/controller/auth.ts @@ -0,0 +1,76 @@ +import { getVercelOidcToken } from '@vercel/oidc'; +import { parseSdkKeyFromFlagsConnectionString } from '../utils/sdk-keys'; + +export type BundledDefinitionsLookup = + | { type: 'sdk-key'; sdkKey: string } + | { type: 'project-id'; projectId: string }; + +export interface Auth { + sdkKey?: string; + resolveToken(): Promise; + resolveBundledDefinitionsLookup(): Promise; +} + +function getProjectIdFromOidcToken(oidcToken: string): string { + const tokenParts = oidcToken.split('.'); + if (tokenParts.length !== 3 || !tokenParts[1]) { + throw new Error('@vercel/flags-core: Invalid OIDC token'); + } + + const payload = JSON.parse( + Buffer.from(tokenParts[1], 'base64url').toString('utf8'), + ) as { project_id?: unknown }; + + if (typeof payload.project_id !== 'string' || !payload.project_id) { + throw new Error( + '@vercel/flags-core: Missing project_id claim in OIDC token', + ); + } + + return payload.project_id; +} + +export class Authentication implements Auth { + public readonly sdkKey?: string; + + constructor(sdkKeyOrConnectionString?: string) { + // validate sdk key format + if (sdkKeyOrConnectionString !== undefined) { + if (typeof sdkKeyOrConnectionString !== 'string') { + throw new Error( + `@vercel/flags-core: Invalid sdkKey. Expected string, got ${typeof sdkKeyOrConnectionString}`, + ); + } + + // Parse connection string if needed (e.g., "flags:edgeConfigId=...&sdkKey=vf_xxx") + const parsed = parseSdkKeyFromFlagsConnectionString( + sdkKeyOrConnectionString, + ); + if (!parsed) { + throw new Error('@vercel/flags-core: Missing sdkKey'); + } + + this.sdkKey = parsed; + } + } + + public async resolveToken() { + if (this.sdkKey) { + return this.sdkKey; + } + + return await getVercelOidcToken(); + } + + public async resolveBundledDefinitionsLookup(): Promise { + if (this.sdkKey) { + return { type: 'sdk-key', sdkKey: this.sdkKey }; + } + + const oidcToken = await this.resolveToken(); + return { + type: 'project-id', + projectId: getProjectIdFromOidcToken(oidcToken), + }; + } +} diff --git a/packages/vercel-flags-core/src/controller/bundled-source.ts b/packages/vercel-flags-core/src/controller/bundled-source.ts index 8a7a47de..139dbc79 100644 --- a/packages/vercel-flags-core/src/controller/bundled-source.ts +++ b/packages/vercel-flags-core/src/controller/bundled-source.ts @@ -5,6 +5,7 @@ import type { DatafileInput, } from '../types'; import type { readBundledDefinitions } from '../utils/read-bundled-definitions'; +import type { Auth } from './auth'; /** * Manages loading of bundled flag definitions. @@ -12,17 +13,13 @@ import type { readBundledDefinitions } from '../utils/read-bundled-definitions'; */ export class BundledSource { private promise: Promise | undefined; - private options: { - sdkKey: string; - readBundledDefinitions: typeof readBundledDefinitions; - }; - constructor(options: { - sdkKey: string; - readBundledDefinitions: typeof readBundledDefinitions; - }) { - this.options = options; - } + constructor( + private options: { + auth: Auth; + readBundledDefinitions: typeof readBundledDefinitions; + }, + ) {} /** * Load bundled definitions. @@ -76,7 +73,7 @@ export class BundledSource { private getResult(): Promise { if (!this.promise) { - this.promise = this.options.readBundledDefinitions(this.options.sdkKey); + this.promise = this.options.readBundledDefinitions(this.options.auth); } return this.promise; } diff --git a/packages/vercel-flags-core/src/controller/fetch-datafile.ts b/packages/vercel-flags-core/src/controller/fetch-datafile.ts index b63b01b6..6427e7dd 100644 --- a/packages/vercel-flags-core/src/controller/fetch-datafile.ts +++ b/packages/vercel-flags-core/src/controller/fetch-datafile.ts @@ -1,5 +1,6 @@ import { version } from '../../package.json'; import type { BundledDefinitions } from '../types'; +import type { Auth } from './auth'; const DEFAULT_FETCH_TIMEOUT_MS = 10_000; @@ -8,10 +9,12 @@ const DEFAULT_FETCH_TIMEOUT_MS = 10_000; */ export async function fetchDatafile(options: { host: string; - sdkKey: string; + auth: Auth; fetch: typeof globalThis.fetch; signal?: AbortSignal; }): Promise { + const token = await options.auth.resolveToken(); + const controller = new AbortController(); const timeoutId = setTimeout( () => controller.abort(), @@ -31,7 +34,7 @@ export async function fetchDatafile(options: { try { const res = await options.fetch(`${options.host}/v1/datafile`, { headers: { - Authorization: `Bearer ${options.sdkKey}`, + Authorization: `Bearer ${token}`, 'User-Agent': `VercelFlagsCore/${version}`, ...(process.env.VERCEL_ENV ? { 'X-Vercel-Env': process.env.VERCEL_ENV } diff --git a/packages/vercel-flags-core/src/controller/index.ts b/packages/vercel-flags-core/src/controller/index.ts index ee8bc35c..f979e245 100644 --- a/packages/vercel-flags-core/src/controller/index.ts +++ b/packages/vercel-flags-core/src/controller/index.ts @@ -113,16 +113,6 @@ export class Controller implements ControllerInterface { private unauthorized = false; constructor(options: ControllerOptions) { - if ( - !options.sdkKey || - typeof options.sdkKey !== 'string' || - !options.sdkKey.startsWith('vf_') - ) { - throw new Error( - '@vercel/flags-core: SDK key must be a string starting with "vf_"', - ); - } - this.options = normalizeOptions(options); // Create source modules @@ -134,7 +124,7 @@ export class Controller implements ControllerInterface { this.pollingSource = new PollingSource(this.options); this.bundledSource = new BundledSource({ - sdkKey: this.options.sdkKey, + auth: this.options.auth, readBundledDefinitions, }); @@ -377,7 +367,7 @@ export class Controller implements ControllerInterface { try { const fetched = await fetchDatafile({ host: this.options.host, - sdkKey: this.options.sdkKey, + auth: this.options.auth, fetch: this.options.fetch, }); this.data = tagData(fetched, 'fetched'); @@ -607,7 +597,7 @@ export class Controller implements ControllerInterface { try { const fetched = await fetchDatafile({ host: this.options.host, - sdkKey: this.options.sdkKey, + auth: this.options.auth, fetch: this.options.fetch, }); return tagData(fetched, 'fetched'); @@ -648,7 +638,7 @@ export class Controller implements ControllerInterface { try { const fetched = await fetchDatafile({ host: this.options.host, - sdkKey: this.options.sdkKey, + auth: this.options.auth, fetch: this.options.fetch, }); this.data = tagData(fetched, 'fetched'); @@ -713,7 +703,7 @@ export class Controller implements ControllerInterface { try { const fetched = await fetchDatafile({ host: this.options.host, - sdkKey: this.options.sdkKey, + auth: this.options.auth, fetch: this.options.fetch, }); this.data = tagData(fetched, 'fetched'); diff --git a/packages/vercel-flags-core/src/controller/normalized-options.ts b/packages/vercel-flags-core/src/controller/normalized-options.ts index 5474e4a3..f17b15a4 100644 --- a/packages/vercel-flags-core/src/controller/normalized-options.ts +++ b/packages/vercel-flags-core/src/controller/normalized-options.ts @@ -1,4 +1,5 @@ import type { DatafileInput, PollingOptions, StreamOptions } from '../types'; +import type { Auth } from './auth'; const DEFAULT_STREAM_INIT_TIMEOUT_MS = 3000; const DEFAULT_POLLING_INTERVAL_MS = 30_000; @@ -9,8 +10,8 @@ const DEFAULT_POLLING_INIT_TIMEOUT_MS = 3_000; * Configuration options for Controller */ export type ControllerOptions = { - /** SDK key for authentication (must start with "vf_") */ - sdkKey: string; + /** Authentication which resolves the token for requests */ + auth: Auth; /** * Initial datafile to use immediately @@ -54,7 +55,7 @@ export type ControllerOptions = { }; export type NormalizedOptions = { - sdkKey: string; + auth: Auth; datafile: DatafileInput | undefined; stream: { enabled: boolean; initTimeoutMs: number }; polling: { enabled: boolean; intervalMs: number; initTimeoutMs: number }; @@ -103,7 +104,7 @@ export function normalizeOptions( } return { - sdkKey: options.sdkKey, + auth: options.auth, datafile: options.datafile, stream, polling, diff --git a/packages/vercel-flags-core/src/controller/polling-source.ts b/packages/vercel-flags-core/src/controller/polling-source.ts index 59c808cb..e39cd191 100644 --- a/packages/vercel-flags-core/src/controller/polling-source.ts +++ b/packages/vercel-flags-core/src/controller/polling-source.ts @@ -1,10 +1,11 @@ import type { DatafileInput } from '../types'; +import type { Auth } from './auth'; import { fetchDatafile } from './fetch-datafile'; import { TypedEmitter } from './typed-emitter'; export type PollingSourceConfig = { host: string; - sdkKey: string; + auth: Auth; polling: { intervalMs: number; }; diff --git a/packages/vercel-flags-core/src/controller/stream-connection.ts b/packages/vercel-flags-core/src/controller/stream-connection.ts index fcf4c156..4019ee21 100644 --- a/packages/vercel-flags-core/src/controller/stream-connection.ts +++ b/packages/vercel-flags-core/src/controller/stream-connection.ts @@ -43,7 +43,8 @@ export type StreamCallbacks = { export type StreamConfig = { host: string; - sdkKey: string; + token?: string; + sdkKey?: string; abortController: AbortController; fetch?: typeof globalThis.fetch; /** Returns the current revision number to send as X-Revision header */ @@ -59,12 +60,11 @@ export async function connectStream( config: StreamConfig, callbacks: StreamCallbacks, ): Promise { - const { - host, - sdkKey, - abortController, - fetch: fetchFn = globalThis.fetch, - } = config; + const { host, abortController, fetch: fetchFn = globalThis.fetch } = config; + const token = config.token ?? config.sdkKey; + if (!token) { + throw new Error('stream: missing auth token'); + } const { onDatafile, onPrimed, onDisconnect } = callbacks; let retryCount = 0; let lastAttemptTime = 0; @@ -115,7 +115,7 @@ export async function connectStream( try { lastAttemptTime = Date.now(); const headers: Record = { - Authorization: `Bearer ${sdkKey}`, + Authorization: `Bearer ${token}`, 'User-Agent': `VercelFlagsCore/${version}`, 'X-Retry-Attempt': String(retryCount), }; diff --git a/packages/vercel-flags-core/src/controller/stream-source.ts b/packages/vercel-flags-core/src/controller/stream-source.ts index 93fba85c..0940d752 100644 --- a/packages/vercel-flags-core/src/controller/stream-source.ts +++ b/packages/vercel-flags-core/src/controller/stream-source.ts @@ -52,27 +52,29 @@ export class StreamSource extends TypedEmitter { ); try { - const promise = connectStream( - { - host: this.options.host, - sdkKey: this.options.sdkKey, - abortController, - fetch: this.options.fetch, - revision: this.revision, - }, - { - onDatafile: (newData) => { - this.emit('data', newData); - this.emit('connected'); + const promise = this.options.auth.resolveToken().then((token) => + connectStream( + { + host: this.options.host, + token, + abortController, + fetch: this.options.fetch, + revision: this.revision, }, - onPrimed: (message) => { - this.emit('primed', message); - this.emit('connected'); + { + onDatafile: (newData) => { + this.emit('data', newData); + this.emit('connected'); + }, + onPrimed: (message) => { + this.emit('primed', message); + this.emit('connected'); + }, + onDisconnect: () => { + this.emit('disconnected'); + }, }, - onDisconnect: () => { - this.emit('disconnected'); - }, - }, + ), ); this.promise = promise; diff --git a/packages/vercel-flags-core/src/create-raw-client.ts b/packages/vercel-flags-core/src/create-raw-client.ts index a9d2494d..e9a773b8 100644 --- a/packages/vercel-flags-core/src/create-raw-client.ts +++ b/packages/vercel-flags-core/src/create-raw-client.ts @@ -45,7 +45,7 @@ export function createCreateRawClient(fns: { origin, }: { controller: ControllerInterface; - origin?: { provider: string; sdkKey: string }; + origin?: { provider: string; sdkKey?: string }; }): FlagsClient { const id = idCount++; controllerInstanceMap.set(id, { diff --git a/packages/vercel-flags-core/src/index.make.test.ts b/packages/vercel-flags-core/src/index.make.test.ts index 3651aa54..89e70e9c 100644 --- a/packages/vercel-flags-core/src/index.make.test.ts +++ b/packages/vercel-flags-core/src/index.make.test.ts @@ -4,8 +4,8 @@ import { make } from './index.make'; // Mock the Controller to avoid real network calls vi.mock('./controller', () => ({ - Controller: vi.fn().mockImplementation(({ sdkKey }) => ({ - sdkKey, + Controller: vi.fn().mockImplementation(({ auth }) => ({ + auth, read: vi.fn().mockResolvedValue({ projectId: 'test', definitions: {}, @@ -20,7 +20,7 @@ vi.mock('./controller', () => ({ import { Controller } from './controller'; function createMockCreateRawClient(): ReturnType { - return vi.fn().mockImplementation(({ dataSource }) => ({ + return vi.fn().mockImplementation(({ controller }) => ({ initialize: vi.fn().mockResolvedValue(undefined), shutdown: vi.fn().mockResolvedValue(undefined), getDatafile: vi.fn().mockResolvedValue({ @@ -38,7 +38,7 @@ function createMockCreateRawClient(): ReturnType { revision: 1, }), evaluate: vi.fn().mockResolvedValue({ value: true, reason: 'static' }), - _dataSource: dataSource, // For testing inspection + _dataSource: controller, // For testing inspection })); } @@ -63,7 +63,7 @@ describe('make', () => { const client = createClient('vf_server_test_key'); expect(Controller).toHaveBeenCalledWith({ - sdkKey: 'vf_server_test_key', + auth: expect.objectContaining({ sdkKey: 'vf_server_test_key' }), }); expect(createRawClient).toHaveBeenCalled(); expect(client).toBeDefined(); @@ -78,7 +78,7 @@ describe('make', () => { const client = createClient(connectionString); expect(Controller).toHaveBeenCalledWith({ - sdkKey: 'vf_client_conn_key', + auth: expect.objectContaining({ sdkKey: 'vf_client_conn_key' }), }); expect(client).toBeDefined(); }); @@ -127,15 +127,16 @@ describe('make', () => { expect(createRawClient).toHaveBeenCalledTimes(1); }); - it('should throw if FLAGS env var is missing when accessed', () => { + it('should create an OIDC-authenticated default client if FLAGS env var is missing', () => { const createRawClient = createMockCreateRawClient(); delete process.env.FLAGS; const { flagsClient } = make(createRawClient); + const _ = flagsClient.evaluate; - expect(() => flagsClient.evaluate).toThrow( - 'flags: Missing environment variable FLAGS', - ); + expect(Controller).toHaveBeenCalledWith({ + auth: expect.objectContaining({ sdkKey: undefined }), + }); }); it('should throw if FLAGS env var has invalid value', () => { @@ -172,7 +173,7 @@ describe('make', () => { const _ = flagsClient.evaluate; expect(Controller).toHaveBeenCalledWith({ - sdkKey: 'vf_server_env_key', + auth: expect.objectContaining({ sdkKey: 'vf_server_env_key' }), }); }); @@ -185,7 +186,7 @@ describe('make', () => { const _ = flagsClient.evaluate; expect(Controller).toHaveBeenCalledWith({ - sdkKey: 'vf_client_flags_key', + auth: expect.objectContaining({ sdkKey: 'vf_client_flags_key' }), }); }); }); @@ -218,7 +219,7 @@ describe('make', () => { // Access with first key const _ = flagsClient.evaluate; expect(Controller).toHaveBeenCalledWith({ - sdkKey: 'vf_server_first_key', + auth: expect.objectContaining({ sdkKey: 'vf_server_first_key' }), }); // Reset and change env @@ -228,7 +229,7 @@ describe('make', () => { // Access again with new key const __ = flagsClient.initialize; expect(Controller).toHaveBeenCalledWith({ - sdkKey: 'vf_client_second_key', + auth: expect.objectContaining({ sdkKey: 'vf_client_second_key' }), }); }); }); diff --git a/packages/vercel-flags-core/src/index.make.ts b/packages/vercel-flags-core/src/index.make.ts index bdb0aef2..1c5aaa5a 100644 --- a/packages/vercel-flags-core/src/index.make.ts +++ b/packages/vercel-flags-core/src/index.make.ts @@ -3,14 +3,14 @@ */ import { Controller, type ControllerOptions } from './controller'; +import { Authentication } from './controller/auth'; import type { createCreateRawClient } from './create-raw-client'; import type { FlagsClient } from './types'; -import { parseSdkKeyFromFlagsConnectionString } from './utils/sdk-keys'; /** * Options for createClient */ -export type CreateClientOptions = Omit; +export type CreateClientOptions = Omit; export function make( createRawClient: ReturnType, @@ -18,7 +18,7 @@ export function make( flagsClient: FlagsClient; resetDefaultFlagsClient: () => void; createClient: >( - sdkKeyOrConnectionString: string, + sdkKeyOrConnectionString?: string, options?: CreateClientOptions, ) => FlagsClient; } { @@ -28,32 +28,16 @@ export function make( // - data source must specify the environment & projectId as sdkKey has that info // - "reuse" functionality relies on the data source having the data for all envs function createClient>( - sdkKeyOrConnectionString: string, + sdkKeyOrConnectionString?: string, options?: CreateClientOptions, ): FlagsClient { - if (!sdkKeyOrConnectionString) - throw new Error('@vercel/flags-core: Missing sdkKey'); - - if (typeof sdkKeyOrConnectionString !== 'string') - throw new Error( - `@vercel/flags-core: Invalid sdkKey. Expected string, got ${typeof sdkKeyOrConnectionString}`, - ); - - // Parse connection string if needed (e.g., "flags:edgeConfigId=...&sdkKey=vf_xxx") - const sdkKey = parseSdkKeyFromFlagsConnectionString( - sdkKeyOrConnectionString, - ); - if (!sdkKey) { - throw new Error( - '@vercel/flags-core: Missing sdkKey in connection string', - ); - } + const auth = new Authentication(sdkKeyOrConnectionString); // sdk key contains the environment - const controller = new Controller({ sdkKey, ...options }); + const controller = new Controller({ auth, ...options }); return createRawClient({ controller, - origin: { provider: 'vercel', sdkKey }, + origin: { provider: 'vercel', sdkKey: auth.sdkKey }, }); } @@ -64,15 +48,7 @@ export function make( const flagsClient: FlagsClient = new Proxy({} as FlagsClient, { get(_, prop) { if (!_defaultFlagsClient) { - if (!process.env.FLAGS) { - throw new Error('flags: Missing environment variable FLAGS'); - } - - const sdkKey = parseSdkKeyFromFlagsConnectionString(process.env.FLAGS); - if (!sdkKey) { - throw new Error('@vercel/flags-core: Missing sdkKey'); - } - _defaultFlagsClient = createClient(sdkKey); + _defaultFlagsClient = createClient(process.env.FLAGS); } return _defaultFlagsClient[prop as keyof FlagsClient]; }, diff --git a/packages/vercel-flags-core/src/types.ts b/packages/vercel-flags-core/src/types.ts index fb684939..1fe8010f 100644 --- a/packages/vercel-flags-core/src/types.ts +++ b/packages/vercel-flags-core/src/types.ts @@ -120,11 +120,12 @@ export type Source = { */ export type FlagsClient> = { /** - * Origin information for this client (provider and sdkKey) + * Origin information for this client. + * sdkKey is only present when the client was explicitly created with one. */ origin?: { provider: string; - sdkKey: string; + sdkKey?: string; }; /** * Evaluate a feature flag diff --git a/packages/vercel-flags-core/src/types/flags-definitions.d.ts b/packages/vercel-flags-core/src/types/flags-definitions.d.ts index fb289556..88e1b022 100644 --- a/packages/vercel-flags-core/src/types/flags-definitions.d.ts +++ b/packages/vercel-flags-core/src/types/flags-definitions.d.ts @@ -1,4 +1,4 @@ declare module '@vercel/flags-definitions' { - export function get(hashedSdkKey: string): Record | null; + export function get(key: string): Record | null; export const version: string; } diff --git a/packages/vercel-flags-core/src/utils/read-bundled-definitions.test.ts b/packages/vercel-flags-core/src/utils/read-bundled-definitions.test.ts index 336b28e1..6ec08fa6 100644 --- a/packages/vercel-flags-core/src/utils/read-bundled-definitions.test.ts +++ b/packages/vercel-flags-core/src/utils/read-bundled-definitions.test.ts @@ -1,4 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { Auth } from '../controller/auth'; // The readBundledDefinitions function uses dynamic import which is hard to mock. // Instead, we test the behavior indirectly through the Controller @@ -7,6 +8,12 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; describe('readBundledDefinitions', () => { const originalEnv = process.env; + const auth: Auth = { + sdkKey: 'test-id', + resolveToken: () => Promise.resolve('test-id'), + resolveBundledDefinitionsLookup: () => + Promise.resolve({ type: 'sdk-key', sdkKey: 'test-id' }), + }; beforeEach(() => { vi.resetModules(); @@ -26,7 +33,7 @@ describe('readBundledDefinitions', () => { expect(typeof readBundledDefinitions).toBe('function'); // Calling it should return a promise (will likely fail since the module doesn't exist) - const result = readBundledDefinitions('test-id'); + const result = readBundledDefinitions(auth); expect(result).toBeInstanceOf(Promise); // The result should have the expected shape @@ -40,7 +47,7 @@ describe('readBundledDefinitions', () => { './read-bundled-definitions' ); - const result = await readBundledDefinitions('nonexistent-id'); + const result = await readBundledDefinitions(auth); // Since @vercel/flags-definitions/definitions.json doesn't exist in test env, // it should return either missing-file or unexpected-error @@ -55,7 +62,7 @@ describe('readBundledDefinitions', () => { './read-bundled-definitions' ); - const result = await readBundledDefinitions('nonexistent-id'); + const result = await readBundledDefinitions(auth); expect(result).toEqual({ definitions: null, @@ -63,6 +70,37 @@ describe('readBundledDefinitions', () => { }); }); + it('should read OIDC bundled definitions by project id', async () => { + const definitions = { + projectId: 'prj_test', + environment: 'production', + definitions: {}, + configUpdatedAt: 1, + digest: 'digest', + revision: 1, + }; + const get = vi.fn((key: string) => + key === 'prj_test' ? definitions : null, + ); + vi.doMock('@vercel/flags-definitions', () => ({ get })); + + const oidcAuth: Auth = { + resolveToken: () => Promise.resolve('token'), + resolveBundledDefinitionsLookup: () => + Promise.resolve({ type: 'project-id', projectId: 'prj_test' }), + }; + + const { readBundledDefinitions } = await import( + './read-bundled-definitions' + ); + + await expect(readBundledDefinitions(oidcAuth)).resolves.toEqual({ + definitions, + state: 'ok', + }); + expect(get).toHaveBeenCalledWith('prj_test'); + }); + // The detailed behavior of readBundledDefinitions is tested indirectly // through Controller tests which mock readBundledDefinitions. // Those tests cover: diff --git a/packages/vercel-flags-core/src/utils/read-bundled-definitions.ts b/packages/vercel-flags-core/src/utils/read-bundled-definitions.ts index aace6ce9..87034733 100644 --- a/packages/vercel-flags-core/src/utils/read-bundled-definitions.ts +++ b/packages/vercel-flags-core/src/utils/read-bundled-definitions.ts @@ -4,6 +4,7 @@ // is degraded or unavailable. // +import type { Auth, BundledDefinitionsLookup } from '../controller/auth'; import type { BundledDefinitions, BundledDefinitionsResult } from '../types'; /** In-memory cache of SDK key to its hashed value, so we don't re-hash repeatedly. */ @@ -35,7 +36,7 @@ function hashSdkKey(sdkKey: string): Promise { * Reads the local definitions that get bundled at build time. */ export async function readBundledDefinitions( - sdkKey: string, + auth: Auth, ): Promise { let get: (sdkKey: string) => BundledDefinitions | null; try { @@ -62,13 +63,27 @@ export async function readBundledDefinitions( return { definitions: null, state: 'missing-file' }; } - // try plain sdk key first - const entry = get(sdkKey); + let lookup: BundledDefinitionsLookup; + try { + lookup = await auth.resolveBundledDefinitionsLookup(); + } catch (error) { + return { definitions: null, state: 'unexpected-error', error }; + } + + if (lookup.type === 'project-id') { + const entry = get(lookup.projectId); + return entry + ? { definitions: entry, state: 'ok' } + : { definitions: null, state: 'missing-entry' }; + } + + // Try plain sdk key first for bundles created by older CLI versions. + const entry = get(lookup.sdkKey); if (entry) return { definitions: entry, state: 'ok' }; // try hashed key but catch any errors try { - const hashedKey = await hashSdkKey(sdkKey); + const hashedKey = await hashSdkKey(lookup.sdkKey); // try original key (older cli versions) and hashed key (newer cli versions) const hashedEntry = get(hashedKey); if (hashedEntry) return { definitions: hashedEntry, state: 'ok' }; diff --git a/packages/vercel-flags-core/src/utils/usage-tracker.test.ts b/packages/vercel-flags-core/src/utils/usage-tracker.test.ts index cb7bd6ab..cb92340e 100644 --- a/packages/vercel-flags-core/src/utils/usage-tracker.test.ts +++ b/packages/vercel-flags-core/src/utils/usage-tracker.test.ts @@ -1,4 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { Auth } from '../controller/auth'; import { setRequestContext } from '../test-utils'; import { type FlagsConfigReadEvent, UsageTracker } from './usage-tracker'; @@ -39,9 +40,18 @@ afterEach(() => { vi.unstubAllEnvs(); }); +function createAuth(sdkKey = 'test-key'): Auth { + return { + sdkKey, + resolveToken: () => Promise.resolve(sdkKey), + resolveBundledDefinitionsLookup: () => + Promise.resolve({ type: 'sdk-key', sdkKey }), + }; +} + function createTracker(sdkKey = 'test-key') { return new UsageTracker({ - sdkKey, + auth: createAuth(sdkKey), host: 'https://example.com', fetch: fetchMock, }); @@ -137,7 +147,7 @@ describe('UsageTracker', () => { fetchMock.mockImplementation(() => jsonResponse({ ok: true })); const tracker = new UsageTracker({ - sdkKey: 'my-secret-key', + auth: createAuth('my-secret-key'), host: 'https://example.com', fetch: fetchMock, }); @@ -229,7 +239,7 @@ describe('UsageTracker', () => { ); const tracker = new FreshUsageTracker({ - sdkKey: 'test-key', + auth: createAuth('test-key'), host: 'https://example.com', fetch: fetchMock, }); @@ -267,7 +277,7 @@ describe('UsageTracker', () => { fetchMock.mockImplementation(() => jsonResponse({ ok: true })); const tracker = new FreshUsageTracker({ - sdkKey: 'test-key', + auth: createAuth('test-key'), host: 'https://example.com', fetch: fetchMock, }); @@ -367,13 +377,13 @@ describe('UsageTracker', () => { }); const tracker1 = new UsageTracker({ - sdkKey: 'key-1', + auth: createAuth('key-1'), host: 'https://example.com', fetch: fetchMock, }); const tracker2 = new UsageTracker({ - sdkKey: 'key-2', + auth: createAuth('key-2'), host: 'https://example.com', fetch: fetchMock, }); diff --git a/packages/vercel-flags-core/src/utils/usage-tracker.ts b/packages/vercel-flags-core/src/utils/usage-tracker.ts index 2be7411f..1668c280 100644 --- a/packages/vercel-flags-core/src/utils/usage-tracker.ts +++ b/packages/vercel-flags-core/src/utils/usage-tracker.ts @@ -1,5 +1,6 @@ import { waitUntil } from '@vercel/functions'; import { version } from '../../package.json'; +import type { Auth } from '../controller/auth'; const RESOLVED_VOID: Promise = Promise.resolve(); @@ -74,7 +75,7 @@ function getRequestContext(): RequestContext { } export interface UsageTrackerOptions { - sdkKey: string; + auth: Auth; host: string; fetch: typeof fetch; } @@ -249,6 +250,8 @@ export class UsageTracker { const flushId = ++this.flushCounter; + const token = await this.options.auth.resolveToken(); + for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) { try { const response = await this.options.fetch( @@ -257,7 +260,7 @@ export class UsageTracker { method: 'POST', headers: { 'Content-Type': 'application/json', - Authorization: `Bearer ${this.options.sdkKey}`, + Authorization: `Bearer ${token}`, 'User-Agent': `VercelFlagsCore/${version}`, ...(process.env.VERCEL_ENV ? { 'X-Vercel-Env': process.env.VERCEL_ENV } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e9caede8..ce55f4a8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -40,7 +40,7 @@ importers: version: 15.2.2 next: specifier: ^16.1.6 - version: 16.1.6(@opentelemetry/api@1.9.0)(@playwright/test@1.58.1)(react-dom@19.2.4(react@19.3.0-canary-561ed529-20260423))(react@19.3.0-canary-561ed529-20260423) + version: 16.1.6(@opentelemetry/api@1.9.0)(@playwright/test@1.58.1)(react-dom@19.2.4(react@19.3.0-canary-f4e0d4ed-20260429))(react@19.3.0-canary-f4e0d4ed-20260429) playwright: specifier: 1.56.1 version: 1.56.1 @@ -49,7 +49,7 @@ importers: version: 0.3.14 react: specifier: canary - version: 19.3.0-canary-561ed529-20260423 + version: 19.3.0-canary-f4e0d4ed-20260429 turbo: specifier: 2.8.15 version: 2.8.15 @@ -484,7 +484,7 @@ importers: dependencies: '@vercel/edge-config': specifier: ^1.4.3 - version: 1.4.3(@opentelemetry/api@1.9.0)(next@16.2.0(@opentelemetry/api@1.9.0)(@playwright/test@1.58.1)(react-dom@19.2.4(react@19.3.0-canary-561ed529-20260423))(react@19.3.0-canary-561ed529-20260423)) + version: 1.4.3(@opentelemetry/api@1.9.0)(next@16.2.0(@opentelemetry/api@1.9.0)(@playwright/test@1.58.1)(react-dom@19.2.4(react@19.3.0-canary-f4e0d4ed-20260429))(react@19.3.0-canary-f4e0d4ed-20260429)) devDependencies: '@types/node': specifier: 20.11.17 @@ -546,7 +546,7 @@ importers: version: 1.6.1 '@vercel/edge-config': specifier: ^1.4.3 - version: 1.4.3(@opentelemetry/api@1.9.0)(next@16.2.0(@opentelemetry/api@1.9.0)(@playwright/test@1.58.1)(react-dom@19.2.4(react@19.3.0-canary-561ed529-20260423))(react@19.3.0-canary-561ed529-20260423)) + version: 1.4.3(@opentelemetry/api@1.9.0)(next@16.2.0(@opentelemetry/api@1.9.0)(@playwright/test@1.58.1)(react-dom@19.2.4(react@19.3.0-canary-f4e0d4ed-20260429))(react@19.3.0-canary-f4e0d4ed-20260429)) '@vercel/functions': specifier: ^1.5.2 version: 1.6.0 @@ -580,7 +580,7 @@ importers: dependencies: '@vercel/edge-config': specifier: ^1.4.3 - version: 1.4.3(@opentelemetry/api@1.9.0)(next@16.2.0(@opentelemetry/api@1.9.0)(@playwright/test@1.58.1)(react-dom@19.2.4(react@19.3.0-canary-561ed529-20260423))(react@19.3.0-canary-561ed529-20260423)) + version: 1.4.3(@opentelemetry/api@1.9.0)(next@16.2.0(@opentelemetry/api@1.9.0)(@playwright/test@1.58.1)(react-dom@19.2.4(react@19.3.0-canary-f4e0d4ed-20260429))(react@19.3.0-canary-f4e0d4ed-20260429)) hypertune: specifier: 2.8.3 version: 2.8.3 @@ -614,10 +614,10 @@ importers: dependencies: '@launchdarkly/vercel-server-sdk': specifier: ^1.3.34 - version: 1.3.34(@opentelemetry/api@1.9.0)(next@16.2.0(@opentelemetry/api@1.9.0)(@playwright/test@1.58.1)(react-dom@19.2.4(react@19.3.0-canary-561ed529-20260423))(react@19.3.0-canary-561ed529-20260423)) + version: 1.3.34(@opentelemetry/api@1.9.0)(next@16.2.0(@opentelemetry/api@1.9.0)(@playwright/test@1.58.1)(react-dom@19.2.4(react@19.3.0-canary-f4e0d4ed-20260429))(react@19.3.0-canary-f4e0d4ed-20260429)) '@vercel/edge-config': specifier: ^1.4.3 - version: 1.4.3(@opentelemetry/api@1.9.0)(next@16.2.0(@opentelemetry/api@1.9.0)(@playwright/test@1.58.1)(react-dom@19.2.4(react@19.3.0-canary-561ed529-20260423))(react@19.3.0-canary-561ed529-20260423)) + version: 1.4.3(@opentelemetry/api@1.9.0)(next@16.2.0(@opentelemetry/api@1.9.0)(@playwright/test@1.58.1)(react-dom@19.2.4(react@19.3.0-canary-f4e0d4ed-20260429))(react@19.3.0-canary-f4e0d4ed-20260429)) devDependencies: '@types/node': specifier: 20.11.17 @@ -705,7 +705,7 @@ importers: dependencies: '@vercel/edge-config': specifier: ^1.4.3 - version: 1.4.3(@opentelemetry/api@1.9.0)(next@16.2.0(@opentelemetry/api@1.9.0)(@playwright/test@1.58.1)(react-dom@19.2.4(react@19.3.0-canary-561ed529-20260423))(react@19.3.0-canary-561ed529-20260423)) + version: 1.4.3(@opentelemetry/api@1.9.0)(next@16.2.0(@opentelemetry/api@1.9.0)(@playwright/test@1.58.1)(react-dom@19.2.4(react@19.3.0-canary-f4e0d4ed-20260429))(react@19.3.0-canary-f4e0d4ed-20260429)) posthog-node: specifier: 4.11.1 version: 4.11.1 @@ -797,7 +797,7 @@ importers: dependencies: '@vercel/edge-config': specifier: ^1.4.3 - version: 1.4.3(@opentelemetry/api@1.9.0)(next@16.2.0(@opentelemetry/api@1.9.0)(@playwright/test@1.58.1)(react-dom@19.2.4(react@19.3.0-canary-561ed529-20260423))(react@19.3.0-canary-561ed529-20260423)) + version: 1.4.3(@opentelemetry/api@1.9.0)(next@16.2.0(@opentelemetry/api@1.9.0)(@playwright/test@1.58.1)(react-dom@19.2.4(react@19.3.0-canary-f4e0d4ed-20260429))(react@19.3.0-canary-f4e0d4ed-20260429)) '@vercel/functions': specifier: ^1.5.2 version: 1.6.0 @@ -806,7 +806,7 @@ importers: version: 0.5.2 statsig-node-vercel: specifier: ^0.7.0 - version: 0.7.0(@vercel/edge-config@1.4.3(@opentelemetry/api@1.9.0)(next@16.2.0(@opentelemetry/api@1.9.0)(@playwright/test@1.58.1)(react-dom@19.2.4(react@19.3.0-canary-561ed529-20260423))(react@19.3.0-canary-561ed529-20260423))) + version: 0.7.0(@vercel/edge-config@1.4.3(@opentelemetry/api@1.9.0)(next@16.2.0(@opentelemetry/api@1.9.0)(@playwright/test@1.58.1)(react-dom@19.2.4(react@19.3.0-canary-f4e0d4ed-20260429))(react@19.3.0-canary-f4e0d4ed-20260429))) devDependencies: '@types/node': specifier: 20.11.17 @@ -850,7 +850,7 @@ importers: version: 2.6.4(@types/node@20.11.17)(typescript@5.6.3) next: specifier: 16.1.5 - version: 16.1.5(@opentelemetry/api@1.9.0)(@playwright/test@1.58.1)(react-dom@19.2.4(react@19.3.0-canary-561ed529-20260423))(react@19.3.0-canary-561ed529-20260423) + version: 16.1.5(@opentelemetry/api@1.9.0)(@playwright/test@1.58.1)(react-dom@19.2.4(react@19.3.0-canary-f4e0d4ed-20260429))(react@19.3.0-canary-f4e0d4ed-20260429) tsup: specifier: 8.5.1 version: 8.5.1(jiti@2.6.1)(postcss@8.5.8)(typescript@5.6.3)(yaml@2.8.1) @@ -945,6 +945,9 @@ importers: '@vercel/functions': specifier: ^3.4.3 version: 3.4.3 + '@vercel/oidc': + specifier: 3.4.0 + version: 3.4.0 jose: specifier: 5.2.1 version: 5.2.1 @@ -960,7 +963,7 @@ importers: version: 20.11.17 next: specifier: ^16.1.6 - version: 16.1.6(@opentelemetry/api@1.9.0)(@playwright/test@1.58.1)(react-dom@19.2.4(react@19.3.0-canary-561ed529-20260423))(react@19.3.0-canary-561ed529-20260423) + version: 16.1.6(@opentelemetry/api@1.9.0)(@playwright/test@1.58.1)(react-dom@19.2.4(react@19.3.0-canary-f4e0d4ed-20260429))(react@19.3.0-canary-f4e0d4ed-20260429) tsup: specifier: 8.5.1 version: 8.5.1(jiti@2.6.1)(postcss@8.5.8)(typescript@5.6.3)(yaml@2.8.1) @@ -4966,6 +4969,10 @@ packages: resolution: {integrity: sha512-UycprH3T6n3jH0k44NHMa7pnFHGu/N05MjojYr+Mc6I7obkoLIJujSWwin1pCvdy/eOxrI/l3uDLQsmcrOb4ug==} engines: {node: '>= 20'} + '@vercel/oidc@3.4.0': + resolution: {integrity: sha512-p0sKfHkfRmMaqqDwNL4tjnX9TgRrLMlEtUjIxfrEns8pOxz1R9ztqOVI+ehqiq93/2/HnfPe/UBZkfAZwnx0UA==} + engines: {node: '>= 20'} + '@vercel/speed-insights@1.3.1': resolution: {integrity: sha512-PbEr7FrMkUrGYvlcLHGkXdCkxnylCWePx7lPxxq36DNdfo9mcUjLOmqOyPDHAOgnfqgGGdmE3XI9L/4+5fr+vQ==} peerDependencies: @@ -8276,8 +8283,8 @@ packages: resolution: {integrity: sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==} engines: {node: '>=0.10.0'} - react@19.3.0-canary-561ed529-20260423: - resolution: {integrity: sha512-tN5JiqCwYgG5kSzVIcM5Sx3NPT6+rfOCVKolHycLBZivIhfcPVQqAn5/UgSxy8QeVNrUlyniVIztNpAeKlUz5w==} + react@19.3.0-canary-f4e0d4ed-20260429: + resolution: {integrity: sha512-FNfU7Fsr/U/6t76mMAOucXovXS7536HmVK4nVWKxLAOY+P7dpZ/rJfR2If4uHjAwQoMsLxZ41gG2VOWJDkprOA==} engines: {node: '>=0.10.0'} read-cache@1.0.0: @@ -9171,6 +9178,7 @@ packages: uuid@8.3.2: resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==} + deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028). hasBin: true validate-npm-package-name@5.0.1: @@ -10630,10 +10638,10 @@ snapshots: '@launchdarkly/js-sdk-common': 2.19.0 semver: 7.5.4 - '@launchdarkly/vercel-server-sdk@1.3.34(@opentelemetry/api@1.9.0)(next@16.2.0(@opentelemetry/api@1.9.0)(@playwright/test@1.58.1)(react-dom@19.2.4(react@19.3.0-canary-561ed529-20260423))(react@19.3.0-canary-561ed529-20260423))': + '@launchdarkly/vercel-server-sdk@1.3.34(@opentelemetry/api@1.9.0)(next@16.2.0(@opentelemetry/api@1.9.0)(@playwright/test@1.58.1)(react-dom@19.2.4(react@19.3.0-canary-f4e0d4ed-20260429))(react@19.3.0-canary-f4e0d4ed-20260429))': dependencies: '@launchdarkly/js-server-sdk-common-edge': 2.6.9 - '@vercel/edge-config': 1.4.3(@opentelemetry/api@1.9.0)(next@16.2.0(@opentelemetry/api@1.9.0)(@playwright/test@1.58.1)(react-dom@19.2.4(react@19.3.0-canary-561ed529-20260423))(react@19.3.0-canary-561ed529-20260423)) + '@vercel/edge-config': 1.4.3(@opentelemetry/api@1.9.0)(next@16.2.0(@opentelemetry/api@1.9.0)(@playwright/test@1.58.1)(react-dom@19.2.4(react@19.3.0-canary-f4e0d4ed-20260429))(react@19.3.0-canary-f4e0d4ed-20260429)) crypto-js: 4.2.0 transitivePeerDependencies: - '@opentelemetry/api' @@ -13296,12 +13304,12 @@ snapshots: '@opentelemetry/api': 1.9.0 next: 16.1.5(@opentelemetry/api@1.9.0)(@playwright/test@1.58.1)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) - '@vercel/edge-config@1.4.3(@opentelemetry/api@1.9.0)(next@16.2.0(@opentelemetry/api@1.9.0)(@playwright/test@1.58.1)(react-dom@19.2.4(react@19.3.0-canary-561ed529-20260423))(react@19.3.0-canary-561ed529-20260423))': + '@vercel/edge-config@1.4.3(@opentelemetry/api@1.9.0)(next@16.2.0(@opentelemetry/api@1.9.0)(@playwright/test@1.58.1)(react-dom@19.2.4(react@19.3.0-canary-f4e0d4ed-20260429))(react@19.3.0-canary-f4e0d4ed-20260429))': dependencies: '@vercel/edge-config-fs': 0.1.0 optionalDependencies: '@opentelemetry/api': 1.9.0 - next: 16.2.0(@opentelemetry/api@1.9.0)(@playwright/test@1.58.1)(react-dom@19.2.4(react@19.3.0-canary-561ed529-20260423))(react@19.3.0-canary-561ed529-20260423) + next: 16.2.0(@opentelemetry/api@1.9.0)(@playwright/test@1.58.1)(react-dom@19.2.4(react@19.3.0-canary-f4e0d4ed-20260429))(react@19.3.0-canary-f4e0d4ed-20260429) '@vercel/edge@1.2.1': {} @@ -13418,6 +13426,8 @@ snapshots: '@vercel/oidc@3.2.0': {} + '@vercel/oidc@3.4.0': {} + '@vercel/speed-insights@1.3.1(@sveltejs/kit@2.50.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@4.0.4(svelte@5.41.3)(vite@5.4.21(@types/node@24.10.13)(lightningcss@1.30.2)))(svelte@5.41.3)(typescript@5.8.2)(vite@5.4.21(@types/node@24.10.13)(lightningcss@1.30.2)))(next@16.2.0(@opentelemetry/api@1.9.0)(@playwright/test@1.58.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4)(svelte@5.41.3)': optionalDependencies: '@sveltejs/kit': 2.50.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@4.0.4(svelte@5.41.3)(vite@5.4.21(@types/node@24.10.13)(lightningcss@1.30.2)))(svelte@5.41.3)(typescript@5.8.2)(vite@5.4.21(@types/node@24.10.13)(lightningcss@1.30.2)) @@ -16916,16 +16926,16 @@ snapshots: - '@babel/core' - babel-plugin-macros - next@16.1.5(@opentelemetry/api@1.9.0)(@playwright/test@1.58.1)(react-dom@19.2.4(react@19.3.0-canary-561ed529-20260423))(react@19.3.0-canary-561ed529-20260423): + next@16.1.5(@opentelemetry/api@1.9.0)(@playwright/test@1.58.1)(react-dom@19.2.4(react@19.3.0-canary-f4e0d4ed-20260429))(react@19.3.0-canary-f4e0d4ed-20260429): dependencies: '@next/env': 16.1.5 '@swc/helpers': 0.5.15 baseline-browser-mapping: 2.10.0 caniuse-lite: 1.0.30001777 postcss: 8.4.31 - react: 19.3.0-canary-561ed529-20260423 - react-dom: 19.2.4(react@19.3.0-canary-561ed529-20260423) - styled-jsx: 5.1.6(react@19.3.0-canary-561ed529-20260423) + react: 19.3.0-canary-f4e0d4ed-20260429 + react-dom: 19.2.4(react@19.3.0-canary-f4e0d4ed-20260429) + styled-jsx: 5.1.6(react@19.3.0-canary-f4e0d4ed-20260429) optionalDependencies: '@next/swc-darwin-arm64': 16.1.5 '@next/swc-darwin-x64': 16.1.5 @@ -16968,16 +16978,16 @@ snapshots: - '@babel/core' - babel-plugin-macros - next@16.1.6(@opentelemetry/api@1.9.0)(@playwright/test@1.58.1)(react-dom@19.2.4(react@19.3.0-canary-561ed529-20260423))(react@19.3.0-canary-561ed529-20260423): + next@16.1.6(@opentelemetry/api@1.9.0)(@playwright/test@1.58.1)(react-dom@19.2.4(react@19.3.0-canary-f4e0d4ed-20260429))(react@19.3.0-canary-f4e0d4ed-20260429): dependencies: '@next/env': 16.1.6 '@swc/helpers': 0.5.15 baseline-browser-mapping: 2.10.0 caniuse-lite: 1.0.30001777 postcss: 8.4.31 - react: 19.3.0-canary-561ed529-20260423 - react-dom: 19.2.4(react@19.3.0-canary-561ed529-20260423) - styled-jsx: 5.1.6(react@19.3.0-canary-561ed529-20260423) + react: 19.3.0-canary-f4e0d4ed-20260429 + react-dom: 19.2.4(react@19.3.0-canary-f4e0d4ed-20260429) + styled-jsx: 5.1.6(react@19.3.0-canary-f4e0d4ed-20260429) optionalDependencies: '@next/swc-darwin-arm64': 16.1.6 '@next/swc-darwin-x64': 16.1.6 @@ -17020,16 +17030,16 @@ snapshots: - '@babel/core' - babel-plugin-macros - next@16.2.0(@opentelemetry/api@1.9.0)(@playwright/test@1.58.1)(react-dom@19.2.4(react@19.3.0-canary-561ed529-20260423))(react@19.3.0-canary-561ed529-20260423): + next@16.2.0(@opentelemetry/api@1.9.0)(@playwright/test@1.58.1)(react-dom@19.2.4(react@19.3.0-canary-f4e0d4ed-20260429))(react@19.3.0-canary-f4e0d4ed-20260429): dependencies: '@next/env': 16.2.0 '@swc/helpers': 0.5.15 baseline-browser-mapping: 2.10.0 caniuse-lite: 1.0.30001777 postcss: 8.4.31 - react: 19.3.0-canary-561ed529-20260423 - react-dom: 19.2.4(react@19.3.0-canary-561ed529-20260423) - styled-jsx: 5.1.6(react@19.3.0-canary-561ed529-20260423) + react: 19.3.0-canary-f4e0d4ed-20260429 + react-dom: 19.2.4(react@19.3.0-canary-f4e0d4ed-20260429) + styled-jsx: 5.1.6(react@19.3.0-canary-f4e0d4ed-20260429) optionalDependencies: '@next/swc-darwin-arm64': 16.2.0 '@next/swc-darwin-x64': 16.2.0 @@ -17528,9 +17538,9 @@ snapshots: react: 19.2.4 scheduler: 0.27.0 - react-dom@19.2.4(react@19.3.0-canary-561ed529-20260423): + react-dom@19.2.4(react@19.3.0-canary-f4e0d4ed-20260429): dependencies: - react: 19.3.0-canary-561ed529-20260423 + react: 19.3.0-canary-f4e0d4ed-20260429 scheduler: 0.27.0 react-is@16.13.1: {} @@ -17638,7 +17648,7 @@ snapshots: react@19.2.4: {} - react@19.3.0-canary-561ed529-20260423: {} + react@19.3.0-canary-f4e0d4ed-20260429: {} read-cache@1.0.0: dependencies: @@ -18149,9 +18159,9 @@ snapshots: transitivePeerDependencies: - encoding - statsig-node-vercel@0.7.0(@vercel/edge-config@1.4.3(@opentelemetry/api@1.9.0)(next@16.2.0(@opentelemetry/api@1.9.0)(@playwright/test@1.58.1)(react-dom@19.2.4(react@19.3.0-canary-561ed529-20260423))(react@19.3.0-canary-561ed529-20260423))): + statsig-node-vercel@0.7.0(@vercel/edge-config@1.4.3(@opentelemetry/api@1.9.0)(next@16.2.0(@opentelemetry/api@1.9.0)(@playwright/test@1.58.1)(react-dom@19.2.4(react@19.3.0-canary-f4e0d4ed-20260429))(react@19.3.0-canary-f4e0d4ed-20260429))): dependencies: - '@vercel/edge-config': 1.4.3(@opentelemetry/api@1.9.0)(next@16.2.0(@opentelemetry/api@1.9.0)(@playwright/test@1.58.1)(react-dom@19.2.4(react@19.3.0-canary-561ed529-20260423))(react@19.3.0-canary-561ed529-20260423)) + '@vercel/edge-config': 1.4.3(@opentelemetry/api@1.9.0)(next@16.2.0(@opentelemetry/api@1.9.0)(@playwright/test@1.58.1)(react-dom@19.2.4(react@19.3.0-canary-f4e0d4ed-20260429))(react@19.3.0-canary-f4e0d4ed-20260429)) statsig-node-lite: 0.4.4 transitivePeerDependencies: - encoding @@ -18315,10 +18325,10 @@ snapshots: client-only: 0.0.1 react: 19.2.0 - styled-jsx@5.1.6(react@19.3.0-canary-561ed529-20260423): + styled-jsx@5.1.6(react@19.3.0-canary-f4e0d4ed-20260429): dependencies: client-only: 0.0.1 - react: 19.3.0-canary-561ed529-20260423 + react: 19.3.0-canary-f4e0d4ed-20260429 stylis@4.3.6: {}