Skip to content
Draft
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
6 changes: 6 additions & 0 deletions .changeset/connected-project-id-option.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@vercel/flags-core': minor
'@flags-sdk/vercel': minor
---

Add a `projectId` option to read the flags of a connected project.
16 changes: 16 additions & 0 deletions packages/adapter-vercel/src/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,22 @@ describe('createVercelAdapter', () => {
} satisfies Origin);
});

it('returns origin when created with a connected projectId', () => {
const adapter = createVercelAdapter({ projectId: 'prj_source' });

expect(adapter()).toHaveProperty('origin', {
provider: 'vercel',
sdkKey: undefined,
projectId: 'prj_source',
} satisfies Origin);
});

it('rejects an invalid projectId', () => {
expect(() => createVercelAdapter({ projectId: 'prj_a/b' })).toThrow(
'@vercel/flags-core: Invalid projectId',
);
});

it('forwards override observations to the flags client', async () => {
const reportOverride = vi.fn();
const fakeClient = {
Expand Down
40 changes: 34 additions & 6 deletions packages/adapter-vercel/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,17 +18,45 @@ export type VercelAdapterDeclaration<ValueType, EntitiesType> = Omit<
'decide' | 'origin'
>;

export interface CreateVercelAdapterOptions {
/**
* Reads the flags of another project in the same team with this
* deployment's OIDC token. The other project must have connected this
* project first.
*/
projectId: string;
}

function isAdapterOptions(
value: FlagsClient | CreateVercelAdapterOptions,
): value is CreateVercelAdapterOptions {
return 'projectId' in value;
}

function resolveFlagsClient(
input: string | FlagsClient | CreateVercelAdapterOptions | undefined,
): FlagsClient {
if (input === undefined || typeof input === 'string') {
return createClient(input);
}
if (isAdapterOptions(input)) {
return createClient({ projectId: input.projectId });
}
return input;
}

/**
* Allows creating a custom Vercel adapter for feature flags
*/
export function createVercelAdapter(
// usually a connection string, but can also be a pre-configured FlagsClient
sdkKeyOrFlagsClient?: string | FlagsClient,
// an SDK key, a pre-configured FlagsClient, or `{ projectId }` to read a
// connected project's flags with this deployment's OIDC token
sdkKeyOrFlagsClientOrOptions?:
| string
| FlagsClient
| CreateVercelAdapterOptions,
) {
const flagsClient =
typeof sdkKeyOrFlagsClient === 'string' || sdkKeyOrFlagsClient === undefined
? createClient(sdkKeyOrFlagsClient)
: sdkKeyOrFlagsClient;
const flagsClient = resolveFlagsClient(sdkKeyOrFlagsClientOrOptions);

// Stable identity for this adapter's underlying flagsClient. Captured in
// the closure so every adapter object the factory below returns shares it,
Expand Down
3 changes: 1 addition & 2 deletions packages/vercel-flags-core/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,8 +55,7 @@ createClient(sdkKey, options)

- `vf_server_*` / `vf_client_*` or `flags:…&sdkKey=vf_…` → SDK key mode. The key is the bearer token and the bundled-definitions lookup key (hashed).
- `undefined` → OIDC mode for the deployment's own project. The bearer token comes from `@vercel/oidc`; bundled definitions are looked up by the token's `project_id`.

`Auth.sourceProjectId` names another project to read with the same OIDC token: `X-Vercel-Flags-Project-Id` is added on datafile, stream, and ingest requests (`authHeaders()`), bundled definitions are looked up by that id, and events bypass the runtime ingest transport because it attributes to the calling project. Nothing sets it yet.
- `createClient({ projectId: 'prj_…' })` → OIDC mode for another project. Same token, plus `X-Vercel-Flags-Project-Id` on datafile, stream, and ingest requests (`authHeaders()`); bundled definitions are looked up by that project id without resolving a token. Events bypass the runtime ingest transport because it attributes to the calling project. Combining `projectId` with an SDK key, or a `projectId` outside `[A-Za-z0-9_]{1,64}`, throws. `@vercel/prepare-flags-definitions` learns which projects to embed from `GET api.vercel.com/v1/feature-flags/connections/sources` with the deployment's OIDC token.

The Controller's `unauthorized` flag gates usage tracking. It is set on a 401 from any source (stream init, poll, one-time fetch) and cleared when a source delivers data again, so a transient 401 does not silence tracking for the client's lifetime.

Expand Down
Loading
Loading