From 2f401ab470f1b5cb230546c8e88579e60b751783 Mon Sep 17 00:00:00 2001 From: gregory Date: Fri, 11 Sep 2026 14:30:52 +0200 Subject: [PATCH 1/2] feat(lowering): send deploy source and client headers to the Management API The Management API records where each Compute deploy came from in its analytics. It cannot tell a GitHub Actions deploy from a laptop deploy today, because alchemy's Prisma client always sends the User-Agent alchemy-prisma/1.0. Composer now adds three headers to its Management API requests: - x-prisma-deploy-source: github-action when GITHUB_ACTIONS is "true", otherwise composer - x-prisma-client-name: composer - x-prisma-client-version: the package version They go on alchemy's Prisma client, through the private node transport in upstreamPrismaProviders (HttpClient.mapRequest), and on Composer's own management client. The artifact upload keeps using the ambient client, so presigned upload URLs get no extra headers. The API behaves the same without the headers. Co-Authored-By: Claude Opus 5 Signed-off-by: gregory --- .../__tests__/deploy-source-headers.test.ts | 40 +++++++++++++++++++ .../0-lowering/lowering/src/client.ts | 8 +++- .../0-lowering/lowering/src/credentials.ts | 12 ++++++ .../0-lowering/lowering/src/providers.ts | 26 ++++++++++-- 4 files changed, 81 insertions(+), 5 deletions(-) create mode 100644 packages/1-prisma-cloud/0-lowering/lowering/src/__tests__/deploy-source-headers.test.ts diff --git a/packages/1-prisma-cloud/0-lowering/lowering/src/__tests__/deploy-source-headers.test.ts b/packages/1-prisma-cloud/0-lowering/lowering/src/__tests__/deploy-source-headers.test.ts new file mode 100644 index 00000000..08e5bffb --- /dev/null +++ b/packages/1-prisma-cloud/0-lowering/lowering/src/__tests__/deploy-source-headers.test.ts @@ -0,0 +1,40 @@ +import { afterEach, describe, expect, test } from 'bun:test'; +import { deploySourceHeaders } from '../credentials.ts'; + +describe('deploySourceHeaders', () => { + const originalEnv = process.env['GITHUB_ACTIONS']; + + afterEach(() => { + if (originalEnv === undefined) { + delete process.env['GITHUB_ACTIONS']; + } else { + process.env['GITHUB_ACTIONS'] = originalEnv; + } + }); + + test('uses "github-action" as source when GITHUB_ACTIONS is "true"', () => { + process.env['GITHUB_ACTIONS'] = 'true'; + expect(deploySourceHeaders()['x-prisma-deploy-source']).toBe('github-action'); + }); + + test('uses "composer" as source outside GitHub Actions', () => { + delete process.env['GITHUB_ACTIONS']; + expect(deploySourceHeaders()['x-prisma-deploy-source']).toBe('composer'); + }); + + test('uses "composer" as source when GITHUB_ACTIONS has another value', () => { + process.env['GITHUB_ACTIONS'] = 'false'; + expect(deploySourceHeaders()['x-prisma-deploy-source']).toBe('composer'); + }); + + test('always sets x-prisma-client-name to "composer"', () => { + expect(deploySourceHeaders()['x-prisma-client-name']).toBe('composer'); + }); + + test('sets x-prisma-client-version to the package version string', () => { + const version = deploySourceHeaders()['x-prisma-client-version'] ?? ''; + expect(version.length).toBeGreaterThan(0); + // Semver-shaped: digits separated by dots. + expect(version).toMatch(/^\d+\.\d+\.\d+/); + }); +}); diff --git a/packages/1-prisma-cloud/0-lowering/lowering/src/client.ts b/packages/1-prisma-cloud/0-lowering/lowering/src/client.ts index ac6f9490..2cbee49f 100644 --- a/packages/1-prisma-cloud/0-lowering/lowering/src/client.ts +++ b/packages/1-prisma-cloud/0-lowering/lowering/src/client.ts @@ -4,7 +4,7 @@ import * as Context from 'effect/Context'; import * as Effect from 'effect/Effect'; import * as Layer from 'effect/Layer'; import * as Redacted from 'effect/Redacted'; -import { managementApiBaseUrl, PrismaCredentials } from './credentials.ts'; +import { deploySourceHeaders, managementApiBaseUrl, PrismaCredentials } from './credentials.ts'; export type ManagementApiClient = ReturnType; @@ -31,6 +31,10 @@ export const layer = (options?: { Effect.gen(function* () { const { token } = yield* PrismaCredentials; const baseUrl = options?.apiOrigin ?? (yield* managementApiBaseUrl()); - return createManagementApiClient({ token: Redacted.value(token), baseUrl }); + return createManagementApiClient({ + token: Redacted.value(token), + baseUrl, + headers: deploySourceHeaders(), + }); }), ); diff --git a/packages/1-prisma-cloud/0-lowering/lowering/src/credentials.ts b/packages/1-prisma-cloud/0-lowering/lowering/src/credentials.ts index e462d981..cd114b76 100644 --- a/packages/1-prisma-cloud/0-lowering/lowering/src/credentials.ts +++ b/packages/1-prisma-cloud/0-lowering/lowering/src/credentials.ts @@ -3,6 +3,7 @@ import * as Context from 'effect/Context'; import * as Effect from 'effect/Effect'; import * as Layer from 'effect/Layer'; import type * as Redacted from 'effect/Redacted'; +import pkg from '../package.json' with { type: 'json' }; /** * The Prisma service token used to authenticate Management API calls. Kept @@ -64,6 +65,17 @@ const normalizeBaseUrl = (value: string): Effect.Effect => : new Error(`Invalid Prisma Management API URL: ${String(cause)}`), }); +/** + * Headers the Management API records in its deploy analytics: which tool sent + * the request, and whether it ran in GitHub Actions. The API behaves the same + * without them. + */ +export const deploySourceHeaders = (): Record => ({ + 'x-prisma-deploy-source': process.env['GITHUB_ACTIONS'] === 'true' ? 'github-action' : 'composer', + 'x-prisma-client-name': 'composer', + 'x-prisma-client-version': pkg.version, +}); + /** * The Management API origin every Prisma-Cloud client in this package uses — * Composer's own SDK client AND upstream alchemy's postgres providers resolve diff --git a/packages/1-prisma-cloud/0-lowering/lowering/src/providers.ts b/packages/1-prisma-cloud/0-lowering/lowering/src/providers.ts index 20d0fa0c..58bcef87 100644 --- a/packages/1-prisma-cloud/0-lowering/lowering/src/providers.ts +++ b/packages/1-prisma-cloud/0-lowering/lowering/src/providers.ts @@ -3,8 +3,15 @@ import * as Prisma from 'alchemy/Prisma'; import * as Provider from 'alchemy/Provider'; import * as Effect from 'effect/Effect'; import * as Layer from 'effect/Layer'; +import * as HttpClient from 'effect/unstable/http/HttpClient'; +import * as HttpClientRequest from 'effect/unstable/http/HttpClientRequest'; import * as client from './client.ts'; -import { fromEnv, managementApiBaseUrl, PrismaCredentials } from './credentials.ts'; +import { + deploySourceHeaders, + fromEnv, + managementApiBaseUrl, + PrismaCredentials, +} from './credentials.ts'; /** The collection of Prisma resource providers. */ export class Providers extends Provider.ProviderCollection()('PrismaComposer') {} @@ -32,6 +39,19 @@ const prismaEnvironment = () => }), ); +/** + * A node:http transport that adds deploy-source headers to every request. + * Used privately by upstreamPrismaProviders — not exposed as the ambient + * HttpClient, so the artifact-upload path is unaffected. + */ +const prismaManagementHttpLayer = Layer.effect( + HttpClient.HttpClient, + Effect.gen(function* () { + const base = yield* HttpClient.HttpClient; + return HttpClient.mapRequest(base, HttpClientRequest.setHeaders(deploySourceHeaders())); + }), +).pipe(Layer.provide(NodeHttpClient.layerNodeHttp)); + /** * Upstream alchemy's live providers for the postgres family (Project, * Database, Connection), the compute family (App, Deployment, @@ -56,10 +76,10 @@ const upstreamPrismaProviders = () => Prisma.BucketAccessKeyProvider(), ).pipe( Layer.provideMerge(Prisma.PrismaClientLive), - // Provide (NOT provideMerge) the node transport privately — mirrors + // Provide (NOT provideMerge) the transport privately — mirrors // upstream's Providers.ts: it must serve only the Prisma management // client, never override the ambient HttpClient of other providers. - Layer.provide(NodeHttpClient.layerNodeHttp), + Layer.provide(prismaManagementHttpLayer), Layer.provideMerge(prismaEnvironment()), ); From de0dc17d9b3bdde05382281f26e9255d8a2b259b Mon Sep 17 00:00:00 2001 From: gregory Date: Mon, 14 Sep 2026 17:00:03 +0200 Subject: [PATCH 2/2] test(lowering): assert the exact package version in the client header Co-Authored-By: Claude Opus 5 Signed-off-by: gregory --- .../lowering/src/__tests__/deploy-source-headers.test.ts | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/packages/1-prisma-cloud/0-lowering/lowering/src/__tests__/deploy-source-headers.test.ts b/packages/1-prisma-cloud/0-lowering/lowering/src/__tests__/deploy-source-headers.test.ts index 08e5bffb..7788a4ef 100644 --- a/packages/1-prisma-cloud/0-lowering/lowering/src/__tests__/deploy-source-headers.test.ts +++ b/packages/1-prisma-cloud/0-lowering/lowering/src/__tests__/deploy-source-headers.test.ts @@ -1,4 +1,5 @@ import { afterEach, describe, expect, test } from 'bun:test'; +import pkg from '../../package.json' with { type: 'json' }; import { deploySourceHeaders } from '../credentials.ts'; describe('deploySourceHeaders', () => { @@ -31,10 +32,8 @@ describe('deploySourceHeaders', () => { expect(deploySourceHeaders()['x-prisma-client-name']).toBe('composer'); }); - test('sets x-prisma-client-version to the package version string', () => { - const version = deploySourceHeaders()['x-prisma-client-version'] ?? ''; - expect(version.length).toBeGreaterThan(0); - // Semver-shaped: digits separated by dots. - expect(version).toMatch(/^\d+\.\d+\.\d+/); + test('sets x-prisma-client-version to the package version', () => { + expect(pkg.version.length).toBeGreaterThan(0); + expect(deploySourceHeaders()['x-prisma-client-version']).toBe(pkg.version); }); });