From ffcb1a3d22ee37095b8628a1814314dfcd3a51e5 Mon Sep 17 00:00:00 2001 From: Karl Waldman Date: Tue, 11 Aug 2026 12:06:40 -0400 Subject: [PATCH 1/2] fix: fail closed on npm publication state --- .github/workflows/publish.yml | 39 ++++++++--- CHANGELOG.md | 10 +++ package-lock.json | 4 +- package.json | 2 +- scripts/resolve-npm-publication.mjs | 101 ++++++++++++++++++++++++++++ src/version.ts | 2 +- tests/npm-publication-state.test.ts | 97 ++++++++++++++++++++++++++ tests/release-readiness.test.ts | 7 +- 8 files changed, 246 insertions(+), 16 deletions(-) create mode 100644 scripts/resolve-npm-publication.mjs create mode 100644 tests/npm-publication-state.test.ts diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 8c8b00b..0634ca9 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -93,6 +93,7 @@ jobs: jq -e '.[0] | {filename, integrity, shasum}' \ "$RUNNER_TEMP/npm-pack.json" > "$ARTIFACT_DIR/pack-metadata.json" cp artifacts/snippets/* "$ARTIFACT_DIR/snippets/" + cp scripts/resolve-npm-publication.mjs "$ARTIFACT_DIR/" NPM_ROOT="$(npm root --global)" tar -czf "$ARTIFACT_DIR/npm-cli-11.12.1.tgz" -C "$NPM_ROOT" npm @@ -147,23 +148,39 @@ jobs: EXPECTED_INTEGRITY="$(jq -er '.integrity' pack-metadata.json)" NAME="$(tar -xOf "$PACKAGE_FILE" package/package.json | jq -er '.name')" VERSION="$(tar -xOf "$PACKAGE_FILE" package/package.json | jq -er '.version')" - EXISTING_INTEGRITY="$(timeout 30s "${NPM[@]}" view "$NAME@$VERSION" dist.integrity --json 2>/dev/null | jq -r '. // empty' || true)" - - if [ -n "$EXISTING_INTEGRITY" ]; then - if [ "$EXISTING_INTEGRITY" != "$EXPECTED_INTEGRITY" ]; then - echo "::error::npm $NAME@$VERSION exists with unexpected integrity" - exit 1 - fi + PUBLICATION_STATE="$( + NPM_PUBLICATION_MODE=publication-state \ + NPM_PUBLICATION_EXPECTED_NAME="$NAME" \ + NPM_PUBLICATION_EXPECTED_VERSION="$VERSION" \ + NPM_PUBLICATION_EXPECTED_INTEGRITY="$EXPECTED_INTEGRITY" \ + NPM_PUBLICATION_TIMEOUT_MS=10000 \ + timeout 30s node resolve-npm-publication.mjs + )" + + if [ "$PUBLICATION_STATE" = "present" ]; then echo "npm $NAME@$VERSION already matches the verified tarball; continuing recovery." - else + elif [ "$PUBLICATION_STATE" = "absent" ]; then timeout 10m "${NPM[@]}" publish "$PACKAGE_FILE" \ --ignore-scripts --provenance --access public + else + echo "::error::Unexpected npm publication state: $PUBLICATION_STATE" + exit 1 fi for attempt in $(seq 1 12); do - PUBLIC_INTEGRITY="$(timeout 30s "${NPM[@]}" view "$NAME@$VERSION" dist.integrity --json 2>/dev/null | jq -r '. // empty' || true)" - PUBLIC_LATEST="$(timeout 30s "${NPM[@]}" view "$NAME" dist-tags.latest --json 2>/dev/null | jq -r '. // empty' || true)" - if [ "$PUBLIC_INTEGRITY" = "$EXPECTED_INTEGRITY" ] && [ "$PUBLIC_LATEST" = "$VERSION" ]; then + if NPM_PUBLICATION_MODE=require-present \ + NPM_PUBLICATION_EXPECTED_NAME="$NAME" \ + NPM_PUBLICATION_EXPECTED_VERSION="$VERSION" \ + NPM_PUBLICATION_EXPECTED_INTEGRITY="$EXPECTED_INTEGRITY" \ + NPM_PUBLICATION_TIMEOUT_MS=10000 \ + timeout 30s node resolve-npm-publication.mjs \ + && NPM_PUBLICATION_MODE=require-present \ + NPM_PUBLICATION_EXPECTED_NAME="$NAME" \ + NPM_PUBLICATION_EXPECTED_VERSION="$VERSION" \ + NPM_PUBLICATION_EXPECTED_INTEGRITY="$EXPECTED_INTEGRITY" \ + NPM_PUBLICATION_SELECTOR=latest \ + NPM_PUBLICATION_TIMEOUT_MS=10000 \ + timeout 30s node resolve-npm-publication.mjs; then echo "Verified npm $NAME@$VERSION integrity and latest tag." exit 0 fi diff --git a/CHANGELOG.md b/CHANGELOG.md index 349e60f..803ea0f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,16 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [1.2.3] - 2026-08-11 + +### Fixed + +- Resolve npm publication state from typed registry responses so only an exact + HTTP 404 permits first publication, while mismatched metadata, malformed + responses, server errors, and network failures stop the release. +- Verify both the exact public version and the `latest` tag against the + checksummed tarball before completing the release. + ## [1.2.2] - 2026-08-11 ### Added diff --git a/package-lock.json b/package-lock.json index 7afd1ef..5ddb28d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "oilpriceapi", - "version": "1.2.2", + "version": "1.2.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "oilpriceapi", - "version": "1.2.2", + "version": "1.2.3", "license": "MIT", "dependencies": { "ws": "^8.21.0" diff --git a/package.json b/package.json index bef21b7..4ad0a61 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "oilpriceapi", - "version": "1.2.2", + "version": "1.2.3", "description": "Official Node.js SDK for source-timestamped OilPriceAPI energy data", "type": "module", "main": "./dist/cjs/index.js", diff --git a/scripts/resolve-npm-publication.mjs b/scripts/resolve-npm-publication.mjs new file mode 100644 index 0000000..bb80868 --- /dev/null +++ b/scripts/resolve-npm-publication.mjs @@ -0,0 +1,101 @@ +#!/usr/bin/env node + +import { pathToFileURL } from "node:url"; + +const REGISTRY_BASE = "https://registry.npmjs.org"; + +function requireExpectedString(value, label) { + if (typeof value !== "string" || value.length === 0) { + throw new Error(`${label} is required`); + } + return value; +} + +function requireIntegrity(value) { + const integrity = requireExpectedString(value, "expected npm integrity"); + if (!/^sha512-[A-Za-z0-9+/]+={0,2}$/.test(integrity)) { + throw new Error("expected npm integrity must be SHA-512 SRI"); + } + return integrity; +} + +export async function resolveNpmPublicationState({ + expectedName, + expectedVersion, + expectedIntegrity, + selector = expectedVersion, + timeoutMs = 10_000, + fetchImpl = fetch, +}) { + const name = requireExpectedString(expectedName, "expected npm package name"); + const version = requireExpectedString(expectedVersion, "expected npm package version"); + const integrity = requireIntegrity(expectedIntegrity); + if (selector !== version && selector !== "latest") { + throw new Error("npm registry selector must be the exact version or latest"); + } + if (!Number.isInteger(timeoutMs) || timeoutMs < 1) { + throw new Error("npm registry timeout must be a positive integer"); + } + + const response = await fetchImpl( + `${REGISTRY_BASE}/${encodeURIComponent(name)}/${encodeURIComponent(selector)}`, + { + headers: { Accept: "application/json" }, + cache: "no-store", + signal: AbortSignal.timeout(timeoutMs), + }, + ); + + if (response.status === 404) return "absent"; + if (!response.ok) { + throw new Error(`npm publication-state readback returned HTTP ${response.status}`); + } + + let document; + try { + document = await response.json(); + } catch (error) { + throw new Error("npm publication-state response was not valid JSON", { cause: error }); + } + if ( + document === null || + typeof document !== "object" || + Array.isArray(document) || + document.dist === null || + typeof document.dist !== "object" || + Array.isArray(document.dist) || + document.name !== name || + document.version !== version || + document.dist.integrity !== integrity + ) { + throw new Error("npm publication-state metadata did not match the verified tarball"); + } + + return "present"; +} + +async function main() { + const mode = process.env.NPM_PUBLICATION_MODE; + if (mode !== "publication-state" && mode !== "require-present") { + throw new Error("NPM_PUBLICATION_MODE must be publication-state or require-present"); + } + const timeoutMs = Number(process.env.NPM_PUBLICATION_TIMEOUT_MS || 10_000); + const state = await resolveNpmPublicationState({ + expectedName: process.env.NPM_PUBLICATION_EXPECTED_NAME, + expectedVersion: process.env.NPM_PUBLICATION_EXPECTED_VERSION, + expectedIntegrity: process.env.NPM_PUBLICATION_EXPECTED_INTEGRITY, + selector: process.env.NPM_PUBLICATION_SELECTOR || process.env.NPM_PUBLICATION_EXPECTED_VERSION, + timeoutMs, + }); + if (mode === "require-present" && state !== "present") { + throw new Error("verified npm release is not publicly present"); + } + process.stdout.write(`${state}\n`); +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + main().catch((error) => { + process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`); + process.exitCode = 1; + }); +} diff --git a/src/version.ts b/src/version.ts index 461a7e3..cd19dd1 100644 --- a/src/version.ts +++ b/src/version.ts @@ -7,7 +7,7 @@ * - X-Client-Version header * - Package.json (should match) */ -export const SDK_VERSION = "1.2.2"; +export const SDK_VERSION = "1.2.3"; /** * SDK identifier used in User-Agent and X-Api-Client headers diff --git a/tests/npm-publication-state.test.ts b/tests/npm-publication-state.test.ts new file mode 100644 index 0000000..d6342a6 --- /dev/null +++ b/tests/npm-publication-state.test.ts @@ -0,0 +1,97 @@ +import { describe, expect, it, vi } from "vitest"; +import { resolveNpmPublicationState } from "../scripts/resolve-npm-publication.mjs"; + +const expected = { + expectedName: "oilpriceapi", + expectedVersion: "1.2.3", + expectedIntegrity: "sha512-dGVzdA==", +}; + +function registryResponse(status: number, payload: unknown) { + return { + ok: status >= 200 && status < 300, + status, + json: vi.fn().mockResolvedValue(payload), + }; +} + +describe("npm publication state", () => { + it("treats only an exact HTTP 404 as absent", async () => { + const fetchImpl = vi.fn().mockResolvedValue(registryResponse(404, { error: "Not found" })); + + await expect(resolveNpmPublicationState({ ...expected, fetchImpl })).resolves.toBe("absent"); + expect(fetchImpl).toHaveBeenCalledWith( + "https://registry.npmjs.org/oilpriceapi/1.2.3", + expect.objectContaining({ cache: "no-store" }), + ); + }); + + it("accepts an exact name, version, and integrity document as present", async () => { + const fetchImpl = vi.fn().mockResolvedValue( + registryResponse(200, { + name: expected.expectedName, + version: expected.expectedVersion, + dist: { integrity: expected.expectedIntegrity }, + }), + ); + + await expect(resolveNpmPublicationState({ ...expected, fetchImpl })).resolves.toBe("present"); + }); + + it("validates the latest selector against the same exact release", async () => { + const fetchImpl = vi.fn().mockResolvedValue( + registryResponse(200, { + name: expected.expectedName, + version: expected.expectedVersion, + dist: { integrity: expected.expectedIntegrity }, + }), + ); + + await expect( + resolveNpmPublicationState({ ...expected, selector: "latest", fetchImpl }), + ).resolves.toBe("present"); + expect(fetchImpl).toHaveBeenCalledWith( + "https://registry.npmjs.org/oilpriceapi/latest", + expect.any(Object), + ); + }); + + it.each([ + ["wrong integrity", { name: "oilpriceapi", version: "1.2.3", dist: { integrity: "sha512-wrong" } }], + ["wrong name", { name: "other", version: "1.2.3", dist: { integrity: "sha512-dGVzdA==" } }], + ["wrong version", { name: "oilpriceapi", version: "9.9.9", dist: { integrity: "sha512-dGVzdA==" } }], + ["malformed document", { error: "not package metadata" }], + ])("rejects a 200 response with %s", async (_label, payload) => { + const fetchImpl = vi.fn().mockResolvedValue(registryResponse(200, payload)); + + await expect(resolveNpmPublicationState({ ...expected, fetchImpl })).rejects.toThrow( + /verified tarball/, + ); + }); + + it("fails closed on non-404 registry errors", async () => { + const fetchImpl = vi.fn().mockResolvedValue(registryResponse(503, { error: "unavailable" })); + + await expect(resolveNpmPublicationState({ ...expected, fetchImpl })).rejects.toThrow("HTTP 503"); + }); + + it("fails closed when a successful response is not valid JSON", async () => { + const fetchImpl = vi.fn().mockResolvedValue({ + ok: true, + status: 200, + json: vi.fn().mockRejectedValue(new SyntaxError("bad JSON")), + }); + + await expect(resolveNpmPublicationState({ ...expected, fetchImpl })).rejects.toThrow( + "not valid JSON", + ); + }); + + it("fails closed on network errors", async () => { + const fetchImpl = vi.fn().mockRejectedValue(new Error("network unavailable")); + + await expect(resolveNpmPublicationState({ ...expected, fetchImpl })).rejects.toThrow( + "network unavailable", + ); + }); +}); diff --git a/tests/release-readiness.test.ts b/tests/release-readiness.test.ts index f9ceb8c..3136d1c 100644 --- a/tests/release-readiness.test.ts +++ b/tests/release-readiness.test.ts @@ -10,7 +10,7 @@ describe("release readiness", () => { const changelog = read("CHANGELOG.md"); const firstRelease = changelog.match(/^## \[([^\]]+)\]/m); - expect(packageJson.version).toBe("1.2.2"); + expect(packageJson.version).toBe("1.2.3"); expect(versionSource).toContain(`SDK_VERSION = "${packageJson.version}"`); expect(firstRelease?.[1]).toBe(packageJson.version); }); @@ -41,11 +41,16 @@ describe("release readiness", () => { expect(workflow).toContain("Verify release tag matches package version and protected main"); expect(workflow).toContain("actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a"); expect(workflow).toContain("actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c"); + expect(workflow).toContain('cp scripts/resolve-npm-publication.mjs "$ARTIFACT_DIR/"'); expect(publishJob).toContain("id-token: write"); expect(publishJob).toContain("sha256sum -c artifact.sha256"); expect(publishJob).not.toMatch(/npm (?:ci|install)/); expect(publishJob).not.toContain("npx "); expect(publishJob).not.toContain("actions/checkout@"); + expect(publishJob).toContain("NPM_PUBLICATION_MODE=publication-state"); + expect(publishJob).toContain("node resolve-npm-publication.mjs"); + expect(publishJob).not.toMatch(/\bnpm(?:-cli\.js)?\b[^\n]*\bview\b/); + expect(publishJob).not.toContain("|| true"); expect(actions).not.toHaveLength(0); for (const action of actions) expect(action).toMatch(/@[0-9a-f]{40}$/); }); From 53f86f93f2fe6b389520ac2a2fcc49721537300b Mon Sep 17 00:00:00 2001 From: Karl Waldman Date: Tue, 11 Aug 2026 12:14:43 -0400 Subject: [PATCH 2/2] Validate npm SHA-512 digest length --- scripts/resolve-npm-publication.mjs | 4 ++++ tests/npm-publication-state.test.ts | 19 ++++++++++++++++--- 2 files changed, 20 insertions(+), 3 deletions(-) diff --git a/scripts/resolve-npm-publication.mjs b/scripts/resolve-npm-publication.mjs index bb80868..bc00d62 100644 --- a/scripts/resolve-npm-publication.mjs +++ b/scripts/resolve-npm-publication.mjs @@ -16,6 +16,10 @@ function requireIntegrity(value) { if (!/^sha512-[A-Za-z0-9+/]+={0,2}$/.test(integrity)) { throw new Error("expected npm integrity must be SHA-512 SRI"); } + const digest = Buffer.from(integrity.slice("sha512-".length), "base64"); + if (digest.length !== 64) { + throw new Error("expected npm integrity must contain a 512-bit digest"); + } return integrity; } diff --git a/tests/npm-publication-state.test.ts b/tests/npm-publication-state.test.ts index d6342a6..c0c62c4 100644 --- a/tests/npm-publication-state.test.ts +++ b/tests/npm-publication-state.test.ts @@ -4,7 +4,7 @@ import { resolveNpmPublicationState } from "../scripts/resolve-npm-publication.m const expected = { expectedName: "oilpriceapi", expectedVersion: "1.2.3", - expectedIntegrity: "sha512-dGVzdA==", + expectedIntegrity: `sha512-${Buffer.alloc(64, 0x5a).toString("base64")}`, }; function registryResponse(status: number, payload: unknown) { @@ -38,6 +38,19 @@ describe("npm publication state", () => { await expect(resolveNpmPublicationState({ ...expected, fetchImpl })).resolves.toBe("present"); }); + it("rejects an SRI-shaped value whose digest is not 512 bits", async () => { + const fetchImpl = vi.fn().mockResolvedValue(registryResponse(404, { error: "Not found" })); + + await expect( + resolveNpmPublicationState({ + ...expected, + expectedIntegrity: "sha512-dGVzdA==", + fetchImpl, + }), + ).rejects.toThrow("512-bit"); + expect(fetchImpl).not.toHaveBeenCalled(); + }); + it("validates the latest selector against the same exact release", async () => { const fetchImpl = vi.fn().mockResolvedValue( registryResponse(200, { @@ -58,8 +71,8 @@ describe("npm publication state", () => { it.each([ ["wrong integrity", { name: "oilpriceapi", version: "1.2.3", dist: { integrity: "sha512-wrong" } }], - ["wrong name", { name: "other", version: "1.2.3", dist: { integrity: "sha512-dGVzdA==" } }], - ["wrong version", { name: "oilpriceapi", version: "9.9.9", dist: { integrity: "sha512-dGVzdA==" } }], + ["wrong name", { name: "other", version: "1.2.3", dist: { integrity: expected.expectedIntegrity } }], + ["wrong version", { name: "oilpriceapi", version: "9.9.9", dist: { integrity: expected.expectedIntegrity } }], ["malformed document", { error: "not package metadata" }], ])("rejects a 200 response with %s", async (_label, payload) => { const fetchImpl = vi.fn().mockResolvedValue(registryResponse(200, payload));