@automattic/vip 4.0.10 → 4.0.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,167 @@
1
+ "use strict";
2
+
3
+ exports.__esModule = true;
4
+ exports.default = createRechallengeLink;
5
+ var _core = require("@apollo/client/core");
6
+ var _debug = _interopRequireDefault(require("debug"));
7
+ var _graphql = require("graphql");
8
+ var _flow = require("./flow");
9
+ var _tokenCache = _interopRequireDefault(require("./token-cache"));
10
+ var _types = require("./types");
11
+ function _interopRequireDefault(e) { return e && e.__esModule ? e : { default: e }; }
12
+ const debug = (0, _debug.default)('@automattic/vip:rechallenge:link');
13
+ function operationDefinition(doc) {
14
+ return doc.definitions.find(def => def.kind === _graphql.Kind.OPERATION_DEFINITION);
15
+ }
16
+ function isMutation(doc) {
17
+ return operationDefinition(doc)?.operation === _graphql.OperationTypeNode.MUTATION;
18
+ }
19
+ function primaryMutationFieldName(doc) {
20
+ const op = operationDefinition(doc);
21
+ if (!op || op.operation !== _graphql.OperationTypeNode.MUTATION) {
22
+ return null;
23
+ }
24
+ const first = op.selectionSet.selections.find(sel => sel.kind === _graphql.Kind.FIELD);
25
+ return first?.name.value ?? null;
26
+ }
27
+ function extractElevatedPermission(result) {
28
+ const errors = result.errors ?? [];
29
+ for (const err of errors) {
30
+ const ext = err.extensions ?? {};
31
+ if (ext.code !== _types.ELEVATED_PERMISSION_ERROR_CODE) {
32
+ continue;
33
+ }
34
+ const rechallenge = ext.rechallenge;
35
+ if (rechallenge && typeof rechallenge.createSessionPath === 'string' && typeof rechallenge.statusPathTemplate === 'string' && typeof rechallenge.exchangePathTemplate === 'string' && typeof rechallenge.elevatedHeaderName === 'string') {
36
+ return {
37
+ rechallenge
38
+ };
39
+ }
40
+ }
41
+ return null;
42
+ }
43
+ function attachElevatedHeader(operation, headerName, token) {
44
+ const ctx = operation.getContext();
45
+ const headers = {
46
+ ...ctx.headers
47
+ };
48
+ headers[headerName] = token.token;
49
+ operation.setContext({
50
+ ...ctx,
51
+ headers
52
+ });
53
+ }
54
+ const DEFAULT_HEADER = 'x-elevated-token';
55
+ function createRechallengeLink() {
56
+ return new _core.ApolloLink((operation, forward) => {
57
+ const scope = primaryMutationFieldName(operation.query);
58
+ const eligible = isMutation(operation.query) && Boolean(scope);
59
+ return new _core.Observable(observer => {
60
+ let retrying = false;
61
+ let cancelled = false;
62
+ let firstSub = null;
63
+ let retrySub = null;
64
+ const abortController = new AbortController();
65
+ const preflight = async () => {
66
+ if (!eligible || !scope) {
67
+ return;
68
+ }
69
+ const cached = await _tokenCache.default.get(scope);
70
+ if (cached) {
71
+ attachElevatedHeader(operation, cached.headerName || DEFAULT_HEADER, cached);
72
+ }
73
+ };
74
+ const handleRetry = async (result, elevated) => {
75
+ retrying = true;
76
+ const headerName = elevated.rechallenge.elevatedHeaderName || DEFAULT_HEADER;
77
+ let token;
78
+ try {
79
+ token = await (0, _flow.runRechallenge)({
80
+ requestedOperation: scope,
81
+ rechallenge: elevated.rechallenge,
82
+ interactive: (0, _flow.isInteractiveContext)(),
83
+ wait: (0, _flow.shouldWaitForRechallenge)(),
84
+ signal: abortController.signal
85
+ });
86
+ } catch (err) {
87
+ debug('rechallenge flow failed: %o', err);
88
+ if (cancelled || observer.closed) {
89
+ return;
90
+ }
91
+ // Surface the original elevated-permission error to upstream
92
+ // so errorLink and consumers see it.
93
+ observer.next(result);
94
+ observer.complete();
95
+ return;
96
+ }
97
+ if (cancelled || observer.closed) {
98
+ return;
99
+ }
100
+ attachElevatedHeader(operation, headerName, token);
101
+ try {
102
+ retrySub = forward(operation).subscribe({
103
+ next: res => observer.next(res),
104
+ error: err => observer.error(err),
105
+ complete: () => observer.complete()
106
+ });
107
+ } catch (err) {
108
+ if (cancelled || observer.closed) {
109
+ return;
110
+ }
111
+ observer.error(err);
112
+ }
113
+ };
114
+ const start = async () => {
115
+ try {
116
+ await preflight();
117
+ } catch (err) {
118
+ debug('preflight error: %o', err);
119
+ }
120
+ if (cancelled || observer.closed) {
121
+ return;
122
+ }
123
+ try {
124
+ firstSub = forward(operation).subscribe({
125
+ next: result => {
126
+ if (retrying || !eligible || !scope) {
127
+ observer.next(result);
128
+ return;
129
+ }
130
+ const elevated = extractElevatedPermission(result);
131
+ if (!elevated) {
132
+ observer.next(result);
133
+ return;
134
+ }
135
+ void handleRetry(result, elevated);
136
+ },
137
+ error: err => observer.error(err),
138
+ complete: () => {
139
+ if (!retrying) {
140
+ observer.complete();
141
+ }
142
+ }
143
+ });
144
+ } catch (err) {
145
+ if (cancelled || observer.closed) {
146
+ return;
147
+ }
148
+ observer.error(err);
149
+ }
150
+ };
151
+ void start().catch(err => {
152
+ if (cancelled || observer.closed) {
153
+ return;
154
+ }
155
+ observer.error(err);
156
+ });
157
+ return () => {
158
+ cancelled = true;
159
+ // Abort any in-flight rechallenge polling so the loop, its tracking
160
+ // events, and the token-cache write stop instead of running to expiry.
161
+ abortController.abort();
162
+ firstSub?.unsubscribe();
163
+ retrySub?.unsubscribe();
164
+ };
165
+ });
166
+ });
167
+ }
@@ -0,0 +1,21 @@
1
+ "use strict";
2
+
3
+ exports.__esModule = true;
4
+ exports.openBrowser = openBrowser;
5
+ var _debug = _interopRequireDefault(require("debug"));
6
+ function _interopRequireDefault(e) { return e && e.__esModule ? e : { default: e }; }
7
+ const debug = (0, _debug.default)('@automattic/vip:rechallenge:open-browser');
8
+
9
+ /** Opens a URL in the default browser. Wraps the ESM-only `open` package. */
10
+ async function openBrowser(url) {
11
+ try {
12
+ const {
13
+ default: open
14
+ } = await import('open');
15
+ await open(url, {
16
+ wait: false
17
+ });
18
+ } catch (err) {
19
+ debug('Failed to open(%s): %o', url, err);
20
+ }
21
+ }
@@ -0,0 +1,113 @@
1
+ "use strict";
2
+
3
+ exports.__esModule = true;
4
+ exports.default = void 0;
5
+ var _debug = _interopRequireDefault(require("debug"));
6
+ var _constants = require("../api/constants");
7
+ var _keychain = require("../keychain");
8
+ function _interopRequireDefault(e) { return e && e.__esModule ? e : { default: e }; }
9
+ const debug = (0, _debug.default)('@automattic/vip:rechallenge:cache');
10
+ // Storage strategy: a single keychain entry holds a JSON map { [scope]: ElevatedToken }.
11
+ // The vip-cli Keychain interface (src/lib/keychain/keychain.ts) is service-only — there
12
+ // is no separate account argument — so per-scope entries under the keytar model would
13
+ // require a different keying scheme. The single-blob approach also keeps clearAll() cheap.
14
+ // This is marked subject-to-change in the spec pending security review.
15
+ const BASE_SERVICE = 'vip-go-cli:elevated';
16
+ function serviceName() {
17
+ if (_constants.API_HOST === _constants.PRODUCTION_API_HOST) {
18
+ return BASE_SERVICE;
19
+ }
20
+ const sanitized = _constants.API_HOST.replace(/[^a-z0-9]/gi, '-');
21
+ return `${BASE_SERVICE}:${sanitized}`;
22
+ }
23
+ let inMemory = null;
24
+ async function read() {
25
+ if (inMemory) {
26
+ return inMemory;
27
+ }
28
+ const keychain = await (0, _keychain.getKeychain)();
29
+ const raw = await keychain.getPassword(serviceName());
30
+ if (!raw) {
31
+ inMemory = {};
32
+ return inMemory;
33
+ }
34
+ try {
35
+ const parsed = JSON.parse(raw);
36
+ if (typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)) {
37
+ inMemory = parsed;
38
+ } else {
39
+ debug('Elevated token blob had unexpected shape; resetting');
40
+ inMemory = {};
41
+ await keychain.deletePassword(serviceName());
42
+ }
43
+ } catch (err) {
44
+ debug('Failed to parse elevated token blob; resetting (%o)', err);
45
+ inMemory = {};
46
+ await keychain.deletePassword(serviceName());
47
+ }
48
+ return inMemory;
49
+ }
50
+ async function write(blob) {
51
+ inMemory = blob;
52
+ const keychain = await (0, _keychain.getKeychain)();
53
+ if (Object.keys(blob).length === 0) {
54
+ await keychain.deletePassword(serviceName());
55
+ } else {
56
+ await keychain.setPassword(serviceName(), JSON.stringify(blob));
57
+ }
58
+ }
59
+ function isExpired(token) {
60
+ const exp = Date.parse(token.expiresAt);
61
+ if (Number.isNaN(exp)) {
62
+ return true;
63
+ }
64
+ // Treat tokens within the next 5 seconds as effectively expired.
65
+ return Date.now() >= exp - 5_000;
66
+ }
67
+ async function get(scope) {
68
+ const blob = await read();
69
+ const token = blob[scope];
70
+ if (!token) {
71
+ return null;
72
+ }
73
+ if (isExpired(token)) {
74
+ debug('Cached elevated token for %s is expired; evicting', scope);
75
+ const {
76
+ [scope]: _evicted,
77
+ ...rest
78
+ } = blob;
79
+ await write(rest);
80
+ return null;
81
+ }
82
+ return token;
83
+ }
84
+ async function set(scope, token) {
85
+ const blob = await read();
86
+ blob[scope] = token;
87
+ await write(blob);
88
+ }
89
+ async function clearScope(scope) {
90
+ const blob = await read();
91
+ if (scope in blob) {
92
+ const {
93
+ [scope]: _removed,
94
+ ...rest
95
+ } = blob;
96
+ await write(rest);
97
+ }
98
+ }
99
+ async function clearAll() {
100
+ inMemory = {};
101
+ const keychain = await (0, _keychain.getKeychain)();
102
+ await keychain.deletePassword(serviceName());
103
+ }
104
+ function _resetInMemoryForTests() {
105
+ inMemory = null;
106
+ }
107
+ var _default = exports.default = {
108
+ get,
109
+ set,
110
+ clearScope,
111
+ clearAll,
112
+ _resetInMemoryForTests
113
+ };
@@ -0,0 +1,15 @@
1
+ "use strict";
2
+
3
+ exports.__esModule = true;
4
+ exports.RECHALLENGE_VERSION = exports.ELEVATED_PERMISSION_ERROR_CODE = exports.CLIENT_TYPE = void 0;
5
+ const ELEVATED_PERMISSION_ERROR_CODE = exports.ELEVATED_PERMISSION_ERROR_CODE = 'elevated-permission-required';
6
+ const RECHALLENGE_VERSION = exports.RECHALLENGE_VERSION = 'v2';
7
+ const CLIENT_TYPE = exports.CLIENT_TYPE = 'cli';
8
+
9
+ /** Shape of `errors[0].extensions.rechallenge` from the API. */
10
+
11
+ /** Response from POST {createSessionPath}. */
12
+
13
+ /** Response from GET {statusPathTemplate}. */
14
+
15
+ /** Response from POST {exchangePathTemplate}. */
package/dist/lib/token.js CHANGED
@@ -4,7 +4,7 @@ exports.__esModule = true;
4
4
  exports.default = exports.SERVICE = void 0;
5
5
  var _jwtDecode = require("jwt-decode");
6
6
  var _nodeCrypto = require("node:crypto");
7
- var _api = require("./api");
7
+ var _constants = require("./api/constants");
8
8
  var _keychain = require("./keychain");
9
9
  // Config
10
10
  const SERVICE = exports.SERVICE = 'vip-go-cli';
@@ -92,8 +92,8 @@ class Token {
92
92
  }
93
93
  static getServiceName(modifier = '') {
94
94
  let service = SERVICE;
95
- if (_api.PRODUCTION_API_HOST !== _api.API_HOST) {
96
- const sanitized = _api.API_HOST.replace(/[^a-z0-9]/gi, '-');
95
+ if (_constants.PRODUCTION_API_HOST !== _constants.API_HOST) {
96
+ const sanitized = _constants.API_HOST.replace(/[^a-z0-9]/gi, '-');
97
97
  service = `${SERVICE}:${sanitized}`;
98
98
  }
99
99
  return `${service}${modifier}`;
@@ -5,7 +5,7 @@ Goal: remove the abandoned `args` package, keep CLI behavior stable, and support
5
5
  ## Migration Outcome
6
6
 
7
7
  - `src/lib/cli/command.js` is the active Commander-backed compatibility wrapper for all bins that call `command()`.
8
- - `args` has been removed from `package.json` and `npm-shrinkwrap.json`.
8
+ - `args` has been removed from `package.json` and `package-lock.json`.
9
9
  - Root command flow (`src/bin/vip.js`) now dispatches via the shared Commander wrapper again, preserving login gating and subcommand chaining.
10
10
  - Temporary side-path wrapper work has been removed (`src/lib/cli/command-commander.ts` deleted).
11
11
 
package/docs/SETUP.md CHANGED
@@ -63,7 +63,7 @@ npm run build
63
63
 
64
64
  ## Usage
65
65
 
66
- The software runs as standalone CLI and relies on environmental variables for configuration and a few configuration files.
66
+ The software runs as standalone CLI and relies on environment variables for configuration and a few configuration files.
67
67
 
68
68
  ### Starting up locally
69
69
 
@@ -85,15 +85,15 @@ By default, we record information about the usage of this tool using an in-house
85
85
 
86
86
  Install the software locally, run and follow the instructions to configure the access token.
87
87
 
88
- ### Environmental variables
88
+ ### Environment variables
89
89
 
90
- #### Configuring environmental variables
90
+ #### Configuring environment variables
91
91
 
92
- Environmental variables are configured in the shell. Use normal shell commands to set them.
92
+ Environment variables are configured in the shell. Use normal shell commands to set them.
93
93
 
94
- #### List of environmental variables
94
+ #### List of environment variables
95
95
 
96
- This application uses environmental variables for vital configuration information. Find a partial list below.
96
+ This application uses environment variables for vital configuration information. Find a partial list below.
97
97
 
98
98
  TODO: Update description of the variables.
99
99
 
@@ -108,6 +108,7 @@ TODO: Update description of the variables.
108
108
  - `HTTP_PROXY`:
109
109
  - `NO_PROXY`:
110
110
  - `VIP_PROXY`: [For internal VIP use](TESTING.md#local-testing).
111
+ - `VIP_RECHALLENGE_WAIT`: set to `1` to print the verification URL and wait for rechallenge completion on another device.
111
112
  - `VIP_USE_SYSTEM_PROXY`:
112
113
  - `WPVIP_DEPLOY_TOKEN`: For use with `vip app deploy` on sites that have custom deploys enabled.
113
114
 
@@ -0,0 +1,87 @@
1
+ ---
2
+ status: accepted
3
+ date: 2026-07-01
4
+ ---
5
+
6
+ # Accept Operation-Scoped Elevated Token Reuse
7
+
8
+ ## Context and Problem Statement
9
+
10
+ Step-up rechallenge currently issues elevated tokens scoped by GraphQL `requestedOperation` (mutation field name or operation domain), not by app, environment, or payload details.
11
+
12
+ The team must decide whether this behavior should be treated as a defect that requires per-resource rebinding, or as intended product behavior.
13
+
14
+ Primary workflow motivation: users may need to perform the same protected operation repeatedly across many targets, such as adding many domains to multiple environments. Requiring a new rechallenge for every target would add significant friction.
15
+
16
+ ## Decision Drivers
17
+
18
+ - Reduce repeated operator friction in bulk workflows.
19
+ - Preserve baseline authorization enforcement at resolver level.
20
+ - Keep step-up semantics understandable: user confirms an operation class, not one specific payload.
21
+ - Avoid unnecessary complexity in token minting, validation, and client caching.
22
+ - Keep room for future hardening if product intent changes.
23
+
24
+ ## Considered Options
25
+
26
+ - Option 1: Keep operation-scoped elevated token reuse.
27
+ - Option 2: Require per-resource scoped elevated tokens (bind to app/environment/payload).
28
+ - Option 3: Disable reuse and force fresh rechallenge on every protected mutation.
29
+
30
+ ## Decision Outcome
31
+
32
+ Chosen option: Option 1, keep operation-scoped elevated token reuse.
33
+
34
+ Accepted behavior: a valid elevated token for a protected operation may be reused across app/environment targets while token validity holds.
35
+
36
+ This is intentional because it removes repeated rechallenge prompts in high-volume administrative workflows, including adding many domains across multiple environments.
37
+
38
+ ### Consequences
39
+
40
+ Good, because:
41
+
42
+ - Bulk operations become materially faster and less error-prone for users.
43
+ - Behavior aligns with current implementation and avoids disruptive contract changes.
44
+ - Security posture remains layered: resolver authorization still enforces target access for the authenticated user.
45
+
46
+ Bad, because:
47
+
48
+ - Some readers may incorrectly assume step-up approval is resource-bound.
49
+ - If future product intent requires per-resource confirmation, this decision will need to be superseded and implementation changed.
50
+
51
+ Neutral, because:
52
+
53
+ - This decision does not change which mutations are protected; it defines scope behavior once a mutation is protected.
54
+
55
+ ### Confirmation
56
+
57
+ The decision is considered correctly implemented when all of the following hold:
58
+
59
+ - Rechallenge token scope is derived from `requestedOperation`.
60
+ - Elevated token validation checks operation/operationDomain compatibility.
61
+ - Resolver-level authorization remains enforced independently of elevated token scope.
62
+ - Documentation and support guidance describe operation-scoped reuse as intended behavior.
63
+
64
+ ## Pros and Cons of the Options
65
+
66
+ ### Option 1: Keep operation-scoped elevated token reuse
67
+
68
+ - Good, because it optimizes repeated protected actions across many targets.
69
+ - Good, because it matches existing VIP CLI and GOOP behavior.
70
+ - Bad, because it may be perceived as broader than least-privilege if readers expect resource scoping.
71
+
72
+ ### Option 2: Require per-resource scoped elevated tokens
73
+
74
+ - Good, because it enforces tighter binding between step-up and target resource.
75
+ - Bad, because it increases user friction and rechallenge frequency in bulk workflows.
76
+ - Bad, because it introduces higher implementation and rollout complexity.
77
+
78
+ ### Option 3: Disable reuse and force fresh rechallenge every time
79
+
80
+ - Good, because the model is simple and strict.
81
+ - Bad, because it has the worst user experience for repetitive operations.
82
+ - Bad, because it provides limited practical security gain beyond existing authorization checks.
83
+
84
+ ## More Information
85
+
86
+ - Source validation and rationale: https://github.com/Automattic/vip-cli/pull/2869
87
+ - Revisit trigger: if product/security policy explicitly requires per-resource step-up approval, supersede this ADR with a new decision and migration plan.
@@ -65,11 +65,19 @@ const parseOutput = output => {
65
65
  }
66
66
 
67
67
  if ( currentSection === SECTION_COMMAND ) {
68
- const [ , command, description ] = line.match( COMMAND_REGEXP );
69
- result.commands.push( {
70
- command,
71
- description,
72
- } );
68
+ const match = line.match( COMMAND_REGEXP );
69
+ if ( match ) {
70
+ const [ , command, description ] = match;
71
+ result.commands.push( {
72
+ command,
73
+ description,
74
+ } );
75
+ } else if ( result.commands.length ) {
76
+ // Continuation of a wrapped command description.
77
+ result.commands[ result.commands.length - 1 ].description += ' ' + line;
78
+ } else {
79
+ console.error( 'Unknown command line', line );
80
+ }
73
81
  continue;
74
82
  }
75
83
  if ( currentSection === SECTION_OPTIONS ) {
@@ -79,8 +87,11 @@ const parseOutput = output => {
79
87
  option,
80
88
  description,
81
89
  } );
90
+ } else if ( result.options.length ) {
91
+ // Continuation of a wrapped option description.
92
+ result.options[ result.options.length - 1 ].description += ' ' + line;
82
93
  } else {
83
- console.log( 'Unknown option', line );
94
+ console.error( 'Unknown option', line );
84
95
  }
85
96
  continue;
86
97
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@automattic/vip",
3
- "version": "4.0.10",
3
+ "version": "4.0.13",
4
4
  "description": "The VIP Javascript library & CLI",
5
5
  "main": "index.js",
6
6
  "bin": {
@@ -27,6 +27,10 @@
27
27
  "vip-config-software-update": "dist/bin/vip-config-software-update.js",
28
28
  "vip-db": "dist/bin/vip-db.js",
29
29
  "vip-db-phpmyadmin": "dist/bin/vip-db-phpmyadmin.js",
30
+ "vip-defensive-mode": "dist/bin/vip-defensive-mode.js",
31
+ "vip-defensive-mode-configure": "dist/bin/vip-defensive-mode-configure.js",
32
+ "vip-defensive-mode-disable": "dist/bin/vip-defensive-mode-disable.js",
33
+ "vip-defensive-mode-enable": "dist/bin/vip-defensive-mode-enable.js",
30
34
  "vip-dev-env": "dist/bin/vip-dev-env.js",
31
35
  "vip-dev-env-create": "dist/bin/vip-dev-env-create.js",
32
36
  "vip-dev-env-update": "dist/bin/vip-dev-env-update.js",
@@ -141,7 +145,7 @@
141
145
  "typescript": "^6.0.2"
142
146
  },
143
147
  "dependencies": {
144
- "@apollo/client": "4.2.3",
148
+ "@apollo/client": "4.2.5",
145
149
  "@automattic/vip-search-replace": "^2.0.0",
146
150
  "@json2csv/plainjs": "^7.0.3",
147
151
  "@wwa/single-line-log": "^1.1.4",
@@ -156,7 +160,7 @@
156
160
  "enquirer": "2.4.1",
157
161
  "esbuild": "^0.28.0",
158
162
  "fetch-retry": "^6.0.0",
159
- "graphql": "17.0.1",
163
+ "graphql": "17.0.2",
160
164
  "graphql-tag": "2.12.7",
161
165
  "https-proxy-agent": "^9.0.0",
162
166
  "js-yaml": "^5.0.0",