Is there an existing issue for this?
Related, but closed without a root cause: #2282, #3449, #3838. #2282 has a 2023 comment reporting it is still broken. This issue supplies the mechanism and a reproduction that does not involve the CLI.
Current Behavior
npm deprecate fails with 400 Bad Request - deprecations must be strings whenever the package already has at least one deprecated version.
The first deprecation on a package with nothing deprecated always succeeds. Every deprecation after that fails, permanently, until all deprecations are cleared again.
$ npm deprecate "jira.js@>=5.0.0-0 <5.0.0" "Development snapshot, not for use."
npm notice deprecating jira.js@5.0.0-dev20250313212005 with message "..."
...
npm error code E400
npm error 400 Bad Request - PUT https://registry.npmjs.org/jira.js - deprecations must be strings
Observed sequence on one package, in order:
| command |
versions already deprecated |
result |
npm deprecate 'jira.js@<5.0.0' "…" (83 versions) |
0 |
success |
npm deprecate 'jira.js@<4.0.0' "" (undeprecate 83) |
83 |
success |
npm deprecate 'jira.js@>=4.1.1-0 <4.1.1' "…" (1 version) |
0 |
success |
npm deprecate 'jira.js@>=5.0.0-0 <5.0.0' "…" (32 versions) |
1 |
E400 |
npm deprecate 'jira.js@>=5.3.1-0 <5.3.1' "…" (1 version) |
1 |
E400 |
The undeprecate step succeeds because an empty string is still a string.
Expected Behavior
Deprecating a version leaves deprecations on other versions untouched, and succeeds regardless of how many versions are already deprecated.
Root cause
lib/commands/deprecate.js fetches the packument with ?write=true, mutates it, and PUTs the whole document back:
const packument = await npmFetch.json(uri, {
...this.npm.flatOptions,
spec: p,
query: { write: true },
})
const versions = Object.keys(packument.versions)
.filter(v => semver.satisfies(v, spec, { includePrerelease: true }))
for (const v of versions) {
packument.versions[v].deprecated = msg
}
return otplease(this.npm, this.npm.flatOptions, opts => npmFetch(uri, {
...opts, spec: p, method: 'PUT', body: packument, ignoreBody: true,
}))
The ?write=true view of the packument does not carry deprecated at all. Both requests below were made within the same minute, against the same package:
$ curl -s 'https://registry.npmjs.org/jira.js' | \
jq '[.versions[] | select(.deprecated)] | length'
40
$ curl -s -H "authorization: Bearer $TOKEN" 'https://registry.npmjs.org/jira.js?write=true' | \
jq '[.versions[] | select(.deprecated)] | length'
0
So the document PUT back implicitly clears every deprecation the package already had. The registry rejects that with deprecations must be strings — presumably because the removed entries arrive as undefined rather than a string.
This is not CLI-specific. Reproduced with a plain PUT, no npm involved: taking the ?write=true document, setting deprecated on a single version and PUTting it back returns
HTTP 400
{"success":false,"error":"deprecations must be strings"}
Re-adding the pre-existing deprecated values into the same document before the PUT makes it succeed, which confirms the mechanism.
Steps To Reproduce
These steps are reconstructed from the mechanism, not run against a fresh
package — everything above was observed on a real package with 145 published
versions, and I did not want to publish a throwaway package to confirm the
minimal case. Please treat this section as the expected minimal reproduction
rather than a verified one.
- Publish a package with at least three versions.
npm deprecate 'pkg@1.0.0' "first" — expected to succeed.
npm deprecate 'pkg@2.0.0' "second" — expected to fail with E400 deprecations must be strings.
npm deprecate 'pkg@<9.9.9' "" to clear, after which step 2 succeeds again.
What is measured and what is inferred
Measured directly:
- the five command outcomes in the table above, in that order;
- the two
curl counts (40 vs 0) against the same package, same minute;
- the plain PUT reproducing
400 deprecations must be strings with no npm involved;
- the same PUT succeeding once the pre-existing
deprecated values are merged back in.
Inferred:
- that the registry rejects the request because the dropped entries arrive as
undefined — the error string is the only evidence for the "why", and the
server-side check is not visible from here;
- the minimal reproduction steps above.
Suggested fix
Either of:
- merge the existing deprecations into the packument before the PUT — read them from the public packument, or from the
?write=true document once the registry includes them; or
- have the registry treat an absent
deprecated as unchanged rather than cleared, so a partial document cannot silently drop deprecations.
The first is fixable in the CLI alone.
Environment
npm: 11.13.0
node: v26.1.0
OS: macOS 15 (darwin arm64)
registry: https://registry.npmjs.org
Package used: jira.js (145 versions, 40 deprecated at time of writing).
Is there an existing issue for this?
Related, but closed without a root cause: #2282, #3449, #3838. #2282 has a 2023 comment reporting it is still broken. This issue supplies the mechanism and a reproduction that does not involve the CLI.
Current Behavior
npm deprecatefails with400 Bad Request - deprecations must be stringswhenever the package already has at least one deprecated version.The first deprecation on a package with nothing deprecated always succeeds. Every deprecation after that fails, permanently, until all deprecations are cleared again.
Observed sequence on one package, in order:
npm deprecate 'jira.js@<5.0.0' "…"(83 versions)npm deprecate 'jira.js@<4.0.0' ""(undeprecate 83)npm deprecate 'jira.js@>=4.1.1-0 <4.1.1' "…"(1 version)npm deprecate 'jira.js@>=5.0.0-0 <5.0.0' "…"(32 versions)npm deprecate 'jira.js@>=5.3.1-0 <5.3.1' "…"(1 version)The undeprecate step succeeds because an empty string is still a string.
Expected Behavior
Deprecating a version leaves deprecations on other versions untouched, and succeeds regardless of how many versions are already deprecated.
Root cause
lib/commands/deprecate.jsfetches the packument with?write=true, mutates it, and PUTs the whole document back:The
?write=trueview of the packument does not carrydeprecatedat all. Both requests below were made within the same minute, against the same package:So the document PUT back implicitly clears every deprecation the package already had. The registry rejects that with
deprecations must be strings— presumably because the removed entries arrive asundefinedrather than a string.This is not CLI-specific. Reproduced with a plain PUT, no npm involved: taking the
?write=truedocument, settingdeprecatedon a single version and PUTting it back returnsRe-adding the pre-existing
deprecatedvalues into the same document before the PUT makes it succeed, which confirms the mechanism.Steps To Reproduce
These steps are reconstructed from the mechanism, not run against a fresh
package — everything above was observed on a real package with 145 published
versions, and I did not want to publish a throwaway package to confirm the
minimal case. Please treat this section as the expected minimal reproduction
rather than a verified one.
npm deprecate 'pkg@1.0.0' "first"— expected to succeed.npm deprecate 'pkg@2.0.0' "second"— expected to fail withE400 deprecations must be strings.npm deprecate 'pkg@<9.9.9' ""to clear, after which step 2 succeeds again.What is measured and what is inferred
Measured directly:
curlcounts (40 vs 0) against the same package, same minute;400 deprecations must be stringswith no npm involved;deprecatedvalues are merged back in.Inferred:
undefined— the error string is the only evidence for the "why", and theserver-side check is not visible from here;
Suggested fix
Either of:
?write=truedocument once the registry includes them; ordeprecatedas unchanged rather than cleared, so a partial document cannot silently drop deprecations.The first is fixable in the CLI alone.
Environment
Package used:
jira.js(145 versions, 40 deprecated at time of writing).