diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..d19086a --- /dev/null +++ b/.dockerignore @@ -0,0 +1,3 @@ +node_modules +bin +.git diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml new file mode 100644 index 0000000..62c2ce8 --- /dev/null +++ b/.github/workflows/docker.yml @@ -0,0 +1,26 @@ +name: Docker + +on: + push: + branches: [feat/headless-listen, main] + +jobs: + push: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: docker/setup-buildx-action@v3 + + - uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - uses: docker/build-push-action@v6 + with: + push: true + platforms: linux/amd64,linux/arm64 + tags: | + ${{ vars.DOCKERHUB_USERNAME }}/polar-cli:latest + ${{ vars.DOCKERHUB_USERNAME }}/polar-cli:${{ github.sha }} diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..344221f --- /dev/null +++ b/Dockerfile @@ -0,0 +1,13 @@ +FROM oven/bun AS builder + +WORKDIR /app +COPY package.json bun.lock* pnpm-lock.yaml ./ +RUN bun install --frozen-lockfile +COPY . . +RUN bun build ./src/cli.ts --compile --outfile polar + +FROM debian:bookworm-slim + +COPY --from=builder /app/polar /usr/local/bin/polar + +ENTRYPOINT ["polar"] diff --git a/package.json b/package.json index d6785f4..661346d 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,7 @@ "scripts": { "build": "tsup ./src/cli.ts --format esm --outDir bin", "dev": "tsc --watch", - "test": "echo \"Error: no test specified\" && exit 1", + "test": "bun test", "check": "biome check --write ./src", "build:binary": "bun build ./src/cli.ts --compile --outfile polar", "build:binary:darwin-arm64": "bun build ./src/cli.ts --compile --target=bun-darwin-arm64 --outfile polar", diff --git a/src/commands/listen.test.ts b/src/commands/listen.test.ts new file mode 100644 index 0000000..d8ccbc0 --- /dev/null +++ b/src/commands/listen.test.ts @@ -0,0 +1,104 @@ +import { describe, expect, it } from "bun:test"; +import { Webhook } from "standardwebhooks"; +import { signPayload } from "./listen"; + +/** + * These tests verify that signPayload produces signatures compatible with + * both the @polar-sh/sdk's validateEvent and raw standardwebhooks verification. + * + * The key derivation chain in @polar-sh/sdk's validateEvent is: + * 1. base64Encode(secret) → pass to new Webhook() + * 2. Webhook constructor base64Decodes → raw UTF-8 bytes of secret + * 3. HMAC-SHA256 with those bytes + * + * signPayload must produce the same HMAC, so it uses Buffer.from(secret, "utf-8") + * directly as the key. + */ + +function verifyWithStandardWebhooks( + body: string, + headers: Record, + secret: string, +): unknown { + // Replicate what @polar-sh/sdk's validateEvent does: + // base64-encode the raw secret, pass to Webhook constructor + const base64Secret = Buffer.from(secret, "utf-8").toString("base64"); + const wh = new Webhook(base64Secret); + return wh.verify(body, headers); +} + +describe("signPayload", () => { + it("produces headers that pass standardwebhooks verification", () => { + const secret = "test-webhook-secret"; + const body = JSON.stringify({ + type: "checkout.created", + data: { id: "123" }, + }); + + const headers = signPayload(body, secret); + + expect(headers["webhook-id"]).toStartWith("msg_"); + expect(headers["webhook-timestamp"]).toBeDefined(); + expect(headers["webhook-signature"]).toStartWith("v1,"); + expect(headers["content-type"]).toBe("application/json"); + + // The signature must be verifiable using the same key derivation as validateEvent + const parsed = verifyWithStandardWebhooks(body, headers, secret); + expect(parsed).toEqual(JSON.parse(body)); + }); + + it("works with secrets containing special characters", () => { + const secret = "s3cr3t!@#$%^&*()_+-="; + const body = JSON.stringify({ type: "order.created", data: {} }); + + const headers = signPayload(body, secret); + const parsed = verifyWithStandardWebhooks(body, headers, secret); + expect(parsed).toEqual(JSON.parse(body)); + }); + + it("works with long secrets", () => { + const secret = "a".repeat(256); + const body = JSON.stringify({ + type: "subscription.active", + data: { id: "sub_1" }, + }); + + const headers = signPayload(body, secret); + const parsed = verifyWithStandardWebhooks(body, headers, secret); + expect(parsed).toEqual(JSON.parse(body)); + }); + + it("fails verification with a different secret", () => { + const body = JSON.stringify({ type: "checkout.created", data: {} }); + const headers = signPayload(body, "correct-secret"); + + expect(() => + verifyWithStandardWebhooks(body, headers, "wrong-secret"), + ).toThrow(); + }); + + it("fails verification if body is tampered", () => { + const secret = "test-secret"; + const body = JSON.stringify({ + type: "checkout.created", + data: { id: "123" }, + }); + const headers = signPayload(body, secret); + + const tampered = JSON.stringify({ + type: "checkout.created", + data: { id: "456" }, + }); + expect(() => + verifyWithStandardWebhooks(tampered, headers, secret), + ).toThrow(); + }); + + it("produces unique message IDs per call", () => { + const body = JSON.stringify({ type: "test", data: {} }); + const h1 = signPayload(body, "secret"); + const h2 = signPayload(body, "secret"); + + expect(h1["webhook-id"]).not.toBe(h2["webhook-id"]); + }); +}); diff --git a/src/commands/listen.ts b/src/commands/listen.ts index 97072d8..b668757 100644 --- a/src/commands/listen.ts +++ b/src/commands/listen.ts @@ -1,135 +1,401 @@ -import { Args, Command } from "@effect/cli"; -import { Effect, Either, Redacted, Schema } from "effect"; +import { createHmac, randomUUID } from "node:crypto"; +import { Args, Command, Options } from "@effect/cli"; +import { Effect, Either, Option, Redacted, Schema } from "effect"; import { EventSource } from "eventsource"; import { environmentPrompt } from "../prompts/environment"; import { organizationLoginPrompt } from "../prompts/organizations"; import { ListenAck, ListenWebhookEvent } from "../schemas/Events"; -import type { Token } from "../schemas/Tokens"; import * as OAuth from "../services/oauth"; +import type * as Polar from "../services/polar"; const LISTEN_BASE_URLS = { - production: "https://api.polar.sh/v1/cli/listen", - sandbox: "https://sandbox-api.polar.sh/v1/cli/listen", + production: "https://api.polar.sh/v1/cli/listen", + sandbox: "https://sandbox-api.polar.sh/v1/cli/listen", +} as const; + +const API_BASE_URLS = { + production: "https://api.polar.sh", + sandbox: "https://sandbox-api.polar.sh", } as const; const url = Args.text({ name: "url" }); -export const listen = Command.make("listen", { url }, ({ url }) => - Effect.gen(function* () { - const environment = yield* environmentPrompt; - const oauth = yield* OAuth.OAuth; - const organization = yield* organizationLoginPrompt(environment); - const listenUrl = `${LISTEN_BASE_URLS[environment]}/${organization.id}`; - - const startListening = (accessToken: string) => - Effect.async((resume) => { - const eventSource = new EventSource(listenUrl, { - fetch: (input, init) => - fetch(input, { - ...init, - headers: { - ...init.headers, - Authorization: `Bearer ${accessToken}`, - }, - }), - }); - - eventSource.onmessage = (event) => { - const json = JSON.parse(event.data); - const ack = Schema.decodeUnknownEither(ListenAck)(json); - - if (Either.isRight(ack)) { - const { secret } = ack.right; - const dim = "\x1b[2m"; - const bold = "\x1b[1m"; - const cyan = "\x1b[36m"; - const reset = "\x1b[0m"; - - console.log(""); - console.log( - ` ${bold}${cyan}Connected${reset} ${bold}${organization.name}${reset}`, - ); - console.log(` ${dim}Secret${reset} ${secret}`); - console.log(` ${dim}Forwarding${reset} ${url}`); - console.log(""); - console.log(` ${dim}Waiting for events...${reset}`); - console.log(""); - - return; - } - - const webhookEvent = - Schema.decodeUnknownEither(ListenWebhookEvent)(json); - - if (Either.isLeft(webhookEvent)) { - console.error(">> Failed to decode event"); - return; - } - - fetch(url, { - method: "POST", - headers: webhookEvent.right.headers, - body: JSON.stringify(webhookEvent.right.payload.payload), - }) - .then((res) => { - const cyan = "\x1b[36m"; - const reset = "\x1b[0m"; - console.log( - `>> '${cyan}${webhookEvent.right.payload.payload.type}${reset}' >> ${res.status} ${res.statusText}`, - ); - }) - .catch((err) => { - console.error(`>> Failed to forward event: ${err}`); - }); - }; - - eventSource.onerror = (error) => { - eventSource.close(); - resume( - Effect.fail( - new OAuth.OAuthError({ - message: - error.message ?? - (error.code - ? `Event stream error (${error.code})` - : "Event stream error"), - cause: error, - }), - ), - ); - }; - - return Effect.sync(() => { - eventSource.close(); - }); - }); - - const isUnauthorized = (error: OAuth.OAuthError) => - (typeof error.cause === "object" && - error.cause !== null && - "code" in error.cause && - (error.cause as { code?: number }).code === 401) || - error.message.includes("401"); - - const listenWithToken = ( - token: Token, - retried = false, - ): Effect.Effect => - startListening(Redacted.value(token.token)).pipe( - Effect.catchTag("OAuthError", (error) => { - if (retried || !isUnauthorized(error)) { - return Effect.fail(error); - } - - return oauth - .login(environment) - .pipe( - Effect.flatMap((newToken) => listenWithToken(newToken, true)), - ); - }), - ); - - const token = yield* oauth.resolveAccessToken(environment); - yield* listenWithToken(token); - }), +const accessTokenOption = Options.text("access-token").pipe( + Options.optional, + Options.withDescription( + "Personal access token (skips OAuth login). Can also be set via POLAR_ACCESS_TOKEN env var.", + ), +); + +const envOption = Options.choice("env", ["sandbox", "production"]).pipe( + Options.optional, + Options.withDescription( + "Environment to use (skips interactive prompt). Can also be set via POLAR_ENVIRONMENT env var.", + ), +); + +const orgOption = Options.text("org").pipe( + Options.optional, + Options.withDescription( + "Organization slug or ID (skips interactive prompt). Auto-selects if only one org exists.", + ), +); + +const webhookSecretOption = Options.text("webhook-secret").pipe( + Options.optional, + Options.withDescription( + "Webhook secret to re-sign forwarded payloads (standardwebhooks format). " + + "Can also be set via POLAR_WEBHOOK_SECRET env var. " + + "When provided, the relay re-signs each payload so the receiving app's " + + "signature verification passes.", + ), +); + +/** + * Re-sign a webhook payload using the standardwebhooks format. + * + * Key derivation must match @polar-sh/sdk's `validateEvent`, which does: + * base64Secret = Buffer.from(secret, "utf-8").toString("base64") + * new Webhook(base64Secret) // internally: base64.decode(base64Secret) + * + * The round-trip means the effective HMAC key is always the raw UTF-8 bytes + * of the secret string. We replicate that directly here. + */ +export function signPayload( + body: string, + secret: string, +): Record { + const msgId = `msg_${randomUUID()}`; + const timestamp = Math.floor(Date.now() / 1000).toString(); + const toSign = `${msgId}.${timestamp}.${body}`; + const key = Buffer.from(secret, "utf-8"); + const sig = createHmac("sha256", key).update(toSign).digest("base64"); + return { + "webhook-id": msgId, + "webhook-timestamp": timestamp, + "webhook-signature": `v1,${sig}`, + "content-type": "application/json", + }; +} + +/** + * Resolve the environment from (in order of priority): + * 1. --env flag + * 2. POLAR_ENVIRONMENT env var + * 3. Interactive prompt + */ +const resolveEnvironment = ( + envFlag: Option.Option, +): Effect.Effect => { + if (Option.isSome(envFlag)) { + return Effect.succeed(envFlag.value as OAuth.PolarEnvironment); + } + const envVar = process.env.POLAR_ENVIRONMENT; + if (envVar === "sandbox" || envVar === "production") { + return Effect.succeed(envVar); + } + return environmentPrompt; +}; + +/** + * Resolve the access token from (in order of priority): + * 1. --access-token flag + * 2. POLAR_ACCESS_TOKEN env var + * 3. OAuth login flow + */ +const resolveAccessToken = ( + tokenFlag: Option.Option, + environment: OAuth.PolarEnvironment, +): Effect.Effect => { + if (Option.isSome(tokenFlag)) { + return Effect.succeed(tokenFlag.value); + } + const envVar = process.env.POLAR_ACCESS_TOKEN; + if (envVar) { + return Effect.succeed(envVar); + } + return Effect.gen(function* () { + const oauth = yield* OAuth.OAuth; + const token = yield* oauth.resolveAccessToken(environment); + return Redacted.value(token.token); + }); +}; + +/** + * Resolve the organization. When using a personal access token (non-OAuth), + * we fetch orgs directly from the API. When using OAuth, we use the existing + * interactive prompt. + * + * Resolution order: + * 1. --org flag (slug or ID) + * 2. Auto-select if only one org + * 3. Interactive prompt (OAuth flow only) + */ +const resolveOrganization = ( + orgFlag: Option.Option, + environment: OAuth.PolarEnvironment, + accessToken: string, + isPersonalToken: boolean, +): Effect.Effect< + { id: string; slug: string; name: string }, + OAuth.OAuthError | Polar.PolarError +> => { + if (isPersonalToken) { + // When using a personal access token, fetch orgs directly via API + return Effect.gen(function* () { + const baseUrl = API_BASE_URLS[environment]; + const res = yield* Effect.tryPromise({ + try: () => + fetch(`${baseUrl}/v1/organizations?page=1&limit=100`, { + headers: { Authorization: `Bearer ${accessToken}` }, + }).then(async (r) => { + if (!r.ok) throw new Error(`${r.status} ${await r.text()}`); + return r.json() as Promise<{ + items: Array<{ id: string; slug: string; name: string }>; + }>; + }), + catch: (error) => + new OAuth.OAuthError({ + message: `Failed to fetch organizations: ${error}`, + cause: error, + }), + }); + + const orgs = res.items; + if (orgs.length === 0) { + return yield* Effect.fail( + new OAuth.OAuthError({ + message: "No organizations found for this access token", + }), + ); + } + + // If --org flag provided, match by slug or ID + if (Option.isSome(orgFlag)) { + const match = orgs.find( + (o) => o.slug === orgFlag.value || o.id === orgFlag.value, + ); + if (!match) { + return yield* Effect.fail( + new OAuth.OAuthError({ + message: `Organization "${orgFlag.value}" not found. Available: ${orgs.map((o) => o.slug).join(", ")}`, + }), + ); + } + return match; + } + + // Auto-select if only one org + if (orgs.length === 1) { + return orgs[0]; + } + + // Multiple orgs, no flag — fail with helpful message in headless mode + return yield* Effect.fail( + new OAuth.OAuthError({ + message: `Multiple organizations found. Use --org to select one: ${orgs.map((o) => o.slug).join(", ")}`, + }), + ); + }); + } + + // OAuth flow — use the interactive prompt + return organizationLoginPrompt(environment); +}; + +export const listen = Command.make( + "listen", + { + url, + accessToken: accessTokenOption, + env: envOption, + org: orgOption, + webhookSecret: webhookSecretOption, + }, + ({ + url, + accessToken: accessTokenFlag, + env: envFlag, + org: orgFlag, + webhookSecret: webhookSecretFlag, + }) => + Effect.gen(function* () { + const webhookSecret = Option.isSome(webhookSecretFlag) + ? webhookSecretFlag.value + : (process.env.POLAR_WEBHOOK_SECRET ?? null); + const environment = yield* resolveEnvironment(envFlag); + const isPersonalToken = + Option.isSome(accessTokenFlag) || !!process.env.POLAR_ACCESS_TOKEN; + const accessToken = yield* resolveAccessToken( + accessTokenFlag, + environment, + ); + const organization = yield* resolveOrganization( + orgFlag, + environment, + accessToken, + isPersonalToken, + ); + const listenUrl = `${LISTEN_BASE_URLS[environment]}/${organization.id}`; + + const startListening = (token: string) => + Effect.async((resume) => { + const eventSource = new EventSource(listenUrl, { + fetch: (input, init) => + fetch(input, { + ...init, + headers: { + ...init.headers, + Authorization: `Bearer ${token}`, + }, + }), + }); + + eventSource.onmessage = (event) => { + const json = JSON.parse(event.data); + const ack = Schema.decodeUnknownEither(ListenAck)(json); + + if (Either.isRight(ack)) { + const { secret } = ack.right; + const dim = "\x1b[2m"; + const bold = "\x1b[1m"; + const cyan = "\x1b[36m"; + const reset = "\x1b[0m"; + + console.log(""); + console.log( + ` ${bold}${cyan}Connected${reset} ${bold}${organization.name}${reset}`, + ); + console.log(` ${dim}Secret${reset} ${secret}`); + console.log(` ${dim}Forwarding${reset} ${url}`); + if (webhookSecret) { + console.log( + ` ${dim}Signing${reset} enabled (--webhook-secret)`, + ); + } + console.log(""); + console.log(` ${dim}Waiting for events...${reset}`); + console.log(""); + + return; + } + + const webhookEvent = + Schema.decodeUnknownEither(ListenWebhookEvent)(json); + + if (Either.isLeft(webhookEvent)) { + const dim = "\x1b[2m"; + const reset = "\x1b[0m"; + const type = json?.type ?? json?.payload?.type ?? "unknown"; + console.error(`>> Failed to decode event: ${dim}${type}${reset}`); + console.error( + ` ${dim}${JSON.stringify(json).slice(0, 200)}${reset}`, + ); + return; + } + + const forwardBody = JSON.stringify( + webhookEvent.right.payload.payload, + ); + const forwardHeaders = webhookSecret + ? signPayload(forwardBody, webhookSecret) + : webhookEvent.right.headers; + + fetch(url, { + method: "POST", + headers: forwardHeaders, + body: forwardBody, + }) + .then((res) => { + const cyan = "\x1b[36m"; + const reset = "\x1b[0m"; + console.log( + `>> '${cyan}${webhookEvent.right.payload.payload.type}${reset}' >> ${res.status} ${res.statusText}`, + ); + }) + .catch((err) => { + console.error(`>> Failed to forward event: ${err}`); + }); + }; + + eventSource.onerror = (error) => { + eventSource.close(); + resume( + Effect.fail( + new OAuth.OAuthError({ + message: + error.message ?? + (error.code + ? `Event stream error (${error.code})` + : "Event stream error"), + cause: error, + }), + ), + ); + }; + + return Effect.sync(() => { + eventSource.close(); + }); + }); + + const isUnauthorized = (error: OAuth.OAuthError) => + (typeof error.cause === "object" && + error.cause !== null && + "code" in error.cause && + (error.cause as { code?: number }).code === 401) || + error.message.includes("401"); + + const RECONNECT_DELAY_MS = 3_000; + + const listenWithRetry = ( + token: string, + authRetried = false, + reconnectDelay = RECONNECT_DELAY_MS, + ): Effect.Effect => + startListening(token).pipe( + Effect.catchTag("OAuthError", (error) => { + // 401 — try refreshing the token once + if (isUnauthorized(error)) { + if (authRetried) return Effect.fail(error); + + if (isPersonalToken) { + return Effect.fail( + new OAuth.OAuthError({ + message: + "Access token was rejected (401). Check that your token is valid and has the required scopes.", + }), + ); + } + + return Effect.gen(function* () { + const oauth = yield* OAuth.OAuth; + const newToken = yield* oauth.login(environment); + return yield* listenWithRetry( + Redacted.value(newToken.token), + true, + ); + }); + } + + // Non-auth error (SSE disconnect, network hiccup) — reconnect + const yellow = "\x1b[33m"; + const dim = "\x1b[2m"; + const reset = "\x1b[0m"; + const delaySec = (reconnectDelay / 1000).toFixed(0); + console.error( + `\n ${yellow}Connection lost${reset} ${dim}(${error.message})${reset}`, + ); + console.error(` ${dim}Reconnecting in ${delaySec}s...${reset}\n`); + + return Effect.sleep(reconnectDelay).pipe( + Effect.flatMap(() => + listenWithRetry(token, false, RECONNECT_DELAY_MS), + ), + ); + }), + ); + + yield* listenWithRetry(accessToken); + }), );