From 58f20f62f8534502bc0a65efc0fa53fee8304f5d Mon Sep 17 00:00:00 2001 From: Tayvon Wright Date: Thu, 3 Sep 2026 15:32:17 +0100 Subject: [PATCH 1/2] feat(token): add read-write-stage-only packages-and-scopes permission Adds the granular-token stage-only publish policy value to the packages-and-scopes-permission option. The value is forwarded as-is to the registry create-token route, where it normalizes to a stage_only publish policy (identical to the web UI). Ref: github/npm#15609 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 0f2e357f-6da8-4978-a637-f14539820093 --- tap-snapshots/test/lib/docs.js.test.cjs | 9 ++++--- test/lib/commands/token.js | 27 +++++++++++++++++++ .../config/lib/definitions/definitions.js | 6 +++-- .../test/type-description.js.test.cjs | 1 + 4 files changed, 38 insertions(+), 5 deletions(-) diff --git a/tap-snapshots/test/lib/docs.js.test.cjs b/tap-snapshots/test/lib/docs.js.test.cjs index c8c296208796e..840664c9a5073 100644 --- a/tap-snapshots/test/lib/docs.js.test.cjs +++ b/tap-snapshots/test/lib/docs.js.test.cjs @@ -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. @@ -6448,7 +6451,7 @@ Options: [--name ] [--token-description ] [--expires ] [--packages [--packages ...]] [--packages-all] [--scopes [--scopes ...]] [--orgs [--orgs ...]] -[--packages-and-scopes-permission ] +[--packages-and-scopes-permission ] [--orgs-permission ] [--cidr [--cidr ...]] [--bypass-2fa] [--password ] [--registry ] [--otp ] [--read-only] diff --git a/test/lib/commands/token.js b/test/lib/commands/token.js index 34297a923c897..57f39bcfb9499 100644 --- a/test/lib/commands/token.js +++ b/test/lib/commands/token.js @@ -475,3 +475,30 @@ 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') +}) diff --git a/workspaces/config/lib/definitions/definitions.js b/workspaces/config/lib/definitions/definitions.js index 047a2da4bfa1f..dc3eaba9deb2e 100644 --- a/workspaces/config/lib/definitions/definitions.js +++ b/workspaces/config/lib/definitions/definitions.js @@ -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, }), diff --git a/workspaces/config/tap-snapshots/test/type-description.js.test.cjs b/workspaces/config/tap-snapshots/test/type-description.js.test.cjs index e585d82dff9f3..c923f6e7a6cb4 100644 --- a/workspaces/config/tap-snapshots/test/type-description.js.test.cjs +++ b/workspaces/config/tap-snapshots/test/type-description.js.test.cjs @@ -486,6 +486,7 @@ Object { null, "read-only", "read-write", + "read-write-stage-only", "no-access", ], "parseable": Array [ From 688c4b71e928d09c081440752a4a1b58ad99aa84 Mon Sep 17 00:00:00 2001 From: Tayvon Wright Date: Thu, 3 Sep 2026 15:32:53 +0100 Subject: [PATCH 2/2] feat(token): warn when creating direct-publish tokens Emits a log.warn when creating a granular access token that can publish directly to the registry (a read-write packages-and-scopes permission), steering users toward read-write-stage-only tokens. bypass-2fa is orthogonal and does not trigger the warning on its own. Ref: github/npm#15609 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 0f2e357f-6da8-4978-a637-f14539820093 --- lib/commands/token.js | 19 +++++++++ test/lib/commands/token.js | 85 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 104 insertions(+) diff --git a/lib/commands/token.js b/lib/commands/token.js index 8f54e9d8725df..f3fce31af8d5f 100644 --- a/lib/commands/token.js +++ b/lib/commands/token.js @@ -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() diff --git a/test/lib/commands/token.js b/test/lib/commands/token.js index 57f39bcfb9499..41ca9395375a9 100644 --- a/test/lib/commands/token.js +++ b/test/lib/commands/token.js @@ -502,3 +502,88 @@ t.test('token create stage-only produces stage-only policy and no warning', asyn 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') +})