Repository: parse-community/Parse-SDK-JS
Pull request #2484 (2025-03-06 19:01:29)

feat: Add `Parse.Query` option `useMaintenanceKey`
## Pull Request

- Report security issues [confidentially](https://github.com/parse-community/Parse-SDK-JS/security/policy).
- Any contribution is under this [license](https://github.com/parse-community/Parse-SDK-JS/blob/alpha/LICENSE).
- Link this pull request to an [issue](https://github.com/parse-community/Parse-SDK-JS/issues?q=is%3Aissue).

## Issue
<!-- Add the link to the issue that this PR closes. -->
The maintenanceKey has been introduced in Parse Server and can be used via REST API to access internal fields, but not via the Parse JS SDK.

Closes: https://github.com/parse-community/Parse-SDK-JS/issues/2392

## Approach
<!-- Describe the changes in this PR. -->
*Needs a server side update to work*
* Same usage as `useMasterKey`

## Tasks
<!-- Delete tasks that don't apply. -->

- [x] Add tests
- [x] Add changes to documentation (guides, repository pages, code comments)


parse-github-assistant[bot]: 
### Thanks for opening this pull request!

<!-- parse-issue-bot-meta-tag-id {"suggestPr":false,"thanksFeature":false,"thanksPr":false} -->


codecov[bot]: ## [Codecov](https://app.codecov.io/gh/parse-community/Parse-SDK-JS/pull/2484?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=parse-community) Report
All modified and coverable lines are covered by tests :white_check_mark:
> Project coverage is 100.00%. Comparing base [(`6d33b19`)](https://app.codecov.io/gh/parse-community/Parse-SDK-JS/commit/6d33b191b8aff8f9b0dd4cac0c76f2f186844d61?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=parse-community) to head [(`7250766`)](https://app.codecov.io/gh/parse-community/Parse-SDK-JS/commit/72507661a76fd71e84df1bd00ecdf51591aedb16?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=parse-community).

<details><summary>Additional details and impacted files</summary>


```diff
@@            Coverage Diff            @@
##             alpha     #2484   +/-   ##
=========================================
  Coverage   100.00%   100.00%           
=========================================
  Files           64        64           
  Lines         6256      6265    +9     
  Branches      1447      1478   +31     
=========================================
+ Hits          6256      6265    +9     
```

</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/parse-community/Parse-SDK-JS/pull/2484?dropdown=coverage&src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=parse-community).   
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=parse-community).

<details><summary>🚀 New features to boost your workflow: </summary>

- ❄ [Test Analytics](https://docs.codecov.com/docs/test-analytics): Detect flaky tests, report on failures, and find test suite problems.
- 📦 [JS Bundle Analysis](https://docs.codecov.com/docs/javascript-bundle-analysis): Save yourself from yourself by tracking and limiting bundle sizes in JS merges.
</details>
mtrezza: > Needs a server side update to work

How come all checks pass? Is this not part of the integration tests then?
dplewis: Just unit tests no integration
dplewis: @mtrezza Conflicts resolved

Patch:
diff --git a/src/CoreManager.ts b/src/CoreManager.ts
index 02629c7..927e286 100644
--- a/src/CoreManager.ts
+++ b/src/CoreManager.ts
@@ -353,6 +353,7 @@ const config: Config & { [key: string]: any } = {
   VERSION: 'js' + require('../package.json').version,
   APPLICATION_ID: null,
   JAVASCRIPT_KEY: null,
+  MAINTENANCE_KEY: null,
   MASTER_KEY: null,
   USE_MASTER_KEY: false,
   PERFORM_USER_REWRITE: true,
diff --git a/src/Parse.ts b/src/Parse.ts
index bd58958..32e096c 100644
--- a/src/Parse.ts
+++ b/src/Parse.ts
@@ -110,9 +110,15 @@ const Parse = {
     Parse._initialize(applicationId, javaScriptKey);
   },
 
-  _initialize(applicationId: string, javaScriptKey: string, masterKey?: string) {
+  _initialize(
+    applicationId: string,
+    javaScriptKey: string,
+    masterKey?: string,
+    maintenanceKey?: string
+  ) {
     CoreManager.set('APPLICATION_ID', applicationId);
     CoreManager.set('JAVASCRIPT_KEY', javaScriptKey);
+    CoreManager.set('MAINTENANCE_KEY', maintenanceKey);
     CoreManager.set('MASTER_KEY', masterKey);
     CoreManager.set('USE_MASTER_KEY', false);
     CoreManager.setIfNeeded('EventEmitter', EventEmitter);
@@ -198,6 +204,17 @@ const Parse = {
     return CoreManager.get('MASTER_KEY');
   },
 
+  /**
+   * @member {string} Parse.maintenanceKey
+   * @static
+   */
+  set maintenanceKey(value) {
+    CoreManager.set('MAINTENANCE_KEY', value);
+  },
+  get maintenanceKey() {
+    return CoreManager.get('MAINTENANCE_KEY');
+  },
+
   /**
    * @member {string} Parse.serverURL
    * @static
diff --git a/src/ParseObject.ts b/src/ParseObject.ts
index 27a6029..0590213 100644
--- a/src/ParseObject.ts
+++ b/src/ParseObject.ts
@@ -513,6 +513,9 @@ class ParseObject<T extends Attributes = Attributes> {
     if (hasOwn(options, 'useMasterKey')) {
       requestOptions.useMasterKey = !!options.useMasterKey;
     }
+    if (hasOwn(options, 'useMaintenanceKey')) {
+      requestOptions.useMaintenanceKey = !!options.useMaintenanceKey;
+    }
     if (hasOwn(options, 'sessionToken') && typeof options.sessionToken === 'string') {
       requestOptions.sessionToken = options.sessionToken;
     }
diff --git a/src/ParseQuery.ts b/src/ParseQuery.ts
index 85eacae..77a2e36 100644
--- a/src/ParseQuery.ts
+++ b/src/ParseQuery.ts
@@ -14,6 +14,7 @@ import type { Pointer } from './ParseObject';
 type BatchOptions = FullOptions & {
   batchSize?: number;
   useMasterKey?: boolean;
+  useMaintenanceKey?: boolean;
   sessionToken?: string;
   context?: { [key: string]: any };
   json?: boolean;
diff --git a/src/RESTController.ts b/src/RESTController.ts
index c85d58e..994ffcd 100644
--- a/src/RESTController.ts
+++ b/src/RESTController.ts
@@ -7,6 +7,7 @@ import XhrWeapp from './Xhr.weapp';
 
 export type RequestOptions = {
   useMasterKey?: boolean;
+  useMaintenanceKey?: boolean;
   sessionToken?: string;
   installationId?: string;
   returnStatus?: boolean;
@@ -23,6 +24,7 @@ export type FullOptions = {
   success?: any;
   error?: any;
   useMasterKey?: boolean;
+  useMaintenanceKey?: boolean;
   sessionToken?: string;
   installationId?: string;
   progress?: any;
@@ -36,6 +38,7 @@ type PayloadType = {
   _JavaScriptKey?: string;
   _ClientVersion: string;
   _MasterKey?: string;
+  _MaintenanceKey?: string;
   _RevocableSession?: string;
   _InstallationId?: string;
   _SessionToken?: string;
@@ -273,7 +276,9 @@ const RESTController = {
         throw new Error('Cannot use the Master Key, it has not been provided.');
       }
     }
-
+    if (options.useMaintenanceKey) {
+      payload._MaintenanceKey = CoreManager.get('MAINTENANCE_KEY');
+    }
     if (CoreManager.get('FORCE_REVOCABLE_SESSION')) {
       payload._RevocableSession = '1';
     }
diff --git a/types/Parse.d.ts b/types/Parse.d.ts
index b36c30a..cefbc56 100644
--- a/types/Parse.d.ts
+++ b/types/Parse.d.ts
@@ -223,7 +223,10 @@ declare const Parse: {
             purge: (className: string) => Promise<any>;
             get: (className: string, options?: import("./RESTController").RequestOptions) => Promise<any>;
             delete: (className: string, options?: import("./RESTController").RequestOptions) => Promise<void>;
-            create: (className: string, params: any, options?: import("./RESTController").RequestOptions) => Promise<any>;
+            create: (className: string, params: any, options? /**
+             * @member {string} Parse.maintenanceKey
+             * @static
+             */: import("./RESTController").RequestOptions) => Promise<any>;
             update: (className: string, params: any, options?: import("./RESTController").RequestOptions) => Promise<any>;
             send(className: string, method: string, params: any, options?: import("./RESTController").RequestOptions): Promise<any>;
         }): void;
@@ -231,7 +234,10 @@ declare const Parse: {
             purge: (className: string) => Promise<any>;
             get: (className: string, options?: import("./RESTController").RequestOptions) => Promise<any>;
             delete: (className: string, options?: import("./RESTController").RequestOptions) => Promise<void>;
-            create: (className: string, params: any, options?: import("./RESTController").RequestOptions) => Promise<any>;
+            create: (className: string, params: any, options? /**
+             * @member {string} Parse.maintenanceKey
+             * @static
+             */: import("./RESTController").RequestOptions) => Promise<any>;
             update: (className: string, params: any, options?: import("./RESTController").RequestOptions) => Promise<any>;
             send(className: string, method: string, params: any, options?: import("./RESTController").RequestOptions): Promise<any>;
         };
@@ -487,7 +493,7 @@ declare const Parse: {
      * @static
      */
     initialize(applicationId: string, javaScriptKey: string): void;
-    _initialize(applicationId: string, javaScriptKey: string, masterKey?: string): void;
+    _initialize(applicationId: string, javaScriptKey: string, masterKey?: string, maintenanceKey?: string): void;
     /**
      * Call this method to set your AsyncStorage engine
      * Starting Parse@1.11, the ParseSDK do not provide a React AsyncStorage as the ReactNative module
@@ -527,6 +533,11 @@ declare const Parse: {
      * @static
      */
     masterKey: any;
+    /**
+     * @member {string} Parse.maintenanceKey
+     * @static
+     */
+    maintenanceKey: any;
     /**
      * @member {string} Parse.serverURL
      * @static
diff --git a/types/ParseQuery.d.ts b/types/ParseQuery.d.ts
index e7b1cba..6035c51 100644
--- a/types/ParseQuery.d.ts
+++ b/types/ParseQuery.d.ts
@@ -6,6 +6,7 @@ import type { Pointer } from './ParseObject';
 type BatchOptions = FullOptions & {
     batchSize?: number;
     useMasterKey?: boolean;
+    useMaintenanceKey?: boolean;
     sessionToken?: string;
     context?: {
         [key: string]: any;
diff --git a/types/RESTController.d.ts b/types/RESTController.d.ts
index 366c963..47863f3 100644
--- a/types/RESTController.d.ts
+++ b/types/RESTController.d.ts
@@ -1,5 +1,6 @@
 export type RequestOptions = {
     useMasterKey?: boolean;
+    useMaintenanceKey?: boolean;
     sessionToken?: string;
     installationId?: string;
     returnStatus?: boolean;
@@ -15,6 +16,7 @@ export type FullOptions = {
     success?: any;
     error?: any;
     useMasterKey?: boolean;
+    useMaintenanceKey?: boolean;
     sessionToken?: string;
     installationId?: string;
     progress?: any;


Test patch:
diff --git a/src/__tests__/Parse-test.js b/src/__tests__/Parse-test.js
index e68ea82..8a4d9c4 100644
--- a/src/__tests__/Parse-test.js
+++ b/src/__tests__/Parse-test.js
@@ -24,10 +24,11 @@ describe('Parse module', () => {
     expect(CoreManager.get('APPLICATION_ID')).toBe('A');
     expect(CoreManager.get('JAVASCRIPT_KEY')).toBe('B');
 
-    Parse._initialize('A', 'B', 'C');
+    Parse._initialize('A', 'B', 'C', 'D');
     expect(CoreManager.get('APPLICATION_ID')).toBe('A');
     expect(CoreManager.get('JAVASCRIPT_KEY')).toBe('B');
     expect(CoreManager.get('MASTER_KEY')).toBe('C');
+    expect(CoreManager.get('MAINTENANCE_KEY')).toBe('D');
   });
 
   it('enables master key use in the node build', () => {
@@ -55,6 +56,10 @@ describe('Parse module', () => {
     expect(CoreManager.get('MASTER_KEY')).toBe('789');
     expect(Parse.masterKey).toBe('789');
 
+    Parse.maintenanceKey = '000';
+    expect(CoreManager.get('MAINTENANCE_KEY')).toBe('000');
+    expect(Parse.maintenanceKey).toBe('000');
+
     Parse.serverURL = 'http://example.com';
     expect(CoreManager.get('SERVER_URL')).toBe('http://example.com');
     expect(Parse.serverURL).toBe('http://example.com');
diff --git a/src/__tests__/ParseQuery-test.js b/src/__tests__/ParseQuery-test.js
index 22413dc..ca3a7d5 100644
--- a/src/__tests__/ParseQuery-test.js
+++ b/src/__tests__/ParseQuery-test.js
@@ -1263,6 +1263,7 @@ describe('ParseQuery', () => {
           },
         });
         expect(options.useMasterKey).toEqual(true);
+        expect(options.useMaintenanceKey).toEqual(true);
         expect(options.sessionToken).toEqual('1234');
         return Promise.resolve({
           results: [],
@@ -1274,6 +1275,7 @@ describe('ParseQuery', () => {
     q.equalTo('size', 'small')
       .first({
         useMasterKey: true,
+        useMaintenanceKey: true,
         sessionToken: '1234',
       })
       .then(obj => {
@@ -1440,6 +1442,7 @@ describe('ParseQuery', () => {
           },
         });
         expect(options.useMasterKey).toEqual(true);
+        expect(options.useMaintenanceKey).toEqual(true);
         expect(options.sessionToken).toEqual('1234');
         expect(options.context).toEqual(context);
         return Promise.resolve({
@@ -1451,6 +1454,7 @@ describe('ParseQuery', () => {
     const q = new ParseQuery('Item');
     q.get('I27', {
       useMasterKey: true,
+      useMaintenanceKey: true,
       sessionToken: '1234',
       context: context,
     }).then(() => {
@@ -1587,6 +1591,7 @@ describe('ParseQuery', () => {
           },
         });
         expect(options.useMasterKey).toEqual(true);
+        expect(options.useMaintenanceKey).toEqual(true);
         expect(options.sessionToken).toEqual('1234');
         expect(options.context).toEqual(context);
         return Promise.resolve({
@@ -1599,6 +1604,7 @@ describe('ParseQuery', () => {
     q.containedIn('size', ['small', 'medium'])
       .find({
         useMasterKey: true,
+        useMaintenanceKey: true,
         sessionToken: '1234',
         context: context,
       })
@@ -1713,6 +1719,7 @@ describe('ParseQuery', () => {
     it('passes options through to the REST API', async () => {
       const batchOptions = {
         useMasterKey: true,
+        useMaintenanceKey: true,
         sessionToken: '1234',
         batchSize: 50,
       };
@@ -1727,6 +1734,7 @@ describe('ParseQuery', () => {
         where: {},
       });
       expect(options.useMasterKey).toBe(true);
+      expect(options.useMaintenanceKey).toEqual(true);
       expect(options.sessionToken).toEqual('1234');
     });
 
@@ -1840,6 +1848,7 @@ describe('ParseQuery', () => {
     it('passes options through to the REST API', async () => {
       const batchOptions = {
         useMasterKey: true,
+        useMaintenanceKey: true,
         sessionToken: '1234',
         batchSize: 50,
         json: true,
@@ -1855,6 +1864,7 @@ describe('ParseQuery', () => {
         where: {},
       });
       expect(options.useMasterKey).toBe(true);
+      expect(options.useMaintenanceKey).toEqual(true);
       expect(options.sessionToken).toEqual('1234');
       expect(options.json).toEqual(true);
     });
@@ -1962,6 +1972,7 @@ describe('ParseQuery', () => {
           },
         });
         expect(options.useMasterKey).toEqual(true);
+        expect(options.useMaintenanceKey).toEqual(true);
         expect(options.sessionToken).toEqual('1234');
         expect(options.context).toEqual(context);
         return Promise.resolve({
@@ -1986,6 +1997,7 @@ describe('ParseQuery', () => {
       },
       {
         useMasterKey: true,
+        useMaintenanceKey: true,
         sessionToken: '1234',
         context: context,
       }
@@ -2014,6 +2026,7 @@ describe('ParseQuery', () => {
           hint: '_id_',
         });
         expect(options.useMasterKey).toEqual(true);
+        expect(options.useMaintenanceKey).toEqual(true);
         expect(options.sessionToken).toEqual('1234');
         expect(options.context).toEqual(context);
         return Promise.resolve({
@@ -2039,6 +2052,7 @@ describe('ParseQuery', () => {
       },
       {
         useMasterKey: true,
+        useMaintenanceKey: true,
         sessionToken: '1234',
         context: context,
       }
@@ -2761,6 +2775,7 @@ describe('ParseQuery', () => {
           group: { objectId: '$name' },
         });
         expect(options.useMasterKey).toEqual(true);
+        expect(options.useMaintenanceKey).toEqual(true);
         expect(options.requestTask).toBeDefined();
         return Promise.resolve({
           results: [],
diff --git a/src/__tests__/RESTController-test.js b/src/__tests__/RESTController-test.js
index 412d5f2..45b99ae 100644
--- a/src/__tests__/RESTController-test.js
+++ b/src/__tests__/RESTController-test.js
@@ -558,6 +558,28 @@ describe('RESTController', () => {
     });
   });
 
+  it('sends the maintenance key when requested', async () => {
+    CoreManager.set('MAINTENANCE_KEY', 'MK');
+    const xhr = {
+      setRequestHeader: jest.fn(),
+      open: jest.fn(),
+      send: jest.fn(),
+    };
+    RESTController._setXHR(function () {
+      return xhr;
+    });
+    RESTController.request('GET', 'classes/MyObject', {}, { useMaintenanceKey: true });
+    await flushPromises();
+    expect(JSON.parse(xhr.send.mock.calls[0][0])).toEqual({
+      _method: 'GET',
+      _ApplicationId: 'A',
+      _JavaScriptKey: 'B',
+      _MaintenanceKey: 'MK',
+      _ClientVersion: 'V',
+      _InstallationId: 'iid',
+    });
+  });
+
   it('includes the status code when requested', done => {
     RESTController._setXHR(mockXHR([{ status: 200, response: { success: true } }]));
     RESTController.request('POST', 'users', {}, { returnStatus: true }).then(response => {