diff --git a/bun.lock b/bun.lock index 41f30d4..ff412e0 100644 --- a/bun.lock +++ b/bun.lock @@ -1,5 +1,6 @@ { "lockfileVersion": 1, + "configVersion": 0, "workspaces": { "": { "name": "polar-cli", @@ -8,9 +9,10 @@ "@effect/platform": "^0.90.2", "@effect/platform-bun": "^0.87.1", "@lemonsqueezy/lemonsqueezy.js": "^4.0.0", - "@polar-sh/sdk": "^0.34.11", + "@polar-sh/sdk": "^0.47.0", "effect": "^3.17.7", "eventsource": "^4.1.0", + "lossless-json": "^4.3.0", "open": "^10.2.0", }, "devDependencies": { @@ -170,7 +172,7 @@ "@parcel/watcher-win32-x64": ["@parcel/watcher-win32-x64@2.5.6", "", { "os": "win32", "cpu": "x64" }, "sha512-hbQlYcCq5dlAX9Qx+kFb0FHue6vbjlf0FrNzSKdYK2APUf7tGfGxQCk2ihEREmbR6ZMc0MVAD5RIX/41gpUzTw=="], - "@polar-sh/sdk": ["@polar-sh/sdk@0.34.17", "", { "dependencies": { "standardwebhooks": "^1.0.0", "zod": "^3.25.76" }, "peerDependencies": { "@modelcontextprotocol/sdk": ">=1.5.0 <1.10.0" }, "optionalPeers": ["@modelcontextprotocol/sdk"], "bin": { "mcp": "bin/mcp-server.js" } }, "sha512-+eJAAyyP4CAtMy9Hd6gaNXErjaH3KuTXJFv72kqlCCvv7SweBlM4U2+zpeYAZvd/YMRZq/c447f0a0DD2e7UEA=="], + "@polar-sh/sdk": ["@polar-sh/sdk@0.47.1", "", { "dependencies": { "standardwebhooks": "^1.0.0", "zod": "^3.25.65 || ^4.0.0" } }, "sha512-fkz7wPLbqfuDmY9LxuXpE2uP2TAV6J0q/YN5hJ4UBxpjbkB0hKM6c4R35N89t83dfzMlG6EOlqOn+Rd1T6XrJQ=="], "@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.57.1", "", { "os": "android", "cpu": "arm" }, "sha512-A6ehUVSiSaaliTxai040ZpZ2zTevHYbvu/lDoeAteHI8QnaosIzm4qwtezfRg1jOYaUmnzLX1AOD6Z+UJjtifg=="], @@ -302,6 +304,8 @@ "load-tsconfig": ["load-tsconfig@0.2.5", "", {}, "sha512-IXO6OCs9yg8tMKzfPZ1YmheJbZCiEsnBdcB03l0OcfK9prKnJb96siuHCr5Fl37/yo9DnKU+TLpxzTUspw9shg=="], + "lossless-json": ["lossless-json@4.3.0", "", {}, "sha512-ToxOC+SsduRmdSuoLZLYAr5zy1Qu7l5XhmPWM3zefCZ5IcrzW/h108qbJUKfOlDlhvhjUK84+8PSVX0kxnit0g=="], + "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], "mlly": ["mlly@1.8.0", "", { "dependencies": { "acorn": "^8.15.0", "pathe": "^2.0.3", "pkg-types": "^1.3.1", "ufo": "^1.6.1" } }, "sha512-l8D9ODSRWLe2KHJSifWGwBqpTZXIXTeo8mlKjY+E2HAakaTeNpqAyBZ8GSqLzHgw4XmHmC8whvpjJNMbFZN7/g=="], diff --git a/package.json b/package.json index 92e676a..732dbbd 100644 --- a/package.json +++ b/package.json @@ -30,6 +30,7 @@ "@polar-sh/sdk": "^0.47.0", "effect": "^3.17.7", "eventsource": "^4.1.0", + "lossless-json": "^4.3.0", "open": "^10.2.0" }, "devDependencies": { diff --git a/src/commands/listen.test.ts b/src/commands/listen.test.ts index 383aa70..a837bed 100644 --- a/src/commands/listen.test.ts +++ b/src/commands/listen.test.ts @@ -3,6 +3,7 @@ import { Effect, Fiber } from "effect"; import { type CreateEventSource, type ListenEventSource, + signedWebhookBody, startListening, } from "./listen"; @@ -34,6 +35,11 @@ class FakeEventSource implements ListenEventSource { emit(data: unknown) { this.onmessage?.({ data: JSON.stringify(data) } as MessageEvent); } + + /** Simulate the server pushing a message frame with exact raw bytes. */ + emitRaw(data: string) { + this.onmessage?.({ data } as MessageEvent); + } } /** Returns the event source at `index`, asserting it exists. */ @@ -77,6 +83,32 @@ afterEach(async () => { FakeEventSource.instances = []; }); +describe("signedWebhookBody", () => { + test("rebuilds the compact body from a spaced frame, keeping number literals", () => { + const frame = + '{"key": "webhook.created", "payload": {"webhook_event_id": "whid_1", "payload": {"type": "customer.state_changed", "data": {"consumed_units": 0.0, "credited_units": 10.50, "balance": 1e3, "count": 42}}}}'; + + expect(signedWebhookBody(frame)).toBe( + '{"type":"customer.state_changed","data":{"consumed_units":0.0,"credited_units":10.50,"balance":1e3,"count":42}}', + ); + }); + + test("preserves key order and nested structure", () => { + const frame = + '{"payload": {"payload": {"b": [1.0, {"z": 2.50}], "a": null, "ok": true}}}'; + + expect(signedWebhookBody(frame)).toBe( + '{"b":[1.0,{"z":2.50}],"a":null,"ok":true}', + ); + }); + + test("returns undefined when the frame carries no body", () => { + expect(signedWebhookBody('{"key": "connected", "secret": "whsec"}')).toBeUndefined(); + expect(signedWebhookBody('{"payload": {}}')).toBeUndefined(); + expect(signedWebhookBody('"just a string"')).toBeUndefined(); + }); +}); + describe("startListening", () => { test("opens a single event source for the listen url", () => { run(); @@ -140,6 +172,23 @@ describe("startListening", () => { ); }); + test("forwards the signed bytes for usage metering payloads", () => { + const forward = mock(okResponse) as unknown as typeof fetch; + run({ forward }); + + instanceAt(0).emitRaw( + '{"id": "evt_1", "key": "webhook", "payload": {"webhook_event_id": "whid_1", "payload": {"type": "customer.state_changed", "timestamp": "2026-01-01T00:00:00Z", "data": {"active_meters": [{"consumed_units": 0.0, "credited_units": 10.50, "balance": 1e3}]}}}, "headers": {"user-agent": "polar.sh webhooks", "content-type": "application/json", "webhook-id": "wh_1", "webhook-timestamp": "12345", "webhook-signature": "sig"}}', + ); + + expect(forward).toHaveBeenCalledWith( + "http://localhost:3000/webhook", + expect.objectContaining({ + method: "POST", + body: '{"type":"customer.state_changed","timestamp":"2026-01-01T00:00:00Z","data":{"active_meters":[{"consumed_units":0.0,"credited_units":10.50,"balance":1e3}]}}', + }), + ); + }); + test("reconnects when the server sends a reconnect event", () => { run(); diff --git a/src/commands/listen.ts b/src/commands/listen.ts index d2d9416..66ac2d3 100644 --- a/src/commands/listen.ts +++ b/src/commands/listen.ts @@ -5,6 +5,7 @@ import { EventSource, type EventSourceInit, } from "eventsource"; +import { parse, stringify } from "lossless-json"; import { environmentPrompt } from "../prompts/environment"; import { organizationLoginPrompt } from "../prompts/organizations"; import { @@ -56,6 +57,23 @@ export interface StartListeningOptions { const defaultCreateEventSource: CreateEventSource = (listenUrl, init) => new EventSource(listenUrl, init); +const hasPayload = (value: unknown): value is { payload: unknown } => + typeof value === "object" && value !== null && "payload" in value; + +/** + * Rebuilds the exact bytes Polar signed for the webhook body embedded in a + * listen stream frame, or `undefined` when the frame carries no body. + */ +export const signedWebhookBody = (frame: string): string | undefined => { + const parsed = parse(frame); + + if (!hasPayload(parsed) || !hasPayload(parsed.payload)) { + return undefined; + } + + return stringify(parsed.payload.payload); +}; + /** * Opens the CLI listen stream and forwards incoming webhook events to the local * server. When the server emits a `reconnect` event the current connection is @@ -130,8 +148,9 @@ export const startListening = ({ const webhookEvent = Schema.decodeUnknownEither(ListenWebhookEvent)(json); + const body = signedWebhookBody(event.data); - if (Either.isLeft(webhookEvent)) { + if (Either.isLeft(webhookEvent) || body === undefined) { console.error(">> Failed to decode event"); return; } @@ -139,7 +158,7 @@ export const startListening = ({ forward(forwardUrl, { method: "POST", headers: webhookEvent.right.headers, - body: JSON.stringify(webhookEvent.right.payload.payload), + body, }) .then((res) => { const cyan = "\x1b[36m";