Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions lib/commands/token.js
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,25 @@ class Token extends BaseCommand {

const validCIDR = await this.validateCIDRList(cidr)

// Warn when creating a token that can publish directly to the registry.
// Only 'read-write' package/scope permission grants direct-publish; stage-only
// tokens ('read-write-stage-only') stage releases instead, and non-publishing
// permissions (read-only/no-access) can't publish at all, so both stay silent.
// bypass-2fa is orthogonal — it removes the 2FA requirement but grants no
// publish capability on its own — so it is not part of this trigger.
if (packagesAndScopesPermission === 'read-write') {
// Deprecation notice for direct-publish tokens; see github/npm#15609.
log.warn(
'token',
'Creating a token that can publish directly to the registry. ' +
'Consider `--packages-and-scopes-permission=read-write-stage-only` ' +
'instead — with a stage-only token, your releases go to a staging ' +
'queue for you to approve before they go public. Bypass-2FA tokens ' +
'with direct-publish access will stop working in January 2027. ' +
'See https://gh.io/bypass-2fa-tokens-no-longer-publish.'
)
}

/* istanbul ignore if - skip testing read input */
if (!password) {
password = await readUserInfo.password()
Expand Down
9 changes: 6 additions & 3 deletions tap-snapshots/test/lib/docs.js.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -1638,11 +1638,14 @@ token access to all packages instead of limiting to specific packages.
#### \`packages-and-scopes-permission\`

* Default: null
* Type: null, "read-only", "read-write", or "no-access"
* Type: null, "read-only", "read-write", "read-write-stage-only", or
"no-access"

When creating a Granular Access Token with \`npm token create\`, sets the
permission level for packages and scopes. Options are "read-only",
"read-write", or "no-access".
"read-write", "read-write-stage-only", or "no-access".
"read-write-stage-only" grants publish access that stages releases instead
of publishing them directly.



Expand Down Expand Up @@ -6448,7 +6451,7 @@ Options:
[--name <name>] [--token-description <token-description>] [--expires <expires>]
[--packages <packages> [--packages <packages> ...]] [--packages-all]
[--scopes <scopes> [--scopes <scopes> ...]] [--orgs <orgs> [--orgs <orgs> ...]]
[--packages-and-scopes-permission <read-only|read-write|no-access>]
[--packages-and-scopes-permission <read-only|read-write|read-write-stage-only|no-access>]
[--orgs-permission <read-only|read-write|no-access>]
[--cidr <cidr> [--cidr <cidr> ...]] [--bypass-2fa] [--password <password>]
[--registry <registry>] [--otp <otp>] [--read-only]
Expand Down
112 changes: 112 additions & 0 deletions test/lib/commands/token.js
Original file line number Diff line number Diff line change
Expand Up @@ -475,3 +475,115 @@ t.test('token create invalid cidr', async t => {
message: 'CIDR whitelist contains invalid CIDR entry: apple/cider',
})
})

t.test('token create stage-only produces stage-only policy and no warning', async t => {
const { npm, outputs, logs } = await loadMockNpm(t, {
config: {
...auth,
name: 'stage-only-token',
password: 'test-password',
'packages-and-scopes-permission': 'read-write-stage-only',
},
})

const registry = new MockRegistry({
tap: t,
registry: npm.config.get('registry'),
authorization: authToken,
})

registry.createToken({
name: 'stage-only-token',
password: 'test-password',
packages_and_scopes_permission: 'read-write-stage-only',
})

await npm.exec('token', ['create'])
t.match(outputs, ['Created token n3wt0k3n'])
t.strictSame(logs.warn, [], 'no deprecation warning for stage-only tokens')
})

t.test('token create read-write warns about direct-publish', async t => {
const { npm, outputs, logs } = await loadMockNpm(t, {
config: {
...auth,
name: 'rw-token',
password: 'test-password',
'packages-and-scopes-permission': 'read-write',
},
})

const registry = new MockRegistry({
tap: t,
registry: npm.config.get('registry'),
authorization: authToken,
})

registry.createToken({
name: 'rw-token',
password: 'test-password',
packages_and_scopes_permission: 'read-write',
})

await npm.exec('token', ['create'])
t.match(outputs, ['Created token n3wt0k3n'])
t.match(logs.warn, [/publish directly to the registry/], 'warns about direct-publish token')
t.match(logs.warn, [/read-write-stage-only/], 'warning points to stage-only tokens')
t.match(logs.warn, [/https:\/\/gh\.io\/bypass-2fa-tokens-no-longer-publish/], 'warning includes the docs link')
})

t.test('token create bypass-2fa alone does not warn', async t => {
const { npm, outputs, logs } = await loadMockNpm(t, {
config: {
...auth,
name: 'bypass-token',
password: 'test-password',
'bypass-2fa': true,
},
})

const registry = new MockRegistry({
tap: t,
registry: npm.config.get('registry'),
authorization: authToken,
})

registry.createToken({
name: 'bypass-token',
password: 'test-password',
bypass_2fa: true,
})

await npm.exec('token', ['create'])
t.match(outputs, ['Created token n3wt0k3n'])
t.strictSame(logs.warn, [], 'bypass-2fa alone grants no publish capability, so no warning')
})

t.test('token create read-write with bypass-2fa warns about direct-publish', async t => {
const { npm, outputs, logs } = await loadMockNpm(t, {
config: {
...auth,
name: 'rw-bypass-token',
password: 'test-password',
'packages-and-scopes-permission': 'read-write',
'bypass-2fa': true,
},
})

const registry = new MockRegistry({
tap: t,
registry: npm.config.get('registry'),
authorization: authToken,
})

registry.createToken({
name: 'rw-bypass-token',
password: 'test-password',
packages_and_scopes_permission: 'read-write',
bypass_2fa: true,
})

await npm.exec('token', ['create'])
t.match(outputs, ['Created token n3wt0k3n'])
t.match(logs.warn, [/publish directly to the registry/], 'warns for read-write automation publish token')
})
6 changes: 4 additions & 2 deletions workspaces/config/lib/definitions/definitions.js
Original file line number Diff line number Diff line change
Expand Up @@ -2335,11 +2335,13 @@ const definitions = {
}),
'packages-and-scopes-permission': new Definition('packages-and-scopes-permission', {
default: null,
type: [null, 'read-only', 'read-write', 'no-access'],
type: [null, 'read-only', 'read-write', 'read-write-stage-only', 'no-access'],
description: `
When creating a Granular Access Token with \`npm token create\`,
sets the permission level for packages and scopes. Options are
"read-only", "read-write", or "no-access".
"read-only", "read-write", "read-write-stage-only", or "no-access".
"read-write-stage-only" grants publish access that stages releases
instead of publishing them directly.
`,
flatten,
}),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -486,6 +486,7 @@ Object {
null,
"read-only",
"read-write",
"read-write-stage-only",
"no-access",
],
"parseable": Array [
Expand Down
Loading