diff --git a/.github/workflows/manage-npm-dist-tags.yml b/.github/workflows/manage-npm-dist-tags.yml index d614495..336af90 100644 --- a/.github/workflows/manage-npm-dist-tags.yml +++ b/.github/workflows/manage-npm-dist-tags.yml @@ -23,7 +23,7 @@ permissions: contents: read concurrency: - group: manage-npm-dist-tags + group: npm-call-e-calle cancel-in-progress: false jobs: @@ -70,6 +70,10 @@ jobs: echo "::error::version must be empty when action is remove." exit 1 fi + if [[ "$INPUT_TAG" == "latest" ]]; then + echo "::error::The latest dist-tag cannot be removed." + exit 1 + fi exit 0 fi @@ -77,6 +81,14 @@ jobs: echo "::error::version must be an exact semantic version without a leading v." exit 1 fi + if [[ "$INPUT_TAG" == "latest" && "$INPUT_VERSION" == *-* ]]; then + echo "::error::The latest dist-tag must point to a stable version." + exit 1 + fi + if [[ "$INPUT_TAG" == "beta" && "$INPUT_VERSION" != *-* ]]; then + echo "::error::The beta dist-tag must point to a prerelease version." + exit 1 + fi - name: Setup Node.js uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6 diff --git a/.github/workflows/publish-npm.yml b/.github/workflows/publish-npm.yml index 67198f9..489d362 100644 --- a/.github/workflows/publish-npm.yml +++ b/.github/workflows/publish-npm.yml @@ -9,7 +9,7 @@ permissions: contents: read concurrency: - group: publish-npm-${{ github.event.release.tag_name || github.ref }} + group: npm-call-e-calle cancel-in-progress: false jobs: @@ -90,6 +90,12 @@ jobs: exit 1 fi + if ! current_latest="$(npm view '@call-e/calle' dist-tags.latest --registry https://registry.npmjs.org 2>/dev/null)"; then + echo "::error::Could not read the current npm latest version." + exit 1 + fi + node scripts/require-newer-stable-version.mjs "$package_version" "$current_latest" + set +e version_lookup="$(npm view "@call-e/calle@${package_version}" version --registry https://registry.npmjs.org 2>&1)" lookup_status=$? @@ -262,7 +268,9 @@ jobs: set -euo pipefail for attempt in {1..10}; do - if [[ "$(npm view "@call-e/calle@${PACKAGE_VERSION}" version 2>/dev/null || true)" == "$PACKAGE_VERSION" ]]; then + visible_version="$(npm view "@call-e/calle@${PACKAGE_VERSION}" version 2>/dev/null || true)" + latest_version="$(npm view '@call-e/calle' dist-tags.latest 2>/dev/null || true)" + if [[ "$visible_version" == "$PACKAGE_VERSION" && "$latest_version" == "$PACKAGE_VERSION" ]]; then exit 0 fi echo "Published package metadata is not visible yet; retrying in 10 seconds." diff --git a/CHANGELOG.md b/CHANGELOG.md index e940371..d65740b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,3 +17,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Stable publishing is initiated by a versioned GitHub Release and uses npm Trusted Publishing. +- npm publishing and dist-tag changes are serialized, and stable releases must + advance the current `latest` version. +- The webhook receiver example bounds request bodies and safely handles + interrupted uploads. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 704fa28..65cbbfd 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -22,7 +22,7 @@ export CALLE_BASE_URL="https://api.heycall-e.com" export CALLE_EXAMPLE_PHONE="+14155550100" pnpm run example:create-and-wait -export CALLE_BASE_URL="https://test-api.heycall-e.com" +export CALLE_BASE_URL="https://api.heycall-e.com" export CALLE_GOAL_ID="" export CALLE_EXAMPLE_PHONE="" export CALLE_GOAL_VARIABLES='{"name":"Alex"}' diff --git a/README.md b/README.md index ae1d44c..8782e40 100644 --- a/README.md +++ b/README.md @@ -76,7 +76,7 @@ pnpm run example:create-and-wait Run a published Goal and wait for its structured result: ```bash -export CALLE_BASE_URL="https://test-api.heycall-e.com" +export CALLE_BASE_URL="https://api.heycall-e.com" export CALLE_GOAL_ID="" export CALLE_EXAMPLE_PHONE="" export CALLE_GOAL_VARIABLES='{"name":"Alex"}' @@ -123,7 +123,9 @@ event JSON without a webhook secret or signature headers. CALL-E sends the event only after the post-call outcome and requested structured results are finalized. Deduplicate side effects with the event `id` or `CALL-E-Event-Id`, and reject events when the required header does not match -the body `id`. +the body `id`. The example defaults to a 10 MiB request-body limit and returns +`413` for larger payloads. Set `CALLE_WEBHOOK_MAX_BODY_BYTES` to match your +provider and ingress limits. The `client.webhooks.verify` and signed `client.webhooks.unwrap` methods implement the legacy SDK `0.2` contract. They remain available for source @@ -264,4 +266,6 @@ in a public issue. Follow [SECURITY.md](./SECURITY.md) for private reporting. ## License -This project is licensed under the [MIT License](./LICENSE). +This project is licensed under the [MIT License](./LICENSE). The same license +applies to the published npm packages `@call-e/calle@0.6.0` and +`@call-e/calle@0.7.0`. diff --git a/RELEASE.md b/RELEASE.md index f6091df..89ab9b8 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -14,10 +14,10 @@ workflow. Configure the trusted publisher on npm with: - Environment name: `npm` - Allowed action: `npm publish` -The GitHub `npm` environment should require maintainer approval and restrict -deployments to protected release tags. The publish job uses OIDC and does not -read a long-lived npm token. `NPM_TOKEN` is retained only for the separate, -manually invoked dist-tag management workflow. +The GitHub `npm` environment should require maintainer approval and allow +deployments only from `main` and `v*` release tags. The publish job uses OIDC +and does not read a long-lived npm token. `NPM_TOKEN` is retained only for the +separate, manually invoked dist-tag management workflow. ## Release gates @@ -46,11 +46,13 @@ For a stable release: corresponding GitHub Release. The release workflow rejects prereleases, tags that do not exactly match the -`package.json` version, and tag commits that are not contained in `origin/main`. -Before dependency installation and again immediately before publication, it -checks npm for the exact version. Only an explicit not-found response is -treated as an available version; registry, network, and permission failures -stop the release. +`package.json` version, tag commits that are not contained in `origin/main`, +and versions that do not advance npm's current `latest` version. Before +dependency installation and again immediately before publication, it checks +npm for the exact version. Only an explicit not-found response is treated as +an available version; registry, network, and permission failures stop the +release. Package publication and manual dist-tag changes share one concurrency +lock. The build job validates and packs once, then uploads exactly one tarball and a SHA-256 manifest. After environment approval, the publish job downloads that @@ -64,7 +66,7 @@ against a published Goal in the test environment: ```bash export CALLE_API_KEY="" -export CALLE_BASE_URL="https://test-api.heycall-e.com" +export CALLE_BASE_URL="" export CALLE_GOAL_ID="" export CALLE_EXAMPLE_PHONE="" export CALLE_GOAL_VARIABLES='{"name":"Alex"}' @@ -108,8 +110,8 @@ version and a validated lowercase tag; `remove` requires a tag; `list` accepts neither. Mutating actions require the `NPM_TOKEN` repository secret and the `npm` environment. -Keep `latest` on the intended stable version. Reserve `beta` for prerelease -versions and do not move it to a stable version. +The workflow prevents removing `latest`, requires `latest` to target a stable +version, and requires `beta` to target a prerelease version. ## Version rules diff --git a/examples/webhook-server.ts b/examples/webhook-server.ts index 6547780..ceefd54 100644 --- a/examples/webhook-server.ts +++ b/examples/webhook-server.ts @@ -3,8 +3,17 @@ import type { IncomingMessage } from "node:http"; import type { WebhookEvent } from "../src/index.js"; const port = Number(process.env.PORT ?? "3000"); +const maxRequestBodyBytes = Number( + process.env.CALLE_WEBHOOK_MAX_BODY_BYTES ?? "10485760" +); const processedEventIds = new Set(); +class RequestBodyTooLargeError extends Error {} + +if (!Number.isSafeInteger(maxRequestBodyBytes) || maxRequestBodyBytes <= 0) { + throw new Error("CALLE_WEBHOOK_MAX_BODY_BYTES must be a positive integer."); +} + const server = createServer(async (request, response) => { if (request.method !== "POST" || request.url !== "/calle/webhook") { response.writeHead(404, { "content-type": "application/json" }); @@ -12,9 +21,8 @@ const server = createServer(async (request, response) => { return; } - const rawBody = await readRequestBody(request); - try { + const rawBody = await readRequestBody(request); const event = JSON.parse(rawBody.toString("utf8")) as WebhookEvent; const eventId = request.headers["call-e-event-id"]; if (typeof eventId !== "string" || eventId !== event.id) { @@ -52,9 +60,18 @@ const server = createServer(async (request, response) => { response.writeHead(200, { "content-type": "application/json" }); response.end(JSON.stringify({ received: true })); - } catch { - response.writeHead(400, { "content-type": "application/json" }); - response.end(JSON.stringify({ error: "invalid_json" })); + } catch (error) { + if (response.destroyed) { + return; + } + + const bodyTooLarge = error instanceof RequestBodyTooLargeError; + response.writeHead(bodyTooLarge ? 413 : 400, { + "content-type": "application/json" + }); + response.end( + JSON.stringify({ error: bodyTooLarge ? "payload_too_large" : "invalid_json" }) + ); } }); @@ -64,8 +81,15 @@ server.listen(port, () => { async function readRequestBody(request: IncomingMessage): Promise { const chunks: Buffer[] = []; + let totalBytes = 0; + for await (const chunk of request) { - chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + const bodyChunk = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); + totalBytes += bodyChunk.length; + if (totalBytes > maxRequestBodyBytes) { + throw new RequestBodyTooLargeError(); + } + chunks.push(bodyChunk); } - return Buffer.concat(chunks); + return Buffer.concat(chunks, totalBytes); } diff --git a/package.json b/package.json index 4f15b71..31001a1 100644 --- a/package.json +++ b/package.json @@ -42,8 +42,9 @@ "test:package": "pnpm run build && node --input-type=module -e \"import('./dist/index.js').then((mod) => { const client = new mod.CalleClient({ apiKey: 'smoke' }); if (typeof client.goals?.runAndWait !== 'function') process.exit(1); })\"", "pack": "rm -f call-e-calle-*.tgz && npm pack --silent", "test:tarball": "pnpm run build && pnpm run pack && tarball=$(find . -maxdepth 1 -name 'call-e-calle-*.tgz' -print -quit) && test -n \"$tarball\" && tar -tzf \"$tarball\" | grep -qx 'package/LICENSE' && tarball=\"$(pwd)/${tarball#./}\" && smoke_dir=$(mktemp -d) && trap 'rm -rf \"$smoke_dir\"' EXIT && cd \"$smoke_dir\" && npm init -y >/dev/null && npm install \"$tarball\" >/dev/null && node --input-type=module -e \"import('@call-e/calle').then((mod) => { const client = new mod.CalleClient({ apiKey: 'smoke' }); if (typeof client.goals?.runAndWait !== 'function') process.exit(1); })\" && ./node_modules/.bin/calle --help | grep \"Usage:\" >/dev/null", + "test:release-version": "node scripts/require-newer-stable-version.mjs --self-test", "check:public-repo": "node scripts/check-public-repo-hygiene.mjs", - "validate": "pnpm run verify:openapi && pnpm run test && pnpm run typecheck && pnpm run typecheck:examples && pnpm run check:public-repo && pnpm run test:package && pnpm run test:tarball", + "validate": "pnpm run verify:openapi && pnpm run test && pnpm run typecheck && pnpm run typecheck:examples && pnpm run test:release-version && pnpm run check:public-repo && pnpm run test:package && pnpm run test:tarball", "typecheck": "tsc -p tsconfig.json --noEmit" }, "publishConfig": { diff --git a/scripts/require-newer-stable-version.mjs b/scripts/require-newer-stable-version.mjs new file mode 100644 index 0000000..d665595 --- /dev/null +++ b/scripts/require-newer-stable-version.mjs @@ -0,0 +1,44 @@ +import assert from "node:assert/strict"; + +const stableVersion = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/u; + +function compare(left, right) { + const leftParts = parse(left); + const rightParts = parse(right); + for (let index = 0; index < leftParts.length; index += 1) { + if (leftParts[index] !== rightParts[index]) { + return leftParts[index] > rightParts[index] ? 1 : -1; + } + } + return 0; +} + +function parse(version) { + const match = stableVersion.exec(version); + if (!match) { + throw new Error(`Invalid stable version: ${version}`); + } + return match.slice(1).map(BigInt); +} + +const [candidate, current] = process.argv.slice(2); + +try { + if (candidate === "--self-test") { + assert.equal(compare("0.7.1", "0.7.0"), 1); + assert.equal(compare("1.0.0", "1.0.0"), 0); + assert.equal(compare("2.0.0", "10.0.0"), -1); + assert.equal(compare("100000000000000000000.0.0", "9.0.0"), 1); + assert.throws(() => compare("1.0.0-beta.1", "1.0.0")); + } else { + if (!candidate || !current) { + throw new Error("Usage: require-newer-stable-version "); + } + if (compare(candidate, current) <= 0) { + throw new Error(`Release version ${candidate} must be newer than npm latest ${current}.`); + } + } +} catch (error) { + console.error(error instanceof Error ? error.message : error); + process.exit(1); +} diff --git a/tests/cli.test.ts b/tests/cli.test.ts index a334f7b..3dfc07e 100644 --- a/tests/cli.test.ts +++ b/tests/cli.test.ts @@ -123,7 +123,7 @@ describe("calle CLI", () => { "--api-key", "cli_key", "--base-url", - "https://test-api.heycall-e.com", + "https://api.example.test", "--idempotency-key", "idem_123", "--interval-ms", @@ -141,7 +141,7 @@ describe("calle CLI", () => { expect(exitCode).toBe(0); expect(createClient).toHaveBeenCalledWith({ apiKey: "cli_key", - baseUrl: "https://test-api.heycall-e.com" + baseUrl: "https://api.example.test" }); expect(create).toHaveBeenCalledWith( { diff --git a/tests/webhook-server.test.ts b/tests/webhook-server.test.ts new file mode 100644 index 0000000..d6fabc0 --- /dev/null +++ b/tests/webhook-server.test.ts @@ -0,0 +1,71 @@ +import type { IncomingMessage, ServerResponse } from "node:http"; +import { Readable } from "node:stream"; +import { describe, expect, it, vi } from "vitest"; + +type RequestListener = ( + request: IncomingMessage, + response: ServerResponse +) => Promise; + +const state = vi.hoisted(() => ({ listener: undefined as RequestListener | undefined })); + +vi.mock("node:http", () => ({ + createServer: vi.fn((listener: RequestListener) => { + state.listener = listener; + return { listen: vi.fn() }; + }) +})); + +process.env.CALLE_WEBHOOK_MAX_BODY_BYTES = "8"; +await import("../examples/webhook-server.js"); +delete process.env.CALLE_WEBHOOK_MAX_BODY_BYTES; + +function response(destroyed = false): ServerResponse { + return { + destroyed, + writeHead: vi.fn(), + end: vi.fn() + } as unknown as ServerResponse; +} + +describe("webhook server example", () => { + it("bounds request bodies and handles aborted streams", async () => { + const listener = state.listener; + expect(listener).toBeDefined(); + + const oversizedRequest = Object.assign( + Readable.from([Buffer.alloc(9)]), + { + method: "POST", + url: "/calle/webhook", + headers: { "call-e-event-id": "evt_large" } + } + ) as IncomingMessage; + const oversizedResponse = response(); + + await listener!(oversizedRequest, oversizedResponse); + + expect(oversizedResponse.writeHead).toHaveBeenCalledWith(413, { + "content-type": "application/json" + }); + expect(oversizedResponse.end).toHaveBeenCalledWith( + JSON.stringify({ error: "payload_too_large" }) + ); + + const abortedRequest = { + method: "POST", + url: "/calle/webhook", + headers: { "call-e-event-id": "evt_abort" }, + async *[Symbol.asyncIterator]() { + yield Buffer.from("{"); + throw Object.assign(new Error("aborted"), { code: "ECONNRESET" }); + } + } as unknown as IncomingMessage; + const abortedResponse = response(true); + + await listener!(abortedRequest, abortedResponse); + + expect(abortedResponse.writeHead).not.toHaveBeenCalled(); + expect(abortedResponse.end).not.toHaveBeenCalled(); + }); +});