From 5be6ed6fe4b6e0937f4d14665961ad48c681ad00 Mon Sep 17 00:00:00 2001 From: Facundo Farias Date: Wed, 19 Aug 2026 16:20:07 +0200 Subject: [PATCH] feat: send X-PixelVault-Client + source=cli for lead attribution The API attributes each signup to the channel that created it (see pixelvault lead-attribution spec). The CLI now identifies itself so CLI-driven signups are attributable: - lib/client.ts: send `X-PixelVault-Client: pixelvault-cli/` on every request (unless the caller overrides it). Server stores it as the `client` label at register/device-login. - commands/register.ts: send `attribution: { source: "cli" }` in the register body so the signup buckets to source=cli (server treats it as a hint that wins over the derived source). Tests: header sent + overridable; register body carries the source hint. typecheck + 32 tests pass; build succeeds. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/commands/register.ts | 10 +++++++-- src/lib/client.ts | 10 +++++++++ tests/commands/register.test.ts | 16 +++++++++++--- tests/lib/client.test.ts | 39 +++++++++++++++++++++++++++++++++ 4 files changed, 70 insertions(+), 5 deletions(-) diff --git a/src/commands/register.ts b/src/commands/register.ts index bac4878..b575b13 100644 --- a/src/commands/register.ts +++ b/src/commands/register.ts @@ -79,8 +79,14 @@ export default defineCommand({ method: "POST", path: "/v1/auth/register", // Only send the password field when one was chosen — omitting it creates - // a passwordless account server-side. - body: password ? { email, password } : { email }, + // a passwordless account server-side. `attribution.source: "cli"` buckets + // the signup to this channel (server treats it as an untrusted hint that + // wins over the derived source — see lead-attribution spec). + body: { + email, + ...(password ? { password } : {}), + attribution: { source: "cli" }, + }, }); updateConfig({ diff --git a/src/lib/client.ts b/src/lib/client.ts index 690abbf..a61b1b1 100644 --- a/src/lib/client.ts +++ b/src/lib/client.ts @@ -13,6 +13,12 @@ interface ApiError { error: { code: string; message: string }; } +// Identifies the CLI to the API so signups made through it can be attributed to +// this channel (server reads X-PixelVault-Client at register/device-login — see +// pixelvault docs/specs/lead-attribution.md). Keep the version in sync with +// package.json on release. +const CLIENT_ID = "pixelvault-cli/0.5.0"; + export async function apiRequest( options: RequestOptions & { auth?: string } ): Promise { @@ -21,6 +27,10 @@ export async function apiRequest( const headers: Record = { ...options.headers }; + if (!headers["X-PixelVault-Client"]) { + headers["X-PixelVault-Client"] = CLIENT_ID; + } + if (options.auth) { headers["Authorization"] = `Bearer ${options.auth}`; } diff --git a/tests/commands/register.test.ts b/tests/commands/register.test.ts index e045ede..524e9ad 100644 --- a/tests/commands/register.test.ts +++ b/tests/commands/register.test.ts @@ -57,18 +57,28 @@ describe("register command — password handling", () => { it("omits password with --passwordless", async () => { await run({ email: "a@b.com", passwordless: true }); - expect(sentBody()).toEqual({ email: "a@b.com" }); + expect(sentBody()).toEqual({ + email: "a@b.com", + attribution: { source: "cli" }, + }); }); it("includes password when provided", async () => { apiRequest.mockResolvedValue(canned({ password_set: true })); await run({ email: "a@b.com", password: "longenough1" }); - expect(sentBody()).toEqual({ email: "a@b.com", password: "longenough1" }); + expect(sentBody()).toEqual({ + email: "a@b.com", + password: "longenough1", + attribution: { source: "cli" }, + }); }); it("defaults to passwordless in a non-TTY run with no password flags", async () => { await run({ email: "a@b.com" }); - expect(sentBody()).toEqual({ email: "a@b.com" }); + expect(sentBody()).toEqual({ + email: "a@b.com", + attribution: { source: "cli" }, + }); }); it("saves the returned API key to config", async () => { diff --git a/tests/lib/client.test.ts b/tests/lib/client.test.ts index 90e0151..0ded7b1 100644 --- a/tests/lib/client.test.ts +++ b/tests/lib/client.test.ts @@ -60,6 +60,45 @@ describe("client", () => { ); }); + it("sends the X-PixelVault-Client identity header on every request", async () => { + globalThis.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ data: {} }), + }); + + await apiRequest({ path: "/v1/images" }); + + expect(globalThis.fetch).toHaveBeenCalledWith( + "https://api.test.pixelvault.dev/v1/images", + expect.objectContaining({ + headers: expect.objectContaining({ + "X-PixelVault-Client": expect.stringMatching(/^pixelvault-cli\//), + }), + }) + ); + }); + + it("lets a caller override the X-PixelVault-Client header", async () => { + globalThis.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ data: {} }), + }); + + await apiRequest({ + path: "/v1/images", + headers: { "X-PixelVault-Client": "custom/9.9.9" }, + }); + + expect(globalThis.fetch).toHaveBeenCalledWith( + "https://api.test.pixelvault.dev/v1/images", + expect.objectContaining({ + headers: expect.objectContaining({ + "X-PixelVault-Client": "custom/9.9.9", + }), + }) + ); + }); + it("throws CliError on HTTP error", async () => { globalThis.fetch = vi.fn().mockResolvedValue({ ok: false,