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,