diff --git a/CHANGELOG.md b/CHANGELOG.md index d719f9fca2..3ee55a1936 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ### master [Full Changelog](https://github.com/parse-community/parse-server/compare/4.2.0...master) +- NEW (EXPERIMENTAL): Idempotency enforcement for client requests. This deduplicates requests where the client intends to send one request to Parse Server but due to network issues the server receives the request multiple times. **Caution, this is an experimental feature that may not be appropriate for production.** [#6744](https://github.com/parse-community/parse-server/issues/6744). Thanks to [Manuel Trezza](https://github.com/mtrezza). ### 4.2.0 [Full Changelog](https://github.com/parse-community/parse-server/compare/4.1.0...4.2.0) diff --git a/README.md b/README.md index 51338bcc31..7dde76a41f 100644 --- a/README.md +++ b/README.md @@ -388,6 +388,39 @@ Parse Server allows developers to choose from several options when hosting files `GridFSBucketAdapter` is used by default and requires no setup, but if you're interested in using S3 or Google Cloud Storage, additional configuration information is available in the [Parse Server guide](http://docs.parseplatform.org/parse-server/guide/#configuring-file-adapters). +### Idempodency Enforcement + +**Caution, this is an experimental feature that may not be appropriate for production.** + +This feature deduplicates identical requests that are received by Parse Server mutliple times, typically due to network issues or network adapter access restrictions on mobile operating systems. + +Identical requests are identified by their request header `X-Parse-Request-Id`. Therefore a client request has to include this header for deduplication to be applied. Requests that do not contain this header cannot be deduplicated and are processed normally by Parse Server. This means rolling out this feature to clients is seamless as Parse Server still processes request without this header when this feature is enbabled. + +> This feature needs to be enabled on the client side to send the header and on the server to process the header. Refer to the specific Parse SDK docs to see whether the feature is supported yet. + +Deduplication is only done for object creation and update (`POST` and `PUT` requests). Deduplication is not done for object finding and deletion (`GET` and `DELETE` requests), as these operations are already idempotent by definition. + +#### Configuration example +``` +let api = new ParseServer({ + idempotencyOptions: { + paths: [".*"], // enforce for all requests + ttl: 120 // keep request IDs for 120s + } +} +``` +#### Parameters + +| Parameter | Optional | Type | Default value | Example values | Environment variable | Description | +|-----------|----------|--------|---------------|-----------|-----------|-------------| +| `idempotencyOptions` | yes | `Object` | `undefined` | | PARSE_SERVER_EXPERIMENTAL_IDEMPOTENCY_OPTIONS | Setting this enables idempotency enforcement for the specified paths. | +| `idempotencyOptions.paths`| yes | `Array` | `[]` | `.*` (all paths, includes the examples below),
`functions/.*` (all functions),
`jobs/.*` (all jobs),
`classes/.*` (all classes),
`functions/.*` (all functions),
`users` (user creation / update),
`installations` (installation creation / update) | PARSE_SERVER_EXPERIMENTAL_IDEMPOTENCY_PATHS | An array of path patterns that have to match the request path for request deduplication to be enabled. The mount path must not be included, for example to match the request path `/parse/functions/myFunction` specifiy the path pattern `functions/myFunction`. A trailing slash of the request path is ignored, for example the path pattern `functions/myFunction` matches both `/parse/functions/myFunction` and `/parse/functions/myFunction/`. | +| `idempotencyOptions.ttl` | yes | `Integer` | `300` | `60` (60 seconds) | PARSE_SERVER_EXPERIMENTAL_IDEMPOTENCY_TTL | The duration in seconds after which a request record is discarded from the database. Duplicate requests due to network issues can be expected to arrive within milliseconds up to several seconds. This value must be greater than `0`. | + +#### Notes + +- This feature is currently only available for MongoDB and not for Postgres. + ### Logging Parse Server will, by default, log: diff --git a/resources/buildConfigDefinitions.js b/resources/buildConfigDefinitions.js index 8215792a82..a640e1c3c7 100644 --- a/resources/buildConfigDefinitions.js +++ b/resources/buildConfigDefinitions.js @@ -52,6 +52,9 @@ function getENVPrefix(iface) { if (iface.id.name === 'LiveQueryOptions') { return 'PARSE_SERVER_LIVEQUERY_'; } + if (iface.id.name === 'IdempotencyOptions') { + return 'PARSE_SERVER_EXPERIMENTAL_IDEMPOTENCY_'; + } } function processProperty(property, iface) { @@ -170,6 +173,13 @@ function parseDefaultValue(elt, value, t) { }); literalValue = t.objectExpression(props); } + if (type == 'IdempotencyOptions') { + const object = parsers.objectParser(value); + const props = Object.keys(object).map((key) => { + return t.objectProperty(key, object[value]); + }); + literalValue = t.objectExpression(props); + } if (type == 'ProtectedFields') { const prop = t.objectProperty( t.stringLiteral('_User'), t.objectPattern([ diff --git a/spec/Idempotency.spec.js b/spec/Idempotency.spec.js new file mode 100644 index 0000000000..2d0e99aa19 --- /dev/null +++ b/spec/Idempotency.spec.js @@ -0,0 +1,247 @@ +'use strict'; +const Config = require('../lib/Config'); +const Definitions = require('../lib/Options/Definitions'); +const request = require('../lib/request'); +const rest = require('../lib/rest'); +const auth = require('../lib/Auth'); +const uuid = require('uuid'); + +describe_only_db('mongo')('Idempotency', () => { + // Parameters + /** Enable TTL expiration simulated by removing entry instead of waiting for MongoDB TTL monitor which + runs only every 60s, so it can take up to 119s until entry removal - ain't nobody got time for that */ + const SIMULATE_TTL = true; + // Helpers + async function deleteRequestEntry(reqId) { + const config = Config.get(Parse.applicationId); + const res = await rest.find( + config, + auth.master(config), + '_Idempotency', + { reqId: reqId }, + { limit: 1 } + ); + await rest.del( + config, + auth.master(config), + '_Idempotency', + res.results[0].objectId); + } + async function setup(options) { + await reconfigureServer({ + appId: Parse.applicationId, + masterKey: Parse.masterKey, + serverURL: Parse.serverURL, + idempotencyOptions: options, + }); + } + // Setups + beforeEach(async () => { + if (SIMULATE_TTL) { jasmine.DEFAULT_TIMEOUT_INTERVAL = 200000; } + await setup({ + paths: [ + "functions/.*", + "jobs/.*", + "classes/.*", + "users", + "installations" + ], + ttl: 30, + }); + }); + // Tests + it('should enforce idempotency for cloud code function', async () => { + let counter = 0; + Parse.Cloud.define('myFunction', () => { + counter++; + }); + const params = { + method: 'POST', + url: 'http://localhost:8378/1/functions/myFunction', + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-Master-Key': Parse.masterKey, + 'X-Parse-Request-Id': 'abc-123' + } + }; + expect(Config.get(Parse.applicationId).idempotencyOptions.ttl).toBe(30); + await request(params); + await request(params).then(fail, e => { + expect(e.status).toEqual(400); + expect(e.data.error).toEqual("Duplicate request"); + }); + expect(counter).toBe(1); + }); + + it('should delete request entry after TTL', async () => { + let counter = 0; + Parse.Cloud.define('myFunction', () => { + counter++; + }); + const params = { + method: 'POST', + url: 'http://localhost:8378/1/functions/myFunction', + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-Master-Key': Parse.masterKey, + 'X-Parse-Request-Id': 'abc-123' + } + }; + await expectAsync(request(params)).toBeResolved(); + if (SIMULATE_TTL) { + await deleteRequestEntry('abc-123'); + } else { + await new Promise(resolve => setTimeout(resolve, 130000)); + } + await expectAsync(request(params)).toBeResolved(); + expect(counter).toBe(2); + }); + + it('should enforce idempotency for cloud code jobs', async () => { + let counter = 0; + Parse.Cloud.job('myJob', () => { + counter++; + }); + const params = { + method: 'POST', + url: 'http://localhost:8378/1/jobs/myJob', + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-Master-Key': Parse.masterKey, + 'X-Parse-Request-Id': 'abc-123' + } + }; + await expectAsync(request(params)).toBeResolved(); + await request(params).then(fail, e => { + expect(e.status).toEqual(400); + expect(e.data.error).toEqual("Duplicate request"); + }); + expect(counter).toBe(1); + }); + + it('should enforce idempotency for class object creation', async () => { + let counter = 0; + Parse.Cloud.afterSave('MyClass', () => { + counter++; + }); + const params = { + method: 'POST', + url: 'http://localhost:8378/1/classes/MyClass', + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-Master-Key': Parse.masterKey, + 'X-Parse-Request-Id': 'abc-123' + } + }; + await expectAsync(request(params)).toBeResolved(); + await request(params).then(fail, e => { + expect(e.status).toEqual(400); + expect(e.data.error).toEqual("Duplicate request"); + }); + expect(counter).toBe(1); + }); + + it('should enforce idempotency for user object creation', async () => { + let counter = 0; + Parse.Cloud.afterSave('_User', () => { + counter++; + }); + const params = { + method: 'POST', + url: 'http://localhost:8378/1/users', + body: { + username: "user", + password: "pass" + }, + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-Master-Key': Parse.masterKey, + 'X-Parse-Request-Id': 'abc-123' + } + }; + await expectAsync(request(params)).toBeResolved(); + await request(params).then(fail, e => { + expect(e.status).toEqual(400); + expect(e.data.error).toEqual("Duplicate request"); + }); + expect(counter).toBe(1); + }); + + it('should enforce idempotency for installation object creation', async () => { + let counter = 0; + Parse.Cloud.afterSave('_Installation', () => { + counter++; + }); + const params = { + method: 'POST', + url: 'http://localhost:8378/1/installations', + body: { + installationId: "1", + deviceType: "ios" + }, + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-Master-Key': Parse.masterKey, + 'X-Parse-Request-Id': 'abc-123' + } + }; + await expectAsync(request(params)).toBeResolved(); + await request(params).then(fail, e => { + expect(e.status).toEqual(400); + expect(e.data.error).toEqual("Duplicate request"); + }); + expect(counter).toBe(1); + }); + + it('should not interfere with calls of different request ID', async () => { + let counter = 0; + Parse.Cloud.afterSave('MyClass', () => { + counter++; + }); + const promises = [...Array(100).keys()].map(() => { + const params = { + method: 'POST', + url: 'http://localhost:8378/1/classes/MyClass', + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-Master-Key': Parse.masterKey, + 'X-Parse-Request-Id': uuid.v4() + } + }; + return request(params); + }); + await expectAsync(Promise.all(promises)).toBeResolved(); + expect(counter).toBe(100); + }); + + it('should re-throw any other error unchanged when writing request entry fails for any other reason', async () => { + spyOn(rest, 'create').and.rejectWith(new Parse.Error(0, "some other error")); + Parse.Cloud.define('myFunction', () => {}); + const params = { + method: 'POST', + url: 'http://localhost:8378/1/functions/myFunction', + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-Master-Key': Parse.masterKey, + 'X-Parse-Request-Id': 'abc-123' + } + }; + await request(params).then(fail, e => { + expect(e.status).toEqual(400); + expect(e.data.error).toEqual("some other error"); + }); + }); + + it('should use default configuration when none is set', async () => { + await setup({}); + expect(Config.get(Parse.applicationId).idempotencyOptions.ttl).toBe(Definitions.IdempotencyOptions.ttl.default); + expect(Config.get(Parse.applicationId).idempotencyOptions.paths).toBe(Definitions.IdempotencyOptions.paths.default); + }); + + it('should throw on invalid configuration', async () => { + await expectAsync(setup({ paths: 1 })).toBeRejected(); + await expectAsync(setup({ ttl: 'a' })).toBeRejected(); + await expectAsync(setup({ ttl: 0 })).toBeRejected(); + await expectAsync(setup({ ttl: -1 })).toBeRejected(); + }); +}); diff --git a/spec/ParseQuery.Aggregate.spec.js b/spec/ParseQuery.Aggregate.spec.js index b7dd291d1c..d9a684d0a9 100644 --- a/spec/ParseQuery.Aggregate.spec.js +++ b/spec/ParseQuery.Aggregate.spec.js @@ -1440,7 +1440,7 @@ describe('Parse.Query Aggregate testing', () => { ['location'], 'geoIndex', false, - '2dsphere' + { indexType: '2dsphere' }, ); // Create objects const GeoObject = Parse.Object.extend('GeoObject'); diff --git a/spec/helper.js b/spec/helper.js index 16d25ba1b8..84e704f7e3 100644 --- a/spec/helper.js +++ b/spec/helper.js @@ -230,6 +230,7 @@ afterEach(function(done) { '_Session', '_Product', '_Audience', + '_Idempotency' ].indexOf(className) >= 0 ); } diff --git a/src/Adapters/Storage/Mongo/MongoStorageAdapter.js b/src/Adapters/Storage/Mongo/MongoStorageAdapter.js index 7b8d2a3ef3..9c08d3a073 100644 --- a/src/Adapters/Storage/Mongo/MongoStorageAdapter.js +++ b/src/Adapters/Storage/Mongo/MongoStorageAdapter.js @@ -692,7 +692,7 @@ export class MongoStorageAdapter implements StorageAdapter { fieldNames: string[], indexName: ?string, caseInsensitive: boolean = false, - indexType: any = 1 + options?: Object = {}, ): Promise { schema = convertParseSchemaToMongoSchema(schema); const indexCreationRequest = {}; @@ -700,11 +700,12 @@ export class MongoStorageAdapter implements StorageAdapter { transformKey(className, fieldName, schema) ); mongoFieldNames.forEach((fieldName) => { - indexCreationRequest[fieldName] = indexType; + indexCreationRequest[fieldName] = options.indexType !== undefined ? options.indexType : 1; }); const defaultOptions: Object = { background: true, sparse: true }; const indexNameOptions: Object = indexName ? { name: indexName } : {}; + const ttlOptions: Object = options.ttl !== undefined ? { expireAfterSeconds: options.ttl } : {}; const caseInsensitiveOptions: Object = caseInsensitive ? { collation: MongoCollection.caseInsensitiveCollation() } : {}; @@ -712,6 +713,7 @@ export class MongoStorageAdapter implements StorageAdapter { ...defaultOptions, ...caseInsensitiveOptions, ...indexNameOptions, + ...ttlOptions, }; return this._adaptiveCollection(className) diff --git a/src/Adapters/Storage/Postgres/PostgresStorageAdapter.js b/src/Adapters/Storage/Postgres/PostgresStorageAdapter.js index 6b58120778..ce7b746f4f 100644 --- a/src/Adapters/Storage/Postgres/PostgresStorageAdapter.js +++ b/src/Adapters/Storage/Postgres/PostgresStorageAdapter.js @@ -1209,6 +1209,7 @@ export class PostgresStorageAdapter implements StorageAdapter { '_GlobalConfig', '_GraphQLConfig', '_Audience', + '_Idempotency', ...results.map((result) => result.className), ...joins, ]; @@ -2576,9 +2577,9 @@ export class PostgresStorageAdapter implements StorageAdapter { fieldNames: string[], indexName: ?string, caseInsensitive: boolean = false, - conn: ?any = null + options?: Object = {}, ): Promise { - conn = conn != null ? conn : this._client; + const conn = options.conn !== undefined ? options.conn : this._client; const defaultIndexName = `parse_default_${fieldNames.sort().join('_')}`; const indexNameOptions: Object = indexName != null ? { name: indexName } : { name: defaultIndexName }; diff --git a/src/Adapters/Storage/StorageAdapter.js b/src/Adapters/Storage/StorageAdapter.js index 0256841b23..7031c21d7f 100644 --- a/src/Adapters/Storage/StorageAdapter.js +++ b/src/Adapters/Storage/StorageAdapter.js @@ -93,7 +93,7 @@ export interface StorageAdapter { fieldNames: string[], indexName?: string, caseSensitive?: boolean, - indexType?: any + options?: Object, ): Promise; ensureUniqueness( className: string, diff --git a/src/Config.js b/src/Config.js index 2077626ff8..214e22ca57 100644 --- a/src/Config.js +++ b/src/Config.js @@ -6,6 +6,7 @@ import AppCache from './cache'; import SchemaCache from './Controllers/SchemaCache'; import DatabaseController from './Controllers/DatabaseController'; import net from 'net'; +import { IdempotencyOptions } from './Options/Definitions'; function removeTrailingSlash(str) { if (!str) { @@ -73,6 +74,7 @@ export class Config { masterKey, readOnlyMasterKey, allowHeaders, + idempotencyOptions, }) { if (masterKey === readOnlyMasterKey) { throw new Error('masterKey and readOnlyMasterKey should be different'); @@ -104,14 +106,27 @@ export class Config { throw 'publicServerURL should be a valid HTTPS URL starting with https://'; } } - this.validateSessionConfiguration(sessionLength, expireInactiveSessions); - this.validateMasterKeyIps(masterKeyIps); - this.validateMaxLimit(maxLimit); - this.validateAllowHeaders(allowHeaders); + this.validateIdempotencyOptions(idempotencyOptions); + } + + static validateIdempotencyOptions(idempotencyOptions) { + if (!idempotencyOptions) { return; } + if (idempotencyOptions.ttl === undefined) { + idempotencyOptions.ttl = IdempotencyOptions.ttl.default; + } else if (!isNaN(idempotencyOptions.ttl) && idempotencyOptions.ttl <= 0) { + throw 'idempotency TTL value must be greater than 0 seconds'; + } else if (isNaN(idempotencyOptions.ttl)) { + throw 'idempotency TTL value must be a number'; + } + if (!idempotencyOptions.paths) { + idempotencyOptions.paths = IdempotencyOptions.paths.default; + } else if (!(idempotencyOptions.paths instanceof Array)) { + throw 'idempotency paths must be of an array of strings'; + } } static validateAccountLockoutPolicy(accountLockout) { diff --git a/src/Controllers/DatabaseController.js b/src/Controllers/DatabaseController.js index f71350eef3..657b40db8a 100644 --- a/src/Controllers/DatabaseController.js +++ b/src/Controllers/DatabaseController.js @@ -244,6 +244,7 @@ const filterSensitiveData = ( }; import type { LoadSchemaOptions } from './types'; +import MongoStorageAdapter from '../Adapters/Storage/Mongo/MongoStorageAdapter'; // Runs an update on the database. // Returns a promise for an object with the new values for field @@ -1736,6 +1737,12 @@ class DatabaseController { ...SchemaController.defaultColumns._Role, }, }; + const requiredIdempotencyFields = { + fields: { + ...SchemaController.defaultColumns._Default, + ...SchemaController.defaultColumns._Idempotency, + }, + }; const userClassPromise = this.loadSchema().then(schema => schema.enforceClassExists('_User') @@ -1743,6 +1750,9 @@ class DatabaseController { const roleClassPromise = this.loadSchema().then(schema => schema.enforceClassExists('_Role') ); + const idempotencyClassPromise = this.adapter instanceof MongoStorageAdapter + ? this.loadSchema().then((schema) => schema.enforceClassExists('_Idempotency')) + : Promise.resolve(); const usernameUniqueness = userClassPromise .then(() => @@ -1807,6 +1817,43 @@ class DatabaseController { throw error; }); + const idempotencyRequestIdIndex = this.adapter instanceof MongoStorageAdapter + ? idempotencyClassPromise + .then(() => + this.adapter.ensureUniqueness( + '_Idempotency', + requiredIdempotencyFields, + ['reqId'] + )) + .catch((error) => { + logger.warn( + 'Unable to ensure uniqueness for idempotency request ID: ', + error + ); + throw error; + }) + : Promise.resolve(); + + const idempotencyExpireIndex = this.adapter instanceof MongoStorageAdapter + ? idempotencyClassPromise + .then(() => + this.adapter.ensureIndex( + '_Idempotency', + requiredIdempotencyFields, + ['expire'], + 'ttl', + false, + { ttl: 0 }, + )) + .catch((error) => { + logger.warn( + 'Unable to create TTL index for idempotency expire date: ', + error + ); + throw error; + }) + : Promise.resolve(); + const indexPromise = this.adapter.updateSchemaWithIndexes(); // Create tables for volatile classes @@ -1819,6 +1866,8 @@ class DatabaseController { emailUniqueness, emailCaseInsensitiveIndex, roleUniqueness, + idempotencyRequestIdIndex, + idempotencyExpireIndex, adapterInit, indexPromise, ]); diff --git a/src/Controllers/SchemaController.js b/src/Controllers/SchemaController.js index c5b04dcfc6..be8a3102b4 100644 --- a/src/Controllers/SchemaController.js +++ b/src/Controllers/SchemaController.js @@ -144,6 +144,10 @@ const defaultColumns: { [string]: SchemaFields } = Object.freeze({ lastUsed: { type: 'Date' }, timesUsed: { type: 'Number' }, }, + _Idempotency: { + reqId: { type: 'String' }, + expire: { type: 'Date' }, + } }); const requiredColumns = Object.freeze({ @@ -161,6 +165,7 @@ const systemClasses = Object.freeze([ '_JobStatus', '_JobSchedule', '_Audience', + '_Idempotency' ]); const volatileClasses = Object.freeze([ @@ -171,6 +176,7 @@ const volatileClasses = Object.freeze([ '_GraphQLConfig', '_JobSchedule', '_Audience', + '_Idempotency' ]); // Anything that start with role @@ -660,6 +666,13 @@ const _AudienceSchema = convertSchemaToAdapterSchema( classLevelPermissions: {}, }) ); +const _IdempotencySchema = convertSchemaToAdapterSchema( + injectDefaultSchema({ + className: '_Idempotency', + fields: defaultColumns._Idempotency, + classLevelPermissions: {}, + }) +); const VolatileClassesSchemas = [ _HooksSchema, _JobStatusSchema, @@ -668,6 +681,7 @@ const VolatileClassesSchemas = [ _GlobalConfigSchema, _GraphQLConfigSchema, _AudienceSchema, + _IdempotencySchema ]; const dbTypeMatchesObjectType = ( diff --git a/src/Options/Definitions.js b/src/Options/Definitions.js index 682784b1f4..3879198d68 100644 --- a/src/Options/Definitions.js +++ b/src/Options/Definitions.js @@ -3,527 +3,522 @@ This code has been generated by resources/buildConfigDefinitions.js Do not edit manually, but update Options/index.js */ -var parsers = require('./parsers'); +var parsers = require("./parsers"); module.exports.ParseServerOptions = { - accountLockout: { - env: 'PARSE_SERVER_ACCOUNT_LOCKOUT', - help: 'account lockout policy for failed login attempts', - action: parsers.objectParser, - }, - allowClientClassCreation: { - env: 'PARSE_SERVER_ALLOW_CLIENT_CLASS_CREATION', - help: 'Enable (or disable) client class creation, defaults to true', - action: parsers.booleanParser, - default: true, - }, - allowCustomObjectId: { - env: 'PARSE_SERVER_ALLOW_CUSTOM_OBJECT_ID', - help: 'Enable (or disable) custom objectId', - action: parsers.booleanParser, - default: false, - }, - allowHeaders: { - env: 'PARSE_SERVER_ALLOW_HEADERS', - help: 'Add headers to Access-Control-Allow-Headers', - action: parsers.arrayParser, - }, - allowOrigin: { - env: 'PARSE_SERVER_ALLOW_ORIGIN', - help: 'Sets the origin to Access-Control-Allow-Origin', - }, - analyticsAdapter: { - env: 'PARSE_SERVER_ANALYTICS_ADAPTER', - help: 'Adapter module for the analytics', - action: parsers.moduleOrObjectParser, - }, - appId: { - env: 'PARSE_SERVER_APPLICATION_ID', - help: 'Your Parse Application ID', - required: true, - }, - appName: { - env: 'PARSE_SERVER_APP_NAME', - help: 'Sets the app name', - }, - auth: { - env: 'PARSE_SERVER_AUTH_PROVIDERS', - help: - 'Configuration for your authentication providers, as stringified JSON. See http://docs.parseplatform.org/parse-server/guide/#oauth-and-3rd-party-authentication', - action: parsers.objectParser, - }, - cacheAdapter: { - env: 'PARSE_SERVER_CACHE_ADAPTER', - help: 'Adapter module for the cache', - action: parsers.moduleOrObjectParser, - }, - cacheMaxSize: { - env: 'PARSE_SERVER_CACHE_MAX_SIZE', - help: 'Sets the maximum size for the in memory cache, defaults to 10000', - action: parsers.numberParser('cacheMaxSize'), - default: 10000, - }, - cacheTTL: { - env: 'PARSE_SERVER_CACHE_TTL', - help: - 'Sets the TTL for the in memory cache (in ms), defaults to 5000 (5 seconds)', - action: parsers.numberParser('cacheTTL'), - default: 5000, - }, - clientKey: { - env: 'PARSE_SERVER_CLIENT_KEY', - help: 'Key for iOS, MacOS, tvOS clients', - }, - cloud: { - env: 'PARSE_SERVER_CLOUD', - help: 'Full path to your cloud code main.js', - }, - cluster: { - env: 'PARSE_SERVER_CLUSTER', - help: - 'Run with cluster, optionally set the number of processes default to os.cpus().length', - action: parsers.numberOrBooleanParser, - }, - collectionPrefix: { - env: 'PARSE_SERVER_COLLECTION_PREFIX', - help: 'A collection prefix for the classes', - default: '', - }, - customPages: { - env: 'PARSE_SERVER_CUSTOM_PAGES', - help: 'custom pages for password validation and reset', - action: parsers.objectParser, - default: {}, - }, - databaseAdapter: { - env: 'PARSE_SERVER_DATABASE_ADAPTER', - help: 'Adapter module for the database', - action: parsers.moduleOrObjectParser, - }, - databaseOptions: { - env: 'PARSE_SERVER_DATABASE_OPTIONS', - help: 'Options to pass to the mongodb client', - action: parsers.objectParser, - }, - databaseURI: { - env: 'PARSE_SERVER_DATABASE_URI', - help: - 'The full URI to your database. Supported databases are mongodb or postgres.', - required: true, - default: 'mongodb://localhost:27017/parse', - }, - directAccess: { - env: 'PARSE_SERVER_ENABLE_EXPERIMENTAL_DIRECT_ACCESS', - help: - 'Replace HTTP Interface when using JS SDK in current node runtime, defaults to false. Caution, this is an experimental feature that may not be appropriate for production.', - action: parsers.booleanParser, - default: false, - }, - dotNetKey: { - env: 'PARSE_SERVER_DOT_NET_KEY', - help: 'Key for Unity and .Net SDK', - }, - emailAdapter: { - env: 'PARSE_SERVER_EMAIL_ADAPTER', - help: 'Adapter module for email sending', - action: parsers.moduleOrObjectParser, - }, - emailVerifyTokenValidityDuration: { - env: 'PARSE_SERVER_EMAIL_VERIFY_TOKEN_VALIDITY_DURATION', - help: 'Email verification token validity duration, in seconds', - action: parsers.numberParser('emailVerifyTokenValidityDuration'), - }, - enableAnonymousUsers: { - env: 'PARSE_SERVER_ENABLE_ANON_USERS', - help: 'Enable (or disable) anonymous users, defaults to true', - action: parsers.booleanParser, - default: true, - }, - enableExpressErrorHandler: { - env: 'PARSE_SERVER_ENABLE_EXPRESS_ERROR_HANDLER', - help: 'Enables the default express error handler for all errors', - action: parsers.booleanParser, - default: false, - }, - enableSingleSchemaCache: { - env: 'PARSE_SERVER_ENABLE_SINGLE_SCHEMA_CACHE', - help: - 'Use a single schema cache shared across requests. Reduces number of queries made to _SCHEMA, defaults to false, i.e. unique schema cache per request.', - action: parsers.booleanParser, - default: false, - }, - expireInactiveSessions: { - env: 'PARSE_SERVER_EXPIRE_INACTIVE_SESSIONS', - help: - 'Sets wether we should expire the inactive sessions, defaults to true', - action: parsers.booleanParser, - default: true, - }, - fileKey: { - env: 'PARSE_SERVER_FILE_KEY', - help: 'Key for your files', - }, - filesAdapter: { - env: 'PARSE_SERVER_FILES_ADAPTER', - help: 'Adapter module for the files sub-system', - action: parsers.moduleOrObjectParser, - }, - graphQLPath: { - env: 'PARSE_SERVER_GRAPHQL_PATH', - help: 'Mount path for the GraphQL endpoint, defaults to /graphql', - default: '/graphql', - }, - graphQLSchema: { - env: 'PARSE_SERVER_GRAPH_QLSCHEMA', - help: 'Full path to your GraphQL custom schema.graphql file', - }, - host: { - env: 'PARSE_SERVER_HOST', - help: 'The host to serve ParseServer on, defaults to 0.0.0.0', - default: '0.0.0.0', - }, - javascriptKey: { - env: 'PARSE_SERVER_JAVASCRIPT_KEY', - help: 'Key for the Javascript SDK', - }, - jsonLogs: { - env: 'JSON_LOGS', - help: 'Log as structured JSON objects', - action: parsers.booleanParser, - }, - liveQuery: { - env: 'PARSE_SERVER_LIVE_QUERY', - help: "parse-server's LiveQuery configuration object", - action: parsers.objectParser, - }, - liveQueryServerOptions: { - env: 'PARSE_SERVER_LIVE_QUERY_SERVER_OPTIONS', - help: - 'Live query server configuration options (will start the liveQuery server)', - action: parsers.objectParser, - }, - loggerAdapter: { - env: 'PARSE_SERVER_LOGGER_ADAPTER', - help: 'Adapter module for the logging sub-system', - action: parsers.moduleOrObjectParser, - }, - logLevel: { - env: 'PARSE_SERVER_LOG_LEVEL', - help: 'Sets the level for logs', - }, - logsFolder: { - env: 'PARSE_SERVER_LOGS_FOLDER', - help: - "Folder for the logs (defaults to './logs'); set to null to disable file based logging", - default: './logs', - }, - masterKey: { - env: 'PARSE_SERVER_MASTER_KEY', - help: 'Your Parse Master Key', - required: true, - }, - masterKeyIps: { - env: 'PARSE_SERVER_MASTER_KEY_IPS', - help: - 'Restrict masterKey to be used by only these ips, defaults to [] (allow all ips)', - action: parsers.arrayParser, - default: [], - }, - maxLimit: { - env: 'PARSE_SERVER_MAX_LIMIT', - help: 'Max value for limit option on queries, defaults to unlimited', - action: parsers.numberParser('maxLimit'), - }, - maxLogFiles: { - env: 'PARSE_SERVER_MAX_LOG_FILES', - help: - "Maximum number of logs to keep. If not set, no logs will be removed. This can be a number of files or number of days. If using days, add 'd' as the suffix. (default: null)", - action: parsers.objectParser, - }, - maxUploadSize: { - env: 'PARSE_SERVER_MAX_UPLOAD_SIZE', - help: 'Max file size for uploads, defaults to 20mb', - default: '20mb', - }, - middleware: { - env: 'PARSE_SERVER_MIDDLEWARE', - help: 'middleware for express server, can be string or function', - }, - mountGraphQL: { - env: 'PARSE_SERVER_MOUNT_GRAPHQL', - help: 'Mounts the GraphQL endpoint', - action: parsers.booleanParser, - default: false, - }, - mountPath: { - env: 'PARSE_SERVER_MOUNT_PATH', - help: 'Mount path for the server, defaults to /parse', - default: '/parse', - }, - mountPlayground: { - env: 'PARSE_SERVER_MOUNT_PLAYGROUND', - help: 'Mounts the GraphQL Playground - never use this option in production', - action: parsers.booleanParser, - default: false, - }, - objectIdSize: { - env: 'PARSE_SERVER_OBJECT_ID_SIZE', - help: "Sets the number of characters in generated object id's, default 10", - action: parsers.numberParser('objectIdSize'), - default: 10, - }, - passwordPolicy: { - env: 'PARSE_SERVER_PASSWORD_POLICY', - help: 'Password policy for enforcing password related rules', - action: parsers.objectParser, - }, - playgroundPath: { - env: 'PARSE_SERVER_PLAYGROUND_PATH', - help: 'Mount path for the GraphQL Playground, defaults to /playground', - default: '/playground', - }, - port: { - env: 'PORT', - help: 'The port to run the ParseServer, defaults to 1337.', - action: parsers.numberParser('port'), - default: 1337, - }, - preserveFileName: { - env: 'PARSE_SERVER_PRESERVE_FILE_NAME', - help: 'Enable (or disable) the addition of a unique hash to the file names', - action: parsers.booleanParser, - default: false, - }, - preventLoginWithUnverifiedEmail: { - env: 'PARSE_SERVER_PREVENT_LOGIN_WITH_UNVERIFIED_EMAIL', - help: - 'Prevent user from login if email is not verified and PARSE_SERVER_VERIFY_USER_EMAILS is true, defaults to false', - action: parsers.booleanParser, - default: false, - }, - protectedFields: { - env: 'PARSE_SERVER_PROTECTED_FIELDS', - help: - 'Protected fields that should be treated with extra security when fetching details.', - action: parsers.objectParser, - default: { - _User: { - '*': ['email'], - }, - }, - }, - publicServerURL: { - env: 'PARSE_PUBLIC_SERVER_URL', - help: 'Public URL to your parse server with http:// or https://.', - }, - push: { - env: 'PARSE_SERVER_PUSH', - help: - 'Configuration for push, as stringified JSON. See http://docs.parseplatform.org/parse-server/guide/#push-notifications', - action: parsers.objectParser, - }, - readOnlyMasterKey: { - env: 'PARSE_SERVER_READ_ONLY_MASTER_KEY', - help: - 'Read-only key, which has the same capabilities as MasterKey without writes', - }, - restAPIKey: { - env: 'PARSE_SERVER_REST_API_KEY', - help: 'Key for REST calls', - }, - revokeSessionOnPasswordReset: { - env: 'PARSE_SERVER_REVOKE_SESSION_ON_PASSWORD_RESET', - help: - "When a user changes their password, either through the reset password email or while logged in, all sessions are revoked if this is true. Set to false if you don't want to revoke sessions.", - action: parsers.booleanParser, - default: true, - }, - scheduledPush: { - env: 'PARSE_SERVER_SCHEDULED_PUSH', - help: 'Configuration for push scheduling, defaults to false.', - action: parsers.booleanParser, - default: false, - }, - schemaCacheTTL: { - env: 'PARSE_SERVER_SCHEMA_CACHE_TTL', - help: - 'The TTL for caching the schema for optimizing read/write operations. You should put a long TTL when your DB is in production. default to 5000; set 0 to disable.', - action: parsers.numberParser('schemaCacheTTL'), - default: 5000, - }, - serverCloseComplete: { - env: 'PARSE_SERVER_SERVER_CLOSE_COMPLETE', - help: 'Callback when server has closed', - }, - serverStartComplete: { - env: 'PARSE_SERVER_SERVER_START_COMPLETE', - help: 'Callback when server has started', - }, - serverURL: { - env: 'PARSE_SERVER_URL', - help: 'URL to your parse server with http:// or https://.', - required: true, - }, - sessionLength: { - env: 'PARSE_SERVER_SESSION_LENGTH', - help: 'Session duration, in seconds, defaults to 1 year', - action: parsers.numberParser('sessionLength'), - default: 31536000, - }, - silent: { - env: 'SILENT', - help: 'Disables console output', - action: parsers.booleanParser, - }, - startLiveQueryServer: { - env: 'PARSE_SERVER_START_LIVE_QUERY_SERVER', - help: 'Starts the liveQuery server', - action: parsers.booleanParser, - }, - userSensitiveFields: { - env: 'PARSE_SERVER_USER_SENSITIVE_FIELDS', - help: - 'Personally identifiable information fields in the user table the should be removed for non-authorized users. Deprecated @see protectedFields', - action: parsers.arrayParser, - }, - verbose: { - env: 'VERBOSE', - help: 'Set the logging to verbose', - action: parsers.booleanParser, - }, - verifyUserEmails: { - env: 'PARSE_SERVER_VERIFY_USER_EMAILS', - help: 'Enable (or disable) user email validation, defaults to false', - action: parsers.booleanParser, - default: false, - }, - webhookKey: { - env: 'PARSE_SERVER_WEBHOOK_KEY', - help: 'Key sent with outgoing webhook calls', - }, + "accountLockout": { + "env": "PARSE_SERVER_ACCOUNT_LOCKOUT", + "help": "account lockout policy for failed login attempts", + "action": parsers.objectParser + }, + "allowClientClassCreation": { + "env": "PARSE_SERVER_ALLOW_CLIENT_CLASS_CREATION", + "help": "Enable (or disable) client class creation, defaults to true", + "action": parsers.booleanParser, + "default": true + }, + "allowCustomObjectId": { + "env": "PARSE_SERVER_ALLOW_CUSTOM_OBJECT_ID", + "help": "Enable (or disable) custom objectId", + "action": parsers.booleanParser, + "default": false + }, + "allowHeaders": { + "env": "PARSE_SERVER_ALLOW_HEADERS", + "help": "Add headers to Access-Control-Allow-Headers", + "action": parsers.arrayParser + }, + "allowOrigin": { + "env": "PARSE_SERVER_ALLOW_ORIGIN", + "help": "Sets the origin to Access-Control-Allow-Origin" + }, + "analyticsAdapter": { + "env": "PARSE_SERVER_ANALYTICS_ADAPTER", + "help": "Adapter module for the analytics", + "action": parsers.moduleOrObjectParser + }, + "appId": { + "env": "PARSE_SERVER_APPLICATION_ID", + "help": "Your Parse Application ID", + "required": true + }, + "appName": { + "env": "PARSE_SERVER_APP_NAME", + "help": "Sets the app name" + }, + "auth": { + "env": "PARSE_SERVER_AUTH_PROVIDERS", + "help": "Configuration for your authentication providers, as stringified JSON. See http://docs.parseplatform.org/parse-server/guide/#oauth-and-3rd-party-authentication", + "action": parsers.objectParser + }, + "cacheAdapter": { + "env": "PARSE_SERVER_CACHE_ADAPTER", + "help": "Adapter module for the cache", + "action": parsers.moduleOrObjectParser + }, + "cacheMaxSize": { + "env": "PARSE_SERVER_CACHE_MAX_SIZE", + "help": "Sets the maximum size for the in memory cache, defaults to 10000", + "action": parsers.numberParser("cacheMaxSize"), + "default": 10000 + }, + "cacheTTL": { + "env": "PARSE_SERVER_CACHE_TTL", + "help": "Sets the TTL for the in memory cache (in ms), defaults to 5000 (5 seconds)", + "action": parsers.numberParser("cacheTTL"), + "default": 5000 + }, + "clientKey": { + "env": "PARSE_SERVER_CLIENT_KEY", + "help": "Key for iOS, MacOS, tvOS clients" + }, + "cloud": { + "env": "PARSE_SERVER_CLOUD", + "help": "Full path to your cloud code main.js" + }, + "cluster": { + "env": "PARSE_SERVER_CLUSTER", + "help": "Run with cluster, optionally set the number of processes default to os.cpus().length", + "action": parsers.numberOrBooleanParser + }, + "collectionPrefix": { + "env": "PARSE_SERVER_COLLECTION_PREFIX", + "help": "A collection prefix for the classes", + "default": "" + }, + "customPages": { + "env": "PARSE_SERVER_CUSTOM_PAGES", + "help": "custom pages for password validation and reset", + "action": parsers.objectParser, + "default": {} + }, + "databaseAdapter": { + "env": "PARSE_SERVER_DATABASE_ADAPTER", + "help": "Adapter module for the database", + "action": parsers.moduleOrObjectParser + }, + "databaseOptions": { + "env": "PARSE_SERVER_DATABASE_OPTIONS", + "help": "Options to pass to the mongodb client", + "action": parsers.objectParser + }, + "databaseURI": { + "env": "PARSE_SERVER_DATABASE_URI", + "help": "The full URI to your database. Supported databases are mongodb or postgres.", + "required": true, + "default": "mongodb://localhost:27017/parse" + }, + "directAccess": { + "env": "PARSE_SERVER_ENABLE_EXPERIMENTAL_DIRECT_ACCESS", + "help": "Replace HTTP Interface when using JS SDK in current node runtime, defaults to false. Caution, this is an experimental feature that may not be appropriate for production.", + "action": parsers.booleanParser, + "default": false + }, + "dotNetKey": { + "env": "PARSE_SERVER_DOT_NET_KEY", + "help": "Key for Unity and .Net SDK" + }, + "emailAdapter": { + "env": "PARSE_SERVER_EMAIL_ADAPTER", + "help": "Adapter module for email sending", + "action": parsers.moduleOrObjectParser + }, + "emailVerifyTokenValidityDuration": { + "env": "PARSE_SERVER_EMAIL_VERIFY_TOKEN_VALIDITY_DURATION", + "help": "Email verification token validity duration, in seconds", + "action": parsers.numberParser("emailVerifyTokenValidityDuration") + }, + "enableAnonymousUsers": { + "env": "PARSE_SERVER_ENABLE_ANON_USERS", + "help": "Enable (or disable) anonymous users, defaults to true", + "action": parsers.booleanParser, + "default": true + }, + "enableExpressErrorHandler": { + "env": "PARSE_SERVER_ENABLE_EXPRESS_ERROR_HANDLER", + "help": "Enables the default express error handler for all errors", + "action": parsers.booleanParser, + "default": false + }, + "enableSingleSchemaCache": { + "env": "PARSE_SERVER_ENABLE_SINGLE_SCHEMA_CACHE", + "help": "Use a single schema cache shared across requests. Reduces number of queries made to _SCHEMA, defaults to false, i.e. unique schema cache per request.", + "action": parsers.booleanParser, + "default": false + }, + "expireInactiveSessions": { + "env": "PARSE_SERVER_EXPIRE_INACTIVE_SESSIONS", + "help": "Sets wether we should expire the inactive sessions, defaults to true", + "action": parsers.booleanParser, + "default": true + }, + "fileKey": { + "env": "PARSE_SERVER_FILE_KEY", + "help": "Key for your files" + }, + "filesAdapter": { + "env": "PARSE_SERVER_FILES_ADAPTER", + "help": "Adapter module for the files sub-system", + "action": parsers.moduleOrObjectParser + }, + "graphQLPath": { + "env": "PARSE_SERVER_GRAPHQL_PATH", + "help": "Mount path for the GraphQL endpoint, defaults to /graphql", + "default": "/graphql" + }, + "graphQLSchema": { + "env": "PARSE_SERVER_GRAPH_QLSCHEMA", + "help": "Full path to your GraphQL custom schema.graphql file" + }, + "host": { + "env": "PARSE_SERVER_HOST", + "help": "The host to serve ParseServer on, defaults to 0.0.0.0", + "default": "0.0.0.0" + }, + "idempotencyOptions": { + "env": "PARSE_SERVER_EXPERIMENTAL_IDEMPOTENCY_OPTIONS", + "help": "Options for request idempotency to deduplicate identical requests that may be caused by network issues. Caution, this is an experimental feature that may not be appropriate for production.", + "action": parsers.objectParser, + "default": {} + }, + "javascriptKey": { + "env": "PARSE_SERVER_JAVASCRIPT_KEY", + "help": "Key for the Javascript SDK" + }, + "jsonLogs": { + "env": "JSON_LOGS", + "help": "Log as structured JSON objects", + "action": parsers.booleanParser + }, + "liveQuery": { + "env": "PARSE_SERVER_LIVE_QUERY", + "help": "parse-server's LiveQuery configuration object", + "action": parsers.objectParser + }, + "liveQueryServerOptions": { + "env": "PARSE_SERVER_LIVE_QUERY_SERVER_OPTIONS", + "help": "Live query server configuration options (will start the liveQuery server)", + "action": parsers.objectParser + }, + "loggerAdapter": { + "env": "PARSE_SERVER_LOGGER_ADAPTER", + "help": "Adapter module for the logging sub-system", + "action": parsers.moduleOrObjectParser + }, + "logLevel": { + "env": "PARSE_SERVER_LOG_LEVEL", + "help": "Sets the level for logs" + }, + "logsFolder": { + "env": "PARSE_SERVER_LOGS_FOLDER", + "help": "Folder for the logs (defaults to './logs'); set to null to disable file based logging", + "default": "./logs" + }, + "masterKey": { + "env": "PARSE_SERVER_MASTER_KEY", + "help": "Your Parse Master Key", + "required": true + }, + "masterKeyIps": { + "env": "PARSE_SERVER_MASTER_KEY_IPS", + "help": "Restrict masterKey to be used by only these ips, defaults to [] (allow all ips)", + "action": parsers.arrayParser, + "default": [] + }, + "maxLimit": { + "env": "PARSE_SERVER_MAX_LIMIT", + "help": "Max value for limit option on queries, defaults to unlimited", + "action": parsers.numberParser("maxLimit") + }, + "maxLogFiles": { + "env": "PARSE_SERVER_MAX_LOG_FILES", + "help": "Maximum number of logs to keep. If not set, no logs will be removed. This can be a number of files or number of days. If using days, add 'd' as the suffix. (default: null)", + "action": parsers.objectParser + }, + "maxUploadSize": { + "env": "PARSE_SERVER_MAX_UPLOAD_SIZE", + "help": "Max file size for uploads, defaults to 20mb", + "default": "20mb" + }, + "middleware": { + "env": "PARSE_SERVER_MIDDLEWARE", + "help": "middleware for express server, can be string or function" + }, + "mountGraphQL": { + "env": "PARSE_SERVER_MOUNT_GRAPHQL", + "help": "Mounts the GraphQL endpoint", + "action": parsers.booleanParser, + "default": false + }, + "mountPath": { + "env": "PARSE_SERVER_MOUNT_PATH", + "help": "Mount path for the server, defaults to /parse", + "default": "/parse" + }, + "mountPlayground": { + "env": "PARSE_SERVER_MOUNT_PLAYGROUND", + "help": "Mounts the GraphQL Playground - never use this option in production", + "action": parsers.booleanParser, + "default": false + }, + "objectIdSize": { + "env": "PARSE_SERVER_OBJECT_ID_SIZE", + "help": "Sets the number of characters in generated object id's, default 10", + "action": parsers.numberParser("objectIdSize"), + "default": 10 + }, + "passwordPolicy": { + "env": "PARSE_SERVER_PASSWORD_POLICY", + "help": "Password policy for enforcing password related rules", + "action": parsers.objectParser + }, + "playgroundPath": { + "env": "PARSE_SERVER_PLAYGROUND_PATH", + "help": "Mount path for the GraphQL Playground, defaults to /playground", + "default": "/playground" + }, + "port": { + "env": "PORT", + "help": "The port to run the ParseServer, defaults to 1337.", + "action": parsers.numberParser("port"), + "default": 1337 + }, + "preserveFileName": { + "env": "PARSE_SERVER_PRESERVE_FILE_NAME", + "help": "Enable (or disable) the addition of a unique hash to the file names", + "action": parsers.booleanParser, + "default": false + }, + "preventLoginWithUnverifiedEmail": { + "env": "PARSE_SERVER_PREVENT_LOGIN_WITH_UNVERIFIED_EMAIL", + "help": "Prevent user from login if email is not verified and PARSE_SERVER_VERIFY_USER_EMAILS is true, defaults to false", + "action": parsers.booleanParser, + "default": false + }, + "protectedFields": { + "env": "PARSE_SERVER_PROTECTED_FIELDS", + "help": "Protected fields that should be treated with extra security when fetching details.", + "action": parsers.objectParser, + "default": { + "_User": { + "*": ["email"] + } + } + }, + "publicServerURL": { + "env": "PARSE_PUBLIC_SERVER_URL", + "help": "Public URL to your parse server with http:// or https://." + }, + "push": { + "env": "PARSE_SERVER_PUSH", + "help": "Configuration for push, as stringified JSON. See http://docs.parseplatform.org/parse-server/guide/#push-notifications", + "action": parsers.objectParser + }, + "readOnlyMasterKey": { + "env": "PARSE_SERVER_READ_ONLY_MASTER_KEY", + "help": "Read-only key, which has the same capabilities as MasterKey without writes" + }, + "restAPIKey": { + "env": "PARSE_SERVER_REST_API_KEY", + "help": "Key for REST calls" + }, + "revokeSessionOnPasswordReset": { + "env": "PARSE_SERVER_REVOKE_SESSION_ON_PASSWORD_RESET", + "help": "When a user changes their password, either through the reset password email or while logged in, all sessions are revoked if this is true. Set to false if you don't want to revoke sessions.", + "action": parsers.booleanParser, + "default": true + }, + "scheduledPush": { + "env": "PARSE_SERVER_SCHEDULED_PUSH", + "help": "Configuration for push scheduling, defaults to false.", + "action": parsers.booleanParser, + "default": false + }, + "schemaCacheTTL": { + "env": "PARSE_SERVER_SCHEMA_CACHE_TTL", + "help": "The TTL for caching the schema for optimizing read/write operations. You should put a long TTL when your DB is in production. default to 5000; set 0 to disable.", + "action": parsers.numberParser("schemaCacheTTL"), + "default": 5000 + }, + "serverCloseComplete": { + "env": "PARSE_SERVER_SERVER_CLOSE_COMPLETE", + "help": "Callback when server has closed" + }, + "serverStartComplete": { + "env": "PARSE_SERVER_SERVER_START_COMPLETE", + "help": "Callback when server has started" + }, + "serverURL": { + "env": "PARSE_SERVER_URL", + "help": "URL to your parse server with http:// or https://.", + "required": true + }, + "sessionLength": { + "env": "PARSE_SERVER_SESSION_LENGTH", + "help": "Session duration, in seconds, defaults to 1 year", + "action": parsers.numberParser("sessionLength"), + "default": 31536000 + }, + "silent": { + "env": "SILENT", + "help": "Disables console output", + "action": parsers.booleanParser + }, + "startLiveQueryServer": { + "env": "PARSE_SERVER_START_LIVE_QUERY_SERVER", + "help": "Starts the liveQuery server", + "action": parsers.booleanParser + }, + "userSensitiveFields": { + "env": "PARSE_SERVER_USER_SENSITIVE_FIELDS", + "help": "Personally identifiable information fields in the user table the should be removed for non-authorized users. Deprecated @see protectedFields", + "action": parsers.arrayParser + }, + "verbose": { + "env": "VERBOSE", + "help": "Set the logging to verbose", + "action": parsers.booleanParser + }, + "verifyUserEmails": { + "env": "PARSE_SERVER_VERIFY_USER_EMAILS", + "help": "Enable (or disable) user email validation, defaults to false", + "action": parsers.booleanParser, + "default": false + }, + "webhookKey": { + "env": "PARSE_SERVER_WEBHOOK_KEY", + "help": "Key sent with outgoing webhook calls" + } }; module.exports.CustomPagesOptions = { - choosePassword: { - env: 'PARSE_SERVER_CUSTOM_PAGES_CHOOSE_PASSWORD', - help: 'choose password page path', - }, - invalidLink: { - env: 'PARSE_SERVER_CUSTOM_PAGES_INVALID_LINK', - help: 'invalid link page path', - }, - invalidVerificationLink: { - env: 'PARSE_SERVER_CUSTOM_PAGES_INVALID_VERIFICATION_LINK', - help: 'invalid verification link page path', - }, - linkSendFail: { - env: 'PARSE_SERVER_CUSTOM_PAGES_LINK_SEND_FAIL', - help: 'verification link send fail page path', - }, - linkSendSuccess: { - env: 'PARSE_SERVER_CUSTOM_PAGES_LINK_SEND_SUCCESS', - help: 'verification link send success page path', - }, - parseFrameURL: { - env: 'PARSE_SERVER_CUSTOM_PAGES_PARSE_FRAME_URL', - help: 'for masking user-facing pages', - }, - passwordResetSuccess: { - env: 'PARSE_SERVER_CUSTOM_PAGES_PASSWORD_RESET_SUCCESS', - help: 'password reset success page path', - }, - verifyEmailSuccess: { - env: 'PARSE_SERVER_CUSTOM_PAGES_VERIFY_EMAIL_SUCCESS', - help: 'verify email success page path', - }, + "choosePassword": { + "env": "PARSE_SERVER_CUSTOM_PAGES_CHOOSE_PASSWORD", + "help": "choose password page path" + }, + "invalidLink": { + "env": "PARSE_SERVER_CUSTOM_PAGES_INVALID_LINK", + "help": "invalid link page path" + }, + "invalidVerificationLink": { + "env": "PARSE_SERVER_CUSTOM_PAGES_INVALID_VERIFICATION_LINK", + "help": "invalid verification link page path" + }, + "linkSendFail": { + "env": "PARSE_SERVER_CUSTOM_PAGES_LINK_SEND_FAIL", + "help": "verification link send fail page path" + }, + "linkSendSuccess": { + "env": "PARSE_SERVER_CUSTOM_PAGES_LINK_SEND_SUCCESS", + "help": "verification link send success page path" + }, + "parseFrameURL": { + "env": "PARSE_SERVER_CUSTOM_PAGES_PARSE_FRAME_URL", + "help": "for masking user-facing pages" + }, + "passwordResetSuccess": { + "env": "PARSE_SERVER_CUSTOM_PAGES_PASSWORD_RESET_SUCCESS", + "help": "password reset success page path" + }, + "verifyEmailSuccess": { + "env": "PARSE_SERVER_CUSTOM_PAGES_VERIFY_EMAIL_SUCCESS", + "help": "verify email success page path" + } }; module.exports.LiveQueryOptions = { - classNames: { - env: 'PARSE_SERVER_LIVEQUERY_CLASSNAMES', - help: "parse-server's LiveQuery classNames", - action: parsers.arrayParser, - }, - pubSubAdapter: { - env: 'PARSE_SERVER_LIVEQUERY_PUB_SUB_ADAPTER', - help: 'LiveQuery pubsub adapter', - action: parsers.moduleOrObjectParser, - }, - redisOptions: { - env: 'PARSE_SERVER_LIVEQUERY_REDIS_OPTIONS', - help: "parse-server's LiveQuery redisOptions", - action: parsers.objectParser, - }, - redisURL: { - env: 'PARSE_SERVER_LIVEQUERY_REDIS_URL', - help: "parse-server's LiveQuery redisURL", - }, - wssAdapter: { - env: 'PARSE_SERVER_LIVEQUERY_WSS_ADAPTER', - help: 'Adapter module for the WebSocketServer', - action: parsers.moduleOrObjectParser, - }, + "classNames": { + "env": "PARSE_SERVER_LIVEQUERY_CLASSNAMES", + "help": "parse-server's LiveQuery classNames", + "action": parsers.arrayParser + }, + "pubSubAdapter": { + "env": "PARSE_SERVER_LIVEQUERY_PUB_SUB_ADAPTER", + "help": "LiveQuery pubsub adapter", + "action": parsers.moduleOrObjectParser + }, + "redisOptions": { + "env": "PARSE_SERVER_LIVEQUERY_REDIS_OPTIONS", + "help": "parse-server's LiveQuery redisOptions", + "action": parsers.objectParser + }, + "redisURL": { + "env": "PARSE_SERVER_LIVEQUERY_REDIS_URL", + "help": "parse-server's LiveQuery redisURL" + }, + "wssAdapter": { + "env": "PARSE_SERVER_LIVEQUERY_WSS_ADAPTER", + "help": "Adapter module for the WebSocketServer", + "action": parsers.moduleOrObjectParser + } }; module.exports.LiveQueryServerOptions = { - appId: { - env: 'PARSE_LIVE_QUERY_SERVER_APP_ID', - help: - 'This string should match the appId in use by your Parse Server. If you deploy the LiveQuery server alongside Parse Server, the LiveQuery server will try to use the same appId.', - }, - cacheTimeout: { - env: 'PARSE_LIVE_QUERY_SERVER_CACHE_TIMEOUT', - help: - "Number in milliseconds. When clients provide the sessionToken to the LiveQuery server, the LiveQuery server will try to fetch its ParseUser's objectId from parse server and store it in the cache. The value defines the duration of the cache. Check the following Security section and our protocol specification for details, defaults to 30 * 24 * 60 * 60 * 1000 ms (~30 days).", - action: parsers.numberParser('cacheTimeout'), - }, - keyPairs: { - env: 'PARSE_LIVE_QUERY_SERVER_KEY_PAIRS', - help: - 'A JSON object that serves as a whitelist of keys. It is used for validating clients when they try to connect to the LiveQuery server. Check the following Security section and our protocol specification for details.', - action: parsers.objectParser, - }, - logLevel: { - env: 'PARSE_LIVE_QUERY_SERVER_LOG_LEVEL', - help: - 'This string defines the log level of the LiveQuery server. We support VERBOSE, INFO, ERROR, NONE, defaults to INFO.', - }, - masterKey: { - env: 'PARSE_LIVE_QUERY_SERVER_MASTER_KEY', - help: - 'This string should match the masterKey in use by your Parse Server. If you deploy the LiveQuery server alongside Parse Server, the LiveQuery server will try to use the same masterKey.', - }, - port: { - env: 'PARSE_LIVE_QUERY_SERVER_PORT', - help: 'The port to run the LiveQuery server, defaults to 1337.', - action: parsers.numberParser('port'), - default: 1337, - }, - pubSubAdapter: { - env: 'PARSE_LIVE_QUERY_SERVER_PUB_SUB_ADAPTER', - help: 'LiveQuery pubsub adapter', - action: parsers.moduleOrObjectParser, - }, - redisOptions: { - env: 'PARSE_LIVE_QUERY_SERVER_REDIS_OPTIONS', - help: "parse-server's LiveQuery redisOptions", - action: parsers.objectParser, - }, - redisURL: { - env: 'PARSE_LIVE_QUERY_SERVER_REDIS_URL', - help: "parse-server's LiveQuery redisURL", - }, - serverURL: { - env: 'PARSE_LIVE_QUERY_SERVER_SERVER_URL', - help: - 'This string should match the serverURL in use by your Parse Server. If you deploy the LiveQuery server alongside Parse Server, the LiveQuery server will try to use the same serverURL.', - }, - websocketTimeout: { - env: 'PARSE_LIVE_QUERY_SERVER_WEBSOCKET_TIMEOUT', - help: - 'Number of milliseconds between ping/pong frames. The WebSocket server sends ping/pong frames to the clients to keep the WebSocket alive. This value defines the interval of the ping/pong frame from the server to clients, defaults to 10 * 1000 ms (10 s).', - action: parsers.numberParser('websocketTimeout'), - }, - wssAdapter: { - env: 'PARSE_LIVE_QUERY_SERVER_WSS_ADAPTER', - help: 'Adapter module for the WebSocketServer', - action: parsers.moduleOrObjectParser, - }, + "appId": { + "env": "PARSE_LIVE_QUERY_SERVER_APP_ID", + "help": "This string should match the appId in use by your Parse Server. If you deploy the LiveQuery server alongside Parse Server, the LiveQuery server will try to use the same appId." + }, + "cacheTimeout": { + "env": "PARSE_LIVE_QUERY_SERVER_CACHE_TIMEOUT", + "help": "Number in milliseconds. When clients provide the sessionToken to the LiveQuery server, the LiveQuery server will try to fetch its ParseUser's objectId from parse server and store it in the cache. The value defines the duration of the cache. Check the following Security section and our protocol specification for details, defaults to 30 * 24 * 60 * 60 * 1000 ms (~30 days).", + "action": parsers.numberParser("cacheTimeout") + }, + "keyPairs": { + "env": "PARSE_LIVE_QUERY_SERVER_KEY_PAIRS", + "help": "A JSON object that serves as a whitelist of keys. It is used for validating clients when they try to connect to the LiveQuery server. Check the following Security section and our protocol specification for details.", + "action": parsers.objectParser + }, + "logLevel": { + "env": "PARSE_LIVE_QUERY_SERVER_LOG_LEVEL", + "help": "This string defines the log level of the LiveQuery server. We support VERBOSE, INFO, ERROR, NONE, defaults to INFO." + }, + "masterKey": { + "env": "PARSE_LIVE_QUERY_SERVER_MASTER_KEY", + "help": "This string should match the masterKey in use by your Parse Server. If you deploy the LiveQuery server alongside Parse Server, the LiveQuery server will try to use the same masterKey." + }, + "port": { + "env": "PARSE_LIVE_QUERY_SERVER_PORT", + "help": "The port to run the LiveQuery server, defaults to 1337.", + "action": parsers.numberParser("port"), + "default": 1337 + }, + "pubSubAdapter": { + "env": "PARSE_LIVE_QUERY_SERVER_PUB_SUB_ADAPTER", + "help": "LiveQuery pubsub adapter", + "action": parsers.moduleOrObjectParser + }, + "redisOptions": { + "env": "PARSE_LIVE_QUERY_SERVER_REDIS_OPTIONS", + "help": "parse-server's LiveQuery redisOptions", + "action": parsers.objectParser + }, + "redisURL": { + "env": "PARSE_LIVE_QUERY_SERVER_REDIS_URL", + "help": "parse-server's LiveQuery redisURL" + }, + "serverURL": { + "env": "PARSE_LIVE_QUERY_SERVER_SERVER_URL", + "help": "This string should match the serverURL in use by your Parse Server. If you deploy the LiveQuery server alongside Parse Server, the LiveQuery server will try to use the same serverURL." + }, + "websocketTimeout": { + "env": "PARSE_LIVE_QUERY_SERVER_WEBSOCKET_TIMEOUT", + "help": "Number of milliseconds between ping/pong frames. The WebSocket server sends ping/pong frames to the clients to keep the WebSocket alive. This value defines the interval of the ping/pong frame from the server to clients, defaults to 10 * 1000 ms (10 s).", + "action": parsers.numberParser("websocketTimeout") + }, + "wssAdapter": { + "env": "PARSE_LIVE_QUERY_SERVER_WSS_ADAPTER", + "help": "Adapter module for the WebSocketServer", + "action": parsers.moduleOrObjectParser + } +}; +module.exports.IdempotencyOptions = { + "paths": { + "env": "PARSE_SERVER_EXPERIMENTAL_IDEMPOTENCY_PATHS", + "help": "An array of paths for which the feature should be enabled. The mount path must not be included, for example instead of `/parse/functions/myFunction` specifiy `functions/myFunction`. The entries are interpreted as regular expression, for example `functions/.*` matches all functions, `jobs/.*` matches all jobs, `classes/.*` matches all classes, `.*` matches all paths.", + "action": parsers.arrayParser, + "default": [] + }, + "ttl": { + "env": "PARSE_SERVER_EXPERIMENTAL_IDEMPOTENCY_TTL", + "help": "The duration in seconds after which a request record is discarded from the database, defaults to 300s.", + "action": parsers.numberParser("ttl"), + "default": 300 + } }; diff --git a/src/Options/docs.js b/src/Options/docs.js index 0e24594bcb..78821a0543 100644 --- a/src/Options/docs.js +++ b/src/Options/docs.js @@ -33,6 +33,7 @@ * @property {String} graphQLPath Mount path for the GraphQL endpoint, defaults to /graphql * @property {String} graphQLSchema Full path to your GraphQL custom schema.graphql file * @property {String} host The host to serve ParseServer on, defaults to 0.0.0.0 + * @property {IdempotencyOptions} idempotencyOptions Options for request idempotency to deduplicate identical requests that may be caused by network issues. Caution, this is an experimental feature that may not be appropriate for production. * @property {String} javascriptKey Key for the Javascript SDK * @property {Boolean} jsonLogs Log as structured JSON objects * @property {LiveQueryOptions} liveQuery parse-server's LiveQuery configuration object @@ -111,3 +112,10 @@ * @property {Number} websocketTimeout Number of milliseconds between ping/pong frames. The WebSocket server sends ping/pong frames to the clients to keep the WebSocket alive. This value defines the interval of the ping/pong frame from the server to clients, defaults to 10 * 1000 ms (10 s). * @property {Adapter} wssAdapter Adapter module for the WebSocketServer */ + +/** + * @interface IdempotencyOptions + * @property {String[]} paths An array of paths for which the feature should be enabled. The mount path must not be included, for example instead of `/parse/functions/myFunction` specifiy `functions/myFunction`. The entries are interpreted as regular expression, for example `functions/.*` matches all functions, `jobs/.*` matches all jobs, `classes/.*` matches all classes, `.*` matches all paths. + * @property {Number} ttl The duration in seconds after which a request record is discarded from the database, defaults to 300s. + */ + diff --git a/src/Options/index.js b/src/Options/index.js index d4c09dd790..0e97d84b5a 100644 --- a/src/Options/index.js +++ b/src/Options/index.js @@ -188,6 +188,10 @@ export interface ParseServerOptions { startLiveQueryServer: ?boolean; /* Live query server configuration options (will start the liveQuery server) */ liveQueryServerOptions: ?LiveQueryServerOptions; + /* Options for request idempotency to deduplicate identical requests that may be caused by network issues. Caution, this is an experimental feature that may not be appropriate for production. + :ENV: PARSE_SERVER_EXPERIMENTAL_IDEMPOTENCY_OPTIONS + :DEFAULT: false */ + idempotencyOptions: ?IdempotencyOptions; /* Full path to your GraphQL custom schema.graphql file */ graphQLSchema: ?string; /* Mounts the GraphQL endpoint @@ -272,3 +276,12 @@ export interface LiveQueryServerOptions { /* Adapter module for the WebSocketServer */ wssAdapter: ?Adapter; } + +export interface IdempotencyOptions { + /* An array of paths for which the feature should be enabled. The mount path must not be included, for example instead of `/parse/functions/myFunction` specifiy `functions/myFunction`. The entries are interpreted as regular expression, for example `functions/.*` matches all functions, `jobs/.*` matches all jobs, `classes/.*` matches all classes, `.*` matches all paths. + :DEFAULT: [] */ + paths: ?(string[]); + /* The duration in seconds after which a request record is discarded from the database, defaults to 300s. + :DEFAULT: 300 */ + ttl: ?number; +} diff --git a/src/Routers/ClassesRouter.js b/src/Routers/ClassesRouter.js index b5bba161fa..23e3016b39 100644 --- a/src/Routers/ClassesRouter.js +++ b/src/Routers/ClassesRouter.js @@ -2,6 +2,7 @@ import PromiseRouter from '../PromiseRouter'; import rest from '../rest'; import _ from 'lodash'; import Parse from 'parse/node'; +import { promiseEnsureIdempotency } from '../middlewares'; const ALLOWED_GET_QUERY_KEYS = [ 'keys', @@ -247,10 +248,10 @@ export class ClassesRouter extends PromiseRouter { this.route('GET', '/classes/:className/:objectId', req => { return this.handleGet(req); }); - this.route('POST', '/classes/:className', req => { + this.route('POST', '/classes/:className', promiseEnsureIdempotency, req => { return this.handleCreate(req); }); - this.route('PUT', '/classes/:className/:objectId', req => { + this.route('PUT', '/classes/:className/:objectId', promiseEnsureIdempotency, req => { return this.handleUpdate(req); }); this.route('DELETE', '/classes/:className/:objectId', req => { diff --git a/src/Routers/FunctionsRouter.js b/src/Routers/FunctionsRouter.js index c31eeb031f..a90e5c7491 100644 --- a/src/Routers/FunctionsRouter.js +++ b/src/Routers/FunctionsRouter.js @@ -4,7 +4,7 @@ var Parse = require('parse/node').Parse, triggers = require('../triggers'); import PromiseRouter from '../PromiseRouter'; -import { promiseEnforceMasterKeyAccess } from '../middlewares'; +import { promiseEnforceMasterKeyAccess, promiseEnsureIdempotency } from '../middlewares'; import { jobStatusHandler } from '../StatusHandler'; import _ from 'lodash'; import { logger } from '../logger'; @@ -34,11 +34,13 @@ export class FunctionsRouter extends PromiseRouter { this.route( 'POST', '/functions/:functionName', + promiseEnsureIdempotency, FunctionsRouter.handleCloudFunction ); this.route( 'POST', '/jobs/:jobName', + promiseEnsureIdempotency, promiseEnforceMasterKeyAccess, function (req) { return FunctionsRouter.handleCloudJob(req); diff --git a/src/Routers/InstallationsRouter.js b/src/Routers/InstallationsRouter.js index 28876b7f31..f87f54c605 100644 --- a/src/Routers/InstallationsRouter.js +++ b/src/Routers/InstallationsRouter.js @@ -2,6 +2,7 @@ import ClassesRouter from './ClassesRouter'; import rest from '../rest'; +import { promiseEnsureIdempotency } from '../middlewares'; export class InstallationsRouter extends ClassesRouter { className() { @@ -36,10 +37,10 @@ export class InstallationsRouter extends ClassesRouter { this.route('GET', '/installations/:objectId', req => { return this.handleGet(req); }); - this.route('POST', '/installations', req => { + this.route('POST', '/installations', promiseEnsureIdempotency, req => { return this.handleCreate(req); }); - this.route('PUT', '/installations/:objectId', req => { + this.route('PUT', '/installations/:objectId', promiseEnsureIdempotency, req => { return this.handleUpdate(req); }); this.route('DELETE', '/installations/:objectId', req => { diff --git a/src/Routers/UsersRouter.js b/src/Routers/UsersRouter.js index 05d0081792..9d015f9659 100644 --- a/src/Routers/UsersRouter.js +++ b/src/Routers/UsersRouter.js @@ -8,6 +8,7 @@ import rest from '../rest'; import Auth from '../Auth'; import passwordCrypto from '../password'; import { maybeRunTrigger, Types as TriggerTypes } from '../triggers'; +import { promiseEnsureIdempotency } from '../middlewares'; export class UsersRouter extends ClassesRouter { className() { @@ -445,7 +446,7 @@ export class UsersRouter extends ClassesRouter { this.route('GET', '/users', req => { return this.handleFind(req); }); - this.route('POST', '/users', req => { + this.route('POST', '/users', promiseEnsureIdempotency, req => { return this.handleCreate(req); }); this.route('GET', '/users/me', req => { @@ -454,7 +455,7 @@ export class UsersRouter extends ClassesRouter { this.route('GET', '/users/:objectId', req => { return this.handleGet(req); }); - this.route('PUT', '/users/:objectId', req => { + this.route('PUT', '/users/:objectId', promiseEnsureIdempotency, req => { return this.handleUpdate(req); }); this.route('DELETE', '/users/:objectId', req => { diff --git a/src/middlewares.js b/src/middlewares.js index a73f4c8105..526372ba39 100644 --- a/src/middlewares.js +++ b/src/middlewares.js @@ -4,9 +4,11 @@ import auth from './Auth'; import Config from './Config'; import ClientSDK from './ClientSDK'; import defaultLogger from './logger'; +import rest from './rest'; +import MongoStorageAdapter from './Adapters/Storage/Mongo/MongoStorageAdapter'; export const DEFAULT_ALLOWED_HEADERS = - 'X-Parse-Master-Key, X-Parse-REST-API-Key, X-Parse-Javascript-Key, X-Parse-Application-Id, X-Parse-Client-Version, X-Parse-Session-Token, X-Requested-With, X-Parse-Revocable-Session, Content-Type, Pragma, Cache-Control'; + 'X-Parse-Master-Key, X-Parse-REST-API-Key, X-Parse-Javascript-Key, X-Parse-Application-Id, X-Parse-Client-Version, X-Parse-Session-Token, X-Requested-With, X-Parse-Revocable-Session, X-Parse-Request-Id, Content-Type, Pragma, Cache-Control'; const getMountForRequest = function (req) { const mountPathLength = req.originalUrl.length - req.url.length; @@ -406,6 +408,52 @@ export function promiseEnforceMasterKeyAccess(request) { return Promise.resolve(); } +/** + * Deduplicates a request to ensure idempotency. Duplicates are determined by the request ID + * in the request header. If a request has no request ID, it is executed anyway. + * @param {*} req The request to evaluate. + * @returns Promise<{}> + */ +export function promiseEnsureIdempotency(req) { + // Enable feature only for MongoDB + if (!(req.config.database.adapter instanceof MongoStorageAdapter)) { return Promise.resolve(); } + // Get parameters + const config = req.config; + const requestId = ((req || {}).headers || {})["x-parse-request-id"]; + const { paths, ttl } = config.idempotencyOptions; + if (!requestId || !config.idempotencyOptions) { return Promise.resolve(); } + // Request path may contain trailing slashes, depending on the original request, so remove + // leading and trailing slashes to make it easier to specify paths in the configuration + const reqPath = req.path.replace(/^\/|\/$/, ''); + // Determine whether idempotency is enabled for current request path + let match = false; + for (const path of paths) { + // Assume one wants a path to always match from the beginning to prevent any mistakes + const regex = new RegExp(path.charAt(0) === '^' ? path : '^' + path); + if (reqPath.match(regex)) { + match = true; + break; + } + } + if (!match) { return Promise.resolve(); } + // Try to store request + const expiryDate = new Date(new Date().setSeconds(new Date().getSeconds() + ttl)); + return rest.create( + config, + auth.master(config), + '_Idempotency', + { reqId: requestId, expire: Parse._encode(expiryDate) } + ).catch (e => { + if (e.code == Parse.Error.DUPLICATE_VALUE) { + throw new Parse.Error( + Parse.Error.DUPLICATE_REQUEST, + 'Duplicate request' + ); + } + throw e; + }); +} + function invalidRequest(req, res) { res.status(403); res.end('{"error":"unauthorized"}'); diff --git a/src/rest.js b/src/rest.js index 812a0da7f7..e3ffe2a38e 100644 --- a/src/rest.js +++ b/src/rest.js @@ -284,6 +284,7 @@ const classesWithMasterOnlyAccess = [ '_Hooks', '_GlobalConfig', '_JobSchedule', + '_Idempotency', ]; // Disallowing access to the _Role collection except by master key function enforceRoleSecurity(method, className, auth) {