Repository navigation
[BUG] npm unpublish <pkg>@<version> deleted the whole package without --force #10116
Description
Is there an existing issue for this?
Related, not the same: #4924 and #7015 report a version-specific unpublish that removed every version, but with --force. Here --force was not given, and the "Refusing to delete the last version" check did not stop it.
This issue exists in the latest npm version
Reproduced on npm 11.19.0 and on latest of this repository (83ea1a1).
Current Behavior
A package of mine (name left out here) had three versions: 0.0.0-stage, 0.0.1, and 0.1.0. 0.1.0 was published about six minutes earlier, through a trusted publisher. Then:
npm unpublish <pkg>@0.0.1
printed - <pkg>@0.0.1 and exited 0, but the registry removed the whole package: all three versions. The debug log shows a whole-package DELETE, and no PUT:
16 http fetch GET 200 https://registry.npmjs.org/<pkg>?write=true 395ms (cache miss)
17 http fetch GET 200 https://registry.npmjs.org/<pkg>?write=true 194ms (cache miss)
18 http fetch DELETE 401 https://registry.npmjs.org/<pkg>/-rev/3-56bf641ba8f19a0f2c2bd1d5197c6040 221ms
...
25 http fetch DELETE 200 https://registry.npmjs.org/<pkg>/-rev/3-56bf641ba8f19a0f2c2bd1d5197c6040 814ms
30 verbose exit 0
(The 401 is the web login; the retry after it is the same request.)
The package can now not be published for 24 hours, and the three version numbers can never be used again.
The cause in the code:
lib/commands/unpublish.jsrefuses whenversions.length === 1 && spec.rawSpec === versions[0]. It readsversionswithpacote.packument(), which requests the abbreviated packument (Accept: application/vnd.npm.install-v1+json).libnpmpublish'sunpublish()reads the full packument (Accept: */*) and deletes the whole package when that document has one version or none.
The check and the deletion read two different documents. When the full one lists fewer versions than the abbreviated one, the check passes and the deletion removes the whole package. Request 16 in the log is the check and request 17 is libnpmpublish. We cannot see from the log why the full document had one version or none at revision 3. The new 0.1.0 not being in it yet is our guess.
A full packument with no versions also passes the check (0 !== 1) and deletes the whole package.
Expected Behavior
npm unpublish <pkg>@<version> without --force either removes that one version, or refuses with "Refusing to delete the last version of the package". It never deletes the whole package.
Steps To Reproduce
-
Save this mock registry as
mock-registry.mjs. Its abbreviated document lists0.0.1and0.1.0; its full document lists only0.0.1:import http from 'node:http' const v = (n) => ({ name: 'demo-pkg', version: n, dist: { tarball: `http://localhost:4873/demo-pkg/-/demo-pkg-${n}.tgz` } }) const abbreviated = { name: 'demo-pkg', 'dist-tags': { latest: '0.1.0' }, versions: { '0.0.1': v('0.0.1'), '0.1.0': v('0.1.0') } } const full = { _id: 'demo-pkg', name: 'demo-pkg', _rev: '3-abc', 'dist-tags': { latest: '0.0.1' }, versions: { '0.0.1': v('0.0.1') } } http.createServer((req, res) => { console.log(req.method, req.url, (req.headers.accept ?? '').slice(0, 30)) if (req.method === 'GET') { res.setHeader('content-type', 'application/json') return res.end(JSON.stringify(req.headers.accept?.startsWith('application/vnd.npm.install-v1') ? abbreviated : full)) } res.end('{}') }).listen(4873)
-
node mock-registry.mjs -
In another shell:
echo '//localhost:4873/:_authToken=fake' > .npmrcandnpm unpublish demo-pkg@0.0.1 --registry http://localhost:4873/ --userconfig ./.npmrc -
npm prints
- demo-pkg@0.0.1. The registry log shows the same three requests as the real log above:GET /demo-pkg?write=true application/vnd.npm.install-v1 GET /demo-pkg?write=true */* DELETE /demo-pkg/-rev/3-abc */*
Environment
- npm: 11.19.0
- Node.js: 26.7.0
- OS Name: macOS (arm64)
- npm config: registry
https://registry.npmjs.org/, no other settings
Activity
meganemura commentedon Oct 12, 2026
Follow-up on the cause. The machine that ran the command uses Aikido safe-chain, which proxies npm's registry requests and removes every version newer than its minimum package age (48 hours by default) from package metadata, including the
?write=truereads. All three versions were under three hours old, so both GETs in the log returnedversions: {}. That matches the log better than the abbreviated/full mismatch described above: the check saw 0 versions (0 !== 1), and libnpmpublish took its no-versions branch.#10117 covers this case too: it treats a packument with no versions as the last version, so the command is refused. The safe-chain side is reported separately: AikidoSec/safe-chain#672.
nkolev92 commentedon Oct 15, 2026
Thanks for tracing this back to safe-chain.
Filtering metadata used for write operations is the underlying problem here.