Skip to content
Open
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
3 changes: 3 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
node_modules
bin
.git
26 changes: 26 additions & 0 deletions .github/workflows/docker.yml
Original file line number Diff line number Diff line change
@@ -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 }}
13 changes: 13 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -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"]
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
104 changes: 104 additions & 0 deletions src/commands/listen.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, string>,
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"]);
});
});
Loading